Notechondria

Version: 0.1.49 Build Date: 2026-04-20T10:00

What's Changed

Editor sidebar: Delete vs Unsubscribe by ownership

When a user long-presses / right-clicks a subscribed cloud category they don't own, the sidebar used to show Delete, which hit DELETE /courses/<id>/ and bounced back as a raw 403 ("You do not have permission to perform this action") — not the shape the user wanted to see.

The fix branches on ownership in the edit dialog:

  • Owned category (local or cloud where is_owned == true): existing behavior. Rename + icon + Delete visible. Deleting moves notes to the default category and calls DELETE /courses/<id>/.
  • Subscribed category owned by another user: rename / icon / Save hidden (those would also 403 server-side). The destructive action label flips to Unsubscribe and calls DELETE /courses/<id>/subscribe/ via the existing client.unsubscribeCourse method. On success the category is dropped from the sidebar; the course itself stays on the server so the user can resubscribe later.

Ownership is decided in _promptEditCategory before the dialog renders:

final isOwned = _isLocalCourse(course) || course['is_owned'] == true;

Local (negative-id) categories are always owned; for cloud courses we trust the is_owned flag that _decorateRemoteCourse writes when the authenticated user's username matches the course's owner.username. When ownership is unclear (no authenticated session at decoration time) we default to the read-only branch so the UI can't produce a backend 403.

Flow — unsubscribe path

  1. Long-press category row → _promptEditCategory(course).
  2. Dialog detects isOwned == false, shows subscribed-view copy
    • Unsubscribe button.
  3. User taps Unsubscribe → _confirmWithDelay(...) prompt.
  4. On confirm → _unsubscribeCategory(course) (new helper) → client.unsubscribeCourse(token, courseId) → removes the row from _courses, re-selects the default category if the unsubscribed one was active, logs shape-§1.7 line under Editor.Sync.Courses/unsubscribe.

TODO.md maintenance

Deleted three stale entries that were already shipped:

  • Invalid-token session-clear + bind short-circuit for planner and portal (landed 0.1.46).
  • Offline-mode toggle (landed 0.1.46 — a finer-grained follow-up remains noted under App preferences).
  • Editor "..." menu restructure (landed 0.1.46 — a follow-up to port the same pattern to planner/portal inline editors replaced the old entry).

Also removed the just-landed Category ownership entry.

Files Changed

New

  • docs/versions/0.1.49.md (this file).

Modified

  • VERSION: 0.1.48 → 0.1.49.
  • frontend/editor_app/lib/app_shell.dart: _promptEditCategory computes isOwned and routes the new unsubscribe action; new _unsubscribeCategory helper (§1.7-shaped telemetry + feedback); _EditCategoryDialog gains isOwned prop and branches its body + actions.
  • docs/TODO.md: stale entries removed.

Verification

  • editor_app / planner_app / portal_app: flutter analyze issue counts unchanged vs 0.1.48 (54 / 70 / 68); no errors. flutter test test/smoke_test.dart passes on all three.

Notes / follow-ups

  • Planner + portal parity not needed here. The Delete-vs-Unsubscribe mismatch was editor-specific — planner and portal already surface unsubscribe on their course detail pages, not via a sidebar long-press menu.
  • is_owned is frontend-derived. The backend CourseSerializer still doesn't emit an is_owned field; _decorateRemoteCourse compares username strings locally. If we ever need server-side enforcement (e.g. hiding the Delete button in a future admin context), add is_owned to the serializer.