Notechondria
Version: 0.1.83 Build Date: 2026-04-27T02:00
What's Changed
Three production bugs reported from a sign-in trace, two behavioral changes around Inbox semantics, a learner-card UI cleanup, plus the long-deferred deep-link regression test.
Bug — editor_mode 400 on every starter-draft sync
Reported: every sign-in produced a flurry of
Editor.HTTP/response — POST /api/v1/notes/ → 400 failures with
'editor_mode: "M" is not a valid choice' and "T" is not a valid choice. Backend Note.editor_mode only accepts G (GFM)
/ B (Blocks) / P (Plain Text), but local_starter.dart
seeded welcome drafts with 'M' (markdown) and 'T' (text)
codes. Sync therefore fell over before any local draft made it
to the cloud.
-
Renamed
'M' → 'G'and'T' → 'P'in editor_app and planner_appcore/local_starter.dartso newly-seeded starter drafts use backend-valid codes. -
Added
_normalizeEditorMode(raw)to all three apps'core/helpers.dart. Maps stale'M' → 'G','T' → 'P', passes'G'/'B'/'P'through, and defaults unknown codes to'P'(matching backend). Wired at the four sync call sites (editorcore/draft_sync.dart; planner / portalcore/local_course_builders.dart) so any pre-0.1.83 local drafts inSharedPreferencesfrom earlier installs sync cleanly without manual data migration.
Bug — restore-starter created a duplicate Inbox when signed in
User report: clicking "Restore default inbox" while signed in
created a fresh local Inbox even when the user already had a
cloud Inbox. The 0.1.77 dedup only scanned _localCourses for
an existing Inbox; the cloud Inbox lives in _courses, so the
restore happily appended a new local row, which then attempted
to sync (and the backend rejected with 400 "already exists").
-
_seedStarterInboxAlongsideExistingineditor_app/lib/core/local_starter.dartnow scans_coursesFIRST. If a cloud Inbox is found, the function selects it and exits — no local row created, no sync attempt. Only when no Inbox exists anywhere (local or cloud) does the function fall back to creating a fresh local Inbox. -
Belt-and-suspenders on the backend (next item):
CourseListApiView.postnow treats Inbox as globally unique per user.
Behavioral change — Inbox is GLOBALLY unique per user (backend)
Replaces the 0.1.77 reject-on-collision behavior for the Inbox specifically. Other category names still reject on duplicate (case-insensitive); Inbox does idempotent get-or-create.
-
backend/notes/api.pyCourseListApiView.post— when a POST collides with an existing Inbox row (case-insensitive title match), return that existing Inbox with 200 OK instead of 400. Frontend's existing_syncLocalCourseflow processes the response identically: drops the local Inbox row, remaps draftcourse_idreferences from local to remote ID, lands on the cloud Inbox. Net effect: pushing a local Inbox to a server that already has one is now seamless.
Behavioral change — Inbox is PRIVATE per user (backend)
Per the user's requirement: "the 'Inbox' Category is the only one that don't have view public notes from others". Pre-0.1.83, Inbox was the public-feed promotion surface — any note in any user's Inbox was visible to non-owners. That made Inbox a public-feed proxy rather than a private scratchpad.
-
backend/notes/api.py— three filter sites updated:note_is_public()helper at line 73 — wasnote.is_public OR course.is_default; now justnote.is_public. Inbox notes no longer auto-promote.CourseSerializer.get_recent_notes()filter — wasQ(is_public=True) | Q(course_id__is_default=True); now justis_public=True. Other users' Inbox notes are no longer surfaced in their course-detail recent-notes section.CourseNotesApiView.getnon-owner filter — same change. Others can no longer browse a user's Inbox via the course-notes endpoint.
is_default flag stays on the Course model — it's still used
for the default-fallback-on-delete logic and the sort key. Only
the public-visibility semantics flipped.
UX — single status icon on _LearnerNoteCard, "sync failed"
distinct from "unsynced"
User report: when a sync attempt failed, the local-draft card showed two icons (the inline cloud-upload status icon next to the title AND the cloud-upload IconButton on the right). The fix collapses to a single icon that reflects the actual state:
- Removed the inline cloud-status icon next to the title. The "Local draft" / "Public" / "Private" badge in the metadata row already conveys the same state, so the inline icon was redundant.
-
The right-hand IconButton is now the single status / action
surface. Four states:
- Cloud note (not local draft) — static
cloud_doneicon, primary color, tooltip "Synced to cloud". - Local draft, no session — static
cloud_officon, onSurfaceVariant color, tooltip "Offline draft — sign in to sync." - Local draft, can sync, no prior failure —
cloud_uploadIconButton (action), tooltip "Sync to cloud", taps triggeronSync. - Local draft, sync failed —
sync_problemIconButton (action), error color, tooltip "Sync failed:\nTap to retry." Same onSynccallback.
- Cloud note (not local draft) — static
-
Failure detection:
_syncAllLocalDraftsin all three apps now stamps the failed draft withlast_sync_errorandlast_sync_attempt_atkeys when the per-item try/catch catches an exception. Persists to_localDraftsstorage so the failure flag survives an app restart. On the next successful sync the draft is removed from_localDraftsentirely (success-clear automatic).
Feature — note-share deep-link regression test
docs/TODO.md's long-standing deep-link test entry (0.1.67's
fix landed without test coverage because the editor smoke
harness lacks a web navigator shim) — addressed.
-
frontend/editor_app/lib/app_shell.dart—_parseNoteUuidFromUrlinstance method became a one-liner forwarder to a new top-levelparseNoteUuidFromFragment(String fragment)function infrontend/editor_app/lib/core/auth_flows.dart. The pure-function variant is unit-testable without mounting the app or stubbingUri.base. -
frontend/editor_app/test/deep_link_parser_test.dart(new) — 12 cases:- Happy paths: leading-slash variant; no-leading-slash;
trailing slash; share-link query suffix
(
?ref=share); uppercase-hex uuid; OAuth-callback-preserves-fragment. - Null paths: empty fragment; home
/fragment; settings fragment; non-uuid suffix; partial-uuid; unrelated/foo/bar/bazpath.
- Happy paths: leading-slash variant; no-leading-slash;
trailing slash; share-link query suffix
(
-
All 12 pass. The test now lives next to
smoke_test.dartin the editor's test suite, so any future refactor that re-anchors the regex (say, back to^/?notes/<uuid>$) fails the regression suite.
Files Changed
VERSION— 0.1.82 → 0.1.83.backend/notes/api.py—note_is_publicsimplified tonote.is_public; two filter sites inCourseSerializerCourseNotesApiView;CourseListApiView.posttreats Inbox as globally unique with idempotent get-or-create.
frontend/editor_app/lib/core/local_starter.dart—M/T → G/Pcodes;_seedStarterInboxAlongsideExistingscans_coursesfirst.frontend/editor_app/lib/core/helpers.dart—_normalizeEditorMode.frontend/editor_app/lib/core/draft_sync.dart—_normalizeEditorModeat two send sites.frontend/editor_app/lib/core/maintenance_actions.dart— stamplast_sync_erroron draft failure; persist.frontend/editor_app/lib/modules/learner.dart—_LearnerNoteCardcollapsed to single icon with four states.frontend/editor_app/lib/app_shell.dart—_parseNoteUuidFromUrlthinned to forwarder.frontend/editor_app/lib/core/auth_flows.dart— new top-levelparseNoteUuidFromFragment+ regex.frontend/editor_app/test/deep_link_parser_test.dart(new) — 12 regression cases.frontend/planner_app/lib/core/local_starter.dart—M → G.frontend/planner_app/lib/core/helpers.dart—_normalizeEditorMode.frontend/planner_app/lib/core/local_course_builders.dart—_normalizeEditorModeat two send sites.frontend/planner_app/lib/core/maintenance_actions.dart— same draft-failure stamping pattern.frontend/portal_app/lib/core/helpers.dart—_normalizeEditorMode.frontend/portal_app/lib/core/local_course_builders.dart—_normalizeEditorModeat two send sites.frontend/portal_app/lib/core/maintenance_actions.dart— same draft-failure stamping pattern.docs/TODO.md— deep-link regression-test entry removed (now covered bydeep_link_parser_test.dart).
Notes
- All four packages pass
flutter analyze(zero errors) andflutter test. Editor's test count grew from 1 (smoke) to 13 (1 smoke + 12 deep-link regressions). - Backend changes:
note_is_publicand the two non-owner filter sites are now strictly more restrictive than before — non- owners see fewer notes, never more. No data migration needed. Inbox public-notes promotion was an implicit feature, not an exposed API contract; users who relied on it (e.g. publishing a note by dropping it in their Inbox) need to explicitly toggleis_publicfrom now on. Backend syntactic check viaast.parse; not run-tested locally (no Django venv). - §1.5 1000-LOC cap respected; largest file is editor's
core/helpers.dartat 944 lines (grew slightly from_normalizeEditorModeaddition). - Sync-failed flag is stored under draft keys
last_sync_errorlast_sync_attempt_at. They're plain Map keys (no schema change), so older app installs reading the persisted draft list see them as harmless extra fields. Cleared automatically on successful sync because the draft is dropped from_localDraftsentirely.
- The "Inbox is private" semantic change is irreversible without another behavioral flip: pre-0.1.83 notes that lived in an Inbox and were seen as public by other users are now hidden from those non-owners. Owners can move those notes to a public-flagged category to restore visibility.