Commit Graph
98 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 4.8 3958db0a8b desktop M10.4: fix stmt lifetime in reminders/search
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m20s
Both chained `stmt.query_map(...)?.collect()?` as the block's tail expression,
so the prepared statement (a block local) was dropped before the borrow held by
the mapped rows ended (E0597). Bind `let rows` first, matching every other query
in the file. rustc error, so this also unblocks test + the release build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 23:10:22 -04:00
bvandeusenandClaude Opus 4.8 41cf4b3598 desktop M10.5: wire the offline local source; boot to board with no server
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 57s
adapters/local.ts implements the repository seam over the M10.4 Tauri commands
via invoke (camelCase args -> the commands' snake_case params); adapters/index.ts
now selects local when running in the desktop shell, rest on web. bridge.ts
exports invoke for it.

This is the commit that resolves the black screen: a fresh desktop launch reads
config + session + notes from the on-device SQLite core, so the router's auth
gate passes with a synthetic local user and the board renders with zero server
and zero account. Account auth, device linking, attachment upload, URL unfurl and
file import reject with a "connect a server" message (no offline meaning yet);
everything else — board, editor, capture, search, filters, labels, checklists,
reminders — works fully offline.

desktop.yml also now rebuilds the app on frontend adapter/bridge changes, since
the desktop bundle embeds the frontend.

Task 1994.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 23:05:04 -04:00
bvandeusenandClaude Opus 4.8 dbe1425bd2 desktop M10.4: apply rustfmt (line wrapping only)
cargo fmt --check output from CI run 2849, applied verbatim: wraps long fn
signatures, query/execute calls, and method chains. No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 23:05:04 -04:00
bvandeusenandClaude Opus 4.8 ed1b3aa814 desktop M10.4: Rust local SQLite store + Tauri commands
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 24s
The on-device core that makes the desktop app work with no server and no login.

- rusqlite (bundled SQLite, so no system libsqlite dependency to vary across
  builds); uuid v4 ids; RFC3339/Date.toISOString-compatible timestamps.
- Schema mirroring the note model: notes, labels, note_labels (with via_tag),
  checklist_items, attachments, link_previews, note_revisions, saved_filters,
  plus per-row sync_revision/dirty + a sync_state row for the M10.7 engine.
  user_version-gated migrations.
- derive.rs: pure [[wiki-link]] + #tag scanners (mirror the frontend inline
  rules, no regex dep) with unit tests; #tags re-sync via_tag labels on save,
  [[links]] drive backlinks at query time (derived, never stored).
- store.rs: the full repository surface (facet/label/date/text list, create,
  PATCH-semantics update, pin/archive/color/kind, checklist items, labels CRUD
  + merge, reminders complete/snooze, reorder, trash/restore/delete, revisions
  + restore, titles/search/backlinks/link-search, saved filters).
- commands.rs: ~38 #[tauri::command]s over a Mutex<Connection> in managed state.
- lib.rs: opens the DB in the platform app-data dir on setup; synthetic offline
  config/user so the auth-gated router resolves with no login.

Attachment upload / URL unfurl / import are intentionally deferred (network/file
concerns); adapters/local.ts (M10.5) wires all of the above via invoke.

Task 1993.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 22:54:58 -04:00
bvandeusenandClaude Opus 4.8 20cf15c99c desktop M10.3: frontend data-source adapter seam (repo interface + rest.ts)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 29s
Extract a typed repository interface (adapters/repo.ts) from the scattered
store/view -> api.* calls, backed by adapters/rest.ts (verbatim HTTP mapping)
and selected through adapters/index.ts. Every store and the notes-facing views
now depend on `repo`, never the HTTP client directly -- the seam the offline
local source (M10.5, over Tauri invoke) plugs into next.

Behavior-preserving for web: rest.ts maps each semantic method to the exact
endpoint the code called before; query-string and multipart building moved out
of the stores/views into rest.ts (the one place that knows the URL shape).
Client-side logic (reconcile/sort/optimistic reorder/toasts) stays in the
stores. GraphView + admin SettingsView keep direct api calls -- out of the
offline-core scope (M10.5 is board/editor/capture/search/filter/labels/
checklists/reminders).

Task 1992.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 22:31:00 -04:00
bvandeusenandClaude Opus 4.8 b08cdb92b5 desktop AppImage: de-bundle host-coupled graphics libs (fixes black window)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Tauri's AppImage bundles the build host's graphics/display stack
(libEGL/libGL/libdrm/libgbm/libwayland-* + Mesa dri drivers) into usr/lib.
On many end-user systems (Arch/CachyOS, NVIDIA, Wayland) those clash with
the running kernel driver + Mesa and abort with EGL_BAD_PARAMETER -> a black
window (issue 2021). They load before any renderer choice, so the runtime
env fallbacks can't rescue it; per the AppImage excludelist they must come
from the host.

Add a CI-only post-build step (desktop/packaging/appimage/debundle-graphics.sh)
that extracts the built AppImage, strips exactly that graphics/display subset
(keeping webkit/gtk bundled for portability), and repackages in place so the
app falls through to the system's graphics libs. Wired into desktop.yml
between the Tauri build and the artifact upload.

Task 2023. Makes the AppImage the zero-install taste-test vehicle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 18:10:02 -04:00
bvandeusenandClaude Opus 4.8 98d918dda2 M10 (task 2022, issue 2021): Arch pacman package + commit icon set (native, system libs)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m27s
Native-first fix for the AppImage black window: a PKGBUILD that builds from source,
linked against SYSTEM graphics libs, so it renders on the host driver.
- desktop/packaging/arch/{PKGBUILD,thoughtsync.desktop,README.md}: `makepkg -si`
  installs /usr/bin/thoughtsync + .desktop + icon; deps webkit2gtk-4.1/gtk3/...;
  builds the frontend + `cargo build --release` (no tauri-cli). Uses the host
  graphics stack -> avoids EGL_BAD_PARAMETER.
- Commit the icon set (desktop/src-tauri/icons/*.png, un-gitignored) so BOTH
  `cargo build` (pacman) and `cargo tauri build` (deb/appimage) work without a
  generate step; bundle.icon -> the 4 committed PNGs; drop the `cargo tauri icon`
  step from desktop.yml.
- Broaden the WebKit software-render hardening (lib.rs) to ALL Linux (was
  AppImage-scoped) so the native build also renders if system WebKit is finicky.

Can't CI-test the PKGBUILD (Arch-only; CI is Debian) -- operator builds locally.
desktop.yml re-verifies the deb+AppImage build with the committed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 17:02:40 -04:00
bvandeusenandClaude Opus 4.8 3f5b87682e issue 2021: harden Linux WebKit rendering (fixes black AppImage window)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m12s
WebKitGTK's DMA-BUF/EGL renderer fails to init on many Linux GPU/driver/Wayland
setups -> 'EGL_BAD_PARAMETER' -> black window (known WebKitGTK issue, not app code).
Per Tauri's Linux-graphics guidance, set the software-fallback env vars at startup
before the webview is created, scoped to AppImage launches (native installs keep GPU
accel): __NV_DISABLE_EXPLICIT_SYNC / WEBKIT_DISABLE_DMABUF_RENDERER /
WEBKIT_DISABLE_COMPOSITING_MODE, each only if the user already set it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 14:38:27 -04:00
bvandeusenandClaude Opus 4.8 7ca09e9a01 desktop CI: pin upload-artifact@v3 (Forgejo rejects v4 protocol)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m9s
The build produces the .deb + .AppImage fine; upload-artifact@v4 failed with
GHESNotSupportedError (Forgejo has no v4 artifact API). v3 uses the older protocol
the instance accepts, so the bundles become downloadable from the run for
hand-testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 13:33:06 -04:00
bvandeusenandClaude Opus 4.8 404e256760 M10 (2013): rustfmt integration.rs (wrap perm chain)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m3s
cargo fmt --check wanted the fs::metadata(p).map_err(..)?.permissions() chain
wrapped (>100 cols). Fixes desktop build #2829; the frontend already typechecked
clean in that run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 13:26:28 -04:00
bvandeusenandClaude Opus 4.8 877a6a572f M10 (task 2013): integrated AppImage — app self-integration (OOBE + Account toggle)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 26s
CI & Build / Build & push image (push) Successful in 33s
The Linux AppImage can now install itself into the applications menu, so it behaves
like an installed app instead of a loose file.

- Rust (desktop/src-tauri/src/integration.rs): integration_status / integrate_desktop
  / unintegrate_desktop commands — detect $APPIMAGE, copy the AppImage to
  ~/Applications, write ~/.local/share/applications/thoughtsync.desktop + embedded
  icon, update-desktop-database. Registered in lib.rs.
- Frontend: withGlobalTauri exposes window.__TAURI__.core.invoke; desktop/bridge.ts
  (isDesktop + typed invoke, NO @tauri-apps/api dep -> web bundle unaffected);
  DesktopIntegrationPrompt (first-run OOBE, remembered) mounted in App.vue;
  AccountView "Desktop app" add/remove control. All desktop-guarded -> no-ops on web.
- desktop.yml: upload the .deb + .AppImage as a run artifact (continue-on-error) so
  the build is downloadable for hand-testing.

Verified by CI: ci.yml (vue-tsc) for the frontend, desktop.yml (cargo + tauri build)
for the Rust + AppImage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 13:24:20 -04:00
bvandeusenandClaude Opus 4.8 1607c77ee5 M10.8: desktop CI lane (.forgejo/workflows/desktop.yml) + app-icon source
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m19s
Separate workflow (not ci.yml) so the ~20-40min Rust/AppImage build only runs on
desktop/** changes, not every backend/frontend push. Builds on the ci-tauri:1.97
image: frontend build -> `cargo tauri icon app-icon.png` (from a committed 1024px
source PNG, sidestepping SVG-input questions) -> cargo fmt --check -> clippy -D
warnings -> cargo test -> `cargo tauri build` (deb + AppImage).
APPIMAGE_EXTRACT_AND_RUN=1 for FUSE-less CI containers. Verifies the M10.2 scaffold
end-to-end and is the foundation for the integrated-AppImage work (task 2013).
Also updates ci-requirements.md with the desktop lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 12:26:24 -04:00
bvandeusenandClaude Opus 4.8 db24d7ea18 M10.2: Tauri v2 desktop scaffold (desktop/) — Vue frontend as the webview
New desktop/ Tauri v2 project (Linux-first, cross-platform-ready):
- src-tauri: Cargo.toml (lib + thin main.rs shim), build.rs, lib.rs (Builder
  entry point), tauri.conf.json (frontendDist -> ../../frontend/dist, devUrl
  :5173, deb+appimage bundles), capabilities/default.json (core:default),
  .gitignore.
- The shared Vue 3 frontend is the sibling ../frontend; before-commands cd via
  "$(git rev-parse --show-toplevel)/frontend" since frontend and src-tauri are
  siblings, not nested.
- Icons generated from frontend/public/icon.svg via `cargo tauri icon` in CI
  (M10.8), not committed.

Boots the shared UI in a native window. The local data adapter (M10.3/M10.5)
and CI build verification (M10.8) follow. desktop/** is not yet in the CI paths
filter — added with the desktop lane in M10.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 11:13:09 -04:00
bvandeusenandClaude Opus 4.8 36b8f65dc6 M9 S1d: split the notes.py monolith into a cohesive package
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 31s
The 1574-line notes.py becomes a `notes/` package. The heavy shared logic moves
into focused modules; the route handlers + blueprint registration stay together
in __init__ so registration is trivially correct (most routes have no CI
auth-test that would otherwise catch a route silently dropping out):

- notes/_bp.py         — the Blueprint (isolated so route modules could import it
                          without a cycle; also the seam for a later route split).
- notes/serialize.py   — note (+labels/items/attachments/previews) serialization.
- notes/links.py       — [[wiki-link]] + #tag parsing and reconciliation.
- notes/recurrence.py  — recurring-reminder next-occurrence math.
- notes/helpers.py     — display-title/empty/filter/owner-fetch + filename/slug utils.
- notes/import_export.py — export markdown + Keep/native import specs + zip budget.
- notes/__init__.py    — the `/api/notes` routes + re-exports the external surface
                          (app.py imports `bp`; sync.py + tests import helpers).

Pure reorganization — no behavior change (routes/helpers moved verbatim). Callers
(app.py, sync.py, test_notes.py) are unchanged: `from thoughtsync.notes import X`
resolves via the package __init__ (rule 22 — the package replaces the module).
No import cycle (nothing in the package's dep chain imports notes; only app.py +
sync.py consume it). New test_all_note_routes_registered asserts all 29 route
endpoints are attached, so CI catches any module that fails to register. Runtime
DB behavior operator-verified on deploy (no Postgres CI lane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 23:50:48 -04:00
bvandeusenandClaude Opus 4.8 44a5466793 M9 S5 (frontend): shared BaseModal for the standard modals + BaseInput in Account
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 27s
- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay,
  bordered rounded panel, role=dialog, close on Escape + backdrop mousedown).
  Caller sizes/pads/shadows the panel via `panelClass`, picks start/center
  `align`, and sets an `ariaLabel` for header-less panels.
- LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop
  their hand-rolled backdrop+panel shells and slot their content into BaseModal
  (~12 lines of overlay boilerplate each → gone).
- NoteEditor deliberately keeps its own shell: its backdrop mousedown is
  drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety),
  so folding it in would risk regressing the app's core editing surface (rule 28).
- AccountView's one device-name field now uses the shared BaseInput. SettingsView
  is intentionally NOT converted — its rows are a horizontal label+control pattern
  (checkbox/number/text, direct value mutation), a different shape than BaseInput's
  vertical form field.

Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:58:59 -04:00
bvandeusenandClaude Opus 4.8 bdc5504419 M9 S5 (backend): _serialize_device adopts common.iso()
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Build & push image (push) Successful in 34s
The two `x.isoformat() if x else None` copies in auth's device serializer
now use the shared iso() helper — completes the isoformat-idiom sweep
outside notes.py (auth + sync done; notes.py's remain, tied to its split).
_serialize_user is unchanged (no datetime, no cross-module duplicate) and
stays in auth.py rather than relocating for no DRY gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:51:27 -04:00
bvandeusenandClaude Opus 4.8 9c679d0ab9 M9 S5: guard the login ?redirect= against open redirect
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 30s
LoginView handed route.query.redirect straight to router.replace, so a
crafted link like /login?redirect=//evil.com (or a backslash variant) could
bounce a just-authenticated user off-site. safeRedirect() now only follows an
in-app absolute path — a single leading slash, rejecting "//host" and "/\\host"
(and anything without a leading slash, i.e. absolute/scheme URLs) → falls back
to "/". Frontend-only; CI vue-tsc is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:48:41 -04:00
bvandeusenandClaude Opus 4.8 be34fe8619 M9 S4: sync adopts serialization/parse_dt toolkit + normalizes push oracle
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 32s
DRY:
- serialize.py: serialize_label_sync(label) = base serialize_label + the
  delta-only fields (sync_revision/purged_at/created_at via iso()). sync's
  changes() adopts it; the local _serialize_label_row near-dup is gone.
- sync adopts common.parse_dt (drops the byte-identical _parse_client_dt;
  4 call sites) and common.iso for the note delta augmentation. (Manual-label
  reconciliation was already shared in S3.) Fully folding the note
  re-augmentation into the serializer waits on the notes.py split.
- test_sync: drops the now-redundant _parse_client_dt test (parse_dt is
  covered in test_notes) + its dead import.

Security (issue — push existence-oracle): a foreign-owned id on push was
rejected with "not yours", distinguishing "another user's note" from a free
id. A legit client only pushes ids of notes it created, so that branch is
only hit by a probe (or ~0-prob UUID collision) — now a GENERIC "cannot apply"
rejection that doesn't confirm the id exists. The residual create-vs-reject
status difference is inherent to client-chosen ids over a global PK and is
practically unexploitable (a shared note already exposes its id to recipients).

Sync behavior operator-verified on deploy (no Postgres CI lane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:45:36 -04:00
bvandeusenandClaude Opus 4.8 e1cf63e875 M9 S3 (frontend): consolidate local-date helpers into notes/datetime.ts
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 30s
The created-date range facets built local-day bounds by hand in two places
(FilterBar's onTo/toInput, TimelineView's buildQuery) — parse a
"YYYY-MM-DD", shift a day for the half-open upper bound, format back.

- datetime.ts: parseLocalDate() / addLocalDays() (non-mutating) / formatLocalDay().
- TimelineView: drops its inline localDate() + the +1-day Date math.
- FilterBar: drops its inline isoDay() + the setDate(±1) mutations.

Behavior-preserving and deliberately NOT unifying output: Timeline still
emits UTC (.toISOString()) bounds, FilterBar still emits naive-local
"…T00:00:00" strings — only the shared primitives are extracted. (The
naive-vs-UTC divergence is a separate backend-datetime-semantics question,
flagged for later, not silently changed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:40:13 -04:00
bvandeusenandClaude Opus 4.8 590d3ff2f6 M9 S3 (frontend): recall views adopt useNoteList + AsyncState/EmptyState
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
The Search / Timeline / Reminders views each hand-rolled the same
items+loading+error scaffold, retry button, and editor-host glue.

- useNoteList(fetcher, fallbackError) (new): the load-a-note-list scaffold
  (items/loading/error + a load() that never leaves a half-state). Views
  supply just the fetcher; the refs drive <AsyncState>.
- SearchView / TimelineView / RemindersView: adopt useNoteList + useNoteEditor
  + <AsyncState>/<EmptyState>; drop the local list/loading/error refs, the
  duplicated retry blocks, and the notes.items-shadowing navigate glue.
- GraphView: editor host now via useNoteEditor (openNode/closeEditor/onNavigate
  collapse to navigate); loading/error via <AsyncState>. Its two empty states
  keep inline markup/buttons, so they stay custom (not forced into EmptyState).
- reminders store: fetchReminders() is the single owner of /api/notes/reminders;
  both the background poll (check) and RemindersView read through it.

Frontend-only; CI vue-tsc is the type gate (no local typecheck, rule 10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:36:24 -04:00
bvandeusenandClaude Opus 4.8 f1033da75e M9 S3 (backend): organize routes adopt toolkit + shared label reconciliation
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 34s
DRY across the "organize/recall" backend surface:

- serialize.py (new): serialize_label(label) — the base {id,name,color}
  shape. labels.py builds on it (adds count); sync deltas will (S4).
- labeling.py (new): resolve_owned_label_ids() + reconcile_manual_labels()
  — the "set a note's MANUAL (picker) labels, leave the via_tag rows alone"
  logic was duplicated line-for-line between notes.set_note_labels and
  sync._apply_note_manual_labels. Now one home; both adopt it (removes the
  redundant `chosen`==owned recompute in notes). Behavior-preserving.
- labels.py: json_error/not_found/parse_uuid, colors.normalize_color, and
  serialize_label; dropped local LABEL_COLORS + _normalize_label_color
  (NOTE_COLORS is the single palette) and `import uuid` (rule 22).
- saved_filters.py: json_error/not_found/parse_uuid for its 2 uuid parses
  + error shapes.
- graph.py: no change — no error/uuid/palette-normalize duplication to fold.

Test: DB-free test_serialize_label_shape guards the base shape.
sync.py's reconciliation swap is behavior-identical; operator-verified on
deploy (no Postgres CI lane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:26:59 -04:00
bvandeusenandClaude Opus 4.8 e8e0d86413 M9 S2: fold raw multipart fetches into api.postForm
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s
Both notes-store uploads (uploadAttachment, importNotes) hand-rolled the
same fetch + resp.json() + !ok error parsing that api.client already does.
Add `api.postForm<T>(path, form)`: request() now detects a FormData body
and lets the browser set the multipart Content-Type (skipping the JSON
header + stringify), reusing the shared error handling — so the two
uploads gain network-error handling and the 5xx infra toast they lacked.
A too-large import returns 413 (< 500), so it still throws for inline
display rather than toasting.

DRY: net -13 lines; no raw fetch() remains in the stores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:18:37 -04:00
bvandeusenandClaude Opus 4.8 3a4c3c8164 S2: fix zip-bomb on import + SVG stored-XSS on attachment download
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 30s
M9 section S2 — two real security fixes in notes.py:

- Zip decompression bomb (issue #1980): note import read each zip entry with a
  whole-entry zf.read() and no cap, so a small archive could inflate to GBs and
  exhaust memory/disk. Add _ImportBudget — streams entries with a per-entry (64MB)
  and cumulative (512MB) decompressed cap, raising _ImportTooLarge past either;
  reject >10k entries up front; abort → 413 with the transaction rolled back.

- SVG stored-XSS (issue #1981): attachment download served anything image/* inline,
  so an image/svg+xml attachment could execute script in-origin — and notes are
  shareable (rule 47), so this hit shared-note viewers. Inline now allowlists the
  trusted raster types only (png/jpeg/gif/webp); svg/html/xml/etc. download.

Verified py_compile + ruff. Runtime (importing a bomb, opening an SVG) is
operator-verified on deploy — no Postgres CI lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 19:47:53 -04:00
bvandeusenandClaude Opus 4.8 18ca4d4db4 S1: frontend infra — AsyncState/EmptyState + useNoteEditor, adopted in BoardView
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 30s
M9 section S1, commit 4 (frontend). Introduce the shared UI primitives the 7 data
views + 5 editor hosts were hand-rolling:

- components/AsyncState.vue: the loading / error(+Retry) wrapper (emits `retry`).
- components/EmptyState.vue: the centered title/subtitle "nothing here" block.
- composables/useNoteEditor.ts: the editing/open/close/navigate glue every editor
  host duplicated, as one controller (onClose hook + local-list resolution for
  [[wiki-link]] navigation).

BoardView adopts all three: its three hand-rolled loading/error/empty blocks
collapse into <AsyncState> + <EmptyState>, and its editor glue into useNoteEditor.
The other views + editor hosts adopt these in the Organize (S3) and Auth (S5)
sections. Behavior-preserving.

Frontend has no local typecheck (rule 10); CI's vue-tsc is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 19:40:37 -04:00
bvandeusenandClaude Opus 4.8 61f7c40db7 S1: iso() datetime helper + one shared colour normalizer (colors.py)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Build & push image (push) Successful in 29s
M9 section S1, commit 3 — two more shared-toolkit pieces:

- common.iso(dt): the "x.isoformat() if x else None" idiom (repeated 20+ times
  across every serializer) as one helper. Adopted in Note.serialize() and the
  revision serializer; other serializers adopt it in their sections.
- colors.py: NOTE_COLORS (canonical, on the model) + a single normalize_color().
  notes.py now imports the palette + normalizer from here and drops its local
  copy. labels.py's identical LABEL_COLORS/_normalize_label_color fold into this
  in the Organize section (S3); sync in S4.

normalize_color and NOTE_COLORS remain importable from thoughtsync.notes (used by
tests + sync), so nothing downstream breaks. common has no in-app imports, so the
model→common→colors chain has no cycle. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 19:33:07 -04:00
bvandeusenandClaude Opus 4.8 6abc82c783 S1: unify error responses + id parsing (responses.py) across notes.py
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 33s
M9 section S1, commit 2. Add src/thoughtsync/responses.py — the app's single
JSON-error shape and the two guards that pair with it:
  - json_error(message, status): the one ({"error": ...}, code) builder
  - not_found(): the standard 404, by far the most common note-route error
  - parse_uuid(raw): parse a path/body id, None on malformed → pair with not_found()

notes.py adopts them everywhere: ~25 hand-built `jsonify({"error":"not found"}),404`
collapse to not_found(); ~20 other error returns to json_error(...); ~13 repeated
`try: uuid.UUID(x) except: ...` blocks to parse_uuid(). Behavior-preserving — same
bodies and status codes, one definition. jsonify stays for the success responses.

Other blueprints (auth, labels, saved_filters, sync, settings_api) adopt the same
helpers in their own M9 sections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 19:29:46 -04:00
bvandeusenandClaude Opus 4.8 2abed7132c S1: shared value helpers (parse_dt/coerce_bool) + auto-Secure session cookie
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 37s
M9 hardening/DRY pass — section S1, commit 1 (the shared-toolkit foundation):

- Add src/thoughtsync/common.py with parse_dt() and coerce_bool(): one home for
  the ISO-date and truthy-flag coercions that were duplicated across modules.
  notes.py adopts them and deletes _parse_iso_dt, _iso_to_dt and _truthy
  (rule 22 — old copies removed; callers, incl. tests, updated).
- Security: the session cookie is now marked Secure automatically on any request
  that arrived over HTTPS (directly or via a proxy's X-Forwarded-Proto), via a
  SecureCookieSessionInterface override. Hardens HTTPS deployments without
  breaking plain-HTTP LAN installs — no config.

Behavior-preserving refactor + one security hardening. The backend serialization
layer, the json_error sweep, and the notes.py split follow as their own commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 19:24:36 -04:00
bvandeusenandClaude Opus 4.8 3c78060051 Header: use the app icon instead of the "TS" text badge (links home)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 33s
The header showed a plain "TS" text badge while the real brand icon
(public/icon.svg — the yellow knowledge-graph tile) was only used as the
favicon/PWA icon. Swap the badge for the actual icon, and make the logo +
name a RouterLink to the board (standard "logo goes home").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 18:07:26 -04:00
bvandeusenandClaude Opus 4.8 1df70c53bd Capture: Enter-to-start + on-board notice; dismiss discards a new note
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
Live-pass feedback: make the "waiting for input" state visible and starting
a note more deliberate, and let dismiss cancel an accidental note.

- New notes are now a confirm-to-keep dialog: Esc OR click-away DISCARD a
  brand-new, not-yet-persisted note (so an accidental keystroke / type-to-
  compose never litters); Ctrl/Cmd+Enter, the footer button, or Shift+Enter
  commit it. A compose already persisted by a rich action, and any existing
  note, still close-and-save on dismiss. The compose footer button is now a
  filled "Add note" so the save path is unmistakable.
- Enter (board, nothing focused) starts a new note; a subtle dashed on-board
  notice — "Press Enter or start typing to add a note" — makes capture
  discoverable instead of silent (click it to compose too). Type-to-compose
  stays as the fast path.
- Empty-state copy + shortcuts help updated (Enter/c for a new note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 14:11:11 -04:00
bvandeusenandClaude Opus 4.8 675b3aa248 Capture UX: "+ New" header button + type-to-compose (two-mode board keys)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 30s
Live-pass feedback: the wide "Take a note…" bar showed through behind the
compose modal and felt redundant. Per operator choice, remove the bar and
make capture header-button + keyboard driven.

- Removed the board's inline "Take a note…" trigger bar.
- AppShell gains a "+ New" header button (next to search) — navigates to the
  board if needed, then opens the compose modal. The `c` shortcut now does
  the same (unified with newNote()).
- Type-to-compose: on the board with no card focused, any other single
  printable key opens a new note SEEDED with that key (AppShell onKeydown
  fall-through, after the reserved / c ? g shortcuts). Seed travels via
  ui.composeSeed → NoteEditor's new `initialBody` prop; caret placed at end.
- Two-mode board keyboard (BoardView): RESTING = arrows enter browse, letters
  type-to-compose; BROWSING (a card focused) = j/k move, e/x/# act, Enter
  opens, Esc exits to resting. ui.boardCardFocused tells the global handler
  to stand down while browsing so it doesn't swallow card keys.
- Updated the shortcuts help + the empty-state copy ("Hit + New — or just
  start typing").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 13:59:23 -04:00
bvandeusenandClaude Opus 4.8 f9f1b77d37 Unify compose + edit onto one surface (modal-only editor)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 30s
Live-pass feedback: compose and edit still felt like different surfaces.
They already shared one component (task 1920), but rendered as two frames —
an inline in-flow box (compose) vs a modal overlay (edit) — which read as
two designs. Per operator choice, make BOTH the modal.

NoteEditor is now modal-only (rule 22 — the inline frame is fully removed):
dropped the `inline`/`autofocus` props, the collapsed "Take a note" frame,
`expanded`, `open()`, `commitInline`, `autoGrow`, and the outside-click
commit. Compose vs edit is purely note=null vs a note. Esc / Ctrl+Enter /
backdrop / Done all commit-and-close (create in compose, save in edit);
Shift+Enter still saves & starts a fresh note in compose (now gated on
isCreate, not the frame). Edit-only sections gate on !isCreate.

BoardView: the always-expanded inline composer becomes a slim "Take a
note…" trigger bar that opens the SAME modal with an empty note; the `c`
shortcut does likewise. One surface for capture and editing — and the
seam the card→editor grow animation (1914) will hook into.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 12:57:03 -04:00
bvandeusenandClaude Opus 4.8 d1bc91a54a M6 1908b: foreground reminder delivery + recurrence/snooze UI (frontend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 29s
Completes 1908 without Web Push. While the app is open, due reminders now
actually surface; recurring reminders + snooze/done are manageable.

- reminders store (singleton): polls /api/notes/reminders every 45s while
  the app is open; each due reminder fires ONCE as a toast (with an "Open"
  action) and, if the user opts in, a page-context OS Notification — no
  service worker, no PWA. Silently primes a stale backlog on first load;
  only announces recently-due ones. AppShell starts/stops it.
- Editor reminder section: a Repeat picker (Does not repeat / Daily /
  Weekly / Monthly / Yearly) + Done (advances a recurring reminder / clears
  a one-off) + Snooze 1h/1d, shown when a reminder is set.
- RemindersView rebuilt as a chronological list: per row a due time +
  recurrence badge + Done / 1h / 1d, click to open; plus an "Enable
  notifications" opt-in and a note that background alerts come with native.
- Note type gains `recurrence`; notes store setRecurrence /
  completeReminder / snoozeReminder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 11:58:44 -04:00
bvandeusenandClaude Opus 4.8 882d4206aa M6 1908a: recurring reminders + complete/snooze (backend, no web-push)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 32s
Per operator: skip Web Push; build the rest of reminder delivery. This is
the client-agnostic half — the model + logic that foreground/native
delivery drives.

- notes.recurrence (migration 0022): daily/weekly/monthly/yearly or null.
  update_note accepts it (cleared when the reminder is cleared); rides
  export/import + sync push. Serialized on the note.
- Pure next_occurrence(remind_at, recurrence, after): the next fire strictly
  after `after`, rolling past missed occurrences; _add_months clamps the day
  to the target month (Jan 31 → Feb 28).
- POST /api/notes/<id>/reminder/complete — a recurring reminder advances to
  its next occurrence; a one-off clears. POST .../reminder/snooze {minutes}
  → remind_at = now + minutes (1 min .. 30 days).

No VAPID / push-subscription / service-worker — foreground + native delivery
land in the UI commit and the native clients.

Tests (DB-free): normalize_recurrence; next_occurrence (daily/weekly/
monthly-clamp/skip-missed/yearly/none); complete + snooze auth-guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 11:53:07 -04:00
bvandeusenandClaude Opus 4.8 5c045aed63 M6 1902b: facet bar + saved views UI (frontend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 37s
The dead-simple facet bar over the board + saved views in the sidebar,
completing task 1902. Filter state lives in the URL query, so a filtered
board is a shareable lens and a saved view is just a link ("one space,
many lenses").

- notes/facets.ts: facetsFromQuery / facetsToQuery / facetCount helpers.
- FilterBar.vue (board only): a "Filters (N)" toggle expanding to text
  search + color swatches + label chips + has-reminder / has-attachment /
  Lists / Notes toggles + a created-date range; Clear + "Save view".
  Each control writes the URL query (router.replace).
- notes store: load(view, label, facets) builds the query; NoteFacets type
  + activeFacets; import reload preserves active facets.
- savedFilters store + sidebar "Views" section (each a query-link, delete
  on hover); loaded on mount.
- BoardView derives facets from the query, reloads on facet change (ignores
  ?open=), and shows a "no notes match these filters" empty state.
- Backend: saved-filter param whitelist keys on `label` (matches the
  repeatable ?label= query) so saved views keep their labels. New filter
  icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 08:45:56 -04:00
bvandeusenandClaude Opus 4.8 5fdc124c77 M6 1902a: richer facet query + saved-filters storage (backend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
GET /api/notes gains combinable, AND-ed facets alongside the existing
filter/date/sort: multiple ?label= (notes with ALL), ?color, ?kind,
?has_reminder, ?has_attachment, and ?q (full-text over title+body, ranked)
— so the facet bar's text box searches, not just filters. All optional;
invalid color/kind → 400.

saved_filters table (migration 0021) + /api/saved-filters CRUD (list /
create / rename+repoint / delete, owner-scoped). `params` is a JSON facet
dict mirroring the query surface; clean_params() whitelists facet keys so a
saved view can't accumulate junk.

Tests (DB-free): _truthy, clean_params key-whitelisting, saved-filters
auth-guards. UI (facet bar + saved-views sidebar) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 08:36:49 -04:00
bvandeusenandClaude Opus 4.8 69bf04e948 M6 1901: URL capture with link-preview unfurl (SSRF-hardened)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 33s
Paste a link → fetch its OpenGraph/meta preview (title, description, image,
site) and show a rich card. User-triggered + persisted (never auto-fetches;
cached so it never re-fetches). Opt-in via a new admin setting
enable_url_unfurl (default on, rule 26).

Security (the whole point of this task): a new dependency-free unfurl.py
does the fetch with layered SSRF defenses — http/https only; resolve the
host and reject EVERY non-public address (private/loopback/link-local/
reserved/multicast/unspecified — blocks 169.254.169.254 etc.); connect to
the vetted IP with SNI so DNS-rebinding can't slip through; ≤3 redirects
each re-validated; 5s timeout; 512 KB cap; text/html only; blocking IO in a
worker thread. No server-side image fetch — the og:image URL is loaded by
the browser.

- note_link_previews table (migration 0020), one per (note, url); serialized
  inline on notes (+ rides the sync pull feed read-only).
- POST /api/notes/<id>/unfurl {url} (owner-scoped, setting-gated, 502 on
  fetch failure); DELETE /api/notes/<id>/previews/<id>.
- enable_url_unfurl exposed in public config so the UI hides the affordance
  when disabled.

Frontend: LinkPreview.vue card; editor detects URLs in the body and offers a
"Preview <domain>" chip per un-previewed link (ensureDraft first), renders
preview cards with remove; card shows previews read-only. New link icon;
notes-store unfurl()/deletePreview().

Tests (DB-free): is_public_ip range blocking, validate_url scheme/parts,
extract_preview (OG + <title> fallback + relative-image resolve), endpoint
auth-guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 08:05:24 -04:00
bvandeusenandClaude Opus 4.8 b5f545f655 M6 1900: any-file attachments + audio memos (broaden beyond images)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 32s
A note can now carry any file, not just images — PDFs, documents, audio
memos, etc. "Dump anything" capture.

Backend:
- note_attachments.filename (migration 0019) records the original name for
  download + display.
- Upload drops the image-only mime gate: accepts any type, derives the
  storage extension from the filename, and enforces a DB-backed per-file
  cap — new setting max_attachment_mb (default 25, rule 25). App body
  ceiling raised 12→64 MB (also lifts the import-zip / sync-push limits);
  the per-file cap is the effective attachment limit.
- Serve sets Content-Disposition: images inline, everything else downloads
  with its original (header-sanitized) filename.
- Import (native + Keep Takeout) now brings in ANY attachment, not just
  images — completing the Keep audio-memo gap; preserves filename + sha256.
- Attachment metadata (delta feed + REST) carries filename.

Frontend:
- Editor renders attachments by kind: images inline (thumbnail), audio via
  an inline <audio> player, any other file as a download chip (paperclip +
  filename + size). File picker accepts any type; "Attach a file".
- Card previews the first image; non-image files show as compact chips.

Tests (DB-free): _safe_filename, _attachment_ext, _header_filename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 23:37:43 -04:00
bvandeusenandClaude Opus 4.8 7dd74d2946 Sync 6: sync protocol doc (docs/sync.md)
The contract the Tauri/Android clients implement against: device-token auth,
the shared-sequence revision cursor, note-as-sync-unit (+ derived links/tags
not synced), trash vs purge tombstones, pull (GET /changes) + push (POST
/push) request/response shapes, last-write-wins + history conflict policy,
attachment blob sync by id + sha256, and the idempotent/resumable sync cycle
(initial since=0 + resume). Docs only — CI paths exclude *.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 23:13:38 -04:00
bvandeusenandClaude Opus 4.8 0ca244be3d Sync 5: attachment blob sync — client id + content hash (M8)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 44s
Let native clients sync attachment blobs deterministically:
- note_attachments gains sha256 (migration 0018, nullable, no backfill).
  The delta feed's attachment metadata now carries size + sha256 so a
  client knows exactly which blobs it already has (dedupe) and can verify
  integrity after download.
- Upload accepts an optional client-supplied attachment id (multipart form
  field), so a file attached offline keeps its identity across sync;
  re-uploading an id the note already has is an idempotent no-op. The
  server hashes the stored bytes (sha256) on upload. Download by id already
  exists (owner/shared scoped).

Frontend Attachment type carries the new optional size/sha256.

(Still image-only mimes — broadening to any-file is task 1900. Blob sync
behavior is operator-verified on deploy.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 23:12:21 -04:00
bvandeusenandClaude Opus 4.8 68abaa0f3f Sync 4: push endpoint POST /api/sync/push (LWW + history snapshot)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 47s
The core conflict-resolution step. Applies a batch of client changes,
additive + owner-scoped, with last-write-wins by client edit-time — and a
version-history snapshot on every overwrite so nothing is ever lost.

client_wins(client_edited_at, server_edited_at): apply iff client >= server;
a missing client time never overwrites a real server edit; a missing server
time (new row) yields. Notes compare against updated_at; labels gain an
updated_at (migration 0017, backfilled from created_at) as their LWW field.

Notes:
- upsert with a client-supplied id: create if absent, else LWW-apply the
  full note state (title/body/color/kind/pins/trash/remind/position/items/
  manual label_ids) with the same ripple as a web edit — derive_display_title,
  _rewrite_links, _reconcile_tags (#tags), _rename_inbound_links. Overwriting
  an existing title/body snapshots the old version into note_revisions first.
  A resurrected tombstone clears purged_at.
- delete: purge tombstone (drop children + attachment files, clear content,
  set purged_at), LWW-guarded so a newer server edit survives a stale delete.

Labels: upsert (create/rename/recolor) + delete (detach from notes, tombstone),
LWW-guarded; per-owner name-uniqueness clash on a different id is rejected
rather than raising.

Response: per-item {status: created|applied|kept|noop|rejected, sync_revision};
the client pulls afterward to converge. Whole-note semantics (client sends the
full state, not a partial patch).

Tests (DB-free): client_wins across all edit-time combinations; _parse_client_dt;
push auth-guard. Apply behavior + triggers operator-verified on deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 23:09:23 -04:00
bvandeusenandClaude Opus 4.8 8e40ea1188 Sync 3: pull endpoint GET /api/sync/changes (M8)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 32s
Delta pull for native clients: returns every note + label the caller owns
whose sync_revision advanced past ?since=<cursor>, ascending by revision,
paginated (?limit, default 500 / max 1000), with the next cursor +
has_more. since=0 is a full initial sync. Web app unaffected (new blueprint).

Notes and labels share one revision sequence, so the cursor is a single
watermark. _page_cursor() handles the two-stream paging: when either stream
fills its page, it advances only to the SMALLER of the two page boundaries
so nothing between the cursor and the next pull is skipped. Notes reuse
_serialize_notes (items/labels/attachments inline) + sync_revision +
purged_at (tombstone); labels carry name/color/purged_at/sync_revision.

Returns ALL of the owner's notes regardless of state (active/archived/
trash/purged) — a client mirrors everything. Registered sync blueprint.

Tests (DB-free): changes auth-guard; _parse_since / _clamp_limit
validation; _page_cursor across empty / drained / one-full / both-full.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 23:03:06 -04:00
bvandeusenandClaude Opus 4.8 3c76b50a9c Sync 2: device-token bearer auth + linked-devices UI (M8)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 33s
Native clients (Tauri/Android) authenticate sync with a long-lived device
bearer token, alongside the existing web session cookie.

Backend:
- security.py: generate_token() (secrets.token_urlsafe) + hash_token()
  (SHA-256 — device tokens are already high-entropy, so no slow KDF; keeps
  per-request bearer auth cheap). Only the hash is stored.
- device_tokens table (migration 0016): id, user_id, token_hash (unique),
  name, created_at, last_used_at.
- login_required now accepts `Authorization: Bearer <token>` OR the session
  cookie. Session path stays DB-free (fast); bearer path looks up the token
  hash, sets g.user_id, and stamps last_used_at.
- Endpoints: POST /api/auth/device-login (public; email+password → token,
  the native first-link flow), POST /api/auth/devices (session/bearer →
  token, web "link a device"), GET /api/auth/devices (list), DELETE
  /api/auth/devices/<id> (revoke). All owner-scoped; token shown once.

Frontend:
- Per-user (not admin) /account view "Linked devices": create a token
  (one-time reveal + copy), list devices (name, linked/last-synced), revoke
  with confirm. Top-bar device icon for all users; devices Pinia store.

Tests (DB-free): token hash determinism + uniqueness; device endpoints
auth-guard (401 without auth, before DB); device-login input validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 22:59:52 -04:00
bvandeusenandClaude Opus 4.8 58b88d2622 Sync 1: revision + tombstone schema (M8 sync hub foundation)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
The delta-sync substrate for the local-first native clients (Tauri,
Android). No API behavior change — pure infrastructure; triggers are
operator-verified on deploy (no Postgres CI lane).

Migration 0015:
- CREATE SEQUENCE sync_revision_seq.
- notes + labels gain sync_revision (bigint) + purged_at (tombstone),
  with existing rows backfilled to distinct increasing revisions.
- Trigger ts_set_sync_revision() BEFORE INSERT OR UPDATE on notes+labels
  stamps a fresh monotonic revision from the sequence, so no mutation
  site can forget to bump it (robustness over app-level bumps).
- Trigger ts_bump_parent_note_revision() AFTER INS/UPD/DEL on note_items,
  note_attachments, note_labels re-bumps the parent note, since a note
  syncs as a whole (items/labels/attachments travel inline).
- Indexes (owner_id, sync_revision) on notes + labels for the delta pull
  WHERE sync_revision > cursor.

purged_at is the hard-delete tombstone (distinct from deleted_at = trash)
so an offline client learns a row is gone instead of resurrecting it.
Model columns added to Note + Label (nullable; trigger populates them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 22:53:34 -04:00
bvandeusenandClaude Opus 4.8 333ab9ce74 Import: ThoughtSync-native round-trip + Google Keep Takeout
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
Complete the export/import pair (task 1907). POST /api/notes/import takes
an uploaded .zip and appends its notes — never overwriting existing ones.

Two formats, auto-detected:
- ThoughtSync export: recognized by its notes.json (app == thoughtsync);
  round-trips title/body/color/kind/pinned/archived/remind_at/timestamps/
  labels/items and re-attaches image media from the zip.
- Google Keep Takeout: each Keep <note>.json → a note. Maps title,
  textContent/listContent (+ checked), labels, Keep color enum (nearest
  palette match), isPinned/isArchived, isTrashed (→ trash), created/edited
  microsecond timestamps; folds annotation URLs into the body; resolves
  attachment filePaths relative to the note's folder.

Imported notes reuse create_note's derivation + reconciliation:
display-title derive, #tag reconcile, [[wiki-link]] rewrite. Explicit
labels attach as manual (via_tag=false); inline #tags reconcile as tags.
Image attachments copied into media storage; non-image types (e.g. Keep
audio) skipped until any-file attachments land.

Frontend: an Import control in the sidebar (next to Export) — hidden file
input + FormData POST + result toast ("Imported N notes (M skipped)"),
reloading the board + labels. New upload icon; notes-store importNotes().

Tests: import auth-guard + pure-helper coverage (_usec_to_dt, _keep_spec
list/text/color/annotation/attachment mapping, _native_spec round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 21:24:02 -04:00
bvandeusenandClaude Opus 4.8 1417479729 Graph: label hubs cluster notes; unlinked notes float by default
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 35s
Add labels as first-class nodes in the graph so tags act as visual
clustering hubs, and let unlinked notes float in the space by default —
per operator request (the graph is a light auxiliary lens, not a focal
surface).

Backend (graph.py):
- Emit a label-hub node (id "label:<uuid>", kind "label", #name, label
  color) for every label attached to a live note.
- Emit note -> label membership edges (kind "label") alongside the
  existing wiki-link edges (now kind "link").

Frontend (GraphView.vue):
- Render hubs as larger ringed nodes; membership edges dashed with a
  slightly longer spring rest so notes ring their hub.
- Default showAll (unlinked notes float) to true; add a Show labels
  toggle (default on) that hides hubs + membership edges.
- Click a label hub -> that label's board lens (/label/<id>); note
  clicks still open the editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 21:16:16 -04:00
bvandeusenandClaude Opus 4.8 cc828559ff M6: export — download all your notes as a zip
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 33s
Data portability / no lock-in (task 1907, export half). GET /api/notes/export streams a zip of the caller's notes: a machine-readable notes.json (notes + labels + items + reminders + attachment refs), a human-readable Markdown file per note (frontmatter + body / checklist), and the attachment media. Sidebar 'Export' link (same-origin GET, session cookie) downloads it. _slugify unit-tested.

Import (Google Keep Takeout) is the follow-up increment of this task.

Pure backend + small frontend; no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 16:48:49 -04:00
bvandeusenandClaude Opus 4.8 efbf981a2a M6: version history — note revisions + restore
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s
A note's title+body is snapshotted on each edit that changes either, so an accidental overwrite can be viewed and restored (task 1906). Underwrites 'dump freely, nothing is lost'.

Backend: note_revisions table (migration 0014) + NoteRevision model; update_note records a revision of the PRE-edit state whenever title/body changes; GET /api/notes/<id>/revisions (newest 50) and POST /api/notes/<id>/revisions/<rev_id>/restore (snapshots the current state first so restore is itself undoable, then applies the revision with the usual title/body ripple — display name, links, #tags, backlinks). Title+body only in v1.

Frontend: a History toggle in the modal editor opens a panel of past versions (timestamp + preview) with per-row Restore. Store gains fetchRevisions/restoreRevision.

Migration 0014 runs on deploy; DB behavior operator-verified (no Postgres CI lane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 16:08:30 -04:00
bvandeusenandClaude Opus 4.8 47974f62ee M7: editor keyboard + one-click card toolbars (live-pass fixes)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 30s
From the unified-editor live pass (task 1922):

- Esc now returns to the base Notes board from any view (AppShell); an open editor consumes Esc first (its handler stops propagation) so it closes before navigating.

- Ctrl/Cmd+Enter = finish & close in BOTH frames — it already closed the modal editor; now it also commits+collapses the board composer (matches the email/chat 'send' convention). Shift+Enter still saves & starts a new note.

- Fix: the inline composer committed on document mousedown, collapsing and shifting the board before a click landed — so clicking another card's pin/archive/trash took two clicks. Commit on click (bubble phase) instead; the target's action fires first, then the composer closes.

Pure frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 14:53:18 -04:00
bvandeusenandClaude Opus 4.8 261d8fcac5 M6: lightweight markdown rendering on note cards
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s
Render a small Markdown subset in the read view — headings (#..###), bullet/ordered lists, blockquote, fenced code, and inline **bold** / *italic* / `code` — alongside the existing [[wiki-links]] (task 1905). Capture stays plain text; only the card render is formatted.

Hand-rolled dependency-free parser (notes/markdown.ts) rendered as Vue vnodes (MarkdownText/MarkdownInline), never v-html, so there is no HTML-injection surface — matches the no-heavy-dep ethos. Headings require '# ' (space), so a #tag (no space) stays plain text and is never mistaken for a heading; the two coexist. Replaces LinkedText (links-only) on the card; LinkedText deleted.

Pure frontend — no backend/DB/migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 14:44:03 -04:00
bvandeusenandClaude Opus 4.8 bb33d5c495 M7: unify compose + edit into one NoteEditor (DRY)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 4s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 40s
The 'Take a note' composer (QuickAdd) and the note editor were two components with duplicated field logic and unequal capabilities — composing lacked [[ links, labels, images, reminders. Merge them into a single NoteEditor with two frames: inline on the board (compose), modal on a card (edit). Same fields, styling, [[ autocomplete and toolbar in both (task 1920).

Compose is now a draft that persists on first real action: on commit-with-content, or on the first label/image/reminder/checklist use (ensureDraft) — so no empty-note litter. Rich controls light up once there's content; note-lifecycle actions (pin/archive/trash, links/backlinks) stay in the modal. notes.create() now returns the created note (for the draft id). QuickAdd.vue deleted; BoardView renders <NoteEditor inline>.

Pure frontend — no backend/DB/migration. Sets up the editor<->card morph (task 1914).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 14:05:00 -04:00