use crate::{
build_reqwest, changelog, fetch, mass_edit, open_db,
output::{OutputFolder, OutputFolderKey},
save_db,
table::Data,
table::Status,
table::Table,
time::UnixEpochTs,
url::ResolvedUrl,
};
use anyhow::{Context as _, Result, ensure};
use std::{collections::HashMap, path::Path};
const TABLE_UPDATE_INTERVAL: UnixEpochTs = UnixEpochTs(60 * 60 * 12);
#[derive(Debug, Clone)]
enum Msg {
AddTable,
ApplyTable(Data, HashMap<String, String>, fetch::TableLocator),
DisplayError(String),
EditTableOutput(usize, OutputFolderKey),
EditTableSymbolFinish(usize, bool),
EditTableSymbolStart(usize),
EditTableSymbolText(usize, String),
EditTableUrlFinish(usize, bool),
EditTableUrlStart(usize),
EditTableUrlText(usize, String),
FetchTables(Vec<fetch::TableLocator>),
MassEditAction(iced::widget::text_editor::Action),
MassEditApply,
MassEditStart(bool),
NewTableOutputSelected(OutputFolderKey),
SaveDb,
SaveDbIfAllDone,
TableFetchingFailed(fetch::TableLocator, String),
TableTextUpdate(String),
Tick(UnixEpochTs),
ToggleAllTableRemoval(bool),
ToggleSaveOnUpdateFinish(bool),
ToggleTableRemoval(usize, bool),
ToggleTagUpdating(bool),
}
pub 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<iced::widget::text_editor::Content>,
new_table_text: String,
new_table_output: Option<OutputFolderKey>,
now: UnixEpochTs,
reqwest: reqwest::Client,
errors: Vec<String>,
fallback_song_titles: HashMap<String, String>,
}
impl App {
pub fn new(
db_path: &Path,
outputs: Vec<OutputFolder>,
save_on_update_finish: bool,
write_tags_on_save: bool,
) -> Result<Self> {
let (db, tables) = open_db(db_path, &outputs)?;
Ok(Self {
save_on_update_finish,
write_tags_on_save,
db,
tables,
mass_edit_text: None,
new_table_text: String::new(),
new_table_output: outputs.first().map(|o| o.0.clone()),
now: UnixEpochTs::now(),
reqwest: build_reqwest()?,
errors: vec![],
fallback_song_titles: HashMap::new(),
outputs,
})
}
pub fn run(self) -> Result<()> {
iced::application("LR2 OxyTabler", Self::update, Self::view)
.subscription(Self::subscription)
.theme(Self::theme)
.run_with(move || (self, iced::Task::none()))
.context("iced error")
}
#[expect(clippy::too_many_lines)]
fn do_update(&mut self, message: Msg) -> Result<iced::Task<Msg>> {
use iced::Task;
match message {
Msg::AddTable => {
let web_url = self.new_table_text.as_str().try_into()?;
let output = self.new_table_output.as_ref().context("no table output")?;
Table::insert_new(&mut self.tables, web_url, output.clone())?;
self.new_table_text.clear();
Ok(Task::none())
}
Msg::ApplyTable(table, new_song_titles, request) => {
let now = UnixEpochTs::now();
self.fallback_song_titles.extend(new_song_titles);
Table::apply_update(
&changelog::SongNameAccessor::new(&self.db, &self.fallback_song_titles),
&mut self.tables,
table,
&request,
now,
)?;
Ok(Task::done(Msg::Tick(now)))
}
Msg::DisplayError(e) => {
log::error!("Action error: {e}");
self.errors.push(e);
Ok(Task::none())
}
Msg::EditTableOutput(idx, output) => {
self.tables[idx].1.output = output;
Ok(Task::none())
}
Msg::EditTableSymbolFinish(idx, apply) => {
let new_symbol = self.tables[idx]
.1
.edited_symbol
.take()
.context("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())
}
Msg::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(iced::widget::text_input::focus(format!("edit-{idx}")))
}
Msg::EditTableSymbolText(idx, text) => {
let t = &mut self.tables[idx];
t.1.edited_symbol = Some(text);
Ok(Task::none())
}
Msg::EditTableUrlFinish(idx, apply) => {
let new_url = self.tables[idx]
.1
.edited_url
.take()
.context("finished editing table but there is no edited url")?;
if apply && new_url != self.tables[idx].0.web_url.as_str() {
ensure!(
!self.tables.iter().any(|t| t.0.web_url.as_str() == new_url),
"Table {new_url} already exists"
);
self.tables[idx].0.web_url = new_url
.as_str()
.try_into()
.with_context(|| format!("bad new URL {new_url}"))?;
}
Ok(Task::none())
}
Msg::EditTableUrlStart(idx) => {
let t = &mut self.tables[idx];
t.1.edited_url = Some(t.0.web_url.as_str().to_string());
Ok(iced::widget::text_input::focus(format!("edit-{idx}")))
}
Msg::EditTableUrlText(idx, text) => {
let t = &mut self.tables[idx];
t.1.edited_url = Some(text);
Ok(Task::none())
}
Msg::FetchTables(tasks) => {
log::info!("Fetching {} tables", tasks.len());
let task = Task::batch(tasks.into_iter().map(|locator| {
if let Some(idx) = locator.locate(&self.tables) {
let t = &mut self.tables[idx];
t.1.status = Status::Updating;
} else {
return Task::done(Msg::DisplayError(format!(
"Failed to find table {locator}"
)));
}
Task::perform(
fetch::fetch_table(self.reqwest.clone(), locator.web_url().clone()),
move |f| {
let locator = locator.clone();
match f {
Ok((table, new_song_titles)) => {
Msg::ApplyTable(table, new_song_titles, locator)
}
Err(e) => Msg::TableFetchingFailed(locator, format!("{e:?}")),
}
},
)
}));
let task = task.chain(Task::done(Msg::SaveDbIfAllDone));
Ok(task)
}
Msg::MassEditAction(s) => {
let content = self
.mass_edit_text
.as_mut()
.context("MassEditAction but no mass_edit_text, huh?")?;
content.perform(s);
Ok(Task::none())
}
Msg::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())
}
Msg::MassEditStart(b) => {
self.mass_edit_text = if b {
Some(iced::widget::text_editor::Content::with_text(
&mass_edit::to_mass_edit_lines(&self.tables)?,
))
} else {
None
};
Ok(Task::none())
}
Msg::NewTableOutputSelected(o) => {
self.new_table_output = Some(o);
Ok(Task::none())
}
Msg::TableFetchingFailed(locator, error) => {
log::warn!("table {locator}: failed to fetch table");
log::debug!("error was: {error}");
let t = locator
.locate(&self.tables)
.with_context(|| format!("{locator}: failed to find table"))?;
let t = &mut self.tables[t];
t.1.status = crate::table::Status::Error(error);
Ok(Task::none())
}
Msg::TableTextUpdate(turl) => {
self.new_table_text = turl;
Ok(Task::none())
}
Msg::SaveDb => {
log::info!("Saving the database...");
save_db(
&mut self.db,
&self.outputs,
&mut self.tables,
self.write_tags_on_save,
)?;
log::info!("Database saved");
Ok(Task::done(Msg::Tick(UnixEpochTs::now())))
}
Msg::SaveDbIfAllDone => {
if self.save_on_update_finish
&& !self
.tables
.iter()
.any(|t| t.1.status == crate::table::Status::Updating)
{
Ok(Task::done(Msg::SaveDb))
} else {
Ok(Task::none())
}
}
Msg::Tick(now) => {
self.now = now;
Ok(Task::none())
}
Msg::ToggleAllTableRemoval(on) => {
for table in &mut self.tables {
table.1.pending_removal = on;
}
Ok(Task::none())
}
Msg::ToggleSaveOnUpdateFinish(on) => {
self.save_on_update_finish = on;
Ok(Task::none())
}
Msg::ToggleTagUpdating(on) => {
self.write_tags_on_save = on;
Ok(Task::none())
}
Msg::ToggleTableRemoval(idx, on) => {
self.tables[idx].1.pending_removal = on;
Ok(Task::none())
}
}
}
fn update(&mut self, message: Msg) -> iced::Task<Msg> {
match self.do_update(message) {
Ok(task) => task,
Err(e) => iced::Task::done(Msg::DisplayError(format!(
"{:?}",
e.context("action error")
))),
}
}
#[expect(clippy::unused_self)]
fn subscription(&self) -> iced::Subscription<Msg> {
const TICK_INTERVAL: Duration = Duration::from_secs(5);
use iced::time::{Duration, every};
every(TICK_INTERVAL).map(|_| Msg::Tick(UnixEpochTs::now()))
}
#[expect(clippy::too_many_lines)]
fn view_table<'a>(&'a self, i: usize, table: &'a Table) -> iced::Element<'a, Msg> {
use iced::{
Alignment,
Length::Fill,
widget::{button, container, row, text, text_input, tooltip},
};
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| Msg::EditTableSymbolText(i, text))
.on_submit(Msg::EditTableSymbolFinish(i, true)),
button("Cancel")
.on_press(Msg::EditTableSymbolFinish(i, false))
.style(button::secondary),
button("Apply")
.on_press(Msg::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.as_str(), edited_url)
.id(format!("edit-{i}"))
.width(Fill)
.on_input(move |text| Msg::EditTableUrlText(i, text))
.on_submit(Msg::EditTableUrlFinish(i, true)),
button("Cancel")
.on_press(Msg::EditTableUrlFinish(i, false))
.style(button::secondary),
button("Apply")
.on_press(Msg::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| UnixEpochTs::diff_str(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 == Status::Updating {
button("Updating...")
} else {
button(if table.wants_updating(self.now, TABLE_UPDATE_INTERVAL) {
"Update"
} else {
"Force-update"
})
.on_press_with(|| {
Msg::FetchTables(vec![fetch::TableLocator::new_for_table(table)])
})
.style(button::secondary)
},
if let Status::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| Msg::EditTableOutput(i, o),
),
button("Edit symbol")
.on_press(Msg::EditTableSymbolStart(i))
.style(button::secondary),
button("Edit URL")
.on_press(Msg::EditTableUrlStart(i))
.style(button::secondary),
if table.1.pending_removal {
tooltip(
button("Restore")
.on_press(Msg::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(Msg::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()
}
}
#[expect(clippy::too_many_lines)]
fn view(&'_ self) -> iced::Element<'_, Msg> {
use iced::{
Alignment, Element,
Length::Fill,
widget::{
Container, button, center, checkbox, column, container, horizontal_space,
keyed_column, row, scrollable, text, text_editor, text_input, tooltip,
},
};
if let Some(mass_edit_text) = self.mass_edit_text.as_ref() {
return scrollable(column![
text_editor(mass_edit_text).on_action(Msg::MassEditAction),
button("Apply").on_press(Msg::MassEditApply),
button("Cancel").on_press(Msg::MassEditStart(false)),
])
.into();
}
let maybe_add_new_table = if self.new_table_output.is_some()
&& ResolvedUrl::validate(self.new_table_text.as_str()).is_ok()
{
Some(Msg::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(Msg::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(),
Msg::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(|| {
Msg::FetchTables(
self.tables
.iter()
.filter(|t| t.wants_updating(self.now, TABLE_UPDATE_INTERVAL))
.map(fetch::TableLocator::new_for_table)
.collect::<Vec<_>>(),
)
})
.padding(10);
let update_tables = tooltip(
update_tables,
text(format!(
"Tables are updated once in {} hours",
TABLE_UPDATE_INTERVAL.0 / 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(Msg::ToggleAllTableRemoval(false))
.style(button::success)
.padding(10)
} else {
button(Container::new(text("Clear")).align_x(Alignment::End))
.on_press(Msg::ToggleAllTableRemoval(true))
.style(button::danger)
.padding(10)
};
let start_mass_edit = button(Container::new(text("Mass edit")).align_x(Alignment::End))
.on_press(Msg::MassEditStart(true))
.padding(10);
let save = button("Save song.db").on_press_maybe(
if self.tables.iter().any(|t| t.1.status == Status::Updating) {
None
} else {
Some(Msg::SaveDb)
},
);
let write_tags =
checkbox("Write tags", self.write_tags_on_save).on_toggle(Msg::ToggleTagUpdating);
let save_on_update_finish = checkbox("Save on update finish", self.save_on_update_finish)
.on_toggle(Msg::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()
}
#[expect(clippy::unused_self)]
const fn theme(&self) -> iced::Theme {
iced::Theme::Dark
}
}