#![cfg_attr(not(test), windows_subsystem = "windows")]
use crate::changelog::TableUpdateChangelog;
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_editor, text_input, tooltip,
},
};
mod changelog;
mod db;
mod fetch;
mod lr2folder;
mod mass_edit;
mod migrations;
mod parsing;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct OutputFolderKey(pub String);
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct OutputFolder(OutputFolderKey, PathBuf);
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct TableEntry {
md5: String,
level: String,
}
#[derive(Clone, Debug, PartialEq)]
struct ResolvedUrl(pub String);
#[derive(Clone, Debug, PartialEq)]
struct TableData {
web_url: ResolvedUrl,
name: String,
symbol: String,
entries: Vec<TableEntry>,
folder_order: Vec<String>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct PlaylistId(pub usize);
#[derive(Clone, Debug, PartialEq)]
enum UpdateStatus {
Ready,
Updating,
Error(String),
}
#[derive(Clone, Debug, PartialEq)]
struct TableAddData {
last_update: Option<UnixEpochTs>,
playlist_id: Option<PlaylistId>,
user_symbol: Option<String>,
output: OutputFolderKey,
changelog: TableUpdateChangelog,
edited_symbol: Option<String>,
edited_url: Option<String>,
pending_removal: bool,
status: UpdateStatus,
}
#[derive(Clone, Debug, PartialEq)]
struct Table(TableData, TableAddData);
struct App {
outputs: Vec<OutputFolder>,
save_on_update_finish: bool,
write_tags_on_save: bool,
db: rusqlite::Connection,
tables: Vec<Table>,
mass_edit_text: Option<text_editor::Content>,
new_table_text: String,
new_table_output: Option<OutputFolderKey>,
now: UnixEpochTs,
reqwest: reqwest::Client,
errors: Vec<String>,
}
impl std::fmt::Display for OutputFolderKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}", self.0))
}
}
impl OutputFolder {
const SEPARATOR: char = ':';
}
impl std::str::FromStr for OutputFolder {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
let (key, val) = value.split_once(Self::SEPARATOR).with_context(|| {
format!(
"separator '{}' not found in output folder string",
Self::SEPARATOR
)
})?;
Ok(Self(OutputFolderKey(key.to_string()), PathBuf::from(val)))
}
}
fn is_any_parent_of_another(outputs: &[OutputFolder]) -> Option<(OutputFolder, OutputFolder)> {
for (i, output1) in outputs.iter().enumerate() {
for output2 in &outputs[i + 1..] {
#[allow(clippy::suspicious_operation_groupings)] if output1.1 != output2.1
&& (output1.1.starts_with(&output2.1) || output2.1.starts_with(&output1.1))
{
return Some((output1.clone(), output2.clone()));
}
}
}
None
}
fn validate_resolved_url(url: &str) -> Result<()> {
ensure!(
url.starts_with("https://") || url.starts_with("http://") || url.starts_with("file://"),
"Invalid protocol in table URL: {url}"
);
Ok(())
}
impl TryFrom<&str> for ResolvedUrl {
type Error = anyhow::Error;
fn try_from(url: &str) -> Result<Self> {
validate_resolved_url(url)?;
Ok(Self(url.to_string()))
}
}
impl TryFrom<String> for ResolvedUrl {
type Error = anyhow::Error;
fn try_from(url: String) -> Result<Self> {
validate_resolved_url(&url)?;
Ok(Self(url))
}
}
impl ResolvedUrl {
pub fn resolve_json_url(&self, raw_url: &str) -> Result<Self> {
if let Ok(url) = raw_url.try_into() {
return Ok(url);
}
let last_slash = self.0.rfind(['/', '\\']).context("No slash in URL")?;
let url_prefix = &self.0[..=last_slash];
format!("{url_prefix}{raw_url}")
.try_into()
.context("don't expect this to fail actually")
}
}
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]
fn empty() -> Self {
Self(
crate::TableData {
web_url: "http://".try_into().unwrap(),
name: String::new(),
symbol: String::new(),
entries: vec![],
folder_order: vec![],
},
crate::TableAddData {
changelog: Default::default(),
edited_symbol: None,
edited_url: None,
last_update: None,
pending_removal: false,
playlist_id: None,
user_symbol: None,
output: OutputFolderKey(String::new()),
status: UpdateStatus::Ready,
},
)
}
#[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
}
fn with_entry_leveled_plus(mut self, md5: u32, level: u32) -> Self {
self.0.entries.push(TableEntry {
md5: format!("foodfoodfoodfoodfoodfoodfood{md5: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_output(mut self, output: impl Into<String>) -> Self {
self.1.output = OutputFolderKey(output.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 = web_url.into().try_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)
}
}
fn add_table(tables: &mut Vec<Table>, web_url: ResolvedUrl, output: OutputFolderKey) -> Result<()> {
ensure!(
!tables.iter().any(|t| t.0.web_url == web_url),
"Table {} already exists",
web_url.0
);
let table = Table(
TableData {
web_url,
name: "NEW TABLE".to_string(),
symbol: "?".to_string(),
entries: vec![],
folder_order: vec![],
},
TableAddData {
changelog: TableUpdateChangelog::default(),
edited_symbol: None,
edited_url: None,
last_update: None,
pending_removal: false,
playlist_id: None,
status: UpdateStatus::Ready,
user_symbol: None,
output,
},
);
tables.insert(tables.partition_point(|t| t.0.name < table.0.name), table);
debug_assert!(tables.is_sorted_by(|l, r| l.0.name < r.0.name));
Ok(())
}
#[allow(clippy::similar_names)] fn apply_table(
db: &rusqlite::Connection,
tables: &mut Vec<Table>,
answer: fetch::RequestAnswer,
now: UnixEpochTs,
) -> Result<()> {
let data = answer.data;
let old = answer
.request
.position(tables)
.context("can't find table to apply update to")?;
log::info!("Table '{}' was successfully updated", data.name);
let old = tables.remove(old);
let changelog = TableUpdateChangelog::try_new(
db,
&old.0.entries,
&data.entries,
&data.folder_order,
old.1.user_symbol.as_deref().unwrap_or(&data.symbol),
)?;
let new = Table(
data,
TableAddData {
status: UpdateStatus::Ready,
changelog,
last_update: Some(now),
..old.1
},
);
tables.insert(tables.partition_point(|t| t.0.name < new.0.name), new);
debug_assert!(tables.is_sorted_by(|l, r| l.0.name < r.0.name));
Ok(())
}
impl App {
const NEGATIVE_NEVER: UnixEpochTs = 0;
const TABLE_UPDATE_INTERVAL: UnixEpochTs = 60 * 60 * 12;
fn new(
db_path: &Path,
mut outputs: Vec<OutputFolder>,
save_on_update_finish: bool,
write_tags_on_save: bool,
) -> Result<(Self, Task<Message>)> {
const APP_USER_AGENT: &str =
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
let reqwest = reqwest::ClientBuilder::new()
.user_agent(APP_USER_AGENT)
.build()
.context("Failed to build reqwest client")?;
outputs.reverse();
outputs.sort_by(|a, b| a.0.0.cmp(&b.0.0)); outputs.dedup_by(|a, b| a.0.0 == b.0.0);
if let Some(p) = is_any_parent_of_another(&outputs) {
anyhow::bail!(
"intersecting output paths found: '{}:{}' and '{}:{}'",
p.0.0,
p.0.1.display(),
p.1.0,
p.1.1.display()
);
}
for OutputFolder(_key, path) in &outputs {
lr2folder::validate_good_playlists_folder(path)
.context("Bad playlists folder selected")?;
}
let (db, tables) = db::open_from_file(db_path)?;
for Table(table, add_data) in &tables {
ensure!(
outputs.iter().any(|o| o.0 == add_data.output),
"Missing --output '{}' required for table '{}'",
add_data.output.0,
table.name,
);
}
let new_table_output = if outputs.is_empty() {
None
} else {
Some(outputs[0].0.clone())
};
Ok((
Self {
outputs,
save_on_update_finish,
write_tags_on_save,
db,
tables,
mass_edit_text: None,
new_table_text: String::new(),
new_table_output,
now: now(),
reqwest,
errors: vec![],
},
Task::none(),
))
}
#[allow(clippy::too_many_lines)]
fn do_update(&mut self, message: Message) -> Result<Task<Message>> {
match message {
Message::AddTable => {
let web_url = self.new_table_text.as_str().try_into()?;
let output = self.new_table_output.as_ref().context("no table output")?;
add_table(&mut self.tables, web_url, output.clone())?;
self.new_table_text.clear();
Ok(Task::none())
}
Message::ApplyTable(table) => {
let now = now();
apply_table(&self.db, &mut self.tables, table, now)?;
Ok(Task::done(Message::Tick(now)))
}
Message::DisplayError(e) => {
log::error!("Action error: {e}");
self.errors.push(e);
Ok(Task::none())
}
Message::EditTableOutput(idx, output) => {
self.tables[idx].1.output = output;
Ok(Task::none())
}
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::EditTableUrlFinish(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 = new_url
.as_str()
.try_into()
.with_context(|| format!("new_url={new_url}"))?;
}
Ok(Task::none())
}
Message::EditTableUrlStart(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::EditTableUrlText(idx, text) => {
let t = &mut self.tables[idx];
t.1.edited_url = Some(text);
Ok(Task::none())
}
Message::FetchTables(tasks) => {
let mut task = Task::batch(tasks.into_iter().map(|data| {
if let Some(t) = data.position(&self.tables) {
let t = &mut self.tables[t];
t.1.status = UpdateStatus::Updating;
Task::perform(
fetch::fetch_table(self.reqwest.clone(), data.clone()),
move |f| match f {
Ok(table) => Message::ApplyTable(table),
Err(e) => Message::RejectTableUpdate(
data.clone(),
format!("{:?}", e.context("fetch table error")),
),
},
)
} else {
Task::done(Message::DisplayError(format!(
"Failed to find table {data:?}"
)))
}
}));
task = task.chain(Task::done(Message::SaveDbIfAllDone));
Ok(task)
}
Message::MassEditAction(s) => {
let content = self
.mass_edit_text
.as_mut()
.context("MassEditAction but no mass_edit_text, huh?")?;
content.perform(s);
Ok(Task::none())
}
Message::MassEditApply => {
if let Some(mass_edit_text) = self.mass_edit_text.as_ref() {
let text = mass_edit_text.text();
let edits = mass_edit::parse_mass_edit_lines(&text)?;
mass_edit::validate_mass_edits(&edits, &self.outputs)?;
self.mass_edit_text = None;
self.tables = mass_edit::with_mass_edit_changes(&self.tables, edits)
.context("failed to apply some mass edit changes")?;
}
Ok(Task::none())
}
Message::MassEditStart(b) => {
self.mass_edit_text = if b {
Some(text_editor::Content::with_text(
&mass_edit::to_mass_edit_lines(&self.tables)?,
))
} else {
None
};
Ok(Task::none())
}
Message::NewTableOutputSelected(o) => {
self.new_table_output = Some(o);
Ok(Task::none())
}
Message::RejectTableUpdate(req, error) => {
log::info!("RejectTableUpdate: {error}");
let t = req
.position(&self.tables)
.context("failed to update table which also doesn't exist now")?;
let t = &mut self.tables[t];
t.1.status = UpdateStatus::Error(error);
Ok(Task::none())
}
Message::TableTextUpdate(turl) => {
self.new_table_text = turl;
Ok(Task::none())
}
Message::SaveDb => {
Table::commit_removals(&mut self.tables);
let tx = self
.db
.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.outputs, &self.tables)?;
tx.commit()
.context("failed to commit after writing playlists")?;
Ok(Task::done(Message::Tick(now())))
}
Message::SaveDbIfAllDone => {
if self.save_on_update_finish
&& !self
.tables
.iter()
.any(|t| t.1.status == UpdateStatus::Updating)
{
Ok(Task::done(Message::SaveDb))
} else {
Ok(Task::none())
}
}
Message::Tick(now) => {
self.now = now;
Ok(Task::none())
}
Message::ToggleAllTableRemoval(on) => {
for table in &mut self.tables {
table.1.pending_removal = on;
}
Ok(Task::none())
}
Message::ToggleSaveOnUpdateFinish(on) => {
self.save_on_update_finish = on;
Ok(Task::none())
}
Message::ToggleTagUpdating(on) => {
self.write_tags_on_save = on;
Ok(Task::none())
}
Message::ToggleTableRemoval(idx, on) => {
self.tables[idx].1.pending_removal = on;
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()))
}
fn wants_updating(t: &Table, now: UnixEpochTs) -> bool {
if t.1.pending_removal || t.1.status == UpdateStatus::Updating {
return false;
}
if t.0.web_url.0.starts_with("file://") {
return true;
}
t.1.last_update.unwrap_or(Self::NEGATIVE_NEVER) + Self::TABLE_UPDATE_INTERVAL < now
}
#[allow(clippy::too_many_lines)]
fn view_table<'a>(&'a self, i: usize, table: &'a Table) -> Element<'a, Message> {
if let Some(edited_symbol) = table.1.edited_symbol.as_ref() {
row![
text_input(&table.0.symbol, edited_symbol)
.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 let Some(edited_url) = table.1.edited_url.as_ref() {
row![
text_input(&table.0.web_url.0, edited_url)
.id(format!("edit-{i}"))
.width(Fill)
.on_input(move |text| Message::EditTableUrlText(i, text))
.on_submit(Message::EditTableUrlFinish(i, true)),
button("Cancel")
.on_press(Message::EditTableUrlFinish(i, false))
.style(button::secondary),
button("Apply")
.on_press(Message::EditTableUrlFinish(i, true))
.style(button::primary),
]
.into()
} else {
row![
text(format!(
"{} ({})",
table.0.name,
table.1.user_symbol.as_ref().unwrap_or(&table.0.symbol),
))
.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)
)),
tooltip(
text(table.1.changelog.summary()),
text(table.1.changelog.full())
.shaping(text::Shaping::Advanced ),
tooltip::Position::Left,
)
.style(container::rounded_box)
.gap(10),
container("").padding(5),
tooltip(
if table.1.status == UpdateStatus::Updating {
button("Updating...")
} else {
button(if Self::wants_updating(table, self.now) {
"Update"
} else {
"Force-update"
})
.on_press_with(|| {
Message::FetchTables(vec![fetch::Request::new_for_table(table)])
})
.style(button::secondary)
},
if let UpdateStatus::Error(error) = &table.1.status {
container(text(error)).style(container::rounded_box)
} else {
container("")
},
tooltip::Position::Left,
)
.gap(10),
iced::widget::pick_list(
self.outputs.iter().map(|o| o.0.clone()).collect::<Vec<_>>(),
Some(table.1.output.clone()),
move |o| Message::EditTableOutput(i, o),
),
button("Edit symbol")
.on_press(Message::EditTableSymbolStart(i))
.style(button::secondary),
button("Edit URL")
.on_press(Message::EditTableUrlStart(i))
.style(button::secondary),
if table.1.pending_removal {
tooltip(
button("Restore")
.on_press(Message::ToggleTableRemoval(i, false))
.style(button::success),
"Otherwise, this table will be removed on save",
tooltip::Position::FollowCursor,
)
.gap(10)
.style(container::rounded_box)
} else {
tooltip(
button("Remove")
.on_press(Message::ToggleTableRemoval(i, true))
.style(button::danger),
"Won't remove until saved",
tooltip::Position::FollowCursor,
)
}
.gap(10)
.style(container::rounded_box)
]
.align_y(Alignment::Center)
.spacing(2)
.into()
}
}
#[allow(clippy::too_many_lines)]
fn view(&'_ self) -> Element<'_, Message> {
if let Some(mass_edit_text) = self.mass_edit_text.as_ref() {
return scrollable(column![
text_editor(mass_edit_text).on_action(Message::MassEditAction),
button("Apply").on_press(Message::MassEditApply),
button("Cancel").on_press(Message::MassEditStart(false)),
])
.into();
}
let maybe_add_new_table = if self.new_table_output.is_some()
&& validate_resolved_url(self.new_table_text.as_str()).is_ok()
{
Some(Message::AddTable)
} else {
None
};
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_maybe(maybe_add_new_table.clone());
let new_table_output = iced::widget::pick_list(
self.outputs.iter().map(|o| o.0.clone()).collect::<Vec<_>>(),
self.new_table_output.clone(),
Message::NewTableOutputSelected,
);
let add_table = button("Add").on_press_maybe(maybe_add_new_table);
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, self.view_table(i, table))),
)
.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| Self::wants_updating(t, 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",
Self::TABLE_UPDATE_INTERVAL / 60 / 60
)),
tooltip::Position::default(),
)
.gap(10)
.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::ToggleAllTableRemoval(false))
.style(button::success)
.padding(10)
} else {
button(Container::new(text("Clear")).align_x(Alignment::End))
.on_press(Message::ToggleAllTableRemoval(true))
.style(button::danger)
.padding(10)
};
let start_mass_edit = button(Container::new(text("Mass edit")).align_x(Alignment::End))
.on_press(Message::MassEditStart(true))
.padding(10);
let save = button("Save song.db").on_press_maybe(
if self
.tables
.iter()
.any(|t| t.1.status == UpdateStatus::Updating)
{
None
} else {
Some(Message::SaveDb)
},
);
let write_tags =
checkbox("Write tags", self.write_tags_on_save).on_toggle(Message::ToggleTagUpdating);
let save_on_update_finish = checkbox("Save on update finish", self.save_on_update_finish)
.on_toggle(Message::ToggleSaveOnUpdateFinish);
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![
row![new_table, new_table_output, add_table].align_y(Alignment::Center),
tables,
row![
update_tables,
horizontal_space(),
clear_tables,
start_mass_edit
]
.align_y(Alignment::Center),
row![save, write_tags, save_on_update_finish]
.spacing(10)
.align_y(Alignment::Center),
]
.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,
ApplyTable(fetch::RequestAnswer),
DisplayError(String),
EditTableOutput(usize, OutputFolderKey),
EditTableSymbolFinish(usize, bool),
EditTableSymbolStart(usize),
EditTableSymbolText(usize, String),
EditTableUrlFinish(usize, bool),
EditTableUrlStart(usize),
EditTableUrlText(usize, String),
FetchTables(Vec<fetch::Request>),
MassEditApply,
MassEditStart(bool),
MassEditAction(text_editor::Action),
NewTableOutputSelected(OutputFolderKey),
RejectTableUpdate(fetch::Request, String),
SaveDb,
SaveDbIfAllDone,
TableTextUpdate(String),
Tick(UnixEpochTs),
ToggleAllTableRemoval(bool),
ToggleSaveOnUpdateFinish(bool),
ToggleTableRemoval(usize, bool),
ToggleTagUpdating(bool),
}
fn main() -> Result<()> {
const HELP: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "
subcommands:
gui:
--db <path> - path to song.db
--output <key:path> - folders to write playlists to, may be several
--save-on-update-finish <true/false> - whether to toggle on saving on update finish, default 'true'
--write-tags <true/false> - whether to toggle on tag writing, default 'false'");
env_logger::init();
let mut args = pico_args::Arguments::from_env();
if args.contains("--help") {
println!("{HELP}");
return Ok(());
}
let cmd = args.subcommand()?;
match cmd.as_deref() {
Some("gui") => {
let db = args.value_from_str::<_, PathBuf>("--db")?;
let outputs = args.values_from_str::<_, OutputFolder>("--output")?;
let write_tags_on_save = args
.opt_value_from_str::<_, bool>("--write-tags")?
.unwrap_or(false);
let save_on_update_finish = args
.opt_value_from_str::<_, bool>("--save-on-update-finish")?
.unwrap_or(true);
let left = args.finish();
ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
let app = App::new(&db, outputs, save_on_update_finish, write_tags_on_save)?;
iced::application("LR2 OxyTabler", App::update, App::view)
.subscription(App::subscription)
.theme(App::theme)
.run_with(move || app)
.context("app error")
}
Some(cmd) => anyhow::bail!("invalid cmd: {cmd}"),
None => {
const fn update(_state: &mut (), _message: ()) {}
fn view(_state: &'_ ()) -> Element<'_, ()> {
text(HELP).into()
}
let left = args.finish();
ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
println!("hint: see --help:\n\n{HELP}");
iced::application("LR2 OxyTabler", update, view)
.run()
.context("app error")
}
}
}
#[cfg(test)]
mod tests {
use test_log::test;
#[test]
fn parse_output_path() {
use crate::{OutputFolder, OutputFolderKey};
assert_eq!(
"a".parse::<OutputFolder>().unwrap_err().to_string(),
"separator ':' not found in output folder string"
);
assert_eq!(
"a:D:\\b".parse::<OutputFolder>().unwrap(),
OutputFolder(OutputFolderKey("a".into()), "D:\\b".into())
);
}
#[test]
fn is_any_parent_of_another() {
let p = |p: &str| crate::OutputFolder(crate::OutputFolderKey("".into()), p.into());
let f = |pp: &[crate::OutputFolder]| crate::is_any_parent_of_another(pp).is_some();
assert!(!f(&[]));
assert!(!f(&[p(""), p("")]));
assert!(f(&[p("a"), p("a/b")]));
assert!(f(&[p("a/b"), p("a")]));
assert!(!f(&[p("/a"), p("/b")]));
assert!(!f(&[p("/"), p("/")]));
assert!(f(&[p("/a"), p("/a/b")]));
assert!(f(&[p("/a/b"), p("/a")]));
assert!(!f(&[p("C:/a"), p("C:/b")]));
assert!(!f(&[p("C:/"), p("C:/")]));
assert!(f(&[p("C:/a"), p("C:/a/b")]));
assert!(f(&[p("C:/a"), p("C:/a/b")]));
}
}