//! Import, export and backups, described rather than built. //! //! //! //! A screen of its own rather than a fourth [`settings`](super::settings) //! section: what a modal wizard would do inline here is the screen. //! //! # The shape //! //! - `GET /data` — the screen. //! - `POST /data/import/{kind}/preview` — parse the picked file, change nothing. //! - `POST /data/import/{kind}` — do it. //! - `POST /data/export/{format}` — hand back a file. `{format}` is `json`, //! `tasks` or `calendar`. //! - `POST /data/backups/{name}/restore` — merge a backup back in. //! - `POST /data/backups/{name}/delete` — remove one. //! - `POST /data/backups/automatic` — the automatic-backup settings. //! //! `{kind}` is `csv`, `contacts` or `calendar`, which is the entity the file //! holds rather than its extension: the CSV importer detects task/project/event //! from the header itself, so the address cannot name what is in the file and //! does not pretend to. //! //! A backup is addressed by **file name**, never by an absolute path. The name //! is resolved against [`backup_dir`] here, so the only paths this screen can //! name are the ones inside it, and [`safe_name`] refuses anything with a //! separator in it before the resolution happens. `delete_backup_at`'s own //! canonicalisation check stays where it is: it guards the command as well, and //! a check moved up to one caller is a check the other caller lost. //! //! # Where the writes live //! //! Every write on this screen is a plain function the Tauri command calls too, //! because a `quasi_router` handler is `fn(&S, Request)` and cannot await: //! [`preview_csv_at`](crate::commands::import::preview_csv_at), //! [`execute_csv_at`](crate::commands::import::execute_csv_at), //! [`preview_vcf_at`](crate::commands::import_external::preview_vcf_at), //! [`import_vcf_at`](crate::commands::import_external::import_vcf_at), //! [`preview_ics_at`](crate::commands::import_external::preview_ics_at), //! [`import_ics_at`](crate::commands::import_external::import_ics_at), //! [`list_backups_in`](crate::commands::export::list_backups_in) and //! [`delete_backup_at`](crate::commands::export::delete_backup_at). //! //! Where a backup lives is [`backup_dir`](crate::backup_scheduler::backup_dir), //! off [`AppState::data_dir`](crate::state::AppState::data_dir): a host fact a //! described screen needs is a host fact the app puts in `S`. //! //! # Exports and long writes //! //! An export control never asks where the file goes. The route answers with the //! file and the host puts it somewhere; the description says the name and the //! kind and nothing else. See [`export_region`]. //! //! `create_backup` is the one genuinely async write here: the gzip write goes //! to the blocking pool because it takes seconds on a large database, and //! freezing the UI on it is a fixed performance finding (Perf S6). A handler is //! synchronous and has no runtime, so the hand-off is //! [`quasi_router::Outcome::Started`] and the offload stays here. See [`create`] //! for the route and [`crate::state::Offload`] for the app's half. //! //! [`backups_region`] is [`Slot::live`] because the scheduler writes automatic //! backups into the same directory, so the region re-asks on a cadence and a //! finished on-demand run is reported by [`listing`] on the next ask. //! //! # Two things to know before changing this screen //! //! **A preview and the write it precedes are two requests, and the file may //! change between them.** The preview parses the path and shows what it holds; //! the import re-reads the same path, which travels in a //! [`Hidden`](makeover_layout::FieldKind::Hidden) field. Re-reading is the right //! answer: a cached parse would answer for a file that is no longer there. //! //! **The duplicate strategy is only offered when it applies.** The question is //! meaningless with no duplicates, so the radio lives in the preview fragment //! rather than in the screen, and Merge is the default when the control is //! absent. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's and not a // choice made here. Same allow, for the same reason, as quasi-axum's tests. #![allow(clippy::needless_pass_by_value)] use goingson_core::ImportOptions; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Accepted, Choice}; use quasi_router::{Action, Node, Response, RouteError, Router}; use crate::backup_scheduler::backup_dir; use crate::commands::export::{ BackupInfoResponse, RestoreOptions, export_events_ics_bytes, export_json_bytes, export_tasks_csv_bytes, list_backups_in, }; use crate::commands::import_external::DuplicateStrategy; use crate::state::{AppState, BackupRun, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// How many rows of a parsed file the preview shows. /// /// 25, which is what all three shipped wizards slice to, each with its own copy /// of the number and its own "...and N more" line. const PREVIEW_ROWS: usize = 25; /// The region a preview lands in, and the one an import empties. const PREVIEW: &str = "data-preview"; /// The region holding the list of backups. const BACKUPS: &str = "data-backups"; /// The region holding the automatic-backup settings. const AUTOMATIC: &str = "data-automatic"; /// A file the user picked, as it arrived. /// /// The name is `file` because that is what a [`FieldKind::File`] submits under, /// which is the name the project dashboard's attach route already reads. Blank /// is what an untouched control sends and is refused rather than passed to the /// importer, which would answer "Failed to open file: No such file". fn picked(request: &quasi_router::Request) -> Result { let path = request.payload.get("file").unwrap_or_default().trim(); if path.is_empty() { return Err(RouteError::not_found("no file was picked")); } Ok(path.to_owned()) } /// The three kinds of file this screen imports. #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { /// Tasks, projects or events, whichever the header says. Csv, /// Contacts, from a vCard. Contacts, /// Events, from an iCalendar file. Calendar, } impl Kind { /// Every kind, in the order the import pane offers them. const EVERY: [Self; 3] = [Self::Csv, Self::Contacts, Self::Calendar]; /// The kind under this address segment, or 404. fn of(slug: &str) -> Result { match slug { "csv" => Ok(Self::Csv), "contacts" => Ok(Self::Contacts), "calendar" => Ok(Self::Calendar), _ => Err(RouteError::not_found("nothing imports that")), } } /// The segment it travels as. const fn slug(self) -> &'static str { match self { Self::Csv => "csv", Self::Contacts => "contacts", Self::Calendar => "calendar", } } /// What the field asks for. const fn label(self) -> &'static str { match self { Self::Csv => "CSV or TSV file", Self::Contacts => "vCard file", Self::Calendar => "iCalendar file", } } /// The extensions this import will take, for the host's file dialog. /// /// Carried as a parameter on the action rather than as /// [`Field::accept`](quasi_router::screen::Field::accept), because there is /// no field: the control is a host-performed act, and this is what the /// description tells the host about what it is asking for. const fn accept(self) -> &'static str { match self { Self::Csv => "csv,tsv", Self::Contacts => "vcf,vcard", Self::Calendar => "ics,ical", } } /// Standing help under the field, which is where the shipped wizard's /// paragraph of column names belongs once there is no modal to head. const fn hint(self) -> &'static str { match self { Self::Csv => { "Columns are matched by name: description, due, priority, project and tags for \ tasks; start and end for events; name and type for projects. The kind is read \ from the header." } Self::Contacts => "Cards already here are matched by email address.", Self::Calendar => "Events already here are matched by their UID.", } } /// The kind under the request's `{kind}` capture, or 404. fn from(request: &quasi_router::Request) -> Result { Self::of( request .captures .get("kind") .ok_or_else(|| RouteError::not_found("no kind"))?, ) } } declare! { /// One import's form: pick a file, see what is in it. /// /// The control is a host-performed act rather than a field, so what the /// host needs to open its dialog travels on the action: `accept` is the /// extensions and `name` is what to call them. shape import_form(kind: Kind) -> Node; region "data-import-{kind.slug()}" as Group { act "Choose a {kind.label()}" to post "/data/import/{kind.slug()}/preview" with "accept" kind.accept() with "name" kind.label() by_host awaiting; text kind.hint(); } } declare! { /// The import half of the screen. shape import_region() -> Slot; region "data-import" as Pane { section "Import"; text "Nothing is created until the preview is confirmed."; for kind in Kind::EVERY { include import_form(kind); } } } /// One value in a preview cell, shortened the way the shipped table shortens it. /// /// The shipped row puts the whole value in a `title=` and the first 50 /// characters in the cell. A description has no word for text that appears on /// hover -- and should not grow one, since hover is absent on a touch screen /// and on a keyboard -- so the same rule the problems port followed applies: /// the truncation stays and the tooltip does not come back as anything. fn short(value: &str) -> String { if value.chars().count() > 50 { let kept: String = value.chars().take(50).collect(); format!("{kept}...") } else { value.to_owned() } } /// The same, for a column whose value the file may have left out. fn short_or_blank(value: Option<&String>) -> String { value.map_or_else(String::new, |value| short(value)) } /// What every preview says, whichever kind it is. /// /// Hoisted so the description draws a parse rather than performing one. The /// path is here because it travels into the write in a hidden field: the two /// requests agree about which file without the screen holding state between /// them, and the write re-reads rather than trusting a cached parse. struct Previewed { /// Which import this is, which is where the confirm form posts. kind: Kind, /// The file, as the picker handed it over. path: String, /// What the file holds, all of it rather than the shown slice. total: usize, /// The first [`PREVIEW_ROWS`], parsed. shown: Vec, } impl Previewed { /// The heading: what is in the file, counted and named. fn counted(&self, singular: &str) -> String { if self.total == 1 { format!("1 {singular}") } else { format!("{} {singular}s", self.total) } } /// The submit button's words. fn commits(&self, singular: &str) -> String { format!("Import {}", self.counted(singular)) } /// Whether the table is showing less than the file holds. fn clipped(&self) -> bool { self.total > PREVIEW_ROWS } /// The line that says so. fn clipping(&self) -> String { format!("Showing the first {PREVIEW_ROWS} of {}.", self.total) } } declare! { /// The form that commits a previewed import. /// /// The path travels in a hidden field rather than in the address, so the /// two requests agree about which file without the screen holding state /// between them. See finding 3 for what that does and does not guarantee. /// /// `duplicates` is zero for every import but contacts, and the question is /// meaningless with none, which is finding 5: the radio is absent rather /// than answered for you, and Merge is the default when the control is not /// there. Its values are the words [`DuplicateStrategy`] deserialises from, /// so the control and the enum cannot drift apart. shape confirm_form(kind: Kind, path: &str, submit: &str, duplicates: usize) -> Node; form post "/data/import/{kind.slug()}" { submit submit; field Hidden "file" "File" { value path; } field Radio "duplicates" already_here(duplicates) when duplicates over 0 { option Choice::new( "merge", "Merge into the existing contact: fill blank fields, add new emails and \ phones, never overwrite" ); option Choice::new("skip", "Skip them"); option Choice::new("importAsNew", "Import them as new contacts"); value "merge"; hint "One choice for the whole import."; } } } /// What the duplicate question is called, which is a count. fn already_here(duplicates: usize) -> String { if duplicates == 1 { "1 contact is already here".to_owned() } else { format!("{duplicates} contacts are already here") } } declare! { /// The empty preview, which is what the screen opens with and what an /// import leaves behind. shape no_preview() -> Node; region PREVIEW as Pane { empty "Pick a file above to see what importing it would do."; } } /// A parsed CSV, as its preview draws it. /// /// Three lists where the file holds one kind, because a dispatch cannot bind /// what it matched: the description asks for the list its arm draws and the /// other two answer empty, which is R9 working rather than around it. The /// shipped `getColumnsForEntityType` keys into the item's camelCase `data` /// object; here the parse is already typed, so a column is a match arm rather /// than a string key that can miss. struct CsvPreview { /// The kind, the path and the counts every preview shares. file: Previewed<()>, /// Which of the three the header said, which is which table is drawn. entity: goingson_core::ImportEntityType, /// Tasks, against Description, Project, Priority and Due. tasks: Vec, /// Projects, against Name, Description, Type and Status. projects: Vec, /// Events, against Title, Start, End and Location. events: Vec, /// Rows the parse could not use, said after the table: they are about rows /// that will not arrive, which is only readable once it is clear what will. warnings: Vec, } impl CsvPreview { /// The word for one of them, which the heading and the button both count. const fn singular(&self) -> &'static str { match self.entity { goingson_core::ImportEntityType::Task => "task", goingson_core::ImportEntityType::Project => "project", goingson_core::ImportEntityType::Event => "event", } } } /// What a CSV file holds. /// /// Takes no state: the CSV preview is a parse and nothing else, and the project /// names a task row might resolve against are looked up by the write rather /// than by the dry run. fn csv_parse(path: &str) -> Result { use goingson_core::ImportItemData; let parsed = crate::commands::import::preview_csv_at(path, &ImportOptions::default()) .map_err(|error| RouteError::internal(error.to_string()))?; let total = parsed.items.len(); let mut preview = CsvPreview { file: Previewed { kind: Kind::Csv, path: path.to_owned(), total, shown: Vec::new(), }, entity: parsed.entity_type, tasks: Vec::new(), projects: Vec::new(), events: Vec::new(), warnings: parsed.warnings, }; for item in parsed.items.into_iter().take(PREVIEW_ROWS) { match item.data { ImportItemData::Task(task) => preview.tasks.push(task), ImportItemData::Project(project) => preview.projects.push(project), ImportItemData::Event(event) => preview.events.push(event), } } Ok(preview) } declare! { /// One task row of a CSV preview. shape csv_task(task: &goingson_core::ImportTaskData) -> Row; cells { cell at "Description" short(&task.description); cell at "Project" short_or_blank(task.project_name.as_ref()); cell at "Priority" short_or_blank(task.priority.as_ref()); cell at "Due" short_or_blank(task.due.as_ref()); } } declare! { /// One project row of a CSV preview. shape csv_project(project: &goingson_core::ImportProjectData) -> Row; cells { cell at "Name" short(&project.name); cell at "Description" short_or_blank(project.description.as_ref()); cell at "Type" short_or_blank(project.project_type.as_ref()); cell at "Status" short_or_blank(project.status.as_ref()); } } declare! { /// One event row of a CSV preview. shape csv_event(event: &goingson_core::ImportEventData) -> Row; cells { cell at "Title" short(&event.title); cell at "Start" short(&event.start); cell at "End" short_or_blank(event.end.as_ref()); cell at "Location" short_or_blank(event.location.as_ref()); } } declare! { /// What a CSV file holds, said back before anything is written. /// /// The column list and the row shape are conditional on the same entity /// type, and until the cells named their columns the two match arms had to /// agree on order with nothing checking that they did. Now the column list /// is the only thing that decides where a value lands, and the row only has /// to spell the heading. /// /// No `more` on any of the three tables, and that is a statement rather /// than an omission: a parsed file is not a page of a query. Every row is /// already in hand, and the 25 shown are a reading convenience rather than /// a window that could be widened. shape csv_preview(preview: &CsvPreview) -> Node; region PREVIEW as Pane { empty "No rows in that file." when preview.file.total is 0; section preview.file.counted(preview.singular()) when preview.file.total over 0; given preview.entity { goingson_core::ImportEntityType::Task -> table { column "Description"; column "Project"; column "Priority"; column "Due"; for task in preview.tasks.iter() { include csv_task(task); } } goingson_core::ImportEntityType::Project -> table { column "Name"; column "Description"; column "Type"; column "Status"; for project in preview.projects.iter() { include csv_project(project); } } otherwise -> table { column "Title"; column "Start"; column "End"; column "Location"; for event in preview.events.iter() { include csv_event(event); } } } text preview.file.clipping() when preview.file.clipped(); include confirm_form( preview.file.kind, &preview.file.path, &preview.file.commits(preview.singular()), 0 ) when preview.file.total over 0; for warning in preview.warnings.iter() { banner Tone::Warning warning; } } } /// A parsed vCard file, as its preview draws it. struct ContactsPreview { /// The kind, the path and the counts every preview shares. file: Previewed, /// How many cards match somebody already here, which is when the duplicate /// question applies. duplicates: usize, } /// What a vCard file holds. fn contacts_parse(state: &AppState, path: &str) -> Result { let cards = crate::commands::import_external::preview_vcf_at(state, path) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(ContactsPreview { duplicates: cards .iter() .filter(|card| card.duplicate_of.is_some()) .count(), file: Previewed { kind: Kind::Contacts, path: path.to_owned(), total: cards.len(), shown: cards.into_iter().take(PREVIEW_ROWS).collect(), }, }) } /// What the Status column says about a card that matched one already here. /// /// The shipped cell says "Already exists" and hides which contact it matched in /// a `title=`. The name is the useful half and it is a fact, so it is said. fn matching(card: &crate::commands::import_external::VCardPreview) -> String { card.duplicate_of .as_ref() .map_or_else(String::new, |existing| format!("Matches {existing}")) } declare! { /// One card of a vCard preview. /// /// Named rather than positional: a push says "after the others", which is /// only the Status column while the four before it are written in exactly /// this order. shape contact_row(card: &crate::commands::import_external::VCardPreview) -> Row; cells { cell at "Name" short(&card.display_name); cell at "Company" short_or_blank(card.company.as_ref()); cell at "Emails" "{card.email_count}"; cell at "Phones" "{card.phone_count}"; cell at "Status" matching(card); } } declare! { /// What a vCard file holds, said back before anything is written. shape contacts_preview(preview: &ContactsPreview) -> Node; region PREVIEW as Pane { empty "No contacts in that file." when preview.file.total is 0; section preview.file.counted("contact") when preview.file.total over 0; table { column "Name"; column "Company"; column "Emails"; column "Phones"; column "Status"; for card in preview.file.shown.iter() { include contact_row(card); } } text preview.file.clipping() when preview.file.clipped(); include confirm_form( preview.file.kind, &preview.file.path, &preview.file.commits("contact"), preview.duplicates ) when preview.file.total over 0; } } /// A parsed iCalendar file, as its preview draws it. type CalendarPreview = Previewed; /// What an iCalendar file holds. fn calendar_parse(path: &str) -> Result { let events = crate::commands::import_external::preview_ics_at(path) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Previewed { kind: Kind::Calendar, path: path.to_owned(), total: events.len(), shown: events.into_iter().take(PREVIEW_ROWS).collect(), }) } declare! { /// One event of an iCalendar preview. shape calendar_row(event: &crate::commands::import_external::IcsPreview) -> Row; cells { cell at "Title" short(&event.title); cell at "Start" short(&event.start_time); cell at "Location" short_or_blank(event.location.as_ref()); cell at "Repeats" short(&event.recurrence); } } declare! { /// What an iCalendar file holds, said back before anything is written. shape calendar_preview(preview: &CalendarPreview) -> Node; region PREVIEW as Pane { empty "No events in that file." when preview.total is 0; section preview.counted("event") when preview.total over 0; table { column "Title"; column "Start"; column "Location"; column "Repeats"; for event in preview.shown.iter() { include calendar_row(event); } } text preview.clipping() when preview.clipped(); include confirm_form( preview.kind, &preview.path, &preview.commits("event"), 0 ) when preview.total over 0; } } /// Parse the picked file and say what importing it would do. fn preview(state: &AppState, request: quasi_router::Request) -> Result { let kind = Kind::from(&request)?; let path = picked(&request)?; let node = match kind { Kind::Csv => csv_preview(&csv_parse(&path)?), Kind::Contacts => contacts_preview(&contacts_parse(state, &path)?), Kind::Calendar => calendar_preview(&calendar_parse(&path)?), }; Ok(Response::fragment(PREVIEW, node)) } /// Do the import the preview described. /// /// Answers the preview region, emptied, with the counts in a toast. The rows /// themselves land on other screens — tasks, contacts, the calendar — and a /// described route answers the region it happened in rather than reaching for /// theirs. fn import(state: &AppState, request: quasi_router::Request) -> Result { let kind = Kind::from(&request)?; let path = picked(&request)?; let (message, tone) = match kind { Kind::Csv => { let done = crate::commands::import::execute_csv_at( state, &path, &ImportOptions::default(), &[], ) .map_err(|error| RouteError::internal(error.to_string()))?; let tone = if done.failed_count > 0 { Tone::Warning } else { Tone::Success }; let message = if done.failed_count > 0 { format!( "Imported {}. {} failed.", done.imported_count, done.failed_count ) } else { format!("Imported {}.", done.imported_count) }; (message, tone) } Kind::Contacts => { let strategy = strategy(&request); let done = crate::commands::import_external::import_vcf_at(state, &path, strategy) .map_err(|error| RouteError::internal(error.to_string()))?; (result_message(&done), result_tone(&done)) } Kind::Calendar => { let done = crate::commands::import_external::import_ics_at(state, &path) .map_err(|error| RouteError::internal(error.to_string()))?; (result_message(&done), result_tone(&done)) } }; Ok(Response::fragment(PREVIEW, no_preview()).toast(tone, message)) } /// What to do with cards that are already here. /// /// Merge when the control was not offered, which is what /// `selectedDuplicateStrategy` answers for the same reason: the question is only /// asked when there are duplicates. An unrecognised word is the same Merge /// rather than a refusal, matching `DuplicateStrategy::default`, since every /// value that can arrive here was put on the control by this screen. fn strategy(request: &quasi_router::Request) -> DuplicateStrategy { match request.payload.get("duplicates") { Some("skip") => DuplicateStrategy::Skip, Some("importAsNew") => DuplicateStrategy::ImportAsNew, _ => DuplicateStrategy::Merge, } } /// The sentence an external import answers with. fn result_message(done: &crate::commands::import_external::ImportResult) -> String { let mut parts = Vec::new(); if done.imported > 0 { parts.push(format!("{} imported", done.imported)); } if done.merged > 0 { parts.push(format!("{} merged", done.merged)); } if done.skipped > 0 { parts.push(format!("{} already here", done.skipped)); } if !done.errors.is_empty() { parts.push(format!("{} failed", done.errors.len())); } if parts.is_empty() { return "Nothing to import.".to_owned(); } format!("{}.", parts.join(", ")) } /// Whether an external import went cleanly. fn result_tone(done: &crate::commands::import_external::ImportResult) -> Tone { if done.errors.is_empty() { Tone::Success } else { Tone::Warning } } /// A backup file name that names a file in the backup directory and nothing /// else. /// /// A path separator, a parent component or a name that is not a backup is a 404 /// rather than a refusal with a reason, for the reason the settings port gives: /// every name this screen can send was put on a control by this screen, so /// anything else is a hand-typed request rather than a user's mistake. fn safe_name(request: &quasi_router::Request) -> Result { let name = request .captures .get("name") .ok_or_else(|| RouteError::not_found("no backup"))?; let bad = name.contains('/') || name.contains('\\') || name.contains(".."); if bad || !name.ends_with(".json.gz") { return Err(RouteError::not_found("not a backup")); } Ok(name.to_owned()) } /// How big a backup is, in the units the shipped list uses. fn size(bytes: u64) -> String { const STEP: f64 = 1024.0; let units = ["bytes", "KB", "MB", "GB"]; if bytes == 0 { return "0 bytes".to_owned(); } let mut value = bytes as f64; let mut unit = 0; while value >= STEP && unit < units.len() - 1 { value /= STEP; unit += 1; } if unit == 0 { format!("{bytes} bytes") } else { format!("{value:.1} {}", units[unit]) } } /// When a backup was taken, as a date rather than as a moment. /// /// The shipped list calls `toLocaleDateString` plus `toLocaleTimeString` in the /// browser's zone. A handler has no browser and no zone, so it says the instant /// it stored: UTC, spelled out. Naming the zone is the honest half — a bare /// "14:05" that is secretly UTC is worse than either answer. fn taken_at(created_at: i64) -> String { chrono::DateTime::from_timestamp(created_at, 0).map_or_else( || "date unknown".to_owned(), |at| at.format("%Y-%m-%d %H:%M UTC").to_string(), ) } declare! { /// One backup, and what can be done to it. shape backup_row(backup: &BackupInfoResponse) -> Row; row &backup.file_name { meta "{taken_at(backup.created_at)} · {size(backup.size_bytes)}"; // What `confirmDelete` asks in the shipped modal, said by the // description instead of by a JS helper at the call site. act "Restore" to post "/data/backups/{backup.file_name}/restore" { confirm "Restore from this backup? Anything already here with the same id is \ left alone."; } act "Delete" to post "/data/backups/{backup.file_name}/delete" { tone Danger; confirm "Delete this backup? That cannot be undone."; } } } /// What the backups region draws: the files on disk, and whether a run is /// still going. struct Backups { /// What is on disk, in the order the command lists it. found: Vec, /// Whether an on-demand run has been handed off and not come back. /// /// Read rather than taken: the outcome of a finished run is the asking /// handler's to say, once, as a toast. What belongs in the region is only /// the fact that one is still going. running: bool, } /// The backups on disk. fn backups(state: &AppState) -> Result { Ok(Backups { found: list_backups_in(state).map_err(|error| RouteError::internal(error.to_string()))?, running: state.backup_running(), }) } declare! { /// The backups half of the screen. /// /// Live, and honestly so: this directory gains files without anybody /// pressing anything, because the scheduler writes automatic backups into /// it. That was already true before "Create Backup" was describable and is /// the reason the region can carry a started answer at all -- the cadence /// exists for the automatic half, and the on-demand half rides it. /// /// The started answer depends on this. `quasi_http` retargets the region /// and swaps its contents, deliberately sending no cadence of its own: the /// `hx-trigger` has to already be on the element, put there by this render. /// Dropping `live` here would leave a "Creating backup" stand-in /// standing forever, so the two move together. The stand-in is `underway` /// and not the region's own readiness for the same reason: a host that /// swaps markup has nowhere to put an attribute. shape backups_region(backups: &Backups) -> Slot; region BACKUPS as Pane { fed_by Action::get("/data/backups"); live; section "Backups"; act "Create Backup" to post "/data/backups/create"; underway "Creating backup…" when backups.running; include backup_list(backups); } } declare! { /// What is on disk, or a line saying nothing is. /// /// Its own shape because a restore and a delete answer with this and not /// with the whole region: the cadence and the Create control are already on /// screen, and re-sending them would replace the element that carries the /// trigger. shape backup_list(backups: &Backups) -> Node; given backups.found.is_empty() { true -> empty "No backups yet. Automatic backups start once they are enabled below."; otherwise -> list { for backup in backups.found.iter() { include backup_row(backup); } } } } /// The backups region on its own, which is what the live cadence asks for. /// /// Also where a finished on-demand run is reported: the region that re-asks is /// the region that was waiting, so the answer it gets is the natural place to /// say how it went. Taken and not read, so it is said once. fn listing(state: &AppState, _request: quasi_router::Request) -> Result { let finished = state.take_finished_backup(); let answer = Response::fragment(BACKUPS, Node::Region(backups_region(&backups(state)?))); Ok(match finished { Some((true, said)) => answer.toast(Tone::Success, said), Some((false, said)) => answer.toast(Tone::Danger, said), None => answer, }) } /// Start a backup, and answer that it started. /// /// quasicoherent `dc2f2b46`. The one write on this screen that genuinely takes /// seconds: the gzip goes to the blocking pool because doing it inline froze /// the UI (Perf S6). The handler stays synchronous — it hands the work to the /// app's own runtime through [`crate::state::Offload`] and answers /// [`quasi_router::Outcome::Started`], which says "this began" rather than /// "this is done" or, as before, saying nothing because the control was absent. /// /// Refuses a second run while one is going. Two concurrent full backups are two /// gzip streams over the same database for no benefit, and the filename is /// collision-safe rather than idempotent, so the second would land as its own /// file. fn create(state: &AppState, _request: quasi_router::Request) -> Result { if state.backup_running() { return Ok( Response::fragment(BACKUPS, Node::Region(backups_region(&backups(state)?))) .toast(Tone::Warning, "A backup is already being written."), ); } let Some(offload) = state.offload.get() else { // No runtime was installed, which is a host that never called // `install_offload`. Said rather than swallowed: the alternative is a // button that reports success and does nothing. return Err(RouteError::internal( "this host cannot run a backup in the background", )); }; state.set_backup_run(BackupRun::Running); let handed_off = offload.run(|state| async move { let outcome = crate::backup_scheduler::create_backup_now(&state).await; state.set_backup_run(match outcome { Ok(done) => BackupRun::Finished { ok: true, said: format!( "Backup created: {}.", std::path::Path::new(&done.file_path) .file_name() .map_or_else( || done.file_path.clone(), |name| name.to_string_lossy().into_owned() ) ), }, Err(error) => BackupRun::Finished { ok: false, said: format!("Backup failed: {error}"), }, }); }); if !handed_off { // The state is already going away, so nothing will run and nothing will // report. Put the record back rather than leaving a run marked in // flight that no longer exists. state.set_backup_run(BackupRun::Idle); return Err(RouteError::internal("the app is shutting down")); } Ok(Response::started(BACKUPS, "Creating backup…")) } /// How often automatic backups are taken, and how many are kept. /// /// Both lists are the shipped ones verbatim, including which value is marked /// recommended, because they are the app's answer rather than this screen's. fn frequency_choices() -> Vec { vec![ Choice::new("15", "Every 15 minutes (recommended)"), Choice::new("30", "Every 30 minutes"), Choice::new("60", "Every hour"), Choice::new("360", "Every 6 hours"), Choice::new("1440", "Daily"), ] } /// How many generations are kept. fn retention_choices() -> Vec { vec![ Choice::new("1", "Keep 1 backup (recommended)"), Choice::new("3", "Keep 3 backups"), Choice::new("7", "Keep 7 backups"), Choice::new("14", "Keep 14 backups"), Choice::new("0", "Keep all backups"), ] } /// The automatic-backup settings, read once for the region that draws them. /// /// Absent settings are the defaults rather than an error: a device that has /// never opened this screen has no row, and "on, every 15 minutes, keep 1" is /// what the scheduler does in that case. struct Automatic { /// Whether backups are taken on a schedule. enabled: bool, /// Minutes between runs. frequency: i32, /// How many generations are kept. retention: i32, /// What the region says about the last run, already in words. last: String, } /// Those settings, off the store. fn automatic(state: &AppState) -> Result { let settings = state .backup_settings .get(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Automatic { enabled: settings.as_ref().is_none_or(|s| s.auto_backup_enabled), frequency: settings.as_ref().map_or(15, |s| s.backup_frequency_minutes), retention: settings.as_ref().map_or(1, |s| s.max_backups_to_keep), last: settings .as_ref() .and_then(|s| s.last_backup_at) .map_or_else( || "No backups yet.".to_owned(), |at| format!("Last backup {}.", at.format("%Y-%m-%d %H:%M UTC")), ), }) } declare! { /// The automatic-backup settings. /// /// A form rather than the bare controls the [`settings`](super::settings) /// screen uses, because these three are answered together behind one Save /// button, which is what a form is. /// /// Frequency and retention are `extended`, so a renderer may put them /// behind a disclosure. The description carries no "open it when this is /// not the default", so a customised value can be hidden. A disclosure that /// hides a setting somebody deliberately changed is the failure, and it is /// the renderer that would have to know. shape automatic_region(automatic: &Automatic) -> Slot; region AUTOMATIC as Pane { section "Automatic backups"; form post "/data/backups/automatic" { submit "Save"; field Checkbox "enabled" "Take backups automatically" { hint "Compressed snapshots on a schedule, kept in the app's own directory."; // Present is ticked, which is how a checkbox submits and how // the vocabulary reads one back. value "on" when automatic.enabled; } field Select "frequency" "How often" { options frequency_choices(); value "{automatic.frequency}"; extended; } field Select "retention" "How many to keep" { options retention_choices(); value "{automatic.retention}"; hint "Older backups are deleted to save space. Three are always kept."; extended; } } text &automatic.last; } } /// The three exports this screen offers. /// /// One entry per shipped button in `export.js`, and the two facts each one /// carried are here rather than in a save dialog: `defaultPath` is /// [`Export::stem`] and `filters` is [`Export::kind`]. Neither is a path, which /// is why the ruling could put the whole control in the description. #[derive(Clone, Copy, PartialEq, Eq)] enum Export { /// Everything, as JSON. Json, /// Tasks, as CSV. Tasks, /// Events, as iCalendar. Calendar, } impl Export { /// Every export, in the order the pane offers them. const EVERY: [Self; 3] = [Self::Json, Self::Tasks, Self::Calendar]; /// The export under this address segment, or 404. fn of(slug: &str) -> Result { match slug { "json" => Ok(Self::Json), "tasks" => Ok(Self::Tasks), "calendar" => Ok(Self::Calendar), _ => Err(RouteError::not_found("nothing exports that")), } } /// The export under the request's `{format}` capture, or 404. fn from(request: &quasi_router::Request) -> Result { Self::of( request .captures .get("format") .ok_or_else(|| RouteError::not_found("no format"))?, ) } /// The segment it travels as. const fn slug(self) -> &'static str { match self { Self::Json => "json", Self::Tasks => "tasks", Self::Calendar => "calendar", } } /// The button's words. const fn label(self) -> &'static str { match self { Self::Json => "Export All (JSON)", Self::Tasks => "Export Tasks (CSV)", Self::Calendar => "Export Calendar (ICS)", } } /// The middle of the suggested file name: `goingson--.`. const fn stem(self) -> &'static str { match self { Self::Json => "export", Self::Tasks => "tasks", Self::Calendar => "calendar", } } /// What kind of file it is. /// /// A media type rather than a suffix, because a media type is the one /// spelling a host can put on the wire and a suffix says nothing about what /// is inside. The suffix is in the name already. fn kind(self) -> Accepted { Accepted::media_type(match self { Self::Json => "application/json", Self::Tasks => "text/csv", Self::Calendar => "text/calendar", }) } /// The file's suffix, with its leading dot. const fn suffix(self) -> &'static str { match self { Self::Json => ".json", Self::Tasks => ".csv", Self::Calendar => ".ics", } } /// The suggested file name, dated from `Local`. /// /// A suggestion only: the host may put a different name on it, and nothing /// here depends on the one it chose. fn file_name(self) -> String { format!( "goingson-{}-{}{}", self.stem(), chrono::Local::now().format("%Y-%m-%d"), self.suffix() ) } /// The sentence the toast says. fn said(self, count: usize) -> String { match self { Self::Json => format!("Exported {count} items to JSON"), Self::Tasks => format!("Exported {count} tasks to CSV"), Self::Calendar => format!("Exported {count} events to ICS"), } } } declare! { /// The export half of the screen. /// /// Three controls that never ask where a file goes: the route answers with /// the file and the host decides where it lands, so a Tauri window opens a /// save dialog, a browser downloads and a terminal writes beside the /// process, and none of that is in the description. /// /// Create Backup is next to them, and its gzip write is handed off rather /// than waited on; see [`create`]. shape export_region() -> Slot; region "data-export" as Pane { section "Export"; text "Nothing here is removed by exporting it. Where the file lands is up to this \ machine."; for export in Export::EVERY { act export.label() to post "/data/export/{export.slug()}"; } } } /// Hand one export over as a file. /// /// The screen the button was pressed on is the screen that stays: the answer is /// a file rather than a fragment, so nothing is replaced and the toast is the /// only thing that changes. fn export(state: &AppState, request: quasi_router::Request) -> Result { let export = Export::from(&request)?; let done = match export { Export::Json => export_json_bytes(state), // No project filter and no past/future toggle, because the shipped // buttons offer neither: both commands take the argument and `export.js` // has never sent one. A control for either is a feature rather than a // port, and this screen is a port. Export::Tasks => export_tasks_csv_bytes(state, None), Export::Calendar => export_events_ics_bytes(state, true), } .map_err(|error| RouteError::internal(error.to_string()))?; let said = export.said(done.item_count); Ok(Response::file(export.file_name(), export.kind(), done.bytes).toast(Tone::Success, said)) } declare! { /// The whole screen. /// /// Reached from the settings sidebar, which is how a person gets here, so /// Settings is the place that stays lit. See `settings::Section::at`. shape screen(backups: &Backups, automatic: &Automatic) -> Screen; screen list_detail "Import & Export" false { at_place super::shell::SETTINGS; region "data-band" as Band { page "Import & Export"; } include import_region(); region PREVIEW as Pane { include no_preview(); } include export_region(); include backups_region(backups); include automatic_region(automatic); } } /// The screen. fn index(state: &AppState, _request: quasi_router::Request) -> Result { Ok(screen(&backups(state)?, &automatic(state)?).into()) } /// Merge a backup back in. /// /// Merge and not replace: `replace_all` is refused by the command it would call /// and has been since it was written, so a described control offering it would /// be a control that errors. The confirmation says which of the two this is. fn restore(state: &AppState, request: quasi_router::Request) -> Result { let name = safe_name(&request)?; let path = backup_dir(state).join(&name); if !path.exists() { return Err(RouteError::not_found("no such backup")); } let done = crate::commands::export::restore_backup_from( state, &path.to_string_lossy(), &RestoreOptions { replace_all: false }, ) .map_err(|error| RouteError::internal(error.to_string()))?; let total = done.projects_restored + done.tasks_restored + done.events_restored + done.emails_restored + done.contacts_restored; Ok(Response::fragment(BACKUPS, backup_list(&backups(state)?)) .toast(Tone::Success, format!("Restored {total} from {name}."))) } /// Remove one backup. fn delete(state: &AppState, request: quasi_router::Request) -> Result { let name = safe_name(&request)?; let path = backup_dir(state).join(&name); let removed = crate::commands::export::delete_backup_at(state, &path.to_string_lossy()) .map_err(|error| RouteError::internal(error.to_string()))?; // Answered with the list re-read either way: a backup that was already gone // leaves a row on screen that is not there, and the list is the correction. Ok( Response::fragment(BACKUPS, backup_list(&backups(state)?)).toast( if removed { Tone::Success } else { Tone::Warning }, if removed { format!("Deleted {name}.") } else { format!("{name} was already gone.") }, ), ) } /// Save the automatic-backup settings. /// /// The clamping stays in the command's own write path, which is where the /// reason for it lives (a non-positive frequency backs up on every scheduler /// tick, a negative retention prunes nothing). A value this screen cannot send /// is still refused there, which is the same arrangement the settings screen has /// with its closed key set. fn save_automatic( state: &AppState, request: quasi_router::Request, ) -> Result { let number = |name: &str, fallback: i32| { request .payload .get(name) .and_then(|value| value.parse::().ok()) .unwrap_or(fallback) }; crate::commands::export::save_backup_settings_for( state, &crate::commands::export::BackupSettingsInput { // A checkbox submits nothing when it is not ticked, which is the // whole of how "off" arrives. auto_backup_enabled: request.payload.get("enabled").is_some(), backup_frequency_minutes: number("frequency", 15), max_backups_to_keep: number("retention", 1), }, ) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Response::fragment( AUTOMATIC, Node::Region(automatic_region(&automatic(state)?)), ) .toast(Tone::Success, "Backup settings saved.")) } /// The import and export screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/data", index) .post("/data/import/{kind}/preview", preview) .post("/data/import/{kind}", import) .post("/data/export/{format}", export) .get("/data/backups", listing) .post("/data/backups/create", create) .post("/data/backups/{name}/restore", restore) .post("/data/backups/{name}/delete", delete) .post("/data/backups/automatic", save_automatic) }