#![windows_subsystem = "windows"]
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 db;
mod fetch;
mod lr2folder;
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,
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, PartialEq)]
struct TableUpdateChangelog {
changed: Vec<(String, String, String)>,
deleted: Vec<(String, String)>,
new: Vec<(String, String)>,
}
#[derive(Clone, Debug, PartialEq)]
struct TableAddData {
last_update: Option<UnixEpochTs>,
playlist_id: Option<PlaylistId>,
user_symbol: Option<String>,
output: OutputFolderKey,
being_updated: bool,
changelog: TableUpdateChangelog,
edited_symbol: Option<String>,
edited_url: Option<String>,
pending_removal: bool,
update_errors: Vec<String>,
}
#[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>,
}
#[derive(Clone, Debug)]
struct TableMassEditLineData {
web_url: String,
output: OutputFolderKey,
symbol: Option<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 validate_resolved_url(url: &str) -> Result<()> {
if !url.is_empty() {
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]
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 {
being_updated: false,
changelog: TableUpdateChangelog {
changed: vec![],
deleted: vec![],
new: vec![],
},
edited_symbol: None,
edited_url: None,
last_update: None,
pending_removal: false,
playlist_id: None,
update_errors: vec![],
user_symbol: None,
output: OutputFolderKey(String::new()),
},
)
}
#[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
}
}
impl std::fmt::Display for TableUpdateChangelog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if f.alternate() {
if !self.new.is_empty() {
f.write_str("New:\n")?;
for (md5, level) in &self.new {
f.write_str(md5)?;
f.write_str(" (Lv ")?;
f.write_str(level)?;
f.write_str(")\n")?;
}
}
if !self.changed.is_empty() {
f.write_str("\nChanged:\n")?;
for (md5, from, to) in &self.changed {
f.write_str(md5)?;
f.write_str(" (Lv ")?;
f.write_str(from)?;
f.write_str(" -> Lv ")?;
f.write_str(to)?;
f.write_str(")\n")?;
}
}
if !self.deleted.is_empty() {
f.write_str("\nDeleted:\n")?;
for (md5, level) in &self.deleted {
f.write_str(md5)?;
f.write_str(" (Lv ")?;
f.write_str(level)?;
f.write_str(")\n")?;
}
}
} else {
if !self.new.is_empty() {
f.write_fmt(format_args!(" +{}", self.new.len()))?;
}
if !self.changed.is_empty() {
f.write_fmt(format_args!(" ~{}", self.changed.len()))?;
}
if !self.deleted.is_empty() {
f.write_fmt(format_args!(" -{}", self.deleted.len()))?;
}
}
Ok(())
}
}
impl std::fmt::Display for TableMassEditLineData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{}{}{}{}{}",
self.web_url,
Self::SEPARATOR,
self.output,
Self::SEPARATOR,
self.symbol.as_deref().unwrap_or(Self::DEFAULT_SYMBOL),
))
}
}
impl TableMassEditLineData {
const DEFAULT_SYMBOL: &str = "DEFAULT";
const SEPARATOR: &str = " ";
fn try_from_table(table: &Table) -> Result<Self> {
let out = Self {
web_url: table.0.web_url.0.clone(),
output: table.1.output.clone(),
symbol: table.1.user_symbol.clone(),
};
ensure!(
!out.web_url.contains(Self::SEPARATOR) && !out.web_url.contains('\n'),
"Table URL must not contain separator"
);
if let Some(symbol) = out.symbol.as_ref() {
ensure!(
!symbol.contains(Self::SEPARATOR) && !symbol.contains('\n'),
"Table symbol must not contain separator"
);
}
Ok(out)
}
fn try_from_string(s: &str) -> Result<Self> {
let split = s.split(Self::SEPARATOR).collect::<Vec<&str>>();
ensure!(split.len() == 3, "invalid entity count in string: {s}");
Ok(Self {
web_url: split[0].to_string(),
output: OutputFolderKey(split[1].to_string()),
symbol: (split[2] != Self::DEFAULT_SYMBOL).then(|| split[2].to_string()),
})
}
}
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>, url: &str, output: OutputFolderKey) -> Result<()> {
ensure!(
!tables.iter().any(|t| t.0.web_url.0 == url),
"Table {url} already exists"
);
let table = Table(
TableData {
web_url: url.try_into()?,
name: "NEW TABLE".to_string(),
symbol: "?".to_string(),
header_url: ResolvedUrl(String::new()),
data_url: ResolvedUrl(String::new()),
entries: vec![],
folder_order: vec![],
},
TableAddData {
being_updated: false,
changelog: TableUpdateChangelog {
changed: vec![],
deleted: vec![],
new: vec![],
},
edited_symbol: None,
edited_url: None,
last_update: None,
pending_removal: false,
playlist_id: None,
update_errors: vec![],
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(())
}
fn calculate_diff(old: &TableData, new: &TableData) -> TableUpdateChangelog {
let mut out = TableUpdateChangelog {
changed: vec![],
deleted: vec![],
new: vec![],
};
for e in &old.entries {
#[derive(PartialEq)]
enum Found {
Not,
DifferentLv(String),
SameLv,
}
let mut found = Found::Not;
for ee in new.entries.iter().filter(|ee| e.md5 == ee.md5) {
if e.level == ee.level {
found = Found::SameLv;
break;
}
found = Found::DifferentLv(ee.level.clone());
}
match found {
Found::Not => out.deleted.push((e.md5.clone(), e.level.clone())),
Found::DifferentLv(to) => out.changed.push((e.md5.clone(), e.level.clone(), to)),
Found::SameLv => {}
}
}
for e in &new.entries {
if !old.entries.iter().any(|ee| e.md5 == ee.md5) {
out.new.push((e.md5.clone(), e.level.clone()));
}
}
out
}
#[allow(clippy::similar_names)] fn apply_table(
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 succesfully updated", data.name);
let old = tables.remove(old);
let changelog = calculate_diff(&old.0, &data);
let new = Table(
data,
TableAddData {
being_updated: false,
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(())
}
fn with_mass_edit_changes(
old_tables: &[Table],
edits: Vec<TableMassEditLineData>,
) -> Result<Vec<Table>> {
let mut tables = old_tables.to_vec();
for t in &mut *tables {
if !edits.iter().any(|line| *line.web_url == t.0.web_url.0) {
t.1.pending_removal = true;
}
}
for edit in edits {
match tables.iter_mut().find(|t| t.0.web_url.0 == *edit.web_url) {
Some(t) => {
t.1.output = edit.output;
t.1.user_symbol = edit.symbol;
}
None => add_table(&mut tables, &edit.web_url, edit.output)?,
}
}
Ok(tables)
}
fn parse_mass_edit_lines(s: &str) -> Result<Vec<TableMassEditLineData>> {
s.split(['\r', '\n'])
.filter(|s| !s.is_empty())
.filter(|s| !s.starts_with('#'))
.map(TableMassEditLineData::try_from_string)
.collect::<Result<Vec<_>>>()
.context("failed to parse mass edit lines")
}
fn validate_mass_edits(edits: &[TableMassEditLineData], outputs: &[OutputFolder]) -> Result<()> {
for edit in edits {
ensure!(
outputs.iter().any(|o| o.0 == edit.output),
"edit references undefined output '{}'",
edit.output.0
);
}
Ok(())
}
fn to_mass_edit_lines(tables: &[Table]) -> Result<String> {
let mut lines = Vec::<String>::with_capacity(tables.len() + 1);
lines.push("# ' ' delimited.\n# URL OUTPUT SYMBOL ('DEFAULT' for none)".to_string());
for te in tables.iter().map(TableMassEditLineData::try_from_table) {
lines.push(
te.context("Failed to convert table to mass edit line")?
.to_string(),
);
}
Ok(lines.as_slice().join("\n"))
}
impl App {
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);
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 => {
if let Some(output) = self.new_table_output.as_ref() {
add_table(
&mut self.tables,
&self.new_table_text.clone(),
output.clone(),
)?;
} else {
anyhow::bail!("no output?? wtf???");
}
Ok(Task::none())
}
Message::ApplyTable(table) => {
let now = now();
apply_table(&mut self.tables, table, now)?;
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 = new_url
.as_str()
.try_into()
.with_context(|| format!("new_url={new_url}"))?;
}
Ok(Task::none())
}
Message::EditTableOutput(idx, output) => {
self.tables[idx].1.output = output;
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, save_after) => {
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.being_updated = true;
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:?}"
)))
}
}));
if save_after {
task = task.chain(Task::done(Message::SaveDb));
}
Ok(task)
}
Message::MassEditAction(s) => {
if let Some(content) = self.mass_edit_text.as_mut() {
content.perform(s);
} else {
log::error!("wtf");
}
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 = parse_mass_edit_lines(&text)?;
validate_mass_edits(&edits, &self.outputs)?;
self.mass_edit_text = None;
self.tables = 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(&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}");
match req.position(&self.tables) {
Some(t) => {
let t = &mut self.tables[t];
t.1.being_updated = false;
t.1.update_errors.push(error);
Ok(Task::none())
}
None => Ok(Task::done(Message::DisplayError(format!(
"Updating of some table failed. Can't figure which one. error:\n\n{error}"
)))),
}
}
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::Tick(now) => {
self.now = now;
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::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;
fn wants_updating(t: &Table, now: UnixEpochTs) -> bool {
!t.1.pending_removal
&& !t.1.being_updated
&& t.1.last_update.unwrap_or(NEGATIVE_NEVER) + UPDATE_INTERVAL < now
}
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 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(if self.new_table_output.is_some() {
Some(Message::AddTable)
} else {
None
});
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(if self.new_table_output.is_some() {
Some(Message::AddTable)
} else {
None
});
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),
))
.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.to_string()),
text(format!("{:#}", table.1.changelog))
.shaping(text::Shaping::Advanced), tooltip::Position::Left,
)
.style(container::rounded_box)
.gap(10),
container("").padding(5),
tooltip(
if table.1.being_updated {
button("Updating...")
} else if wants_updating(table, self.now) {
button("Update")
.on_press_with(|| {
Message::FetchTables(
vec![fetch::Request::new_for_table(table)],
false,
)
})
.style(button::secondary)
} else {
button("Force-update")
.on_press_with(|| {
Message::FetchTables(
vec![fetch::Request::new_for_table(table)],
false,
)
})
.style(button::secondary)
},
if table.1.update_errors.is_empty() {
container("")
} else {
container(text(format!(
"{} errors:\n\n{}",
table.1.update_errors.len(),
table.1.update_errors.join("\n\n")
)))
.style(container::rounded_box)
},
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::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,
)
.gap(10)
.style(container::rounded_box)
} else {
tooltip(
button("Remove")
.on_press(Message::RemoveTable(i))
.style(button::danger),
"Won't remove until saved",
tooltip::Position::FollowCursor,
)
}
.gap(10)
.style(container::rounded_box)
]
.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| wants_updating(t, self.now))
.map(fetch::Request::new_for_table)
.collect::<Vec<_>>(),
self.save_on_update_finish,
)
})
.padding(10);
let update_tables = tooltip(
update_tables,
text(format!(
"Tables are updated once in {} hours",
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::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 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(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),
ClearTables,
DisplayError(String),
EditTableFinish(usize, bool),
EditTableOutput(usize, OutputFolderKey),
EditTableStart(usize),
EditTableSymbolFinish(usize, bool),
EditTableSymbolStart(usize),
EditTableSymbolText(usize, String),
EditTableText(usize, String),
FetchTables(Vec<fetch::Request>, bool),
MassEditApply,
MassEditStart(bool),
MassEditAction(text_editor::Action),
NewTableOutputSelected(OutputFolderKey),
RejectTableUpdate(fetch::Request, String),
RemoveTable(usize),
SaveDb,
TableTextUpdate(String),
Tick(UnixEpochTs),
ToggleSaveOnUpdateFinish(bool),
ToggleTagUpdating(bool),
UndoAllRemoveTable,
UndoRemoveTable(usize),
}
fn main() -> Result<()> {
const HELP: &str = "lr2-oxytabler
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 to_mass_edit_lines() {
use crate::{Table, to_mass_edit_lines as f};
assert_eq!(
f(&[Table::empty().with_url("https:// ")])
.unwrap_err()
.to_string(),
"Failed to convert table to mass edit line"
);
assert_eq!(
f(&[Table::empty().with_user_symbol(" ")])
.unwrap_err()
.to_string(),
"Failed to convert table to mass edit line"
);
assert_eq!(
f(&[]).unwrap(),
"# ' ' delimited.\n# URL OUTPUT SYMBOL ('DEFAULT' for none)"
);
assert_eq!(
f(&[
Table::empty().with_url("https://whatever"),
Table::empty()
.with_url("https://solomon")
.with_user_symbol("jew")
.with_output("output"),
])
.unwrap(),
"# ' ' delimited.\n# URL OUTPUT SYMBOL ('DEFAULT' for none)\nhttps://whatever DEFAULT\nhttps://solomon output jew"
);
}
#[test]
fn parse_mass_edit_lines() {
use crate::TableMassEditLineData;
use crate::parse_mass_edit_lines as f;
let to_str = |t: &[TableMassEditLineData]| {
t.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
};
assert_eq!(f("\n# abracadabra this is ignored\r\n\n").unwrap().len(), 0);
assert_eq!(
to_str(&f("#\nbad-protocol:// output DEFAULT\r\n").unwrap()),
"bad-protocol:// output DEFAULT"
);
assert_eq!(
to_str(&f("#\nbad-protocol:// DEFAULT").unwrap()),
"bad-protocol:// DEFAULT"
);
assert_eq!(
&f("bad").unwrap_err().to_string(),
"failed to parse mass edit lines"
);
assert_eq!(
&f("a b").unwrap_err().to_string(),
"failed to parse mass edit lines"
);
}
#[test]
fn validate_mass_edits() {
use crate::{
OutputFolder, OutputFolderKey, TableMassEditLineData, validate_mass_edits as f,
};
f(&[], &[]).unwrap();
f(
&[TableMassEditLineData {
web_url: "http://".to_string(),
output: OutputFolderKey("some".into()),
symbol: None,
}],
&[OutputFolder(OutputFolderKey("some".into()), "/tmp".into())],
)
.unwrap();
assert_eq!(
f(
&[TableMassEditLineData {
web_url: "http://".to_string(),
output: OutputFolderKey("some".into()),
symbol: None,
}],
&[OutputFolder(
OutputFolderKey("another".into()),
"/tmp".into(),
)],
)
.unwrap_err()
.to_string(),
"edit references undefined output 'some'"
);
}
#[test]
fn with_mass_edit_changes() {
use crate::{OutputFolderKey, Table, TableMassEditLineData, with_mass_edit_changes as f};
assert_eq!(
f(
&[],
vec![TableMassEditLineData {
web_url: "bad-url".to_string(),
output: OutputFolderKey("".into()),
symbol: None
}]
)
.unwrap_err()
.to_string(),
"Invalid protocol in table URL: bad-url"
);
{
let mut tables = f(
&[
Table::empty().with_url("http://1").with_name("1"),
Table::empty()
.with_url("http://2")
.with_name("2")
.with_user_symbol("old"),
Table::empty().with_url("http://3").with_name("3"),
],
vec![
TableMassEditLineData {
web_url: "http://2".to_string(),
output: OutputFolderKey("out-for-2".into()),
symbol: Some("new".to_string()),
},
TableMassEditLineData {
web_url: "http://3".to_string(),
output: OutputFolderKey("out-for-3".into()),
symbol: None,
},
TableMassEditLineData {
web_url: "http://4".to_string(),
output: OutputFolderKey("out-for-4".into()),
symbol: None,
},
],
)
.unwrap();
Table::commit_removals(&mut tables);
assert_eq!(
tables
.iter()
.map(|t| format!(
"{} {} {}",
t.0.web_url.0,
t.1.output.0,
t.1.user_symbol.as_deref().unwrap_or("nil")
))
.collect::<Vec<_>>()
.as_slice()
.join(","),
"http://2 out-for-2 new,http://3 out-for-3 nil,http://4 out-for-4 nil"
);
}
}
#[test]
fn calculate_diff() {
use crate::{Table, TableUpdateChangelog, calculate_diff};
assert_eq!(
calculate_diff(
&Table::empty()
.with_entry_leveled_plus(1, 1)
.with_entry_leveled_plus(1, 1)
.with_entry_leveled_plus(69, 1)
.with_entry_leveled_plus(69, 2)
.with_entry_leveled_plus(420, 1)
.with_entry_leveled_plus(1234, 1)
.0,
&Table::empty()
.with_entry_leveled_plus(1, 1)
.with_entry_leveled_plus(2, 1)
.with_entry_leveled_plus(2, 1)
.with_entry_leveled_plus(69, 1)
.with_entry_leveled_plus(69, 2)
.with_entry_leveled_plus(420, 10)
.0,
),
TableUpdateChangelog {
changed: vec![(
"foodfoodfoodfoodfoodfoodfood 420".to_string(),
"1".to_string(),
"10".to_string()
)],
deleted: vec![
(
"foodfoodfoodfoodfoodfoodfood1234".to_string(),
"1".to_string()
),
],
new: vec![
(
"foodfoodfoodfoodfoodfoodfood 2".to_string(),
"1".to_string()
),
(
"foodfoodfoodfoodfoodfoodfood 2".to_string(),
"1".to_string()
),
]
}
);
}
#[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())
);
}
}