Skip to main content

max / goingson

50.6 KB · 1417 lines History Blame Raw
1 //! Import, export and backups, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A screen of its own rather than a fourth [`settings`](super::settings)
6 //! section: what a modal wizard would do inline here is the screen.
7 //!
8 //! # The shape
9 //!
10 //! - `GET /data` — the screen.
11 //! - `POST /data/import/{kind}/preview` — parse the picked file, change nothing.
12 //! - `POST /data/import/{kind}` — do it.
13 //! - `POST /data/export/{format}` — hand back a file. `{format}` is `json`,
14 //! `tasks` or `calendar`.
15 //! - `POST /data/backups/{name}/restore` — merge a backup back in.
16 //! - `POST /data/backups/{name}/delete` — remove one.
17 //! - `POST /data/backups/automatic` — the automatic-backup settings.
18 //!
19 //! `{kind}` is `csv`, `contacts` or `calendar`, which is the entity the file
20 //! holds rather than its extension: the CSV importer detects task/project/event
21 //! from the header itself, so the address cannot name what is in the file and
22 //! does not pretend to.
23 //!
24 //! A backup is addressed by **file name**, never by an absolute path. The name
25 //! is resolved against [`backup_dir`] here, so the only paths this screen can
26 //! name are the ones inside it, and [`safe_name`] refuses anything with a
27 //! separator in it before the resolution happens. `delete_backup_at`'s own
28 //! canonicalisation check stays where it is: it guards the command as well, and
29 //! a check moved up to one caller is a check the other caller lost.
30 //!
31 //! # Where the writes live
32 //!
33 //! Every write on this screen is a plain function the Tauri command calls too,
34 //! because a `quasi_router` handler is `fn(&S, Request)` and cannot await:
35 //! [`preview_csv_at`](crate::commands::import::preview_csv_at),
36 //! [`execute_csv_at`](crate::commands::import::execute_csv_at),
37 //! [`preview_vcf_at`](crate::commands::import_external::preview_vcf_at),
38 //! [`import_vcf_at`](crate::commands::import_external::import_vcf_at),
39 //! [`preview_ics_at`](crate::commands::import_external::preview_ics_at),
40 //! [`import_ics_at`](crate::commands::import_external::import_ics_at),
41 //! [`list_backups_in`](crate::commands::export::list_backups_in) and
42 //! [`delete_backup_at`](crate::commands::export::delete_backup_at).
43 //!
44 //! Where a backup lives is [`backup_dir`](crate::backup_scheduler::backup_dir),
45 //! off [`AppState::data_dir`](crate::state::AppState::data_dir): a host fact a
46 //! described screen needs is a host fact the app puts in `S`.
47 //!
48 //! # Exports and long writes
49 //!
50 //! An export control never asks where the file goes. The route answers with the
51 //! file and the host puts it somewhere; the description says the name and the
52 //! kind and nothing else. See [`export_region`].
53 //!
54 //! `create_backup` is the one genuinely async write here: the gzip write goes
55 //! to the blocking pool because it takes seconds on a large database, and
56 //! freezing the UI on it is a fixed performance finding (Perf S6). A handler is
57 //! synchronous and has no runtime, so the hand-off is
58 //! [`quasi_router::Outcome::Started`] and the offload stays here. See [`create`]
59 //! for the route and [`crate::state::Offload`] for the app's half.
60 //!
61 //! [`backups_region`] is [`Slot::live`] because the scheduler writes automatic
62 //! backups into the same directory, so the region re-asks on a cadence and a
63 //! finished on-demand run is reported by [`listing`] on the next ask.
64 //!
65 //! # Two things to know before changing this screen
66 //!
67 //! **A preview and the write it precedes are two requests, and the file may
68 //! change between them.** The preview parses the path and shows what it holds;
69 //! the import re-reads the same path, which travels in a
70 //! [`Hidden`](makeover_layout::FieldKind::Hidden) field. Re-reading is the right
71 //! answer: a cached parse would answer for a file that is no longer there.
72 //!
73 //! **The duplicate strategy is only offered when it applies.** The question is
74 //! meaningless with no duplicates, so the radio lives in the preview fragment
75 //! rather than in the screen, and Merge is the default when the control is
76 //! absent.
77
78 // Handlers take their request by value because `quasi_router::Handler` is a
79 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
80 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
81 #![allow(clippy::needless_pass_by_value)]
82
83 use goingson_core::ImportOptions;
84 use makeover_layout::Tone;
85 use quasi_declare::declare;
86 use quasi_router::screen::{Accepted, Choice};
87 use quasi_router::{Action, Node, Response, RouteError, Router};
88
89 use crate::backup_scheduler::backup_dir;
90 use crate::commands::export::{
91 BackupInfoResponse, RestoreOptions, export_events_ics_bytes, export_json_bytes,
92 export_tasks_csv_bytes, list_backups_in,
93 };
94 use crate::commands::import_external::DuplicateStrategy;
95 use crate::state::{AppState, BackupRun, DESKTOP_USER_ID};
96
97 #[cfg(test)]
98 mod tests;
99
100 /// How many rows of a parsed file the preview shows.
101 ///
102 /// 25, which is what all three shipped wizards slice to, each with its own copy
103 /// of the number and its own "...and N more" line.
104 const PREVIEW_ROWS: usize = 25;
105
106 /// The region a preview lands in, and the one an import empties.
107 const PREVIEW: &str = "data-preview";
108
109 /// The region holding the list of backups.
110 const BACKUPS: &str = "data-backups";
111
112 /// The region holding the automatic-backup settings.
113 const AUTOMATIC: &str = "data-automatic";
114
115 /// A file the user picked, as it arrived.
116 ///
117 /// The name is `file` because that is what a [`FieldKind::File`] submits under,
118 /// which is the name the project dashboard's attach route already reads. Blank
119 /// is what an untouched control sends and is refused rather than passed to the
120 /// importer, which would answer "Failed to open file: No such file".
121 fn picked(request: &quasi_router::Request) -> Result<String, RouteError> {
122 let path = request.payload.get("file").unwrap_or_default().trim();
123 if path.is_empty() {
124 return Err(RouteError::not_found("no file was picked"));
125 }
126 Ok(path.to_owned())
127 }
128
129 /// The three kinds of file this screen imports.
130 #[derive(Clone, Copy, PartialEq, Eq)]
131 enum Kind {
132 /// Tasks, projects or events, whichever the header says.
133 Csv,
134 /// Contacts, from a vCard.
135 Contacts,
136 /// Events, from an iCalendar file.
137 Calendar,
138 }
139
140 impl Kind {
141 /// Every kind, in the order the import pane offers them.
142 const EVERY: [Self; 3] = [Self::Csv, Self::Contacts, Self::Calendar];
143
144 /// The kind under this address segment, or 404.
145 fn of(slug: &str) -> Result<Self, RouteError> {
146 match slug {
147 "csv" => Ok(Self::Csv),
148 "contacts" => Ok(Self::Contacts),
149 "calendar" => Ok(Self::Calendar),
150 _ => Err(RouteError::not_found("nothing imports that")),
151 }
152 }
153
154 /// The segment it travels as.
155 const fn slug(self) -> &'static str {
156 match self {
157 Self::Csv => "csv",
158 Self::Contacts => "contacts",
159 Self::Calendar => "calendar",
160 }
161 }
162
163 /// What the field asks for.
164 const fn label(self) -> &'static str {
165 match self {
166 Self::Csv => "CSV or TSV file",
167 Self::Contacts => "vCard file",
168 Self::Calendar => "iCalendar file",
169 }
170 }
171
172 /// The extensions this import will take, for the host's file dialog.
173 ///
174 /// Carried as a parameter on the action rather than as
175 /// [`Field::accept`](quasi_router::screen::Field::accept), because there is
176 /// no field: the control is a host-performed act, and this is what the
177 /// description tells the host about what it is asking for.
178 const fn accept(self) -> &'static str {
179 match self {
180 Self::Csv => "csv,tsv",
181 Self::Contacts => "vcf,vcard",
182 Self::Calendar => "ics,ical",
183 }
184 }
185
186 /// Standing help under the field, which is where the shipped wizard's
187 /// paragraph of column names belongs once there is no modal to head.
188 const fn hint(self) -> &'static str {
189 match self {
190 Self::Csv => {
191 "Columns are matched by name: description, due, priority, project and tags for \
192 tasks; start and end for events; name and type for projects. The kind is read \
193 from the header."
194 }
195 Self::Contacts => "Cards already here are matched by email address.",
196 Self::Calendar => "Events already here are matched by their UID.",
197 }
198 }
199
200 /// The kind under the request's `{kind}` capture, or 404.
201 fn from(request: &quasi_router::Request) -> Result<Self, RouteError> {
202 Self::of(
203 request
204 .captures
205 .get("kind")
206 .ok_or_else(|| RouteError::not_found("no kind"))?,
207 )
208 }
209 }
210
211 declare! {
212 /// One import's form: pick a file, see what is in it.
213 ///
214 /// The control is a host-performed act rather than a field, so what the
215 /// host needs to open its dialog travels on the action: `accept` is the
216 /// extensions and `name` is what to call them.
217 shape import_form(kind: Kind) -> Node;
218
219 region "data-import-{kind.slug()}" as Group {
220 act "Choose a {kind.label()}"
221 to post "/data/import/{kind.slug()}/preview"
222 with "accept" kind.accept()
223 with "name" kind.label()
224 by_host awaiting;
225
226 text kind.hint();
227 }
228 }
229
230 declare! {
231 /// The import half of the screen.
232 shape import_region() -> Slot;
233
234 region "data-import" as Pane {
235 section "Import";
236 text "Nothing is created until the preview is confirmed.";
237
238 for kind in Kind::EVERY {
239 include import_form(kind);
240 }
241 }
242 }
243
244 /// One value in a preview cell, shortened the way the shipped table shortens it.
245 ///
246 /// The shipped row puts the whole value in a `title=` and the first 50
247 /// characters in the cell. A description has no word for text that appears on
248 /// hover -- and should not grow one, since hover is absent on a touch screen
249 /// and on a keyboard -- so the same rule the problems port followed applies:
250 /// the truncation stays and the tooltip does not come back as anything.
251 fn short(value: &str) -> String {
252 if value.chars().count() > 50 {
253 let kept: String = value.chars().take(50).collect();
254 format!("{kept}...")
255 } else {
256 value.to_owned()
257 }
258 }
259
260 /// The same, for a column whose value the file may have left out.
261 fn short_or_blank(value: Option<&String>) -> String {
262 value.map_or_else(String::new, |value| short(value))
263 }
264
265 /// What every preview says, whichever kind it is.
266 ///
267 /// Hoisted so the description draws a parse rather than performing one. The
268 /// path is here because it travels into the write in a hidden field: the two
269 /// requests agree about which file without the screen holding state between
270 /// them, and the write re-reads rather than trusting a cached parse.
271 struct Previewed<T> {
272 /// Which import this is, which is where the confirm form posts.
273 kind: Kind,
274 /// The file, as the picker handed it over.
275 path: String,
276 /// What the file holds, all of it rather than the shown slice.
277 total: usize,
278 /// The first [`PREVIEW_ROWS`], parsed.
279 shown: Vec<T>,
280 }
281
282 impl<T> Previewed<T> {
283 /// The heading: what is in the file, counted and named.
284 fn counted(&self, singular: &str) -> String {
285 if self.total == 1 {
286 format!("1 {singular}")
287 } else {
288 format!("{} {singular}s", self.total)
289 }
290 }
291
292 /// The submit button's words.
293 fn commits(&self, singular: &str) -> String {
294 format!("Import {}", self.counted(singular))
295 }
296
297 /// Whether the table is showing less than the file holds.
298 fn clipped(&self) -> bool {
299 self.total > PREVIEW_ROWS
300 }
301
302 /// The line that says so.
303 fn clipping(&self) -> String {
304 format!("Showing the first {PREVIEW_ROWS} of {}.", self.total)
305 }
306 }
307
308 declare! {
309 /// The form that commits a previewed import.
310 ///
311 /// The path travels in a hidden field rather than in the address, so the
312 /// two requests agree about which file without the screen holding state
313 /// between them. See finding 3 for what that does and does not guarantee.
314 ///
315 /// `duplicates` is zero for every import but contacts, and the question is
316 /// meaningless with none, which is finding 5: the radio is absent rather
317 /// than answered for you, and Merge is the default when the control is not
318 /// there. Its values are the words [`DuplicateStrategy`] deserialises from,
319 /// so the control and the enum cannot drift apart.
320 shape confirm_form(kind: Kind, path: &str, submit: &str, duplicates: usize) -> Node;
321
322 form post "/data/import/{kind.slug()}" {
323 submit submit;
324
325 field Hidden "file" "File" {
326 value path;
327 }
328
329 field Radio "duplicates" already_here(duplicates) when duplicates over 0 {
330 option Choice::new(
331 "merge",
332 "Merge into the existing contact: fill blank fields, add new emails and \
333 phones, never overwrite"
334 );
335 option Choice::new("skip", "Skip them");
336 option Choice::new("importAsNew", "Import them as new contacts");
337 value "merge";
338 hint "One choice for the whole import.";
339 }
340 }
341 }
342
343 /// What the duplicate question is called, which is a count.
344 fn already_here(duplicates: usize) -> String {
345 if duplicates == 1 {
346 "1 contact is already here".to_owned()
347 } else {
348 format!("{duplicates} contacts are already here")
349 }
350 }
351
352 declare! {
353 /// The empty preview, which is what the screen opens with and what an
354 /// import leaves behind.
355 shape no_preview() -> Node;
356
357 region PREVIEW as Pane {
358 empty "Pick a file above to see what importing it would do.";
359 }
360 }
361
362 /// A parsed CSV, as its preview draws it.
363 ///
364 /// Three lists where the file holds one kind, because a dispatch cannot bind
365 /// what it matched: the description asks for the list its arm draws and the
366 /// other two answer empty, which is R9 working rather than around it. The
367 /// shipped `getColumnsForEntityType` keys into the item's camelCase `data`
368 /// object; here the parse is already typed, so a column is a match arm rather
369 /// than a string key that can miss.
370 struct CsvPreview {
371 /// The kind, the path and the counts every preview shares.
372 file: Previewed<()>,
373 /// Which of the three the header said, which is which table is drawn.
374 entity: goingson_core::ImportEntityType,
375 /// Tasks, against Description, Project, Priority and Due.
376 tasks: Vec<goingson_core::ImportTaskData>,
377 /// Projects, against Name, Description, Type and Status.
378 projects: Vec<goingson_core::ImportProjectData>,
379 /// Events, against Title, Start, End and Location.
380 events: Vec<goingson_core::ImportEventData>,
381 /// Rows the parse could not use, said after the table: they are about rows
382 /// that will not arrive, which is only readable once it is clear what will.
383 warnings: Vec<String>,
384 }
385
386 impl CsvPreview {
387 /// The word for one of them, which the heading and the button both count.
388 const fn singular(&self) -> &'static str {
389 match self.entity {
390 goingson_core::ImportEntityType::Task => "task",
391 goingson_core::ImportEntityType::Project => "project",
392 goingson_core::ImportEntityType::Event => "event",
393 }
394 }
395 }
396
397 /// What a CSV file holds.
398 ///
399 /// Takes no state: the CSV preview is a parse and nothing else, and the project
400 /// names a task row might resolve against are looked up by the write rather
401 /// than by the dry run.
402 fn csv_parse(path: &str) -> Result<CsvPreview, RouteError> {
403 use goingson_core::ImportItemData;
404
405 let parsed = crate::commands::import::preview_csv_at(path, &ImportOptions::default())
406 .map_err(|error| RouteError::internal(error.to_string()))?;
407
408 let total = parsed.items.len();
409 let mut preview = CsvPreview {
410 file: Previewed {
411 kind: Kind::Csv,
412 path: path.to_owned(),
413 total,
414 shown: Vec::new(),
415 },
416 entity: parsed.entity_type,
417 tasks: Vec::new(),
418 projects: Vec::new(),
419 events: Vec::new(),
420 warnings: parsed.warnings,
421 };
422
423 for item in parsed.items.into_iter().take(PREVIEW_ROWS) {
424 match item.data {
425 ImportItemData::Task(task) => preview.tasks.push(task),
426 ImportItemData::Project(project) => preview.projects.push(project),
427 ImportItemData::Event(event) => preview.events.push(event),
428 }
429 }
430
431 Ok(preview)
432 }
433
434 declare! {
435 /// One task row of a CSV preview.
436 shape csv_task(task: &goingson_core::ImportTaskData) -> Row;
437
438 cells {
439 cell at "Description" short(&task.description);
440 cell at "Project" short_or_blank(task.project_name.as_ref());
441 cell at "Priority" short_or_blank(task.priority.as_ref());
442 cell at "Due" short_or_blank(task.due.as_ref());
443 }
444 }
445
446 declare! {
447 /// One project row of a CSV preview.
448 shape csv_project(project: &goingson_core::ImportProjectData) -> Row;
449
450 cells {
451 cell at "Name" short(&project.name);
452 cell at "Description" short_or_blank(project.description.as_ref());
453 cell at "Type" short_or_blank(project.project_type.as_ref());
454 cell at "Status" short_or_blank(project.status.as_ref());
455 }
456 }
457
458 declare! {
459 /// One event row of a CSV preview.
460 shape csv_event(event: &goingson_core::ImportEventData) -> Row;
461
462 cells {
463 cell at "Title" short(&event.title);
464 cell at "Start" short(&event.start);
465 cell at "End" short_or_blank(event.end.as_ref());
466 cell at "Location" short_or_blank(event.location.as_ref());
467 }
468 }
469
470 declare! {
471 /// What a CSV file holds, said back before anything is written.
472 ///
473 /// The column list and the row shape are conditional on the same entity
474 /// type, and until the cells named their columns the two match arms had to
475 /// agree on order with nothing checking that they did. Now the column list
476 /// is the only thing that decides where a value lands, and the row only has
477 /// to spell the heading.
478 ///
479 /// No `more` on any of the three tables, and that is a statement rather
480 /// than an omission: a parsed file is not a page of a query. Every row is
481 /// already in hand, and the 25 shown are a reading convenience rather than
482 /// a window that could be widened.
483 shape csv_preview(preview: &CsvPreview) -> Node;
484
485 region PREVIEW as Pane {
486 empty "No rows in that file." when preview.file.total is 0;
487
488 section preview.file.counted(preview.singular()) when preview.file.total over 0;
489
490 given preview.entity {
491 goingson_core::ImportEntityType::Task -> table {
492 column "Description";
493 column "Project";
494 column "Priority";
495 column "Due";
496
497 for task in preview.tasks.iter() {
498 include csv_task(task);
499 }
500 }
501 goingson_core::ImportEntityType::Project -> table {
502 column "Name";
503 column "Description";
504 column "Type";
505 column "Status";
506
507 for project in preview.projects.iter() {
508 include csv_project(project);
509 }
510 }
511 otherwise -> table {
512 column "Title";
513 column "Start";
514 column "End";
515 column "Location";
516
517 for event in preview.events.iter() {
518 include csv_event(event);
519 }
520 }
521 }
522
523 text preview.file.clipping() when preview.file.clipped();
524
525 include confirm_form(
526 preview.file.kind,
527 &preview.file.path,
528 &preview.file.commits(preview.singular()),
529 0
530 ) when preview.file.total over 0;
531
532 for warning in preview.warnings.iter() {
533 banner Tone::Warning warning;
534 }
535 }
536 }
537
538 /// A parsed vCard file, as its preview draws it.
539 struct ContactsPreview {
540 /// The kind, the path and the counts every preview shares.
541 file: Previewed<crate::commands::import_external::VCardPreview>,
542 /// How many cards match somebody already here, which is when the duplicate
543 /// question applies.
544 duplicates: usize,
545 }
546
547 /// What a vCard file holds.
548 fn contacts_parse(state: &AppState, path: &str) -> Result<ContactsPreview, RouteError> {
549 let cards = crate::commands::import_external::preview_vcf_at(state, path)
550 .map_err(|error| RouteError::internal(error.to_string()))?;
551
552 Ok(ContactsPreview {
553 duplicates: cards
554 .iter()
555 .filter(|card| card.duplicate_of.is_some())
556 .count(),
557 file: Previewed {
558 kind: Kind::Contacts,
559 path: path.to_owned(),
560 total: cards.len(),
561 shown: cards.into_iter().take(PREVIEW_ROWS).collect(),
562 },
563 })
564 }
565
566 /// What the Status column says about a card that matched one already here.
567 ///
568 /// The shipped cell says "Already exists" and hides which contact it matched in
569 /// a `title=`. The name is the useful half and it is a fact, so it is said.
570 fn matching(card: &crate::commands::import_external::VCardPreview) -> String {
571 card.duplicate_of
572 .as_ref()
573 .map_or_else(String::new, |existing| format!("Matches {existing}"))
574 }
575
576 declare! {
577 /// One card of a vCard preview.
578 ///
579 /// Named rather than positional: a push says "after the others", which is
580 /// only the Status column while the four before it are written in exactly
581 /// this order.
582 shape contact_row(card: &crate::commands::import_external::VCardPreview) -> Row;
583
584 cells {
585 cell at "Name" short(&card.display_name);
586 cell at "Company" short_or_blank(card.company.as_ref());
587 cell at "Emails" "{card.email_count}";
588 cell at "Phones" "{card.phone_count}";
589 cell at "Status" matching(card);
590 }
591 }
592
593 declare! {
594 /// What a vCard file holds, said back before anything is written.
595 shape contacts_preview(preview: &ContactsPreview) -> Node;
596
597 region PREVIEW as Pane {
598 empty "No contacts in that file." when preview.file.total is 0;
599
600 section preview.file.counted("contact") when preview.file.total over 0;
601
602 table {
603 column "Name";
604 column "Company";
605 column "Emails";
606 column "Phones";
607 column "Status";
608
609 for card in preview.file.shown.iter() {
610 include contact_row(card);
611 }
612 }
613
614 text preview.file.clipping() when preview.file.clipped();
615
616 include confirm_form(
617 preview.file.kind,
618 &preview.file.path,
619 &preview.file.commits("contact"),
620 preview.duplicates
621 ) when preview.file.total over 0;
622 }
623 }
624
625 /// A parsed iCalendar file, as its preview draws it.
626 type CalendarPreview = Previewed<crate::commands::import_external::IcsPreview>;
627
628 /// What an iCalendar file holds.
629 fn calendar_parse(path: &str) -> Result<CalendarPreview, RouteError> {
630 let events = crate::commands::import_external::preview_ics_at(path)
631 .map_err(|error| RouteError::internal(error.to_string()))?;
632
633 Ok(Previewed {
634 kind: Kind::Calendar,
635 path: path.to_owned(),
636 total: events.len(),
637 shown: events.into_iter().take(PREVIEW_ROWS).collect(),
638 })
639 }
640
641 declare! {
642 /// One event of an iCalendar preview.
643 shape calendar_row(event: &crate::commands::import_external::IcsPreview) -> Row;
644
645 cells {
646 cell at "Title" short(&event.title);
647 cell at "Start" short(&event.start_time);
648 cell at "Location" short_or_blank(event.location.as_ref());
649 cell at "Repeats" short(&event.recurrence);
650 }
651 }
652
653 declare! {
654 /// What an iCalendar file holds, said back before anything is written.
655 shape calendar_preview(preview: &CalendarPreview) -> Node;
656
657 region PREVIEW as Pane {
658 empty "No events in that file." when preview.total is 0;
659
660 section preview.counted("event") when preview.total over 0;
661
662 table {
663 column "Title";
664 column "Start";
665 column "Location";
666 column "Repeats";
667
668 for event in preview.shown.iter() {
669 include calendar_row(event);
670 }
671 }
672
673 text preview.clipping() when preview.clipped();
674
675 include confirm_form(
676 preview.kind,
677 &preview.path,
678 &preview.commits("event"),
679 0
680 ) when preview.total over 0;
681 }
682 }
683
684 /// Parse the picked file and say what importing it would do.
685 fn preview(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
686 let kind = Kind::from(&request)?;
687 let path = picked(&request)?;
688 let node = match kind {
689 Kind::Csv => csv_preview(&csv_parse(&path)?),
690 Kind::Contacts => contacts_preview(&contacts_parse(state, &path)?),
691 Kind::Calendar => calendar_preview(&calendar_parse(&path)?),
692 };
693 Ok(Response::fragment(PREVIEW, node))
694 }
695
696 /// Do the import the preview described.
697 ///
698 /// Answers the preview region, emptied, with the counts in a toast. The rows
699 /// themselves land on other screens — tasks, contacts, the calendar — and a
700 /// described route answers the region it happened in rather than reaching for
701 /// theirs.
702 fn import(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
703 let kind = Kind::from(&request)?;
704 let path = picked(&request)?;
705
706 let (message, tone) = match kind {
707 Kind::Csv => {
708 let done = crate::commands::import::execute_csv_at(
709 state,
710 &path,
711 &ImportOptions::default(),
712 &[],
713 )
714 .map_err(|error| RouteError::internal(error.to_string()))?;
715 let tone = if done.failed_count > 0 {
716 Tone::Warning
717 } else {
718 Tone::Success
719 };
720 let message = if done.failed_count > 0 {
721 format!(
722 "Imported {}. {} failed.",
723 done.imported_count, done.failed_count
724 )
725 } else {
726 format!("Imported {}.", done.imported_count)
727 };
728 (message, tone)
729 }
730 Kind::Contacts => {
731 let strategy = strategy(&request);
732 let done = crate::commands::import_external::import_vcf_at(state, &path, strategy)
733 .map_err(|error| RouteError::internal(error.to_string()))?;
734 (result_message(&done), result_tone(&done))
735 }
736 Kind::Calendar => {
737 let done = crate::commands::import_external::import_ics_at(state, &path)
738 .map_err(|error| RouteError::internal(error.to_string()))?;
739 (result_message(&done), result_tone(&done))
740 }
741 };
742
743 Ok(Response::fragment(PREVIEW, no_preview()).toast(tone, message))
744 }
745
746 /// What to do with cards that are already here.
747 ///
748 /// Merge when the control was not offered, which is what
749 /// `selectedDuplicateStrategy` answers for the same reason: the question is only
750 /// asked when there are duplicates. An unrecognised word is the same Merge
751 /// rather than a refusal, matching `DuplicateStrategy::default`, since every
752 /// value that can arrive here was put on the control by this screen.
753 fn strategy(request: &quasi_router::Request) -> DuplicateStrategy {
754 match request.payload.get("duplicates") {
755 Some("skip") => DuplicateStrategy::Skip,
756 Some("importAsNew") => DuplicateStrategy::ImportAsNew,
757 _ => DuplicateStrategy::Merge,
758 }
759 }
760
761 /// The sentence an external import answers with.
762 fn result_message(done: &crate::commands::import_external::ImportResult) -> String {
763 let mut parts = Vec::new();
764 if done.imported > 0 {
765 parts.push(format!("{} imported", done.imported));
766 }
767 if done.merged > 0 {
768 parts.push(format!("{} merged", done.merged));
769 }
770 if done.skipped > 0 {
771 parts.push(format!("{} already here", done.skipped));
772 }
773 if !done.errors.is_empty() {
774 parts.push(format!("{} failed", done.errors.len()));
775 }
776 if parts.is_empty() {
777 return "Nothing to import.".to_owned();
778 }
779 format!("{}.", parts.join(", "))
780 }
781
782 /// Whether an external import went cleanly.
783 fn result_tone(done: &crate::commands::import_external::ImportResult) -> Tone {
784 if done.errors.is_empty() {
785 Tone::Success
786 } else {
787 Tone::Warning
788 }
789 }
790
791 /// A backup file name that names a file in the backup directory and nothing
792 /// else.
793 ///
794 /// A path separator, a parent component or a name that is not a backup is a 404
795 /// rather than a refusal with a reason, for the reason the settings port gives:
796 /// every name this screen can send was put on a control by this screen, so
797 /// anything else is a hand-typed request rather than a user's mistake.
798 fn safe_name(request: &quasi_router::Request) -> Result<String, RouteError> {
799 let name = request
800 .captures
801 .get("name")
802 .ok_or_else(|| RouteError::not_found("no backup"))?;
803 let bad = name.contains('/') || name.contains('\\') || name.contains("..");
804 if bad || !name.ends_with(".json.gz") {
805 return Err(RouteError::not_found("not a backup"));
806 }
807 Ok(name.to_owned())
808 }
809
810 /// How big a backup is, in the units the shipped list uses.
811 fn size(bytes: u64) -> String {
812 const STEP: f64 = 1024.0;
813 let units = ["bytes", "KB", "MB", "GB"];
814 if bytes == 0 {
815 return "0 bytes".to_owned();
816 }
817 let mut value = bytes as f64;
818 let mut unit = 0;
819 while value >= STEP && unit < units.len() - 1 {
820 value /= STEP;
821 unit += 1;
822 }
823 if unit == 0 {
824 format!("{bytes} bytes")
825 } else {
826 format!("{value:.1} {}", units[unit])
827 }
828 }
829
830 /// When a backup was taken, as a date rather than as a moment.
831 ///
832 /// The shipped list calls `toLocaleDateString` plus `toLocaleTimeString` in the
833 /// browser's zone. A handler has no browser and no zone, so it says the instant
834 /// it stored: UTC, spelled out. Naming the zone is the honest half — a bare
835 /// "14:05" that is secretly UTC is worse than either answer.
836 fn taken_at(created_at: i64) -> String {
837 chrono::DateTime::from_timestamp(created_at, 0).map_or_else(
838 || "date unknown".to_owned(),
839 |at| at.format("%Y-%m-%d %H:%M UTC").to_string(),
840 )
841 }
842
843 declare! {
844 /// One backup, and what can be done to it.
845 shape backup_row(backup: &BackupInfoResponse) -> Row;
846
847 row &backup.file_name {
848 meta "{taken_at(backup.created_at)} · {size(backup.size_bytes)}";
849
850 // What `confirmDelete` asks in the shipped modal, said by the
851 // description instead of by a JS helper at the call site.
852 act "Restore" to post "/data/backups/{backup.file_name}/restore" {
853 confirm "Restore from this backup? Anything already here with the same id is \
854 left alone.";
855 }
856
857 act "Delete" to post "/data/backups/{backup.file_name}/delete" {
858 tone Danger;
859 confirm "Delete this backup? That cannot be undone.";
860 }
861 }
862 }
863
864 /// What the backups region draws: the files on disk, and whether a run is
865 /// still going.
866 struct Backups {
867 /// What is on disk, in the order the command lists it.
868 found: Vec<BackupInfoResponse>,
869 /// Whether an on-demand run has been handed off and not come back.
870 ///
871 /// Read rather than taken: the outcome of a finished run is the asking
872 /// handler's to say, once, as a toast. What belongs in the region is only
873 /// the fact that one is still going.
874 running: bool,
875 }
876
877 /// The backups on disk.
878 fn backups(state: &AppState) -> Result<Backups, RouteError> {
879 Ok(Backups {
880 found: list_backups_in(state).map_err(|error| RouteError::internal(error.to_string()))?,
881 running: state.backup_running(),
882 })
883 }
884
885 declare! {
886 /// The backups half of the screen.
887 ///
888 /// Live, and honestly so: this directory gains files without anybody
889 /// pressing anything, because the scheduler writes automatic backups into
890 /// it. That was already true before "Create Backup" was describable and is
891 /// the reason the region can carry a started answer at all -- the cadence
892 /// exists for the automatic half, and the on-demand half rides it.
893 ///
894 /// The started answer depends on this. `quasi_http` retargets the region
895 /// and swaps its contents, deliberately sending no cadence of its own: the
896 /// `hx-trigger` has to already be on the element, put there by this render.
897 /// Dropping `live` here would leave a "Creating backup" stand-in
898 /// standing forever, so the two move together. The stand-in is `underway`
899 /// and not the region's own readiness for the same reason: a host that
900 /// swaps markup has nowhere to put an attribute.
901 shape backups_region(backups: &Backups) -> Slot;
902
903 region BACKUPS as Pane {
904 fed_by Action::get("/data/backups");
905 live;
906
907 section "Backups";
908 act "Create Backup" to post "/data/backups/create";
909
910 underway "Creating backup…" when backups.running;
911
912 include backup_list(backups);
913 }
914 }
915
916 declare! {
917 /// What is on disk, or a line saying nothing is.
918 ///
919 /// Its own shape because a restore and a delete answer with this and not
920 /// with the whole region: the cadence and the Create control are already on
921 /// screen, and re-sending them would replace the element that carries the
922 /// trigger.
923 shape backup_list(backups: &Backups) -> Node;
924
925 given backups.found.is_empty() {
926 true -> empty "No backups yet. Automatic backups start once they are enabled below.";
927 otherwise -> list {
928 for backup in backups.found.iter() {
929 include backup_row(backup);
930 }
931 }
932 }
933 }
934
935 /// The backups region on its own, which is what the live cadence asks for.
936 ///
937 /// Also where a finished on-demand run is reported: the region that re-asks is
938 /// the region that was waiting, so the answer it gets is the natural place to
939 /// say how it went. Taken and not read, so it is said once.
940 fn listing(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
941 let finished = state.take_finished_backup();
942 let answer = Response::fragment(BACKUPS, Node::Region(backups_region(&backups(state)?)));
943 Ok(match finished {
944 Some((true, said)) => answer.toast(Tone::Success, said),
945 Some((false, said)) => answer.toast(Tone::Danger, said),
946 None => answer,
947 })
948 }
949
950 /// Start a backup, and answer that it started.
951 ///
952 /// quasicoherent `dc2f2b46`. The one write on this screen that genuinely takes
953 /// seconds: the gzip goes to the blocking pool because doing it inline froze
954 /// the UI (Perf S6). The handler stays synchronous — it hands the work to the
955 /// app's own runtime through [`crate::state::Offload`] and answers
956 /// [`quasi_router::Outcome::Started`], which says "this began" rather than
957 /// "this is done" or, as before, saying nothing because the control was absent.
958 ///
959 /// Refuses a second run while one is going. Two concurrent full backups are two
960 /// gzip streams over the same database for no benefit, and the filename is
961 /// collision-safe rather than idempotent, so the second would land as its own
962 /// file.
963 fn create(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
964 if state.backup_running() {
965 return Ok(
966 Response::fragment(BACKUPS, Node::Region(backups_region(&backups(state)?)))
967 .toast(Tone::Warning, "A backup is already being written."),
968 );
969 }
970
971 let Some(offload) = state.offload.get() else {
972 // No runtime was installed, which is a host that never called
973 // `install_offload`. Said rather than swallowed: the alternative is a
974 // button that reports success and does nothing.
975 return Err(RouteError::internal(
976 "this host cannot run a backup in the background",
977 ));
978 };
979
980 state.set_backup_run(BackupRun::Running);
981
982 let handed_off = offload.run(|state| async move {
983 let outcome = crate::backup_scheduler::create_backup_now(&state).await;
984 state.set_backup_run(match outcome {
985 Ok(done) => BackupRun::Finished {
986 ok: true,
987 said: format!(
988 "Backup created: {}.",
989 std::path::Path::new(&done.file_path)
990 .file_name()
991 .map_or_else(
992 || done.file_path.clone(),
993 |name| name.to_string_lossy().into_owned()
994 )
995 ),
996 },
997 Err(error) => BackupRun::Finished {
998 ok: false,
999 said: format!("Backup failed: {error}"),
1000 },
1001 });
1002 });
1003
1004 if !handed_off {
1005 // The state is already going away, so nothing will run and nothing will
1006 // report. Put the record back rather than leaving a run marked in
1007 // flight that no longer exists.
1008 state.set_backup_run(BackupRun::Idle);
1009 return Err(RouteError::internal("the app is shutting down"));
1010 }
1011
1012 Ok(Response::started(BACKUPS, "Creating backup…"))
1013 }
1014
1015 /// How often automatic backups are taken, and how many are kept.
1016 ///
1017 /// Both lists are the shipped ones verbatim, including which value is marked
1018 /// recommended, because they are the app's answer rather than this screen's.
1019 fn frequency_choices() -> Vec<Choice> {
1020 vec![
1021 Choice::new("15", "Every 15 minutes (recommended)"),
1022 Choice::new("30", "Every 30 minutes"),
1023 Choice::new("60", "Every hour"),
1024 Choice::new("360", "Every 6 hours"),
1025 Choice::new("1440", "Daily"),
1026 ]
1027 }
1028
1029 /// How many generations are kept.
1030 fn retention_choices() -> Vec<Choice> {
1031 vec![
1032 Choice::new("1", "Keep 1 backup (recommended)"),
1033 Choice::new("3", "Keep 3 backups"),
1034 Choice::new("7", "Keep 7 backups"),
1035 Choice::new("14", "Keep 14 backups"),
1036 Choice::new("0", "Keep all backups"),
1037 ]
1038 }
1039
1040 /// The automatic-backup settings, read once for the region that draws them.
1041 ///
1042 /// Absent settings are the defaults rather than an error: a device that has
1043 /// never opened this screen has no row, and "on, every 15 minutes, keep 1" is
1044 /// what the scheduler does in that case.
1045 struct Automatic {
1046 /// Whether backups are taken on a schedule.
1047 enabled: bool,
1048 /// Minutes between runs.
1049 frequency: i32,
1050 /// How many generations are kept.
1051 retention: i32,
1052 /// What the region says about the last run, already in words.
1053 last: String,
1054 }
1055
1056 /// Those settings, off the store.
1057 fn automatic(state: &AppState) -> Result<Automatic, RouteError> {
1058 let settings = state
1059 .backup_settings
1060 .get(DESKTOP_USER_ID)
1061 .map_err(|error| RouteError::internal(error.to_string()))?;
1062
1063 Ok(Automatic {
1064 enabled: settings.as_ref().is_none_or(|s| s.auto_backup_enabled),
1065 frequency: settings.as_ref().map_or(15, |s| s.backup_frequency_minutes),
1066 retention: settings.as_ref().map_or(1, |s| s.max_backups_to_keep),
1067 last: settings
1068 .as_ref()
1069 .and_then(|s| s.last_backup_at)
1070 .map_or_else(
1071 || "No backups yet.".to_owned(),
1072 |at| format!("Last backup {}.", at.format("%Y-%m-%d %H:%M UTC")),
1073 ),
1074 })
1075 }
1076
1077 declare! {
1078 /// The automatic-backup settings.
1079 ///
1080 /// A form rather than the bare controls the [`settings`](super::settings)
1081 /// screen uses, because these three are answered together behind one Save
1082 /// button, which is what a form is.
1083 ///
1084 /// Frequency and retention are `extended`, so a renderer may put them
1085 /// behind a disclosure. The description carries no "open it when this is
1086 /// not the default", so a customised value can be hidden. A disclosure that
1087 /// hides a setting somebody deliberately changed is the failure, and it is
1088 /// the renderer that would have to know.
1089 shape automatic_region(automatic: &Automatic) -> Slot;
1090
1091 region AUTOMATIC as Pane {
1092 section "Automatic backups";
1093
1094 form post "/data/backups/automatic" {
1095 submit "Save";
1096
1097 field Checkbox "enabled" "Take backups automatically" {
1098 hint "Compressed snapshots on a schedule, kept in the app's own directory.";
1099 // Present is ticked, which is how a checkbox submits and how
1100 // the vocabulary reads one back.
1101 value "on" when automatic.enabled;
1102 }
1103
1104 field Select "frequency" "How often" {
1105 options frequency_choices();
1106 value "{automatic.frequency}";
1107 extended;
1108 }
1109
1110 field Select "retention" "How many to keep" {
1111 options retention_choices();
1112 value "{automatic.retention}";
1113 hint "Older backups are deleted to save space. Three are always kept.";
1114 extended;
1115 }
1116 }
1117
1118 text &automatic.last;
1119 }
1120 }
1121
1122 /// The three exports this screen offers.
1123 ///
1124 /// One entry per shipped button in `export.js`, and the two facts each one
1125 /// carried are here rather than in a save dialog: `defaultPath` is
1126 /// [`Export::stem`] and `filters` is [`Export::kind`]. Neither is a path, which
1127 /// is why the ruling could put the whole control in the description.
1128 #[derive(Clone, Copy, PartialEq, Eq)]
1129 enum Export {
1130 /// Everything, as JSON.
1131 Json,
1132 /// Tasks, as CSV.
1133 Tasks,
1134 /// Events, as iCalendar.
1135 Calendar,
1136 }
1137
1138 impl Export {
1139 /// Every export, in the order the pane offers them.
1140 const EVERY: [Self; 3] = [Self::Json, Self::Tasks, Self::Calendar];
1141
1142 /// The export under this address segment, or 404.
1143 fn of(slug: &str) -> Result<Self, RouteError> {
1144 match slug {
1145 "json" => Ok(Self::Json),
1146 "tasks" => Ok(Self::Tasks),
1147 "calendar" => Ok(Self::Calendar),
1148 _ => Err(RouteError::not_found("nothing exports that")),
1149 }
1150 }
1151
1152 /// The export under the request's `{format}` capture, or 404.
1153 fn from(request: &quasi_router::Request) -> Result<Self, RouteError> {
1154 Self::of(
1155 request
1156 .captures
1157 .get("format")
1158 .ok_or_else(|| RouteError::not_found("no format"))?,
1159 )
1160 }
1161
1162 /// The segment it travels as.
1163 const fn slug(self) -> &'static str {
1164 match self {
1165 Self::Json => "json",
1166 Self::Tasks => "tasks",
1167 Self::Calendar => "calendar",
1168 }
1169 }
1170
1171 /// The button's words.
1172 const fn label(self) -> &'static str {
1173 match self {
1174 Self::Json => "Export All (JSON)",
1175 Self::Tasks => "Export Tasks (CSV)",
1176 Self::Calendar => "Export Calendar (ICS)",
1177 }
1178 }
1179
1180 /// The middle of the suggested file name: `goingson-<stem>-<date>.<suffix>`.
1181 const fn stem(self) -> &'static str {
1182 match self {
1183 Self::Json => "export",
1184 Self::Tasks => "tasks",
1185 Self::Calendar => "calendar",
1186 }
1187 }
1188
1189 /// What kind of file it is.
1190 ///
1191 /// A media type rather than a suffix, because a media type is the one
1192 /// spelling a host can put on the wire and a suffix says nothing about what
1193 /// is inside. The suffix is in the name already.
1194 fn kind(self) -> Accepted {
1195 Accepted::media_type(match self {
1196 Self::Json => "application/json",
1197 Self::Tasks => "text/csv",
1198 Self::Calendar => "text/calendar",
1199 })
1200 }
1201
1202 /// The file's suffix, with its leading dot.
1203 const fn suffix(self) -> &'static str {
1204 match self {
1205 Self::Json => ".json",
1206 Self::Tasks => ".csv",
1207 Self::Calendar => ".ics",
1208 }
1209 }
1210
1211 /// The suggested file name, dated from `Local`.
1212 ///
1213 /// A suggestion only: the host may put a different name on it, and nothing
1214 /// here depends on the one it chose.
1215 fn file_name(self) -> String {
1216 format!(
1217 "goingson-{}-{}{}",
1218 self.stem(),
1219 chrono::Local::now().format("%Y-%m-%d"),
1220 self.suffix()
1221 )
1222 }
1223
1224 /// The sentence the toast says.
1225 fn said(self, count: usize) -> String {
1226 match self {
1227 Self::Json => format!("Exported {count} items to JSON"),
1228 Self::Tasks => format!("Exported {count} tasks to CSV"),
1229 Self::Calendar => format!("Exported {count} events to ICS"),
1230 }
1231 }
1232 }
1233
1234 declare! {
1235 /// The export half of the screen.
1236 ///
1237 /// Three controls that never ask where a file goes: the route answers with
1238 /// the file and the host decides where it lands, so a Tauri window opens a
1239 /// save dialog, a browser downloads and a terminal writes beside the
1240 /// process, and none of that is in the description.
1241 ///
1242 /// Create Backup is next to them, and its gzip write is handed off rather
1243 /// than waited on; see [`create`].
1244 shape export_region() -> Slot;
1245
1246 region "data-export" as Pane {
1247 section "Export";
1248 text "Nothing here is removed by exporting it. Where the file lands is up to this \
1249 machine.";
1250
1251 for export in Export::EVERY {
1252 act export.label() to post "/data/export/{export.slug()}";
1253 }
1254 }
1255 }
1256
1257 /// Hand one export over as a file.
1258 ///
1259 /// The screen the button was pressed on is the screen that stays: the answer is
1260 /// a file rather than a fragment, so nothing is replaced and the toast is the
1261 /// only thing that changes.
1262 fn export(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1263 let export = Export::from(&request)?;
1264 let done = match export {
1265 Export::Json => export_json_bytes(state),
1266 // No project filter and no past/future toggle, because the shipped
1267 // buttons offer neither: both commands take the argument and `export.js`
1268 // has never sent one. A control for either is a feature rather than a
1269 // port, and this screen is a port.
1270 Export::Tasks => export_tasks_csv_bytes(state, None),
1271 Export::Calendar => export_events_ics_bytes(state, true),
1272 }
1273 .map_err(|error| RouteError::internal(error.to_string()))?;
1274
1275 let said = export.said(done.item_count);
1276 Ok(Response::file(export.file_name(), export.kind(), done.bytes).toast(Tone::Success, said))
1277 }
1278
1279 declare! {
1280 /// The whole screen.
1281 ///
1282 /// Reached from the settings sidebar, which is how a person gets here, so
1283 /// Settings is the place that stays lit. See `settings::Section::at`.
1284 shape screen(backups: &Backups, automatic: &Automatic) -> Screen;
1285
1286 screen list_detail "Import & Export" false {
1287 at_place super::shell::SETTINGS;
1288
1289 region "data-band" as Band {
1290 page "Import & Export";
1291 }
1292
1293 include import_region();
1294
1295 region PREVIEW as Pane {
1296 include no_preview();
1297 }
1298
1299 include export_region();
1300 include backups_region(backups);
1301 include automatic_region(automatic);
1302 }
1303 }
1304
1305 /// The screen.
1306 fn index(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
1307 Ok(screen(&backups(state)?, &automatic(state)?).into())
1308 }
1309
1310 /// Merge a backup back in.
1311 ///
1312 /// Merge and not replace: `replace_all` is refused by the command it would call
1313 /// and has been since it was written, so a described control offering it would
1314 /// be a control that errors. The confirmation says which of the two this is.
1315 fn restore(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1316 let name = safe_name(&request)?;
1317 let path = backup_dir(state).join(&name);
1318 if !path.exists() {
1319 return Err(RouteError::not_found("no such backup"));
1320 }
1321
1322 let done = crate::commands::export::restore_backup_from(
1323 state,
1324 &path.to_string_lossy(),
1325 &RestoreOptions { replace_all: false },
1326 )
1327 .map_err(|error| RouteError::internal(error.to_string()))?;
1328
1329 let total = done.projects_restored
1330 + done.tasks_restored
1331 + done.events_restored
1332 + done.emails_restored
1333 + done.contacts_restored;
1334
1335 Ok(Response::fragment(BACKUPS, backup_list(&backups(state)?))
1336 .toast(Tone::Success, format!("Restored {total} from {name}.")))
1337 }
1338
1339 /// Remove one backup.
1340 fn delete(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1341 let name = safe_name(&request)?;
1342 let path = backup_dir(state).join(&name);
1343
1344 let removed = crate::commands::export::delete_backup_at(state, &path.to_string_lossy())
1345 .map_err(|error| RouteError::internal(error.to_string()))?;
1346
1347 // Answered with the list re-read either way: a backup that was already gone
1348 // leaves a row on screen that is not there, and the list is the correction.
1349 Ok(
1350 Response::fragment(BACKUPS, backup_list(&backups(state)?)).toast(
1351 if removed {
1352 Tone::Success
1353 } else {
1354 Tone::Warning
1355 },
1356 if removed {
1357 format!("Deleted {name}.")
1358 } else {
1359 format!("{name} was already gone.")
1360 },
1361 ),
1362 )
1363 }
1364
1365 /// Save the automatic-backup settings.
1366 ///
1367 /// The clamping stays in the command's own write path, which is where the
1368 /// reason for it lives (a non-positive frequency backs up on every scheduler
1369 /// tick, a negative retention prunes nothing). A value this screen cannot send
1370 /// is still refused there, which is the same arrangement the settings screen has
1371 /// with its closed key set.
1372 fn save_automatic(
1373 state: &AppState,
1374 request: quasi_router::Request,
1375 ) -> Result<Response, RouteError> {
1376 let number = |name: &str, fallback: i32| {
1377 request
1378 .payload
1379 .get(name)
1380 .and_then(|value| value.parse::<i32>().ok())
1381 .unwrap_or(fallback)
1382 };
1383
1384 crate::commands::export::save_backup_settings_for(
1385 state,
1386 &crate::commands::export::BackupSettingsInput {
1387 // A checkbox submits nothing when it is not ticked, which is the
1388 // whole of how "off" arrives.
1389 auto_backup_enabled: request.payload.get("enabled").is_some(),
1390 backup_frequency_minutes: number("frequency", 15),
1391 max_backups_to_keep: number("retention", 1),
1392 },
1393 )
1394 .map_err(|error| RouteError::internal(error.to_string()))?;
1395
1396 Ok(Response::fragment(
1397 AUTOMATIC,
1398 Node::Region(automatic_region(&automatic(state)?)),
1399 )
1400 .toast(Tone::Success, "Backup settings saved."))
1401 }
1402
1403 /// The import and export screen's routes.
1404 #[must_use]
1405 pub fn routes(router: Router<AppState>) -> Router<AppState> {
1406 router
1407 .get("/data", index)
1408 .post("/data/import/{kind}/preview", preview)
1409 .post("/data/import/{kind}", import)
1410 .post("/data/export/{format}", export)
1411 .get("/data/backups", listing)
1412 .post("/data/backups/create", create)
1413 .post("/data/backups/{name}/restore", restore)
1414 .post("/data/backups/{name}/delete", delete)
1415 .post("/data/backups/automatic", save_automatic)
1416 }
1417