Notechondria

Version: 0.1.77 Build Date: 2026-04-26T23:00

What's Changed

Three reported bugs and one feature, follow-ups to 0.1.76. The biggest change is replacing the is_default lock-bit semantics on the Inbox category with name-based protection, which fixes the "Restore default inbox" duplication bug and adds duplicate-name prevention as a side benefit.

Bug — restore-inbox produced duplicate Inboxes

User report: tapping "Restore default inbox" twice yielded two "Inbox" categories. Root cause from 0.1.76: my fix appended a fresh Inbox course unconditionally each call, with no de-dupe.

  • Rewrote _seedStarterInboxAlongsideExisting in frontend/editor_app/lib/core/local_starter.dart as reuse-or-create:
    • Scan _localCourses for a category whose title (case- insensitive) is "Inbox". If found, reuse it; if not, _buildLocalCourse a fresh one.
    • Welcome drafts are only seeded when the discovered Inbox has zero drafts pointing at it (checked via _decodeNoteMetadata on every local draft's course_id).
    • Net effect: tapping Restore N times yields exactly one Inbox and exactly two starter drafts, idempotently.
  • Log message branches between "created", "reused", and "refilled" so the debug log explains which branch fired.

Bug — is_default lock-bit on category protection

Per user direction: "Remove the hard coded locks attribute on whether the category could be deleted, just deny all the request for deleting the category named Inbox." The is_default boolean on Course was being used as a lock-bit in two places (PATCH and DELETE rejected when is_default=True); when local Inbox state disagreed with the server (or when restore-inbox produced a second "Inbox" without setting the bit), the protection was inconsistent. Solution: protect by name, not by flag.

Backend

  • backend/notes/api.py:
    • CourseDetailApiView.patch now allows description / icon edits on the Inbox row but rejects renames away from "Inbox" (case-insensitive). Renames TO an existing other category's title are also rejected (name uniqueness on update path).
    • CourseDetailApiView.delete rejects deletion when course.title.casefold() == "inbox" instead of when course.is_default == True.
    • CourseListApiView.post adds a name-uniqueness guard: creating a category whose title (case-insensitive) collides with the user's existing categories returns 400. Applies on both the upsert path (client_course_id match) and fresh creates, with excludeId for self-exclusion on rename.
    • Newly-created courses set is_default = (title.casefold() == "inbox") so the flag stays accurate for downstream legacy consumers (sort key, default-fallback course resolution, the "Inbox shows public notes" filter), even though it's no longer the protection mechanism.
  • TemplateCourseRestoreApiView is unaffected: it calls bootstrap_platform directly and uses Course.objects.get_or_create internally, so it's already idempotent and bypasses the new uniqueness check on the create endpoint.

Frontend

  • frontend/editor_app/lib/core/category_actions.dart adds two helpers and uses them throughout:
    • _isInboxCategory(course) — single source of truth for the Inbox check. Title-based (case-insensitive); deliberately does NOT consult is_default so UI doesn't disagree with the backend during sync.
    • _categoryNameExists(title, {excludeId}) — scans both _localCourses and _courses for a duplicate, optionally skipping a self-id so renames-to-self pass.
  • _createCategory now rejects duplicate names client-side (avoiding a server round-trip for the obvious case).
  • _updateCategory now rejects renames away from "Inbox" AND renames that would collide with another existing category.
  • _deleteCategory now uses _isInboxCategory instead of the is_default field check.
  • _unsubscribeCategory, _buildCategoryRow, and the delete-fallback "find Inbox to reassign notes" logic all switched to _isInboxCategory so the entire frontend agrees on what makes a row the Inbox.
  • _promptCreateCategory now surfaces the duplicate-name error via SnackBar (previously it swallowed ActionFeedback results).
  • Inbox protection dialog text updated to say "Inbox is the default category" instead of "This is the default category" for clarity.

Bug — "Local drafts only" filter didn't actually filter

User report: switching the dropdown to "Local drafts only" didn't change the visible list. Root cause: scopedLocalDrafts in core/build_helpers.dart always intersected local drafts with the active category, so on a cloud category with no local drafts the user saw an empty page (cloud notes hidden by scope, local drafts empty by category-filter).

  • Fix: core/build_helpers.dart now drops the category filter on local drafts when _learnerSearchScope == 'local', so the "Local drafts only" choice means "show ALL local drafts across all categories" — which is the dropdown option's obvious intent. Other scopes still scope local drafts to the active category. Behavior summary documented in the helper's comment.

Feature — Bootstrap-card-style cover image on public note cards

User request: show feature image in public note cards, blog-post- card style.

  • frontend/editor_app/lib/modules/learner.dart _LearnerNoteCard now wraps its existing content in a Column with a top NoteCoverImage banner (21:9 aspect ratio, clipBehavior: Clip.antiAlias on the Card so the corners round cleanly). The banner only shows on PUBLIC cloud notes — private cloud notes and local drafts skip it so they remain compact list rows.
  • Cover URL pulls from note['cover_image_url'] (already exposed by NoteSummarySerializer since 0.1.76). When empty, the same NoteCoverImage widget renders the deterministic theme-colored barcode placeholder so every public card has a visual top — no awkward image-missing gap.

Files Changed

  • VERSION — 0.1.76 → 0.1.77.
  • backend/notes/api.py — name-based Inbox protection (patch + delete), name-uniqueness on create, accurate is_default flag on new rows.
  • frontend/editor_app/lib/core/category_actions.dart_isInboxCategory, _categoryNameExists, swap throughout _createCategory / _updateCategory / _deleteCategory / _unsubscribeCategory / _buildCategoryRow / _promptEditCategory, surface create-failure SnackBar in _promptCreateCategory.
  • frontend/editor_app/lib/core/local_starter.dart_seedStarterInboxAlongsideExisting rewritten as reuse-or-create.
  • frontend/editor_app/lib/core/build_helpers.dart_learnerSearchScope == 'local' drops the per-category intersection on local drafts.
  • frontend/editor_app/lib/modules/learner.dart_LearnerNoteCard adds top NoteCoverImage banner on public cloud notes; small whitespace shift around the existing Row.

Notes

  • All four packages (editor / planner / portal / shared) pass flutter analyze with zero errors and flutter test smoke suites. Backend changes are syntactically valid (ast.parse) but not run-tested locally (no Django venv); the new uniqueness guard mirrors the existing slug-uniqueness shape so it should drop in cleanly.
  • is_default is still stored on the Course model — it's used for sort ordering, "Inbox shows public notes too" filtering, and finding the fallback default course after deleting another category. The lock-bit semantics are gone; the flag itself remains as a correctness hint for those legacy consumers.
  • Cover-image upload row in the metadata dialog is unchanged from 0.1.76 (still editor-only, owner-only, 5 MB cap).