Skip to main content

max / goingson

Declare the import, export and backups screen Wave 14, first of four files. Eighteen shapes, and data.rs leaves the queue. Four shapes were deleted rather than declared, all of them containers passed in and handed back: `preview_region` took a `Vec<Node>`, `preview_table` took the columns and the rows, `confirm_form` took a `Vec<Field>` of extras, and `behind_disclosure` took a `Field` to set one bool on it. `Field::extended` already had the builder that last one existed for. Three reads were hoisted so the description draws a parse rather than performing one: `Backups`, `Automatic` and `Previewed<T>`. The three previews now share the counting and the clipping line instead of each spelling them. One production earned, in quasi@1f1765a: `underway`, the third stand-in state, which the backups region needs because it is live and a swapping host has nowhere to put a readiness attribute. Two things the form said no to and the read answered. A dispatch cannot bind what it matched, so a parsed CSV carries three lists and the arm that draws one asks for it while the other two answer empty. And `Node::Form`'s fields are a literal, so the duplicate question is a guarded field on the form rather than a `Vec<Field>` built beside it, which is the same answer wave 11 gave the email settings' advanced block.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 19:33 UTC
Signed with PGP, not checked
Commit: 3e500d298467bca722e1f0bb38f53be742fdca77
Parent: 29e1eb5
1 file changed, +262 insertions, -191 deletions
@@ -81,9 +81,10 @@
81 81 #![allow(clippy::needless_pass_by_value)]
82 82
83 83 use goingson_core::ImportOptions;
84 - use makeover_layout::{FieldKind, Tone};
85 - use quasi_router::screen::{Accepted, Act, Cells, Choice, Column, Field, Row, Table};
86 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
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};
87 88
88 89 use crate::backup_scheduler::backup_dir;
89 90 use crate::commands::export::{
@@ -137,6 +138,9 @@
137 138 }
138 139
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 +
140 144 /// The kind under this address segment, or 404.
141 145 fn of(slug: &str) -> Result<Self, RouteError> {
142 146 match slug {
@@ -204,123 +208,46 @@
204 208 }
205 209 }
206 210
207 - /// One import's form: pick a file, see what is in it.
208 - fn import_form(kind: Kind) -> Node {
209 - Node::Region(
210 - Slot::group(format!("data-import-{}", kind.slug()))
211 - .with(Node::Act(Act::new(
212 - format!("Choose a {}", kind.label()),
213 - Action::post(format!("/data/import/{}/preview", kind.slug()))
214 - .with("accept", kind.accept())
215 - .with("name", kind.label())
216 - .by_host()
217 - .awaiting(),
218 - )))
219 - .with(Node::text(kind.hint())),
220 - )
221 - }
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;
222 218
223 - /// The import half of the screen.
224 - fn import_region() -> Slot {
225 - Slot::new("data-import", RegionKind::Pane)
226 - .with(Node::section("Import"))
227 - .with(Node::text(
228 - "Nothing is created until the preview is confirmed.",
229 - ))
230 - .with(import_form(Kind::Csv))
231 - .with(import_form(Kind::Contacts))
232 - .with(import_form(Kind::Calendar))
233 - }
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;
234 225
235 - /// The form that commits a previewed import.
236 - ///
237 - /// The path travels in a hidden field rather than in the address, so the two
238 - /// requests agree about which file without the screen holding state between
239 - /// them. See finding 3 for what that does and does not guarantee.
240 - fn confirm_form(kind: Kind, path: &str, submit: String, extra: Vec<Field>) -> Node {
241 - let mut fields = vec![Field::new(FieldKind::Hidden, "file", "File").value(path)];
242 - fields.extend(extra);
243 - Node::Form {
244 - action: Action::post(format!("/data/import/{}", kind.slug())),
245 - submit,
246 - fields,
226 + text kind.hint();
247 227 }
248 228 }
249 229
250 - /// The choice offered when a vCard holds cards that are already here.
251 - ///
252 - /// Absent when there are none, which is finding 5. The values are the words
253 - /// [`DuplicateStrategy`] deserialises from, so the control and the enum cannot
254 - /// drift apart.
255 - fn duplicate_choice(duplicates: usize) -> Vec<Field> {
256 - if duplicates == 0 {
257 - return Vec::new();
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 + }
258 241 }
259 - let label = if duplicates == 1 {
260 - "1 contact is already here".to_owned()
261 - } else {
262 - format!("{duplicates} contacts are already here")
263 - };
264 - vec![
265 - Field::radio(
266 - "duplicates",
267 - label,
268 - vec![
269 - Choice::new(
270 - "merge",
271 - "Merge into the existing contact: fill blank fields, add new emails and \
272 - phones, never overwrite",
273 - ),
274 - Choice::new("skip", "Skip them"),
275 - Choice::new("importAsNew", "Import them as new contacts"),
276 - ],
277 - )
278 - .value("merge")
279 - .hint("One choice for the whole import."),
280 - ]
281 - }
282 -
283 - /// The preview table, and the line saying what it left out.
284 - ///
285 - /// Two of the three callers hand rows that name their columns and the third
286 - /// hands positional ones; the table takes either, since a row that named
287 - /// nothing is passed through as it was written.
288 - fn preview_table(columns: &[&str], rows: Vec<Cells>, total: usize) -> Vec<Node> {
289 - // No `more`, and that is a statement rather than an omission: a parsed file
290 - // is not a page of a query. Every row is already in hand, and the 25 shown
291 - // are a reading convenience rather than a window that could be widened. A
292 - // `Rest` would describe an address that fetches more, and there is none.
293 - let mut out = vec![Node::from(
294 - Table::new(columns.iter().map(|name| Column::new(*name))).rows(rows),
295 - )];
296 - if total > PREVIEW_ROWS {
297 - out.push(Node::text(format!(
298 - "Showing the first {PREVIEW_ROWS} of {total}.",
299 - )));
300 - }
301 - out
302 - }
303 -
304 - /// A parsed file, said back to the user before anything is written.
305 - fn preview_region(nodes: Vec<Node>) -> Node {
306 - Node::Region(Slot::new(PREVIEW, RegionKind::Pane).extend(nodes))
307 - }
308 -
309 - /// The empty preview, which is what the screen opens with and what an import
310 - /// leaves behind.
311 - fn no_preview() -> Node {
312 - preview_region(vec![Node::empty(
313 - "Pick a file above to see what importing it would do.",
314 - )])
315 242 }
316 243
317 244 /// One value in a preview cell, shortened the way the shipped table shortens it.
318 245 ///
319 246 /// The shipped row puts the whole value in a `title=` and the first 50
320 247 /// characters in the cell. A description has no word for text that appears on
321 - /// hover — and should not grow one, since hover is absent on a touch screen and
322 - /// on a keyboard — so the same rule the problems port followed applies: the
323 - /// truncation stays and the tooltip does not come back as anything.
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.
324 251 fn short(value: &str) -> String {
325 252 if value.chars().count() > 50 {
326 253 let kept: String = value.chars().take(50).collect();
@@ -330,218 +257,428 @@
330 257 }
331 258 }
332 259
333 - /// What a CSV file holds.
334 - ///
335 - /// Takes no state: the CSV preview is a parse and nothing else, and the project
336 - /// names a task row might resolve against are looked up by the write rather than
337 - /// by the dry run.
338 - fn csv_preview(path: &str) -> Result<Node, RouteError> {
339 - let parsed = crate::commands::import::preview_csv_at(path, &ImportOptions::default())
340 - .map_err(|error| RouteError::internal(error.to_string()))?;
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 + }
341 264
342 - let mut nodes = Vec::new();
343 - if parsed.items.is_empty() {
344 - nodes.push(Node::empty("No rows in that file."));
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()
345 347 } else {
346 - let kind = match parsed.entity_type {
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 {
347 390 goingson_core::ImportEntityType::Task => "task",
348 391 goingson_core::ImportEntityType::Project => "project",
349 392 goingson_core::ImportEntityType::Event => "event",
350 - };
351 - let total = parsed.items.len();
352 - nodes.push(Node::section(if total == 1 {
353 - format!("1 {kind}")
354 - } else {
355 - format!("{total} {kind}s")
356 - }));
357 -
358 - let (columns, rows) = csv_rows(&parsed);
359 - nodes.extend(preview_table(&columns, rows, total));
360 - nodes.push(confirm_form(
361 - Kind::Csv,
362 - path,
363 - format!("Import {total} {kind}{}", if total == 1 { "" } else { "s" }),
364 - Vec::new(),
365 - ));
393 + }
366 394 }
367 -
368 - // Warnings after the table: they are about rows that will not arrive, which
369 - // is only readable once it is clear what will.
370 - for warning in &parsed.warnings {
371 - nodes.push(Node::banner(Tone::Warning, warning));
372 - }
373 -
374 - Ok(preview_region(nodes))
375 395 }
376 396
377 - /// The columns a parsed CSV shows, and its rows against them.
397 + /// What a CSV file holds.
378 398 ///
379 - /// The shipped `getColumnsForEntityType` keys into the item's camelCase `data`
380 - /// object; here the parse is already typed, so a column is a match arm rather
381 - /// than a string key that can miss.
382 - ///
383 - /// Both halves are conditional on the same entity type, and until the cells
384 - /// named their columns the two match arms had to agree on order with nothing
385 - /// checking that they did. Now the column list is the only thing that decides
386 - /// where a value lands, and the row arm only has to spell the heading.
387 - fn csv_rows(parsed: &goingson_core::ImportParseResult) -> (Vec<&'static str>, Vec<Cells>) {
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> {
388 403 use goingson_core::ImportItemData;
389 404
390 - let columns = match parsed.entity_type {
391 - goingson_core::ImportEntityType::Task => vec!["Description", "Project", "Priority", "Due"],
392 - goingson_core::ImportEntityType::Project => vec!["Name", "Description", "Type", "Status"],
393 - goingson_core::ImportEntityType::Event => vec!["Title", "Start", "End", "Location"],
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,
394 421 };
395 422
396 - let blank = String::new();
397 - let rows = parsed
398 - .items
399 - .iter()
400 - .take(PREVIEW_ROWS)
401 - .map(|item| match &item.data {
402 - ImportItemData::Task(task) => Cells::default()
403 - .at("Description", short(&task.description))
404 - .at(
405 - "Project",
406 - short(task.project_name.as_ref().unwrap_or(&blank)),
407 - )
408 - .at("Priority", short(task.priority.as_ref().unwrap_or(&blank)))
409 - .at("Due", short(task.due.as_ref().unwrap_or(&blank))),
410 - ImportItemData::Project(project) => Cells::default()
411 - .at("Name", short(&project.name))
412 - .at(
413 - "Description",
414 - short(project.description.as_ref().unwrap_or(&blank)),
415 - )
416 - .at(
417 - "Type",
418 - short(project.project_type.as_ref().unwrap_or(&blank)),
419 - )
420 - .at("Status", short(project.status.as_ref().unwrap_or(&blank))),
421 - ImportItemData::Event(event) => Cells::default()
422 - .at("Title", short(&event.title))
423 - .at("Start", short(&event.start))
424 - .at("End", short(event.end.as_ref().unwrap_or(&blank)))
425 - .at("Location", short(event.location.as_ref().unwrap_or(&blank))),
426 - })
427 - .collect();
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 + }
428 430
429 - (columns, rows)
431 + Ok(preview)
432 + }
433 +
434 + declare! {
435 + /// One task row of a CSV preview.
436 + shape csv_task(task: &goingson_core::ImportTaskData) -> Cells;
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) -> Cells;
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) -> Cells;
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 + }
Lines truncated