use std::{
collections::HashSet,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, ensure};
use iced::{
Alignment, Element,
Length::Fill,
Subscription, Task,
widget::{
Container, button, center, checkbox, column, container, horizontal_space, keyed_column,
row, scrollable, text, text_input, tooltip,
},
};
mod db;
mod fetch;
mod lr2folder;
mod migrations;
mod parsing;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct TableEntry {
md5: String,
level: String,
}
#[derive(Clone, Debug)]
struct ResolvedUrl(pub String);
#[derive(Clone, Debug)]
struct TableData {
web_url: ResolvedUrl,
name: String,
symbol: String,
data_url: ResolvedUrl,
entries: Vec<TableEntry>,
folder_order: Vec<String>,
header_url: ResolvedUrl,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct PlaylistId(pub usize);
#[derive(Clone, Debug)]
struct TableAddData {
last_update: Option<UnixEpochTs>,
playlist_id: Option<PlaylistId>,
user_symbol: Option<String>,
edited_symbol: Option<String>,
edited_url: Option<String>,
pending_removal: bool,
}
#[derive(Clone, Debug)]
struct Table(TableData, TableAddData);
struct App {
lr2_db: PathBuf,
playlists_folder: PathBuf,
write_tags_on_save: bool,
db: Option<rusqlite::Connection>,
tables: Vec<Table>,
new_table_text: String,
now: UnixEpochTs,
reqwest: reqwest::Client,
errors: Vec<String>,
}
impl ResolvedUrl {
fn try_from_str(url: String) -> Result<Self> {
if !url.is_empty() {
ensure!(
url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("file://"),
"Invalid protocol in table URL: {url}"
);
}
Ok(Self(url))
}
}
impl TableEntry {
fn extract_levels(entries: &[Self], folder_order: &[String]) -> Vec<String> {
let mut levels = entries
.iter()
.map(|e| e.level.clone())
.collect::<HashSet<String>>()
.into_iter()
.collect::<Vec<String>>();
let key_by_order = |e: &str| {
folder_order
.iter()
.position(|o| *o == *e)
.unwrap_or(usize::MAX)
};
let cmp_by_order = |a: &String, b: &String| -> std::cmp::Ordering {
key_by_order(a).cmp(&key_by_order(b))
};
levels
.sort_by(|a, b| cmp_by_order(a, b).then_with(|| alphanumeric_sort::compare_str(a, b)));
levels
}
}
impl Table {
fn commit_removals(tables: &mut Vec<Self>) {
let old_len = tables.len();
tables.retain(|t| !t.1.pending_removal);
if tables.len() != old_len {
log::info!("Removed {} table(s) while saving", old_len - tables.len());
}
}
}
#[cfg(test)]
impl Table {
#[must_use]
const fn empty() -> Self {
Self(
crate::TableData {
web_url: ResolvedUrl(String::new()),
name: String::new(),
symbol: String::new(),
data_url: ResolvedUrl(String::new()),
entries: vec![],
folder_order: vec![],
header_url: ResolvedUrl(String::new()),
},
crate::TableAddData {
last_update: None,
playlist_id: None,
user_symbol: None,
edited_symbol: None,
edited_url: None,
pending_removal: false,
},
)
}
#[must_use]
fn with_entry(self) -> Self {
self.with_entry_leveled("1")
}
#[must_use]
fn with_entry_leveled(mut self, level: impl ToString) -> Self {
let idx = self.0.entries.len();
self.0.entries.push(TableEntry {
md5: format!("foodfoodfoodfoodfoodfoodfood{idx:4}"),
level: level.to_string(),
});
self
}
#[must_use]
fn with_id(mut self, id: &mut PlaylistId) -> Self {
self.1.playlist_id = Some(*id);
id.0 += 1;
self
}
#[must_use]
fn with_name(mut self, name: impl Into<String>) -> Self {
self.0.name = name.into();
self
}
#[must_use]
fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
self.0.symbol = symbol.into();
self
}
#[must_use]
fn with_url(mut self, web_url: impl Into<String>) -> Self {
self.0.web_url = ResolvedUrl::try_from_str(web_url.into()).unwrap();
self
}
#[must_use]
fn with_user_symbol(mut self, symbol: impl Into<String>) -> Self {
self.1.user_symbol = Some(symbol.into());
self
}
}
type UnixEpochTs = u64;
fn now() -> UnixEpochTs {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("UNIX_EPOCH")
.as_secs()
}
fn since_string(time: UnixEpochTs, now: UnixEpochTs) -> String {
let diff =
i64::try_from(now).expect("u64 from i64") - i64::try_from(time).expect("u64 from i64");
if diff < 60 {
format!("{diff} seconds ago")
} else if diff < 60 * 60 {
format!("{} minutes ago", diff / 60)
} else if diff < 60 * 60 * 60 {
format!("{} hours ago", diff / 60 / 60)
} else {
format!("{} days ago", diff / 60 / 60 / 24)
}
}
impl App {
fn new(
db: Option<PathBuf>,
playlists_folder: Option<PathBuf>,
write_tags_on_save: bool,
) -> (Self, Task<Message>) {
(
Self {
lr2_db: PathBuf::new(),
playlists_folder: PathBuf::new(),
write_tags_on_save,
db: None,
tables: vec![],
new_table_text: String::new(),
now: now(),
reqwest: reqwest::ClientBuilder::new()
.user_agent("curl/8.12.1")
.build()
.expect("Failed to build reqwest client"),
errors: vec![],
},
Task::batch([
db.map_or_else(Task::none, |db| Task::done(Message::SetSongDb(db))),
playlists_folder.map_or_else(Task::none, |path| {
Task::done(Message::SetPlaylistsFolder(path))
}),
]),
)
}
async fn fetch_table(client: reqwest::Client, req: fetch::Request) -> Result<Table> {
fetch::fetch_table(&client, req, now()).await
}
fn add_table(&mut self, url: &str) -> Result<()> {
ensure!(
!self.tables.iter().any(|t| t.0.web_url.0 == url),
"Table {url} already exists"
);
let table = Table(
TableData {
web_url: ResolvedUrl::try_from_str(url.to_string())?,
name: "NEW TABLE".to_string(),
symbol: "?".to_string(),
header_url: ResolvedUrl(String::new()),
data_url: ResolvedUrl(String::new()),
entries: vec![],
folder_order: vec![],
},
TableAddData {
last_update: None,
playlist_id: None,
user_symbol: None,
edited_symbol: None,
edited_url: None,
pending_removal: false,
},
);
self.tables.insert(
self.tables.partition_point(|t| t.0.name < table.0.name),
table,
);
debug_assert!(self.tables.is_sorted_by(|l, r| l.0.name < r.0.name));
Ok(())
}
fn set_lr2_db(&mut self, db_path: &Path) -> Result<()> {
let (db, tables) = db::open_from_file(db_path)?;
self.db = Some(db);
self.lr2_db = db_path.to_path_buf();
self.tables = tables;
Ok(())
}
fn set_playlists_folder(&mut self, path: PathBuf) -> Result<()> {
lr2folder::validate_good_playlists_folder(&path)
.context("Bad playlists folder selected")?;
self.playlists_folder = path;
Ok(())
}
fn apply_tables(&mut self, table: Table) {
log::info!("Table '{}' was succesfully updated", table.0.name);
self.tables.retain(|t| t.0.web_url.0 != table.0.web_url.0);
self.tables.insert(
self.tables.partition_point(|t| t.0.name < table.0.name),
table,
);
debug_assert!(self.tables.is_sorted_by(|l, r| l.0.name < r.0.name));
}
#[allow(clippy::too_many_lines)]
fn do_update(&mut self, message: Message) -> Result<Task<Message>> {
match message {
Message::AddTable => {
self.add_table(&self.new_table_text.clone())?;
Ok(Task::none())
}
Message::ApplyTables(table) => {
self.apply_tables(table);
Ok(Task::done(Message::Tick(now())))
}
Message::RemoveTable(idx) => {
self.tables[idx].1.pending_removal = true;
Ok(Task::none())
}
Message::ClearTables => {
for table in &mut self.tables {
table.1.pending_removal = true;
}
Ok(Task::none())
}
Message::DisplayError(e) => {
log::error!("Action error: {e}");
self.errors.push(e);
Ok(Task::none())
}
Message::EditTableFinish(idx, apply) => {
let new_url = self.tables[idx]
.1
.edited_url
.take()
.expect("finished editing table but there is no edited url");
if apply && new_url != self.tables[idx].0.web_url.0 {
ensure!(
!self.tables.iter().any(|t| t.0.web_url.0 == new_url),
"Table {new_url} already exists"
);
self.tables[idx].0.web_url = ResolvedUrl::try_from_str(new_url.clone())
.with_context(|| format!("new_url={new_url}"))?;
}
Ok(Task::none())
}
Message::EditTableStart(idx) => {
let t = &mut self.tables[idx];
t.1.edited_url = Some(t.0.web_url.0.clone());
Ok(text_input::focus(format!("edit-{idx}")))
}
Message::EditTableSymbolFinish(idx, apply) => {
let new_symbol = self.tables[idx]
.1
.edited_symbol
.take()
.expect("finished editing table but there is no edited symbol");
if apply {
self.tables[idx].1.user_symbol = match new_symbol.as_ref() {
"" => None,
_ => Some(new_symbol),
};
}
Ok(Task::none())
}
Message::EditTableSymbolStart(idx) => {
let t = &mut self.tables[idx];
t.1.edited_symbol = Some(t.1.user_symbol.as_ref().unwrap_or(&t.0.symbol).clone());
Ok(text_input::focus(format!("edit-{idx}")))
}
Message::EditTableSymbolText(idx, text) => {
let t = &mut self.tables[idx];
t.1.edited_symbol = Some(text);
Ok(Task::none())
}
Message::EditTableText(idx, text) => {
let t = &mut self.tables[idx];
t.1.edited_url = Some(text);
Ok(Task::none())
}
Message::FetchTables(tasks) => Ok(Task::batch(tasks.into_iter().map(|data| {
Task::perform(Self::fetch_table(self.reqwest.clone(), data), |f| match f {
Ok(table) => Message::ApplyTables(table),
Err(e) => {
Message::DisplayError(format!("{:?}", e.context("fetch table error")))
}
})
}))),
Message::PickPlaylistsFolder => rfd::FileDialog::new()
.set_title("Choose playlists folder")
.pick_folder()
.map_or_else(
|| Ok(Task::none()),
|path| Ok(Task::done(Message::SetPlaylistsFolder(path))),
),
Message::PickSongDb => rfd::FileDialog::new()
.set_title("Choose song.db")
.add_filter("song.db", &["db"])
.pick_file()
.map_or_else(
|| Ok(Task::none()),
|db| Ok(Task::done(Message::SetSongDb(db))),
),
Message::TableTextUpdate(turl) => {
self.new_table_text = turl;
Ok(Task::none())
}
Message::SaveDb => {
Table::commit_removals(&mut self.tables);
let tx = self
.db
.as_mut()
.context("no db opened")?
.transaction()
.context("failed to start transaction for updating db")?;
db::save_db(&tx, &mut self.tables)?;
if self.write_tags_on_save {
db::update_tags_inplace(&tx)?;
}
lr2folder::write_files(&self.playlists_folder, &self.tables)?;
tx.commit()
.context("failed to commit after writing playlists")?;
Ok(Task::done(Message::Tick(now())))
}
Message::SetPlaylistsFolder(path) => {
self.set_playlists_folder(path)?;
Ok(Task::none())
}
Message::SetSongDb(path) => {
self.set_lr2_db(&path)?;
Ok(Task::none())
}
Message::Tick(now) => {
self.now = now;
Ok(Task::none())
}
Message::ToggleTagUpdating(on) => {
self.write_tags_on_save = on;
Ok(Task::none())
}
Message::UndoAllRemoveTable => {
for table in &mut self.tables {
table.1.pending_removal = false;
}
Ok(Task::none())
}
Message::UndoRemoveTable(idx) => {
self.tables[idx].1.pending_removal = false;
Ok(Task::none())
}
}
}
fn update(&mut self, message: Message) -> Task<Message> {
match self.do_update(message) {
Ok(task) => task,
Err(e) => Task::done(Message::DisplayError(format!(
"{:?}",
e.context("action error")
))),
}
}
#[allow(clippy::unused_self)]
fn subscription(&self) -> Subscription<Message> {
const TICK_INTERVAL: Duration = Duration::from_secs(5);
use iced::time::{Duration, every};
every(TICK_INTERVAL).map(|_| Message::Tick(now()))
}
#[allow(clippy::too_many_lines)]
fn view(&self) -> Element<Message> {
const NEGATIVE_NEVER: UnixEpochTs = 0;
const UPDATE_INTERVAL: UnixEpochTs = 60 * 60 * 12;
const SETUP_HINT: &str = "HINT: creating a shortcut to the executable file is the \
recommended to configure, as there is no other way to save \
configuration. See `lr2-oxytabler --help` for help.";
let db_ui = row![
button("Pick song.db").on_press(Message::PickSongDb),
text(if self.lr2_db.as_os_str().is_empty() {
"/path/to/song.db".to_string()
} else {
self.lr2_db.display().to_string()
})
]
.align_y(Alignment::Center);
let playlist_ui = row![
button("Pick playlists folder").on_press(Message::PickPlaylistsFolder),
text(if self.playlists_folder.as_os_str().is_empty() {
"/path/to/playlists/".to_string()
} else {
self.playlists_folder.display().to_string()
})
]
.align_y(Alignment::Center);
if self.lr2_db.as_os_str().is_empty() || self.playlists_folder.as_os_str().is_empty() {
return column![db_ui, playlist_ui, SETUP_HINT].into();
}
let table_url_hint = "https://example.com/table";
let new_table = text_input(table_url_hint, &self.new_table_text)
.on_input(Message::TableTextUpdate)
.on_submit(Message::AddTable);
let add_table = button("Add").on_press(Message::AddTable);
let tables: Element<_> = if self.tables.is_empty() {
center(text("Go on, add some tables!")).height(100).into()
} else {
keyed_column(self.tables.iter().enumerate().map(|(i, table)| {
(
i,
if self.tables[i].1.edited_symbol.is_some() {
row![
text_input(
&self.tables[i].0.symbol,
self.tables[i].1.edited_symbol.as_ref().unwrap()
)
.id(format!("edit-{i}"))
.width(Fill)
.on_input(move |text| Message::EditTableSymbolText(i, text))
.on_submit(Message::EditTableSymbolFinish(i, true)),
button("Cancel")
.on_press(Message::EditTableSymbolFinish(i, false))
.style(button::secondary),
button("Apply")
.on_press(Message::EditTableSymbolFinish(i, true))
.style(button::primary),
]
.into()
} else if self.tables[i].1.edited_url.is_some() {
row![
text_input(
table_url_hint,
self.tables[i].1.edited_url.as_ref().unwrap()
)
.id(format!("edit-{i}"))
.width(Fill)
.on_input(move |text| Message::EditTableText(i, text))
.on_submit(Message::EditTableFinish(i, true)),
button("Cancel")
.on_press(Message::EditTableFinish(i, false))
.style(button::secondary),
button("Apply")
.on_press(Message::EditTableFinish(i, true))
.style(button::primary),
]
.into()
} else {
row![
text(format!(
"{} ({}) {}",
table.0.name,
table.1.user_symbol.as_ref().unwrap_or(&table.0.symbol),
table.0.web_url.0
))
.shaping(text::Shaping::Advanced) .width(Fill),
text(table.1.last_update.map_or_else(
|| "Never".to_string(),
|last_update| since_string(last_update, self.now)
)),
container("").padding(5),
button("Force-update")
.on_press_with(|| Message::FetchTables(vec![
fetch::Request::new_for_table(table)
],))
.style(button::secondary),
button("Edit symbol")
.on_press(Message::EditTableSymbolStart(i))
.style(button::secondary),
button("Edit URL")
.on_press(Message::EditTableStart(i))
.style(button::secondary),
if table.1.pending_removal {
tooltip(
button("Restore")
.on_press(Message::UndoRemoveTable(i))
.style(button::success),
"Otherwise, this table will be removed on save",
tooltip::Position::FollowCursor,
)
} else {
tooltip(
button("Remove")
.on_press(Message::RemoveTable(i))
.style(button::danger),
"Won't remove until saved",
tooltip::Position::FollowCursor,
)
}
]
.align_y(Alignment::Center)
.spacing(2)
.into()
},
)
}))
.spacing(4)
.into()
};
let update_tables = button(Container::new(text("Update")).align_x(Alignment::Start))
.on_press_with(|| {
Message::FetchTables(
self.tables
.iter()
.filter(|t| {
t.1.last_update.unwrap_or(NEGATIVE_NEVER) + UPDATE_INTERVAL < self.now
})
.map(fetch::Request::new_for_table)
.collect::<Vec<_>>(),
)
})
.padding(10);
let update_tables = tooltip(
update_tables,
text(format!(
"Tables are updated once in {} hours",
UPDATE_INTERVAL / 60 / 60
)),
tooltip::Position::default(),
)
.style(container::rounded_box);
let clear_tables = if self.tables.iter().any(|t| t.1.pending_removal) {
button(Container::new(text("Restore all")).align_x(Alignment::End))
.on_press(Message::UndoAllRemoveTable)
.style(button::success)
.padding(10)
} else {
button(Container::new(text("Clear")).align_x(Alignment::End))
.on_press(Message::ClearTables)
.style(button::danger)
.padding(10)
};
let save = button("Save song.db").on_press_maybe(if self.db.is_some() {
Some(Message::SaveDb)
} else {
None
});
let write_tags =
checkbox("Write tags", self.write_tags_on_save).on_toggle(Message::ToggleTagUpdating);
let error_ui: Element<_> = self.errors.last().map_or_else(
|| row![].into(),
|err| {
scrollable(text(format!(
"{} action errors. Last error: {}",
self.errors.len(),
err
)))
.width(Fill)
.height(150)
.into()
},
);
column![
scrollable(
column![
db_ui,
playlist_ui,
row![new_table, add_table].align_y(Alignment::Center),
tables,
row![update_tables, horizontal_space(), clear_tables]
.align_y(Alignment::Center),
row![save, write_tags]
.spacing(10)
.align_y(Alignment::Center),
SETUP_HINT,
]
.spacing(10)
)
.spacing(0)
.width(Fill)
.height(Fill),
error_ui
]
.align_x(Alignment::Center)
.into()
}
#[allow(clippy::unused_self)]
const fn theme(&self) -> iced::Theme {
iced::Theme::Dark
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
enum Message {
AddTable,
ApplyTables(Table),
ClearTables,
DisplayError(String),
EditTableFinish(usize, bool),
EditTableStart(usize),
EditTableSymbolFinish(usize, bool),
EditTableSymbolStart(usize),
EditTableSymbolText(usize, String),
EditTableText(usize, String),
FetchTables(Vec<fetch::Request>),
PickPlaylistsFolder,
PickSongDb,
RemoveTable(usize),
SaveDb,
SetPlaylistsFolder(PathBuf),
SetSongDb(PathBuf),
TableTextUpdate(String),
Tick(UnixEpochTs),
ToggleTagUpdating(bool),
UndoAllRemoveTable,
UndoRemoveTable(usize),
}
fn main() -> Result<()> {
env_logger::init();
let mut args = pico_args::Arguments::from_env();
let cmd = args.subcommand()?;
match cmd.as_deref() {
Some("gui") => {
if args.contains("--help") {
println!(
"flags:
--db <path> - path to song.db, optional
--playlists-path <path> - folder to write playlists to, optional
--write-tags <true/false> - whether to toggle on tag writing, default 'false'"
);
return Ok(());
};
let db = args.opt_value_from_str::<_, PathBuf>("--db")?;
let playlists_folder = args.opt_value_from_str::<_, PathBuf>("--playlists-path")?;
let write_tags_on_save = args.opt_value_from_str::<_, bool>("--write-tags")?;
let left = args.finish();
ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
iced::application("LR2 OxyTabler", App::update, App::view)
.subscription(App::subscription)
.theme(App::theme)
.run_with(move || {
App::new(db, playlists_folder, write_tags_on_save.unwrap_or(false))
})
.context("app error")
}
Some(cmd) => anyhow::bail!("invalid cmd: {cmd}"),
None => {
println!("hint: use the 'gui' subcommand to be able to pass command line arguments");
if args.contains("--help") {
println!(
"subcommands:
lr2-oxytabler gui --help
lr2-oxytabler"
);
return Ok(());
};
let left = args.finish();
ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
iced::application("LR2 OxyTabler", App::update, App::view)
.subscription(App::subscription)
.theme(App::theme)
.run_with(move || App::new(None, None, false))
.context("app error")
}
}
}