Notechondria

Version: 0.1.40 Build Date: 2026-04-19T10:00

What's Changed

Attachment CDN rework, commit 1 of 3: shared LocalAttachmentStore

Ships the platform-split local attachment storage that the editor / planner / portal wiring will consume in later rounds. This commit stays self-contained: no app-shell changes, no UI. The new store sits alongside LocalArchive in frontend/notechondria_shared/lib/src/utils/ and exposes a single LocalAttachmentStore facade with a platform-specific backend selected via conditional import.

API

  • LocalAttachmentStore.open() \u2014 async singleton.
  • put(noteUuid, filename, contentType, bytes) \u2014 writes the blob and metadata, returns a LocalAttachment record with a canonical localUrl of shape local://<note_uuid>/<filename>. The store owns filename sanitization (same rules as LocalArchive: slashes \u2192 _, control bytes stripped) and enforces a 20 MB per-file cap via maxBytesPerAttachment (matches the existing editor upload cap).
  • getBytes({localUrl | noteUuid+filename}) \u2014 throws a \u00a71.7-shaped LocalAttachmentStoreException if the entry doesn't exist.
  • get(\u2026) \u2014 non-throwing metadata lookup that returns null for missing entries.
  • listForNote(noteUuid) \u2014 sorted list of attachments, empty list when the note has none.
  • delete(\u2026) / deleteAllForNote(noteUuid) \u2014 silent no-op on missing; native path cleans up empty per-note directories so a long session doesn't leave thousands of stubs on disk.
  • totalBytes() \u2014 sum of every stored attachment's bytes, used for future storage-budget warnings on web.
  • parseLocalUrl(url) \u2014 returns ({noteUuid, filename}) or null; rejects nested subpaths and non-local:// schemes so the markdown preview's custom image builder can reliably resolve or skip a URL.

Migration shim

LocalAttachmentStore.migrateBase64Drafts(List<Map>) walks a drafts bucket, finds any metadata_json['queued_attachments'] entries with inline bytes_base64 payloads (the 0.1.37 format), writes each blob into the store, and:

  • Rewrites the draft's markdown body by substituting every data:<content-type>;base64,<base64> URI with the new local://<note_uuid>/<filename> URL.
  • Replaces the queued entry's bytes_base64 field with local_url, content_type, and size_bytes pointers.

Drafts with an empty queue pass through untouched. A lookup callback lets the caller map draft \u2192 note_uuid; the default uses the server-issued uuid when available and falls back to local-<client_draft_id> so store keys never collide with server uuids. Covered by 2 unit tests.

Backends

  • Native (local_attachment_store_io.dart): uses path_provider.getApplicationSupportDirectory() to anchor the store under <app_support>/notechondria/attachments/. Under flutter test (where path_provider is unregistered) it falls back to a tempdir via Directory.systemTemp.createTemp, which keeps unit tests self-contained without any mock-file-system dependency.
  • Web (local_attachment_store_web.dart): ships an in-memory map keyed by (noteUuid, filename). The blob survives the tab's lifetime but not across reloads. A follow-up round swaps this for an IndexedDB implementation (idb_shim-backed). The public contract stays identical, so editor / planner / portal wiring can land on top of the placeholder.

Tests

New frontend/notechondria_shared/test/local_attachment_store_test.dart with 9 passing cases:

  • put + get round-trips bytes + metadata.
  • parseLocalUrl rejects malformed inputs (wrong scheme, nested paths, missing uuid, missing filename).
  • listForNote returns sorted entries; deleteAllForNote clears.
  • Per-file cap rejects oversized payloads with a \u00a71.7-shaped error.
  • getBytes throws a \u00a71.7-shaped error for missing entries.
  • Filename sanitization strips /, \u0000, and control bytes.
  • migrateBase64Drafts moves inline base64 into the store and rewrites markdown + queue entries.
  • migrateBase64Drafts passes through drafts with no queue.
  • totalBytes reports the sum of stored attachment blobs.

Combined with the existing local_archive_test.dart, the shared test suite is now at 15 passing cases.

Files Changed

New

  • docs/versions/0.1.40.md (this file).
  • frontend/notechondria_shared/lib/src/utils/local_attachment_store.dart \u2014 facade + migration shim + abstract LocalAttachmentBackend.
  • frontend/notechondria_shared/lib/src/utils/local_attachment_store_io.dart \u2014 native filesystem backend.
  • frontend/notechondria_shared/lib/src/utils/local_attachment_store_web.dart \u2014 in-memory web backend (IndexedDB-backed in a later round).
  • frontend/notechondria_shared/test/local_attachment_store_test.dart \u2014 9 unit tests.

Modified

  • VERSION: 0.1.39 \u2192 0.1.40.
  • frontend/notechondria_shared/pubspec.yaml: adds path_provider: ^2.1.0.
  • frontend/notechondria_shared/lib/notechondria_shared.dart: exports the new LocalAttachment* symbols.

Verification

  • notechondria_shared: flutter analyze \u2014 3 pre-existing info-level hints (2 surfaceVariant deprecation, 1 const suggestion on a test local). No errors. flutter test -r compact \u2014 15 tests pass.
  • editor_app / planner_app / portal_app: issue counts unchanged vs 0.1.39 (52 / 70 / 68). Smoke tests pass on all three \u2014 the new shared dependency doesn't regress anything.

Notes / follow-ups

Next in the three-commit plan for the attachment CDN rework:

  • Commit 2 \u2014 editor wiring. Replace the _pickAndUploadAttachment base64-queue path in editor_app/lib/modules/note_editor.dart with LocalAttachmentStore.put(\u2026) + local:// URL embedding. Rewrite _promoteQueuedAttachments in app_shell.dart to stream bytes from LocalAttachmentStore.getBytes(\u2026) instead of base64-decoding, delete the local blob on successful upload, and rewrite the body local://... URL to the CDN URL. Add a flutter_markdown custom image builder so local:// URLs render in-place. Add an "Attachments" section under the editor toolbar.
  • Commit 3 \u2014 planner + portal wiring. Same pattern, their inlined editors in modules/learner.dart.
  • IndexedDB backend on web. Swap the in-memory map in local_attachment_store_web.dart for idb_shim-backed storage once the editor wiring round proves the flow end-to-end.
  • Storage-budget warning. Wire totalBytes() into the settings / debug log card so users see local consumption before hitting browser eviction thresholds.