#83 — CSV Import and Export for Todo Lists and Shopping Lists #83

Closed
opened 2026-08-18 13:13:44 +02:00 by lena · 1 comment
lena commented 2026-08-18 13:13:44 +02:00 (Migrated from git.butzei.de)

#83 — CSV Import and Export for Todo Lists and Shopping Lists

Problem

There is no way to bulk-add items to a list or extract list data for use in other tools. Users who maintain lists in spreadsheets, want to seed a new list from an existing file, or need to share list contents outside the app have no supported path.

Acceptance Criteria

Entry Points

  • The list action menu (existing overflow/kebab menu) gains two new entries: "Import from CSV" and "Export as CSV".
  • Both entries are available on Todo Lists and Shopping Lists.
  • For Shopping Lists only, a first step asks the user to choose a target/source:
    • Shopping List (the active items currently being shopped)
    • Master List (the permanent product catalog)

Export Flow

  1. [Shopping List only] Target picker: "Export from: Shopping List | Master List".
  2. Column selector: a checklist of all exportable fields for the selected list/target type (see Available fields below). The user can check/uncheck fields and drag them to set the column order in the output file.
  3. At least one column must be selected; the confirm button is disabled otherwise.
  4. Clicking Export generates and immediately downloads a .csv file.

File format

  • Delimiter: semicolon (;).
  • Encoding: UTF-8 with BOM (ensures correct rendering in Excel on all locales).
  • First row: header row using the canonical English field names (e.g., Title;Category;DueDate).
  • Date values: ISO 8601 (YYYY-MM-DD).
  • Boolean values: true / false.
  • Multi-value fields (Labels): values joined with a comma within the cell, quoted if necessary.
  • Cells containing a semicolon or newline are wrapped in double quotes; internal double quotes are escaped as "".

Import Flow

  1. [Shopping List only] Target picker: "Import to: Shopping List | Master List".
  2. File picker: the user selects a .csv file from their device.
  3. The importer reads the file and counts the number of columns from the first data row (semicolon-delimited). A checkbox "First row is a header — skip it" is shown (default: checked if the first row contains no numeric or date-like values, unchecked otherwise).
  4. Column mapping: for each detected column position (Column 1, Column 2, …) the user selects from a dropdown:
    • One of the available fields for this list/target type (see Available fields below), or
    • "Ignore" (column is skipped).
    • The same field may not be mapped to two columns; already-assigned fields are greyed out in other dropdowns.
    • The required field (Title / Name) must be mapped before the user can proceed.
  5. Preview: a read-only table shows the first 5 rows of the file with the mapping applied, so the user can verify before committing.
  6. Import summary: a confirmation line shows "X items will be added; Y new categories will be created." Clicking Import executes the operation.

Import behaviour

  • Items are appended to the existing list (no existing items are modified or deleted).
  • If a Category value from the CSV does not exist in the target list, it is automatically created (with no emoji, the user can edit it later).
  • Rows where the required field (Title / Name) is empty are skipped silently; a count of skipped rows is shown in the post-import summary.
  • Any other field with an unparseable value (e.g., invalid date) is imported as empty for that field; the row is not skipped.
  • After import, a toast shows: "Imported X items (Y skipped, Z categories created)."

Available Fields

Todo List

Field Export label Notes
Title Title Required on import
Description Description
Category Category Name of the category
Due Date DueDate ISO 8601
Priority Priority None / Low / Normal / High
Done Done Boolean
Labels Labels Comma-separated label names within cell

Shopping List — Master List

Field Export label Notes
Name Name Required on import
Category Category Name of the category
Last Quantity LastQuantity Free-text quantity string

Shopping List — Shopping List (active items)

Field Export label Notes
Name Name Required on import; auto-creates Master List entry if absent
Category Category Name of the category
Quantity Quantity Free-text quantity string

Out of Scope

  • Importing from other delimiters (comma, tab). Semicolon only.
  • Replacing or overwriting an existing list on import.
  • Auto-detecting column names from the CSV header row.
  • Exporting/importing checklist sub-items, comments, assignees, or recurrence rules.
  • Importing Shopping List active items as already checked-off (done state is not importable for Shopping Lists).

Priority

Should — Standalone utility feature; no dependency on #80–#82.

Decisions

  • Semicolon delimiter: avoids ambiguity with decimal commas in European locales; safe default for German-locale Excel.
  • UTF-8 with BOM: the BOM (EF BB BF) causes Excel to recognise the encoding automatically, preventing garbled umlauts.
  • Always ask for column mapping (no auto-map): avoids silent mis-imports when the user's CSV doesn't match expected header names. The mapping step is a one-time cost per file.
  • "Skip header" checkbox with heuristic default: reduces friction for users who always export with headers, while remaining correct for headerless files.
  • Append-only import: destructive replace would be a high-risk operation on a shared list; append is safe and reversible (user can delete unwanted items).
  • Auto-create categories: the alternative (blocking the import on unknown categories) would require the user to pre-create every category before importing, which defeats the purpose of bulk import. Categories created this way can be edited or merged afterward.
  • Shopping List target picker: importing to the active Shopping List and to the Master List have different semantics (one activates items, the other just catalogues them); the extra step makes the distinction explicit rather than inferring it.
# `#83` — CSV Import and Export for Todo Lists and Shopping Lists ## Problem There is no way to bulk-add items to a list or extract list data for use in other tools. Users who maintain lists in spreadsheets, want to seed a new list from an existing file, or need to share list contents outside the app have no supported path. ## Acceptance Criteria ### Entry Points - The list action menu (existing overflow/kebab menu) gains two new entries: **"Import from CSV"** and **"Export as CSV"**. - Both entries are available on Todo Lists and Shopping Lists. - For **Shopping Lists only**, a first step asks the user to choose a target/source: - **Shopping List** (the active items currently being shopped) - **Master List** (the permanent product catalog) --- ### Export Flow 1. [Shopping List only] Target picker: "Export from: Shopping List | Master List". 2. **Column selector**: a checklist of all exportable fields for the selected list/target type (see *Available fields* below). The user can check/uncheck fields and drag them to set the column order in the output file. 3. At least one column must be selected; the confirm button is disabled otherwise. 4. Clicking **Export** generates and immediately downloads a `.csv` file. #### File format - Delimiter: **semicolon** (`;`). - Encoding: UTF-8 with BOM (ensures correct rendering in Excel on all locales). - First row: header row using the canonical English field names (e.g., `Title;Category;DueDate`). - Date values: ISO 8601 (`YYYY-MM-DD`). - Boolean values: `true` / `false`. - Multi-value fields (Labels): values joined with a comma within the cell, quoted if necessary. - Cells containing a semicolon or newline are wrapped in double quotes; internal double quotes are escaped as `""`. --- ### Import Flow 1. [Shopping List only] Target picker: "Import to: Shopping List | Master List". 2. **File picker**: the user selects a `.csv` file from their device. 3. The importer reads the file and counts the number of columns from the first data row (semicolon-delimited). A checkbox "First row is a header — skip it" is shown (default: checked if the first row contains no numeric or date-like values, unchecked otherwise). 4. **Column mapping**: for each detected column position (Column 1, Column 2, …) the user selects from a dropdown: - One of the available fields for this list/target type (see *Available fields* below), or - **"Ignore"** (column is skipped). - The same field may not be mapped to two columns; already-assigned fields are greyed out in other dropdowns. - The required field (Title / Name) must be mapped before the user can proceed. 5. **Preview**: a read-only table shows the first 5 rows of the file with the mapping applied, so the user can verify before committing. 6. **Import summary**: a confirmation line shows "X items will be added; Y new categories will be created." Clicking **Import** executes the operation. #### Import behaviour - Items are **appended** to the existing list (no existing items are modified or deleted). - If a Category value from the CSV does not exist in the target list, it is **automatically created** (with no emoji, the user can edit it later). - Rows where the required field (Title / Name) is empty are skipped silently; a count of skipped rows is shown in the post-import summary. - Any other field with an unparseable value (e.g., invalid date) is imported as empty for that field; the row is not skipped. - After import, a toast shows: "Imported X items (Y skipped, Z categories created)." --- ### Available Fields #### Todo List | Field | Export label | Notes | |--------------|----------------|--------------------------------------------| | Title | `Title` | Required on import | | Description | `Description` | | | Category | `Category` | Name of the category | | Due Date | `DueDate` | ISO 8601 | | Priority | `Priority` | `None` / `Low` / `Normal` / `High` | | Done | `Done` | Boolean | | Labels | `Labels` | Comma-separated label names within cell | #### Shopping List — Master List | Field | Export label | Notes | |---------------|-----------------|--------------------------------------------| | Name | `Name` | Required on import | | Category | `Category` | Name of the category | | Last Quantity | `LastQuantity` | Free-text quantity string | #### Shopping List — Shopping List (active items) | Field | Export label | Notes | |-----------|---------------|--------------------------------------------| | Name | `Name` | Required on import; auto-creates Master List entry if absent | | Category | `Category` | Name of the category | | Quantity | `Quantity` | Free-text quantity string | --- ## Out of Scope - Importing from other delimiters (comma, tab). Semicolon only. - Replacing or overwriting an existing list on import. - Auto-detecting column names from the CSV header row. - Exporting/importing checklist sub-items, comments, assignees, or recurrence rules. - Importing Shopping List active items as already checked-off (done state is not importable for Shopping Lists). ## Priority **Should** — Standalone utility feature; no dependency on `#80`–`#82`. ## Decisions - **Semicolon delimiter**: avoids ambiguity with decimal commas in European locales; safe default for German-locale Excel. - **UTF-8 with BOM**: the BOM (`EF BB BF`) causes Excel to recognise the encoding automatically, preventing garbled umlauts. - **Always ask for column mapping (no auto-map)**: avoids silent mis-imports when the user's CSV doesn't match expected header names. The mapping step is a one-time cost per file. - **"Skip header" checkbox with heuristic default**: reduces friction for users who always export with headers, while remaining correct for headerless files. - **Append-only import**: destructive replace would be a high-risk operation on a shared list; append is safe and reversible (user can delete unwanted items). - **Auto-create categories**: the alternative (blocking the import on unknown categories) would require the user to pre-create every category before importing, which defeats the purpose of bulk import. Categories created this way can be edited or merged afterward. - **Shopping List target picker**: importing to the active Shopping List and to the Master List have different semantics (one activates items, the other just catalogues them); the extra step makes the distinction explicit rather than inferring it.
lena commented 2026-08-18 13:13:44 +02:00 (Migrated from git.butzei.de)

design (83_csv_import_export_design.md)

Design: #83 CSV Import and Export for Todo Lists and Shopping Lists

Architect note, 2026-07-25

Backend

  • Export has no backend endpoint at all. All the data a Todo List or Shopping List export
    needs is already reachable client-side — todos via the live-synced Zustand store (WS-kept
    current), categories/products via the existing Get*ForListQuery handlers this feature's dialog
    fetches directly. CSV generation (column selection/order, quoting, BOM, delimiter) is pure string
    formatting with no server-side business logic, so building a new query that just re-serves data
    the frontend already has would be pure duplication. docs/features/done/49_typed_callapi...'s
    precedent of "no handler without an AC" cuts the same way here in reverse: no new handler where
    an existing one (or the store) already answers the question.
  • Import composes existing single-item commands — ImportTodosFromCsvCommand /
    ImportShoppingProductsFromCsvCommand, each wrapped in one DbTransactionDecorator, loop over
    their rows calling CreateTodoCommand/SetTodoPriorityCommand/CheckTodoCommand/
    TagTodoCommand (Todos) or CreateShoppingProductCommand/ActivateShoppingProductCommand
    (Shopping) — the same "seeded through the normal pipeline" precedent
    CreateTodoListCommandHandler's template seeding established (#63), so Nr/SortOrder assignment,
    WS broadcast, and activity-feed events are correct for free instead of reimplemented as a raw
    bulk insert. CreateTodoCommand.NotifyEmail: false for every imported row — a bulk import
    shouldn't email every Instant-subscribed list member once per row.
  • Categories are auto-created, matched case-insensitively, cached per import batch (a
    Dictionary<string, TCategoryId> seeded from the list's existing categories, updated as new ones
    are created) so 50 rows sharing one new category name only create it once.
  • Labels are matched, never auto-created — the AC's import-behaviour section only calls out
    category auto-creation; a label also carries a colour an import row has no way to specify, so an
    unmatched label name is silently skipped rather than invented with an arbitrary colour.
  • Shopping's two import targets share one command (ActivateOnImport: bool), not two, since
    the only difference is whether a matched/created product is also activated with the row's
    quantity — Master List target additionally treats an already-catalogued name as "no existing
    items are modified" (skip, don't touch), where Shopping List target activates it instead (mirrors
    AddOrActivateShoppingProductCommand's find-or-create-then-activate shape, extended to also
    carry a Category and Quantity from the row, which that command doesn't accept).
  • Every unparseable field (bad date, unknown priority, Done not literally "true") is silently
    imported as empty/default rather than failing or skipping the row, per the AC — DateOnly.TryParse
    / Enum.TryParse failures just leave the field at CreateTodoCommand's own default.

Frontend

  • CsvExportDialog/CsvImportDialog are shared across both domains via a kind: 'todo' | 'shopping' prop and a shared CSV_FIELDS field-spec table (utils/csvFields.ts) rather than
    three near-duplicate dialog components — the two dialogs' UI structure (column
    selector/mapping, file picker, preview) doesn't vary by domain, only the field list and the
    API call at the end do.
  • Column reorder uses up/down buttons, not literal drag-and-drop — the same tradeoff
    #80/#81's CategoryManager made and documented (a fireEvent-testable equivalent reaching
    the same end state, built in a sandbox with no live browser to ever watch a drag gesture work).
  • CSV parsing is a hand-written state machine, not a line-by-line split (utils/csv.ts,
    parseCsv) — a quoted field's embedded newline would otherwise be mistaken for a row boundary,
    the one case a naive split gets wrong and CSV files legitimately contain (a multi-line
    Description).
  • Import sends every parsed row to the backend unfiltered (including blank-required-field
    rows) rather than pre-filtering client-side — the backend already computes and returns the
    authoritative Imported/Skipped/CategoriesCreated counts, so duplicating that logic
    client-side would risk the two disagreeing. The frontend only shows a rough pre-confirm row
    count, not a predicted skip/create count.
  • CSV formula injection (OWASP) mitigation on export: a data cell starting with =, +, -,
    @, tab, or CR is prefixed with a ' before being written, since exported cells are
    user-entered todo/product text opened directly in Excel/Sheets/LibreOffice, which would
    otherwise evaluate a leading =... as a formula. Header labels (our own fixed strings) are never
    neutralized, only data cells.

Known limitation (not fixed this cycle)

Todo category auto-creation during import doesn't live-refresh an already-open TodoList view's
category grouping — OwnerActionsMenu (where the CSV menu items live) and TodoList (which owns
the categories fetch) are separate branches of the component tree with no shared invalidation
signal, unlike Shopping's ShoppingListPage, which owns both the menu and the refetch and so wires
onImported directly. Newly-imported todos still appear immediately (WS-synced), just not
regrouped under a newly-created category until the list is reselected or the page reloads. Judged
out of proportion to fix this cycle (would need lifting category state up or a pub/sub mechanism);
flagged here rather than left as a silent gap.

Security

  • CSV formula injection mitigation on export, above.
  • Import reuses every existing command's own authorization/validation (AuthorizeTodoListAccessFor CurrentUserQuery, AuthorizeTodoListIsNotArchivedQuery, AuthorizeShoppingListAccessForCurrent UserQuery) — no new authorization surface, since nothing here bypasses the normal per-item
    commands' own checks.
  • No file is ever uploaded to the server — import parses the .csv entirely client-side and sends
    only the already-mapped, already-typed row data over the wire.

Blockers

None (standalone utility feature, no dependency on #80–#82, per the story).

**design** (`83_csv_import_export_design.md`) # Design: `#83` CSV Import and Export for Todo Lists and Shopping Lists **Architect note, 2026-07-25** ## Backend - **Export has no backend endpoint at all.** All the data a Todo List or Shopping List export needs is already reachable client-side — todos via the live-synced Zustand store (WS-kept current), categories/products via the existing `Get*ForListQuery` handlers this feature's dialog fetches directly. CSV generation (column selection/order, quoting, BOM, delimiter) is pure string formatting with no server-side business logic, so building a new query that just re-serves data the frontend already has would be pure duplication. `docs/features/done/49_typed_callapi...`'s precedent of "no handler without an AC" cuts the same way here in reverse: no *new* handler where an existing one (or the store) already answers the question. - **Import composes existing single-item commands** — `ImportTodosFromCsvCommand` / `ImportShoppingProductsFromCsvCommand`, each wrapped in one `DbTransactionDecorator`, loop over their rows calling `CreateTodoCommand`/`SetTodoPriorityCommand`/`CheckTodoCommand`/ `TagTodoCommand` (Todos) or `CreateShoppingProductCommand`/`ActivateShoppingProductCommand` (Shopping) — the same "seeded through the normal pipeline" precedent `CreateTodoListCommandHandler`'s template seeding established (`#63`), so Nr/SortOrder assignment, WS broadcast, and activity-feed events are correct for free instead of reimplemented as a raw bulk insert. `CreateTodoCommand.NotifyEmail: false` for every imported row — a bulk import shouldn't email every Instant-subscribed list member once per row. - **Categories are auto-created, matched case-insensitively, cached per import batch** (a `Dictionary<string, TCategoryId>` seeded from the list's existing categories, updated as new ones are created) so 50 rows sharing one new category name only create it once. - **Labels are matched, never auto-created** — the AC's import-behaviour section only calls out category auto-creation; a label also carries a colour an import row has no way to specify, so an unmatched label name is silently skipped rather than invented with an arbitrary colour. - **Shopping's two import targets share one command** (`ActivateOnImport: bool`), not two, since the only difference is whether a matched/created product is also activated with the row's quantity — Master List target additionally treats an already-catalogued name as "no existing items are modified" (skip, don't touch), where Shopping List target activates it instead (mirrors `AddOrActivateShoppingProductCommand`'s find-or-create-then-activate shape, extended to also carry a `Category` and `Quantity` from the row, which that command doesn't accept). - Every unparseable field (bad date, unknown priority, `Done` not literally `"true"`) is silently imported as empty/default rather than failing or skipping the row, per the AC — `DateOnly.TryParse` / `Enum.TryParse` failures just leave the field at `CreateTodoCommand`'s own default. ## Frontend - **`CsvExportDialog`/`CsvImportDialog` are shared across both domains** via a `kind: 'todo' | 'shopping'` prop and a shared `CSV_FIELDS` field-spec table (`utils/csvFields.ts`) rather than three near-duplicate dialog components — the two dialogs' UI structure (column selector/mapping, file picker, preview) doesn't vary by domain, only the field list and the API call at the end do. - **Column reorder uses up/down buttons, not literal drag-and-drop** — the same tradeoff `#80`/`#81`'s `CategoryManager` made and documented (a `fireEvent`-testable equivalent reaching the same end state, built in a sandbox with no live browser to ever watch a drag gesture work). - **CSV parsing is a hand-written state machine, not a line-by-line split** (`utils/csv.ts`, `parseCsv`) — a quoted field's embedded newline would otherwise be mistaken for a row boundary, the one case a naive split gets wrong and CSV files legitimately contain (a multi-line Description). - **Import sends every parsed row to the backend unfiltered** (including blank-required-field rows) rather than pre-filtering client-side — the backend already computes and returns the authoritative `Imported`/`Skipped`/`CategoriesCreated` counts, so duplicating that logic client-side would risk the two disagreeing. The frontend only shows a rough pre-confirm row count, not a predicted skip/create count. - **CSV formula injection (OWASP) mitigation on export**: a data cell starting with `=`, `+`, `-`, `@`, tab, or CR is prefixed with a `'` before being written, since exported cells are user-entered todo/product text opened directly in Excel/Sheets/LibreOffice, which would otherwise evaluate a leading `=...` as a formula. Header labels (our own fixed strings) are never neutralized, only data cells. ## Known limitation (not fixed this cycle) Todo category auto-creation during import doesn't live-refresh an already-open `TodoList` view's category grouping — `OwnerActionsMenu` (where the CSV menu items live) and `TodoList` (which owns the categories fetch) are separate branches of the component tree with no shared invalidation signal, unlike Shopping's `ShoppingListPage`, which owns both the menu and the refetch and so wires `onImported` directly. Newly-imported todos still appear immediately (WS-synced), just not regrouped under a newly-created category until the list is reselected or the page reloads. Judged out of proportion to fix this cycle (would need lifting category state up or a pub/sub mechanism); flagged here rather than left as a silent gap. ## Security - CSV formula injection mitigation on export, above. - Import reuses every existing command's own authorization/validation (`AuthorizeTodoListAccessFor CurrentUserQuery`, `AuthorizeTodoListIsNotArchivedQuery`, `AuthorizeShoppingListAccessForCurrent UserQuery`) — no new authorization surface, since nothing here bypasses the normal per-item commands' own checks. - No file is ever uploaded to the server — import parses the `.csv` entirely client-side and sends only the already-mapped, already-typed row data over the wire. ## Blockers None (standalone utility feature, no dependency on `#80`–`#82`, per the story).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#83
No description provided.