--- type: Architecture title: Joplin Shopping List Plugin — Architecture description: Components, data flow, module map, and rejected alternatives for the shopping list plugin. resource: https://github.com/lansidev/joplin-shopping-list tags: [joplin, plugin, architecture] timestamp: 2026-06-14T00:00:00Z --- # Overview The plugin is a Joplin **editor-view plugin**: it registers an alternative editor that replaces the Markdown editor for notes that opt in via a marker. The Markdown note is the single source of truth. The plugin main process parses the note + the store configuration into a *view model* and sends it to a webview; the webview renders it and sends back small *intents* (add / toggle / move). All parsing, routing, sorting and serialization lives in pure modules so it is unit-testable without Joplin. # Components - **Plugin main** (`src/index.ts`) — runs in Joplin's Node environment. Registers the (non-public) settings (stores + units), the Tools-menu commands and the editor view; owns per-editor state and the message handlers. View toggling uses Joplin's built-in editor-toggle button (no custom button). - **Pure core** (`src/shopping/*`) — no Joplin imports: - `config.ts` — parse the DSL into stores + terms; compile each term so a leading/trailing quantity (number + a configurable unit) is matched (anchored, case-insensitive); parse the units list. - `parser.ts` — `parseNote` / `serializeNote` (Markdown ↔ model, with the ordering contract). - `router.ts` — matching, routing, walking-path sort, and the move cycle. - `operations.ts` — `addItem` / `toggleChecked` / `moveNext` (parse → mutate → serialize). - `viewmodel.ts` — build the webview's view model (hide checked, group, "Other" last, `canMove`). - `types.ts` — shared data + IPC types and constants (`NOTE_MARKER`, `OTHER_SECTION`). - **Config dialog** (`src/configDialog.ts`) — a `joplin.views.dialogs` dialog with two textareas (stores + units) and validation. - **Webview** (`src/webview.ts` + `src/style/main.css`) — a thin vanilla-TS renderer. # Data flow ``` Markdown note (source of truth) │ onUpdate({noteId,newBody}) / workspace.selectedNote() ▼ [plugin main] parseConfig(settings) + parseNote(body) │ buildViewModel → editors.postMessage({type:'render', payload}) (push) ▼ [webview] persistent input bar + re-rendered list container │ webviewApi.postMessage(intent) (request/response) ▼ [plugin main] operations.* → serializeNote → editors.saveNote(...) │ returns the new view model to the webview AND pushes 'render' ▼ note updated, view refreshed ``` # Message protocol - **webview → main** (request/response via `webviewApi.postMessage`, resolves to a `ViewModel`): `{type:'ready'}`, `{type:'addItem', text}`, `{type:'toggleChecked', id}`, `{type:'moveNext', id}`, `{type:'clearChecked'}`. - **main → webview** (push via `editors.postMessage`): `{type:'render', payload: ViewModel}`. - The `ViewModel` carries `checkedCount` (bought items, hidden from the list) so the webview can enable/disable its delete-bought-items button without being told the hidden rows. - `clearChecked` is the only intent the main process **confirms** before applying: it shows `dialogs.showMessageBox` (OK/Cancel, cross-platform incl. mobile) and only deletes on OK. - The webview renders both from the request's resolved value and from pushed `render` messages, so it stays correct whether the change originates in the webview or in the Markdown editor. - An item's `id` is an `{section, index}` reference, recomputed each render — valid for one render/action round-trip and re-resolved against the freshly parsed body on each action. # Content sync & echo avoidance - External edits (Markdown editor, sync) arrive via `editors.onUpdate({noteId, newBody})`; the plugin re-renders from `newBody`. - The plugin persists its own edits with `editors.saveNote(handle, {noteId, body})`, which saves **without** firing `onUpdate` for the same editor — avoiding an echo loop. The webview is then refreshed directly with the recomputed view model. # Module / file map | Path | Responsibility | | --- | --- | | `src/index.ts` | Plugin main: settings (stores + units), Tools-menu commands, editor registration, handlers | | `src/shopping/config.ts` | DSL → stores + terms; compile terms with auto quantity/unit; parse units; validation | | `src/shopping/parser.ts` | `parseNote` / `serializeNote` (ordering contract, lossless-enough) | | `src/shopping/router.ts` | match / route / sort / move cycle | | `src/shopping/operations.ts` | add / toggle / move (pure body→body transforms) | | `src/shopping/viewmodel.ts` | build the webview view model | | `src/shopping/types.ts` | shared data + IPC types, constants | | `src/configDialog.ts` | configuration editor dialog | | `src/webview.ts`, `src/style/main.css` | webview renderer + theme-aware styles | | `__tests__/*` | jest unit tests for the pure core | | `flake.nix`, `shell.nix`, `.envrc` | reproducible Nix dev shell | | `webpack.config.js`, `plugin.config.json`, `api/` | Joplin build pipeline (from generator-joplin) | # Build & dev environment - Nix dev shell pins **Node 20** (`nix develop` / `nix-shell`); `npx` runs `generator-joplin` on demand (no global installs). - `npm run dist` runs three webpack passes (main, extra scripts, archive) and emits `publish/io.github.lansidev.shopping-list.jpl`. The webview is declared as an `extraScripts` entry (`plugin.config.json`) and loaded with `editors.addScript('./webview.js')`. - `npm test` runs jest + ts-jest against the pure core. # Rejected alternatives - **CodeMirror 6 content script** (`ContentScriptType.CodeMirrorPlugin`, the [cm6 tutorial](https://joplinapp.org/help/api/tutorials/cm6_plugin)) — only *enhances* the existing text editor (decorations, commands); it cannot present a separate toggled UI with an input box and a rendered, filterable list. Rejected. - **Side panel** (`joplin.views.panels`) — would dock beside the editor rather than replace it, and does not match the requested "toggle the note into a view" interaction. Rejected in favour of the editor view. - **Custom shopping-cart toolbar button** (bound to `toggleEditorPlugin`) — initially added for a domain-specific icon, but Joplin already shows its built-in editor-toggle button whenever the editor is active (marker present), while a custom button is shown *unconditionally* — even on notes without the marker, where the view cannot activate. Removed as redundant and misleading; the built-in toggle is used instead. - **Native toolbar button for "delete bought items"** (`views.toolbarButtons`) — rejected for the same reason as the cart button: a native button shows on *every* note, and a destructive action on a non-shopping note is worse than merely misleading. It also can't depend on a native editor toolbar being rendered above our custom editor view. The trash button therefore lives **inside the webview** (`.sl-input-bar`), so it appears exactly when the shopping list is shown and always acts on the current note. # Risks - Joplin has no multi-line setting field, so the configuration (stores + units) is stored in non-public settings and edited via a dialog (Tools menu) rather than inline in the settings screen. - The manifest now declares `"platforms": ["desktop", "mobile"]` (without it a plugin defaults to desktop-only and never loads on mobile). The pieces this feature relies on are cross-platform — editor views and `dialogs.showMessageBox` carry no desktop-only annotation — but actual editor-view behaviour on Joplin mobile (iOS/Android) should still be verified on a device. # Citations [1] [JoplinViewsEditors API](https://joplinapp.org/api/references/plugin_api/classes/JoplinViewsEditors.html) [2] [generator-joplin build template](https://github.com/joplin/generator-joplin)