Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 dc68386d1a desktop: point the install command at a branch that exists
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m27s
The advertised curl URL referenced raw/branch/main, but main has never been
created (creating it is rejected by a branch-protection rule that matches the
name even with no branch behind it), so the one-command install 404'd. dev is
currently the repo's only branch and serves the script fine now that the repo
is public — verified 200, with all three v0.1.0 release assets resolving and
the AppImage downloading in full.

Flagged in the header to move back to main once that branch exists, so the
public install command stops tracking day-to-day work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 19:19:05 -04:00
bvandeusenandClaude Opus 5 85ca7c2a2d desktop: drop the redundant deb depends, correct the pacman docs
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m28s
Follow-up to 8a8b2b1, driven by what run 2872's new checks actually printed.

The .deb verification did its job on its first run: tauri already infers
exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring the same two in
tauri.conf.json produced a control file listing each of them twice. Removed
the declaration — verify.sh is the real guard, and it fails the build if
inference ever stops covering what the binary links.

The pacman step revealed ci-tauri carries neither zstd nor bsdtar, so packages
currently ship as .pkg.tar.xz with no .MTREE. Both are working outcomes
(pacman reads xz; only `pacman -Qkk` needs .MTREE), but the docs promised
.zst, so the README, the release notes and ci-requirements.md now describe
what the build actually produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 18:26:24 -04:00
bvandeusenandClaude Opus 5 8a8b2b17e6 desktop: prebuilt pacman package + verified .deb (tasks 2022, 2074)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m41s
Native packages the installer can actually fetch, before task 2014 wires up
the fetching.

Arch (task 2022, re-scoped): the source PKGBUILD is gone — asking every user
to install rust+node and compile for minutes isn't distribution. Replaced by
desktop/packaging/arch/package-prebuilt.sh, which wraps the binary the Linux
job already built into a .pkg.tar.zst. No second Rust build, no Arch CI image:
the binary bundles nothing and resolves webkit/gtk/soup by soname, identical
on both distros, with SQLite compiled in and glibc used in the safe
built-old/run-new direction. CI is Debian and has no pacman, so the step logs
.PKGINFO plus the full file listing for audit instead of pretending to verify.

Debian (task 2074): install.sh hands the .deb to every Debian/Ubuntu user and
nothing had ever inspected it. tauri.conf.json now declares
libwebkit2gtk-4.1-0 + libgtk-3-0 explicitly rather than trusting inference —
and deliberately declares no appindicator or sqlite dep, since tauri is built
with features=[] and rusqlite is "bundled". desktop/packaging/deb/verify.sh
prints the generated control file, cross-checks it against what the ELF
actually needs via dpkg-shlibdeps, confirms every declared dep exists in apt,
and clean-container installs when a docker CLI is available.

Both artifacts join the run artifact and the tagged release; install.sh grows
a pacman branch so Arch/CachyOS gets a native install instead of the AppImage
fallback. Still no release cut (rule 2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 18:19:30 -04:00
bvandeusenandClaude Opus 4.8 36c05f5029 desktop: release-publish pipeline + one-command Linux installer
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m45s
Phase A of the desktop Release + install path (M10 / tasks 2014, 1998):

- .forgejo/workflows/desktop.yml: tag-gated "Publish release" step +
  contents:write. On a v* tag the build now publishes a Fabled-Git Release
  with the de-bundled AppImage + .deb attached — a stable, versioned fetch
  target (Actions artifacts are ephemeral/test-only). Dormant on dev/main.
- desktop/packaging/publish-release.sh: creates/reuses the Release via the
  Forgejo API using the runner-injected token; idempotent asset replace.
- desktop/packaging/install.sh: curl|sh one-command installer — native .deb
  on Debian/Ubuntu, de-bundled AppImage everywhere else (installed to
  ~/Applications/ThoughtSync.AppImage, matching src/integration.rs so the app
  sees itself integrated). AppImage path needs no sudo.

Plumbing only — no release cut (rule 2); activates on the operator's first
v* tag. In-app self-update (tauri-plugin-updater + signed latest.json) is
Phase B, gated on the operator's signing key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-25 14:13:50 -04:00
bvandeusenandClaude Opus 4.8 c3855b0ff1 desktop: rustfmt the summary() count closure
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m42s
cargo fmt --check (run 2856) wanted the long closure body wrapped in a block.
Formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-25 10:19:25 -04:00
bvandeusenandClaude Opus 4.8 f325402902 desktop: robust startup + operation logging (portability troubleshooting)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 32s
There was essentially no logging — useless for proving the app renders across
different environments. Add real observability:

- tauri-plugin-log -> stdout (so `2>&1 | tee` captures a run) AND a persistent
  file in the app log dir (grabbable after the fact on any machine). Level Info.
- Startup diagnostics: app version, OS/arch, the Linux display/session stack
  (XDG_SESSION_TYPE, desktop, Wayland/X11, GDK_BACKEND), the WebKit render-
  hardening vars actually in effect, resolved log + data dirs, DB open/migrate
  result, and note/label counts.
- log_event command + a frontend logEvent() helper: boot line (data source +
  WebKit user-agent) from main.ts, first-route config/session/destination from
  the router guard, and — via the bridge invoke() wrapper — every failed Tauri
  command named with its error, so a broken basic function is self-identifying.

Task 2040.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-25 10:18:06 -04:00
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
bvandeusenandClaude Opus 4.8 ae0c748507 M6: label management — usage counts + merge
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 50s
Extends the existing label manager (create/rename/color/delete) with the two missing maintenance tools (task 1904), so the label list stays clean — which matters more now that #tags mint labels automatically.

Backend: GET /api/labels returns a per-label note count (one grouped query); new POST /api/labels/<id>/merge moves the source label's notes onto a target and deletes the source (repoint via delete+reinsert to avoid mutating the composite PK; preserves via_tag; dedupes notes already on the target). Body #tags are NOT rewritten, so a tag-sourced label re-mints on next edit if its #tag text remains — a documented nuance.

Frontend: LabelsModal shows each label's note count and a 'merge into…' picker; also restores the previously-missing close (x) and merge icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 12:36:50 -04:00
bvandeusenandClaude Opus 4.8 95b0e30fc7 M6: browse notes by creation date (Timeline lens)
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 1m2s
A temporal recall path — find a note by WHEN it was captured, not just what it contains (task 1903, first of the M6 recall items).

Backend: list_notes gains an optional created_at range (created_after / created_before, half-open interval) + sort=created; also lays groundwork for the richer-search facets (task 1902). New _parse_iso_dt helper with a DB-free unit test.

Frontend: a Timeline view (sidebar nav + 'g t' + command palette) grouping active notes newest-first into local-time buckets (Today / Yesterday / Earlier this week / this month / Month YYYY), plus an optional From/To date filter. Built as a lens on the same NoteCard masonry, consistent with the existing Reminders/Search views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 12:26:35 -04:00
bvandeusenandClaude Opus 4.8 c4914fe587 m4.5: inline #tags — hashtags in a note become labels (two-way sync)
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
Fast, cross-device labelling: type #groceries in a note and it becomes
the "groceries" label. The body is the source of truth for tag-labels;
manual picker labels stay independent (rule 28 — additive).

- note_labels.via_tag (migration 0013) marks tag-sourced attachments.
- parse_tags(): #tag at start-of-body or after whitespace, needs a
  letter (so #2024, URL #frags, mid#word are ignored). unit-tested.
- _reconcile_tags() on create + body-update: attach labels for current
  #tags (find-or-create, case-insensitive), detach tag-labels whose tag
  was removed; never touches manual (via_tag=false) rows.
- label picker (set_note_labels + editor onLabelsChange) now preserves
  tag-labels on save, so a picker action can't strip a label the #tag
  still mandates.
- serialize via_tag; card/editor chips render tag-labels as "#name",
  and the editor hides the × on them (remove by editing the tag text).
- LabelPicker builds manual NoteLabels (via_tag:false).

Fourth and final item of M4.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 08:39:20 -04:00
bvandeusenandClaude Opus 4.8 80324fba3b m4.5: lists first-class — create a checklist straight from quick-add
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 34s
Checklist notes existed (M2) but could only be made by creating a text
note and toggling it in the editor — so they were undiscoverable. Now
the board's quick-add can make one in one shot.

- create endpoint accepts kind + items: POST /api/notes with
  kind:"list" and items:[...] creates a checklist note and its items
  atomically. A list note is non-empty when it has a title or ≥1 item.
- quick-add gets a checklist toggle (checkbox icon): flip it and each
  body line becomes an item on save; placeholder switches to
  "One item per line"; resets to a plain note after close.
- notes store create() accepts kind + items; parse_list_items() helper
  (trims, drops blanks) with a unit test.
- title placeholders now read "Title (optional)" in quick-add too.

Third item of M4.5. Display/editing of checklists was already built in
M2; this closes the creation gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 08:26:19 -04:00
bvandeusenandClaude Opus 4.8 0ae02858f7 m4.5: content-aware [[ linking — autocomplete searches note body
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 33s
The [[ autocomplete only matched note names, so you could only link a
note you could name. Now it searches note NAME *and* body, so you can
link by recalling any phrase.

- new GET /api/notes/link-search?q= — owner-scoped, non-trashed;
  substring ILIKE on display_title OR body; ranked name-first, then
  name-prefix, then recency; empty q returns recent notes as
  suggestions. Deterministic (no semantic/AI search); the FTS index
  still powers the heavier /search. LIKE wildcards in q are escaped.
- editor [[ autocomplete now calls link-search (debounced 120ms)
  instead of filtering the cached titles index; excludes the note
  itself; inserts the matched note's display name as [[Name]].
- unit tests for the LIKE-escaping + a link-search auth guard.

Second item of M4.5; builds on the display_title work (every note has
a name to link to). Command-palette content search is a natural
follow-on, left out to keep this focused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 08:19:05 -04:00
bvandeusenandClaude Opus 4.8 2b6a353666 m4.5: titles optional — every note gets an auto display name
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
Capture starts in the body, so forcing a title feels odd and body-only
notes had no name — which made them unlinkable. Fix both: persist a
display_title = explicit title if set, else the note's first non-empty
body line (deterministic, no AI). The title field stays optional.

- migration 0012: notes.display_title (NOT NULL, best-effort backfill;
  the app recomputes precisely on next save)
- derive_display_title() helper, set on create + update
- drive the /titles index, backlinks, graph edges + node labels, and
  [[wiki-link]] resolution off display_title so body-only notes are
  nameable, findable (command palette / [[ autocomplete), and linkable
- rename-repoint generalized: inbound [[Old Name]] links now survive a
  name change via the first body line too, not just an explicit title
- unit tests for the derivation (explicit wins, first non-empty line,
  blank/empty, length cap)
- frontend: display_title on the Note type; title field placeholder now
  reads "Title (optional)"

First item of M4.5 (frictionless input & recall); unblocks the linking
work. Card rendering unchanged (no first-line duplication).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 08:00:01 -04:00
bvandeusenandClaude Opus 4.8 1b6eb0053b m4: perf pass — lazy-load note images
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 27s
Light, audit-first perf pass (task 1870):
- lazy-load + async-decode the card thumbnail and editor attachment
  images so an image-heavy board / large note doesn't eagerly fetch
  and decode every off-screen image up front

Audit found the rest already in good shape and left as-is: all routes
are code-split via dynamic import(); the titles index (palette,
[[-autocomplete, wiki-link resolution) is cached in its store and only
fetched once; board refetches are scoped to real view/label changes with
no double-load on mount.

(Server-side thumbnail generation — cards load full-res uploads — is a
larger, separate feature, noted for later, out of scope for a light pass.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 08:37:44 -04:00
bvandeusenandClaude Opus 4.8 5e225075f3 m4: drag-to-reorder polish — grip handle + drop affordances
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 26s
Refine the masonry drag interaction (task 1869) so reordering reads as
intentional instead of an accidental, feedbackless jump:

- explicit grip handle (top-left, hover-revealed, board views only) gates
  dragging — a plain click or text-select no longer starts a drag
- source card dims while dragging; the card under the pointer shows a
  brand ring + slight lift, so the drop position is clear before release
- proper move cursor via dataTransfer effectAllowed/dropEffect
- dragleave uses a relatedTarget guard so the target ring doesn't flicker
  over child elements
- clear drag state on dragend (BoardView clears the tracked source even
  when a drag is cancelled off-target)
- add a "grip" icon to the shared Icon set

Native HTML5 DnD stays library-free; it remains a desktop/mouse
affordance (touch reorder is the M5 Android client's domain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 08:33:29 -04:00
bvandeusenandClaude Opus 4.8 3e9b6095dd m4: PWA install — manifest, icons, service worker
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 31s
Make ThoughtSync installable ("Add to Home Screen") without going
offline-first (the Android app is the real offline client, M5):

- web app manifest (name, icons incl. maskable + SVG, standalone, theme)
- generated PNG icon set + apple-touch-icon + favicon, from committed
  SVG sources (a linked-thoughts constellation on the brand tile)
- minimal service worker: installable shell only — caches just an
  offline fallback page, never the app shell / hashed assets / API, so
  data stays fresh and deploys never serve a stale shell
- register the SW in main.ts (progressive enhancement; failures ignored)
- index.html: manifest/icon links, apple-mobile meta, description
- backend: register the .webmanifest MIME type so it serves as
  application/manifest+json
- README: note that install needs a secure context (HTTPS/localhost)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 08:25:41 -04:00
bvandeusenandClaude Opus 4.8 b7b37e5e1a m4: settings-UI polish (dirty tracking + load-error state)
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 27s
Save is disabled unless something actually changed (dirty = current values
vs. the last loaded/saved snapshot); "Saved." clears the moment you edit
again, and a quiet "No unsaved changes" shows at rest. A failed load now
renders an explicit error + Retry instead of a blank form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 00:13:17 -04:00
bvandeusenandClaude Opus 4.8 545f946754 m4: per-view error states (board / search / graph / reminders)
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 27s
Each data view now catches a failed load and shows an explicit error message
with a Retry button, instead of falling through to a misleading empty state.
Pairs with the global error toast: the toast says something failed, the view
says which and offers a retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 00:10:40 -04:00
bvandeusenandClaude Opus 4.8 d3bb091f73 m4: global API-error surface (no more silent failures)
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 31s
The api client now catches network failures and non-JSON bodies robustly,
and routes unexpected errors (offline / 5xx) to a global toast — 4xx stay
with the caller so forms keep their inline messages. Toast actions are now
optional (undo toasts keep their button; error toasts are message-only), and
ToastHost moved from AppShell to the app root so toasts show everywhere,
including the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 00:07:04 -04:00
bvandeusenandClaude Opus 4.8 ecbdd12ba9 m4: mobile nav — hamburger + slide-in drawer
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 29s
Below sm the sidebar had no replacement (nav was inaccessible on phones).
The sidebar aside is now a responsive drawer: fixed + off-canvas with a
hamburger toggle and backdrop on mobile, static in-flow on sm+ (same markup,
no duplication). Closes on nav-click, backdrop, Esc, or route change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-21 00:03:15 -04:00
bvandeusenandClaude Opus 4.8 894dd4ac42 graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
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 35s
Backend /api/graph now returns ALL non-trashed notes as nodes, each with its
first label's color; edges unchanged. GraphView reworked: drag a node to
reposition it (pins to cursor + reheats the sim), pan the background, wheel-
zoom toward the cursor, a "Show unlinked notes" toggle (connected-only by
default), a Reset view button, and nodes filled by label color. Click (a
press without a drag) still opens the note; re-fetches on view open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 22:40:28 -04:00
bvandeusenandClaude Opus 4.8 27b09e18e3 docs: add a copy-paste deploy compose to the README
New "Deploy (self-host)" section with a complete app + Postgres stack using
the published image, restart policies, a healthcheck, the /var/thoughtsync
data volume, and the single required env (THOUGHTSYNC_DATABASE_URL). Notes:
first account is admin, optional SECRET_KEY, auto wait+migrate on boot, and
the image-tag scheme (:latest / :dev / :<sha>).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 21:42:26 -04:00
bvandeusenandClaude Opus 4.8 fe12859ba2 ops: wait for the database on startup (no crash-loop on a slow DB)
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 33s
New thoughtsync.dbwait polls the DB (SELECT 1) up to 60×1s before startup,
logging each attempt, so a briefly slow/unready database no longer crash-
loops the container. Wired as `python -m thoughtsync.dbwait &&` ahead of
`alembic upgrade head` in the image CMD and the dev compose command; exits
non-zero after the window so a restart policy can take over. Prod compose
gains restart: unless-stopped as the complementary piece.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 20:10:45 -04:00
bvandeusenandClaude Opus 4.8 08258f81d9 config: fix media path at /var/thoughtsync (no env knob)
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 30s
The media/data location is no longer configurable — DATA_DIR is a fixed
constant (/var/thoughtsync) and THOUGHTSYNC_MEDIA_ROOT is removed, so a
mutable path can't drift from where the volume is mounted. media_root() =
DATA_DIR/media. Both compose files drop THOUGHTSYNC_DATA_DIR and mount the
data volume at /var/thoughtsync (was the contradictory /data). conftest
drops the stale MEDIA_ROOT monkeypatch (create_app never reads DATA_DIR).

Net env surface: THOUGHTSYNC_DATABASE_URL (required) + THOUGHTSYNC_SECRET_KEY
(optional break-glass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 20:07:15 -04:00
bvandeusenandClaude Opus 4.8 d570939253 ui: capture polish — auto-grow quick-add + per-card color popover
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 27s
QuickAdd body textarea auto-grows to fit content (min ~3 rows, capped with
scroll) instead of a fixed 3 rows. NoteCard's hover toolbar gains a color
dot that opens a swatch popover to recolor the note in place (outside-click
closes it; listener attached only while open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 13:20:04 -04:00
bvandeusenandClaude Opus 4.8 9baa9217c7 ui: undo toast after trash / archive
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 29s
A global ToastHost (mounted in the app shell) shows a transient toast with
an Undo action, driven by the ui store (showToast/dismiss/runAction, 5s
auto-dismiss). The notes store fires it centrally: trash → Undo restores;
archive → Undo unarchives — so it works from card, editor, and keyboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 13:15:19 -04:00
bvandeusenandClaude Opus 4.8 f321c2bf70 labels: per-label color
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 32s
Give labels a color (migration 0011 adds labels.color, server_default
'default' so existing labels keep the neutral chip). The PATCH endpoint now
updates name and/or color; note serialization carries each label's color.
Frontend: a swatch picker per label in the Edit-labels modal, colored chips
on cards + in the editor (LABEL_CHIP_CLASSES), and a color dot on each
sidebar label. Reuses the note color vocabulary. (Graph node coloring rides
this in the graph-liveliness task.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 13:11:50 -04:00
bvandeusenandClaude Opus 4.8 f3145a5e3f links: rename repoints inbound backlinks (fix orphaning)
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 29s
Renaming a note now rewrites [[Old Title]] references (and their note_links
rows) in every note that links to it, so backlinks survive the rename
instead of silently orphaning. Pure/case-only renames are skipped since they
still resolve. New pure helper rewrite_link_title() (DB-free unit tests) does
the token rewrite; _rename_inbound_links() applies it across owner-scoped,
non-trashed sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:26:49 -04:00
bvandeusenandClaude Opus 4.8 9be8920362 links: clickable [[wiki-links]] on cards
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 27s
LinkedText now makes each [[link]] segment interactive: click (or Enter when
focused) resolves the title and opens the target note, creating it first if
it doesn't exist. Reuses the palette's /?open=<id> open path. @click.stop /
keydown.stop keep the surrounding card's click-to-open from also firing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:23:57 -04:00
bvandeusenandClaude Opus 4.8 2a06f7499a links: [[ autocomplete in the note editor
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 28s
Typing `[[` in a text note's body opens a title-suggestion menu from the
titles index. Arrow keys move the selection, Enter/Tab inserts `[[Title]]`
and places the caret after it, Esc dismisses just the menu (kept from
bubbling to the dialog's Esc-close), mouse hover/click also select. The
menu appears only while the caret sits inside an unclosed [[… token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:21:53 -04:00
bvandeusenandClaude Opus 4.8 4c72dfea0b keyboard: board card navigation (j/k/arrows + actions)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 34s
Focused-card selection on the board: j/k or arrows move it, Enter opens,
e archives, # pins, x trashes the focused card (actions gated off the Trash
view). The selection is a brand ring on the card; it scrolls into view and
clamps as the list/view changes. A window keydown listener on BoardView,
ignored while typing, on a button/link, or while the editor modal is open.
Added the card keys to the ? cheat-sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:18:28 -04:00
bvandeusenandClaude Opus 4.8 b24316843e keyboard: command palette (Cmd/Ctrl+K)
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 30s
Fuzzy quick-open overlay: type to jump to any note by title (titles index)
or run a command — New note, Go to Board/Graph/Reminders/Archive/Trash, and
Open Settings (admins). Arrow keys move the selection, Enter activates, Esc
closes; Cmd/Ctrl+K toggles it (works even while typing).

Selecting a note navigates to the board with ?open=<id>; BoardView watches
that query and opens the editor (fetching the note if it isn't loaded), then
clears the param. Added to the ? cheat-sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:05:50 -04:00
bvandeusenandClaude Opus 4.8 4bd4ce5a1f keyboard: global shortcuts + ? cheat-sheet
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 27s
App-shell keydown dispatcher (active only inside the authed shell):
- c        new note (jump to board / reopen composer via a ui-store signal)
- /        focus the search box
- g b/g/r  jump to Board / Graph / Reminders (two-key, 800ms window)
- ?        open a keyboard-shortcuts cheat-sheet overlay
- Esc      blur a focused field / close the cheat-sheet

Ignored while typing in an input/textarea/contenteditable (except Esc).
New stores/ui.ts carries the compose signal; QuickAdd exposes open(); the
board reopens the composer on the signal when already on the board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 10:23:44 -04:00
bvandeusenandClaude Opus 4.8 d7c452ca2b feel: float the card hover toolbar as a top-right pill
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 26s
Move the overlay toolbar from bottom-right to top-right and make it a
rounded-full pill (window-control style, per operator). Top placement also
avoids ever overlapping the reminder/label chips at the card bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 10:16:34 -04:00
bvandeusenandClaude Opus 4.8 6b0ac7696f feel: card resting spacing + editor Cmd/Ctrl+Enter
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
- NoteCard: overlay the hover toolbar (absolute bottom-right, translucent
  backdrop, pointer-events gated) instead of reserving a permanent action row.
  At rest cards are now content-sized with even padding, so text no longer
  looks pinned to the top above an empty strip. Affects every card surface
  (board, search, graph, reminders).
- NoteEditor: Cmd/Ctrl+Enter saves & closes (mirrors Esc-closes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 10:14:11 -04:00
bvandeusenandClaude Opus 4.8 c57982d910 M3 reminders: notes.remind_at + Reminders view
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 32s
- Migration 0010: notes.remind_at (nullable tz). PATCH accepts remind_at
  (ISO set / null clear); GET /api/notes/reminders (soonest first, non-trashed);
  serialize includes remind_at.
- Frontend: datetime util (local<->ISO, format, overdue); notes store setReminder;
  editor datetime-local picker + clear; card reminder chip (overdue = red);
  sidebar Reminders entry + /reminders view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:12:14 -04:00
bvandeusenandClaude Opus 4.8 ad006ccb58 M3 graph view: /api/graph + force-directed SVG
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 28s
- graph blueprint: GET /api/graph resolves note_links to target notes by
  normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
  of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
  cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
  editor with link navigation). Sidebar Graph entry + route; empty state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:05:45 -04:00
bvandeusenandClaude Opus 4.8 2d72dcc7cb M3 wiki-links: [[links]] + backlinks
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 38s
- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
  create/update and rewrite the source's links. GET /api/notes/titles (owner
  {id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
  shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
  clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:00:08 -04:00
bvandeusenandClaude Opus 4.8 339cc5c2d2 M2 drag-reorder: notes.position + reorder API + native DnD
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
- Migration 0008: notes.position (int). Board orders pinned -> position ->
  updated_at; new notes created at top (max position + 1). POST /api/notes/reorder
  assigns positions from the given order (owner-scoped).
- notes store: position on Note, position-aware sort, optimistic reorder().
- NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves
  the dragged note before the drop target and persists.

Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns
reflow); order persists correctly. Candidate for a polish pass / layout tweak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:28:43 -04:00
bvandeusenandClaude Opus 4.8 e4c898cd1b M2 attachments: image upload + owner-scoped media serving
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
- Migration 0007: note_attachments (path/mime/size). Upload POST
  /api/notes/<id>/attachments (multipart, png/jpeg/gif/webp, 12MB cap via
  MAX_CONTENT_LENGTH) stored under Config.media_root() (first use of DATA_DIR);
  owner/ACL-scoped GET serves the file (nosniff); DELETE removes row + file.
  Note responses include attachments[].
- Frontend: notes store uploadAttachment (FormData)/deleteAttachment; editor
  image button + paste-to-upload + thumbnail grid with remove; card shows the
  first image as a cover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:21:26 -04:00
bvandeusenandClaude Opus 4.8 31be66ac60 M2 checklists: note kind + items backend + editor/card UI
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 27s
- Migration 0006: notes.kind ('text'|'list') + note_items (text, checked,
  position). Item API: add/update(toggle)/delete/reorder; PATCH note kind; note
  responses include kind + items[] (merged in one query alongside labels).
- notes store: kind/items on Note, setKind/addItem/updateItem/deleteItem.
- NoteChecklist component (toggle/add/edit/delete items); rendered read-only-ish
  on cards (checkboxes toggle) and editable in the editor.
- Editor: convert text<->checklist (body lines become items on convert to list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:13:25 -04:00
bvandeusenandClaude Opus 4.8 ffc008bf4d M2 search: Postgres FTS backend + top search bar + results
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
- Migration 0005: generated tsvector column (title A + body B) + GIN index on
  notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped,
  excludes trash), labels merged into results.
- Persistent AppShell layout (parent route + <RouterView> children) so the new
  top search box keeps focus across board/search/label navigation.
- SearchView (debounced live search from the shell → /search?q=, results masonry,
  no-match empty state); BoardView/SearchView render inside the shared shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:03:19 -04:00
bvandeusenandClaude Opus 4.8 2046600a95 M2 labels frontend: sidebar shell + label filter, chips, picker, manage
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 34s
- AppShell: persistent left sidebar (Notes · labels · Archive · Trash) + top bar
  (site name, admin Settings, sign out); BoardView now renders inside it.
- labels store (list/create/rename/delete); Note gains labels[]; notes store
  gains setLabels + label-aware reconcile + /api/notes?label= loading.
- /label/:id route → label-filtered board.
- LabelPicker (tag a note, create-on-the-fly) in the editor; label chips shown
  on cards and in the editor; LabelsModal to create/rename/delete labels.
- api client PUT; new icons (note/tag/pencil/plus/check); nav-link styles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 21:56:14 -04:00
bvandeusenandClaude Opus 4.8 4d1fc1bdf9 M2 labels backend: labels + note_labels, CRUD, note-label set, filter
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 32s
- Label + NoteLabel models; migration 0004 (labels unique per owner + note_labels
  join, cascade).
- /api/labels: list/create(idempotent)/rename(clash-checked)/delete, owner-scoped.
- PUT /api/notes/<id>/labels to set a note's labels (validated against owned).
- Note responses now include labels[] (merged via one explicit join query — no
  lazy relationship); GET /api/notes?...&label=<id> filters by label.
- DB-free auth-guard tests for labels endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 21:43:21 -04:00
bvandeusenandClaude Opus 4.8 fdaf5c370c QoL: Shift+Enter save-and-continue + autofocus quick-add on landing
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 42s
- Shift+Enter in the quick-add title/body saves the current note and immediately
  starts a fresh one, staying focused for rapid capture. Plain Enter is still a
  newline (body) / title→body (title). Placeholder hints the shortcut.
- Landing on the board auto-opens + focuses the note input (QuickAdd autofocus
  prop, passed from BoardView) so you can start typing on arrival.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 21:31:48 -04:00
bvandeusenandClaude Opus 4.8 2ae533e967 Add hot-reload docker-compose.dev.yml
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Dev stack with live reload: Postgres + backend (hypercorn --reload, src mounted)
+ Vite dev server (mounted, proxies /api to the api service). vite proxy target
is now env-configurable (VITE_API_TARGET, defaults localhost:5000 for host dev).
README documents both compose files (dev hot-reload vs prod image).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 20:57:09 -04:00
bvandeusenandClaude Opus 4.8 68f7b260de M1.5 frontend: admin Settings UI + config store + register gating
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 31s
- config store (public /api/config: site_name, allow_registration, version),
  loaded in the router guard; site name drives the board header.
- session User gains is_admin.
- /settings route with requiresAdmin guard + admin-only gear link in the header;
  SettingsView renders grouped typed fields (text/number/toggle), saves via
  PATCH /api/settings with saved/error feedback, refreshes public config.
- Register link hidden + /register route blocked when registration is closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 18:28:56 -04:00
bvandeusenandClaude Opus 4.8 b46bda38ee M1.5 backend: admin role + DB-backed settings, DB-URL-only install
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 31s
- users.is_admin; first registered user becomes admin; registration gated by the
  allow_registration setting (first account always allowed). is_admin in
  /api/auth/* responses; require_admin guard (live DB check).
- settings table + code registry (site_name, allow_registration, session_ttl_days)
  with typed defaults — empty table = all defaults (rule 26). get/set/validate
  service; GET /api/config (public) + GET/PATCH /api/settings (admin), live
  session-TTL apply with no restart (rule 25).
- Cookie-signing secret now persisted in the DB (before_serving load-or-create),
  so sessions survive restarts with no volume. Config: DATABASE_URL is the only
  required env; SECRET_KEY + DATA_DIR are optional break-glass items.
- Migration 0003; DB-free tests for settings validation + admin guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 18:25:04 -04:00
bvandeusenandClaude Opus 4.8 0f604f9a26 M1: masonry board UI — quick-add, note cards, editor, views
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 30s
- notes Pinia store (load/create/pin/archive/color/trash/restore/delete) with
  view-aware reconcile; api client patch/del methods.
- colors.ts palette (10 keys → light/dark card + swatch tints).
- QuickAdd (collapsed → expand, title/body/color, click-outside/Esc to save),
  NoteCard (color tint, click-to-edit, hover action bar), NoteEditor modal,
  ColorPicker, inline Icon set (no icon dep).
- BoardView rewrite: Notes/Archive/Trash routes, CSS-columns masonry, Pinned +
  Others sections, per-view empty states, sign-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 16:08:53 -04:00
bvandeusenandClaude Opus 4.8 df59a30cca M1: notes model + migration 0002 + notes API (CRUD, trash/restore)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Build & push image (push) Successful in 36s
- Note model (owner_id, title, body, color-key, pinned, archived, deleted_at
  soft-delete, timestamps) + board index; NOTE_COLORS palette keys.
- Migration 0002 (notes table + ix_notes_owner_board).
- /api/notes blueprint (login_required): list (?filter=active|archived|trash,
  pinned-then-updated, read via visible_to_user ACL), create, get, patch
  (title/body/color/pinned/archived), trash, restore, permanent delete
  (trash-only). Mutations owner-scoped; empty note rejected (400).
- DB-free unit tests: is_empty_note, normalize_color, palette, serialize,
  auth-guard on list/create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 15:59:43 -04:00
bvandeusenandClaude Opus 4.8 05d5249e20 M0 CI: derive registry username from github.repository_owner
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 36s
The registry login needed a REGISTRY_USER secret that couldn't be set/read on
this fresh repo (harness blocks secret writes; the value never surfaced via the
API). The username is the repo owner and is public (it's in the image path), so
derive it from github.repository_owner instead of a secret. REGISTRY_TOKEN
remains the actual credential. Removes an entire class of setup friction.

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