23 Commits
Author SHA1 Message Date
bvandeusen 1b3e29e4f6 0.2.0 — a notebook in your pocket, ready to be hosted (#3)
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
2026-08-23 16:38:00 -04:00
bvandeusenandClaude Opus 5 c851b901df The proxy-hops test still read the value from Config
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
09b5f87 moved trusted_proxy_hops out of the environment and into the
settings registry, but tests/test_proxy.py kept asserting against
Config.trusted_proxy_hops() — which no longer exists. The unit lane has
been red since that commit.

Assert through live() instead. That is what proxy.py actually calls, and
it is seeded from the defaults at import time, so the test covers the
case that matters: a boot that has not reached the database yet still
counts one hop rather than zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:31:21 -04:00
bvandeusen abe01da5f7 compose: say which of the three deployment shapes you're in
The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value.
Fair question, and the answer exposed that my own advice was incomplete: I told
them to set it to 127.0.0.1 without asking where their proxy runs, and for a
proxy inside Docker that is the wrong fix.

There are three shapes, not two:

1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop
   can reach the server. This is why the default is NOT the locked-down value: a
   server reachable only from the machine it runs on isn't hardened, it's broken,
   and that is the primary documented use of this app.
2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the
   app over the compose network; publishing a host port is a second,
   unauthenticated way in that bypasses whatever the proxy does about TLS. Safer
   than 127.0.0.1, because there is no host port to reach even from the host.
3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`.

The compose file now spells out all three where the decision is made, and
`docs/public-hosting.md` item 4 asks where your proxy runs before telling you what
to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another
machine should NOT answer once you're proxied.

No default changed. Changing it would silently break every LAN install on the next
`docker compose pull` — the phone would just stop syncing, with nothing saying why.
2026-08-23 15:30:01 -04:00
bvandeusen 09b5f874b6 Security values move into the Settings UI
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the
envs, we need the security values to be in the UI."* Overrules the call I made
yesterday, and rule 25 is on your side — I argued deployment-topology, but the
operator has to be able to SEE what protects them, and reading a container's
environment is not seeing.

Six new settings in a **Security** group: trusted proxy hops (default 1), the
per-account and per-address sign-in limits with their shared window, and the
sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate
limits are no longer hardcoded constants.

**The hard part was keeping the throttle cheap.** It consults these BEFORE opening
a database connection — deliberately, because a refused attempt is meant to cost
nothing, and the hop count is needed to know who is even asking. A query per
attempt would undo both. So there is a small cache seeded from the registry
defaults (the app works with no database at all, which is what the DB-free unit
lane relies on), loaded at boot, and refreshed on every settings save — the same
live-update contract `session_ttl_days` already had.

`SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so
a saved number applies to the next attempt instead of the next deploy.

**Bounds are rejected, not clamped.** A hop count of 99 would trust anything a
caller sent; a sign-in limit of 0 would lock every account out permanently. Both
now fail validation with a message naming the range, and the number input carries
min/max so the browser objects first. Silently storing a different number than the
one typed is how somebody ends up believing a protection is set to something it is
not.

`MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself
rather than the app from a caller, and there is no operator judgment to apply.

Two integration tests, because the whole point is the round trip: a dangerous
value refused, a legitimate one reaching the cache the throttle reads and
persisting; and every Security row reaching the admin payload with bounds and a
description that explains itself.
2026-08-23 15:24:15 -04:00
bvandeusen a85c53ba2c Trust proxy headers by hop count, and log every credential event
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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 / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy
hops setting for how many proxy hops we should trust a shared real-ip at… and is
there any session logging."* Neither existed, and the first one was a real hole.

**The address was forgeable.** `client_address()` read the LEFTMOST
`X-Forwarded-For` entry — nominally "the original client", and precisely the one a
caller controls, because anything they send arrives before what proxies append. So
`curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh
rate-limit bucket every time.

Concretely: stuffing ONE account stayed limited (the account key is unforgeable
and that is why it exists), but spraying MANY accounts from one source was not —
each account got its own budget, and the per-address cap meant to bound the total
was defeated by a header. On a LAN that is nothing. It is not nothing on a public
host.

Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1.
Each hop appends what it saw, so the rightmost entries are the ones our own
infrastructure wrote and a forged prefix lands to the left of them where it can
never be selected — proven for the honest, forged, padded, CDN and
shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in
front of a proxy. Too high is the dangerous direction, so a header shorter than
configured falls back to the socket address rather than reaching further left.

`X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a
new `proxy.py` rather than being written twice — two places holding one decision
is how issue 2183 happened, and this is the same decision.

Env rather than the Settings UI, against rule 25's usual pull: it is deployment
topology rather than preference, and the limiter consults it BEFORE opening a
database connection, which is the entire point of checking a throttle before doing
expensive work. Easy to move if that reads wrong.

**And there was no logging at all** — `auth.py` had no logger, and the only record
of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips,
new accounts and device-token issuance now all log, with the attempted email and
the trusted address. Deliberately including the email: it is the operator's own
server, and "somebody failed a login" without saying against which account is not
actionable.

`basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers
and leaves the root at WARNING — without it every line above would have gone
nowhere, which is a worse failure than not writing them.

This is the app log, not an audit table. Not queryable, not retained past log
rotation. The table is task 2939; this is what makes the next few days observable.
2026-08-23 15:12:14 -04:00
bvandeusen 2141a0ac45 Registration closes itself once the instance has an owner
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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 / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 28s
Operator: *"registration should be open only for the first user and they get
granted admin privileges. then registration is closed."*

The old shape had a window in it. The first account was always allowed and became
admin; every account after that was gated by `allow_registration` — which
defaulted to ON. So the door stayed open between "my account exists" and "I
remembered to turn it off in Settings", and on a public host that gap is the
entire exposure: it starts the moment DNS resolves and lasts until someone
remembers.

Now the door shuts as a CONSEQUENCE of the admin account existing, in the same
transaction that creates it. Not "defaults closed" — that would still need the
first person to get in somehow. There is no window to remember, because there is
no window.

Re-opening it is a deliberate act in Settings → Access: turn it on, have the
person register, turn it off. Crude, and it is the only mechanism there is —
**there is no invite system**, not even a stub. That is real work (a token table,
admin create/revoke, a redemption flow, expiry) and is filed as later work rather
than smuggled into a release.

An integration test covers it, because it is the interaction between two writes
in one transaction: first register → 201 and `is_admin: true`; the setting is
then false; a second register → 403; re-open deliberately and a third → 201, not
admin.

**This does not retroactively close an instance that already has users.** The
close fires on first-account creation, so a server whose admin predates this
keeps whatever the setting was — which was on. `docs/public-hosting.md` now says
so explicitly, and step 1 of the checklist is "check" rather than "do" for
exactly that reason.
2026-08-23 14:18:58 -04:00
bvandeusen 1aca294b95 Bump to 0.2.0 — a release at 0.1.0 would have been a downgrade
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m0s
Found while preparing the release, and it would have quietly defeated the point
of cutting one.

The version does NOT come from the tag. `desktop/packaging/build-version.sh`
reads `desktop/src-tauri/Cargo.toml`, and on `dev` it appends the CI run number
(`0.1.269`) while on a tag or `main` it ships the file's value verbatim — which
was still `0.1.0`.

So tagging today would have published a "release" numbered BELOW every dev build
already out there, and below the 0.1.227 on the operator's phone. The desktop
updater compares semver: an installed build would have read the stable manifest,
seen a version older than its own, and correctly concluded it was already
current. The APK would have installed (versionCode is the run number and keeps
rising) while displaying a version that reads as going backwards.

build-version.sh predicted this in its own comment: "Bumping the minor in
Cargo.toml still wins over any dev build on the old line, which is the ordering
you want: 0.2.0 > 0.1.2932."

Bumped in four places, which is every one that can be read by something:
- `desktop/src-tauri/Cargo.toml` — the actual source; everything else derives
- `tauri.conf.json` — overridden at build time by `--config`, but a checked-in
  value that lies is exactly how issue 2183 happened
- `pyproject.toml` + `__init__.py` — the server's APP_VERSION fallback when no
  BUILD_VERSION is injected

`core` and `android/ffi` stay at 0.1.0 deliberately: internal library crates whose
version reaches no surface, and versioning workspace libs independently of the
app is normal.

Cargo.lock regenerated with `cargo fetch` per ci-requirements — one line, the
version itself. Verified: a tag build now yields 0.2.0 and a dev build 0.2.270,
so stable is an upgrade for every existing install and dev stays ahead of stable.
2026-08-23 14:02:02 -04:00
bvandeusen 7033995975 search is a facet on the board, not a place you go
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator (note 2930): tags exist so you can *"filter during a search"*. The
server has always been able to do that — `GET /api/notes` composes `?q=` with
`?label=` and the rest into one AND-ed query. The frontend never reached it.

The header search box navigated to `/search`, and that view called a DIFFERENT
endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the
one screen you landed on when you searched was the one screen where you could not
narrow by tag. Tag filtering lived on the board's FilterBar, which is where you
weren't searching. Two search boxes, two endpoints, and only the hidden one did
what tags are for.

Now the header box writes `?q=` into the board's URL beside whatever labels are
already there, and stays on the lens you're in — searching while looking at Trash
searches Trash. The box READS from the URL rather than holding its own copy, so
it stays in step with the Filters panel's Clear and with a saved view opened from
the sidebar.

Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search`
and both adapter implementations, and the `notes_search` Tauri command whose only
caller was the adapter entry. FilterBar loses its own "Search text…" input — it
was the same facet, hidden behind a collapsed panel, duplicating a box that is
always on screen. Filters now does what its name says: narrowing. The header does
searching.

`core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and
has its own search surface — which has the same no-tag-filter gap the web just
lost, and deserves the same fix on its own terms rather than as a rider here.
2026-08-23 10:58:23 -04:00
bvandeusen de72d27bd4 URLs unfurl on their own, and a lone link becomes the note
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a
single URL, and to be a compact slot on the bottom of the note when the URL is
inline. We also need to support multiple URLs in a single note."*

Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG
tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)`
— so several URLs per note has worked at the storage layer all along. What was
missing was that it needed a button, had one size, and drew that size in the
wrong place.

**Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs
and fetches them on a background task AFTER the note is committed. Capture speed
is the product: an unfurl is a five-second timeout against a host nobody
controls, and a note has to persist the instant someone stops typing. Scheduled
from create, from a body edit, and from a synced push — so a linked desktop or
Android client gets previews too, on its next pull. An unlinked one has no server
to ask and simply has none, which is the honest consequence of being offline.

Safe to call on every save: it re-reads what's cached and does nothing when
nothing is new. Capped at five URLs per note, silent on every failure (a link
that won't fetch isn't an error the person needs — the note is fine, the link is
still there), and it re-checks before storing, so a slow fetch can't resurrect a
preview for a URL that was deleted while it was in flight.

**Two presentations.** A note whose body is nothing but a URL renders as its
preview and nothing else — printing the raw URL under a card that already says
where it goes is saying the same thing twice, badly. Until the fetch lands, or if
it never does, the URL stands in, so the card is never blank. Anything else gets
a compact strip.

**And the strip moved.** Previews were rendered ABOVE the body, which put a
stranger's headline where the note's own first line should be — worse now that
the first line IS the note's name. They sit at the foot of the card now, under
the note's own words.

The editor's "Preview example.com" button is gone with the manual path; removing
an unwanted preview stays, and stays editor-only.

Nine tests: three on detection (order, dedupe, sentence-punctuation trimming,
non-http rejection) in the unit lane, and three in the integration lane for what
only a real database shows — the upsert landing on the right row, a second pass
fetching nothing, and a preview NOT being stored for a URL that left the body.
2026-08-23 01:05:37 -04:00
bvandeusen c99cbb3e14 cards: clamp the web note preview, as Android always has
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M13 step 4 asked for no bold first line, and step 3 already delivered that —
removing `note.title` took the card's <h3> and the Android editor's bold field
with it. What step 4 also asked for, and hadn't been done, was the other half:
"be willing to spend something small on legibility that isn't weight on the
first line."

The web card rendered the entire body. Android has always clamped to eight lines
(`MAX_PREVIEW_LINES`), so one long note produced a card taller than the screen on
the web and pushed the rest of the board off it — a real asymmetry between two
surfaces that are supposed to be peers.

It matters more without a title. The first line used to be what your eye caught;
with one weight throughout, an unbounded card is just a wall, and the note beside
it is the one you were actually looking for.

Clamped in the STRING, not with CSS `line-clamp` — that needs a `-webkit-box` and
behaves unreliably around the block elements MarkdownText emits (lists, quotes,
fenced code). Doing it before the parse is deterministic, matches Android's
semantics exactly, and skips parsing a body the card was never going to show.
2026-08-23 01:00:39 -04:00
bvandeusen 6f21db85a1 ci: an integration lane, so the migrations are finally run by something
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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 / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 25s
26 Alembic revisions and none had ever been executed by CI. `alembic upgrade
head` ran for the first time when the operator's container started, and the
schema the migrations build had never been checked against the models that read
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
watching but a server boot.

Copied from FabledScribe's `integration` job, which had already solved the parts
that are easy to get wrong — and which are family rules precisely because they
were: a separator-free job key with no `name:` (act_runner derives the service
container name from the truncated display name, and the discovery step filters
`docker ps` by it), bridge-IP resolution because service hostnames aren't
routable on this runner, and a Python readiness wait because `run:` is busybox
sh with no `/dev/tcp`.

`postgres:16-alpine` to match the production compose. The schema is built by
real migrations, never metadata.create_all — that step IS the migration test.

Six tests, each pinning something that has only ever been checked by hand:

- an ORM insert against the migrated schema, which is the model/migration
  agreement nothing has verified until now;
- `notes.title`, `notes.kind` and `note_revisions.title` are actually gone, and
  `note_links` with them — a silently no-op migration shows up here;
- the rebuilt `search_vector` indexes both the name and the body, which matters
  because 0026 had to DROP and recreate a generated column rather than alter it;
- a note keeps its body AND its items, the shape step 2 made normal;
- `_apply_note_items` leaves items alone when a change doesn't mention them —
  the data-loss path step 2 removed, pinned so its return would be caught;
- a note with no body is still named by its first item, the hole that made
  removing the title unsafe until checklists stopped being their own kind.

Runs for visibility; does not gate the build, matching `test` and Scribe.

No local equivalent: running it means standing up Postgres on the workstation,
which rule 12 reserves for an explicit request. Documented in ci-requirements
alongside the Rust, Kotlin and frontend gates.
2026-08-23 00:24:57 -04:00
bvandeusen 924ddb20db notes: saveEdit still asked for a title
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The one thing step 3 missed, and the typecheck lane caught it: `saveEdit`'s
parameter type still declared `title`, so the editor's call — correctly no
longer passing one — didn't match.

I gated Rust locally and not the frontend. Both are now in ci-requirements,
including WHY the frontend one has to be `npm run build` rather than
`vue-tsc --noEmit`: the typecheck only reads the script block, so a malformed
template sails past it and fails `vite build` in a different workflow, which is
exactly how the stray `</div>` got two commits away from where it was written.
2026-08-22 21:38:13 -04:00
bvandeusen 95aa10c2c3 Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
2026-08-22 19:33:57 -04:00
bvandeusen 6d778f26a7 Fix the ktlint and compat-test failures, and start using the Rust gate
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m46s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m47s
Two more from the step-2 removals:

**Two unused Kotlin imports** — `FilterChip` (the Note/List switch) and
`Icons.Filled.Create` (the "switch to a note" icon), both orphaned when their
callers went. ktlint treats them as errors.

**`server_info_tolerates_unknown_and_absent_fields`** pinned
`sync_protocol_version: 1` as a literal, so bumping the protocol to v2 made it
fail for a reason that has nothing to do with what it tests. It is about unknown
FIELDS; the versions now come from `CLIENT_PROTOCOL_VERSION`, like every other
test in that file already did.

The bigger fix is the habit. `ci-requirements.md` has documented since
2026-08-18 that the operator authorised running fmt/clippy/test against the CI
image locally, and I had not been doing it. All three now pass here — 116 tests,
clippy clean, fmt clean — and every Rust failure in this milestone so far would
have been caught by them in under a minute instead of by CI, several commits
downstream. Noted in ci-requirements so the next session doesn't relearn it: a
removal is exactly the change that looks too safe to check.
2026-08-22 14:51:38 -04:00
bvandeusen 33e9278975 Fix three breaks the removals left behind
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m27s
**`snapshot_revision` was deleted with `create_titled`** (bc22f8e). I sliced the
function out by scanning to the next `pub fn`, and the private `fn` sitting
between them went too. Nothing in Python or TypeScript compiles Rust, so it sat
undetected until the first lane that does. Restored verbatim.

**An orphaned serde attribute** in push.rs: removing `pub kind: Option<String>`
left its `#[serde(skip_serializing_if)]` behind, which then stacked onto the
next field's. That failed the derive, which is why three follow-on errors all
said `Change: Serialize is not satisfied` — one cause, four messages.

**An unbalanced `</div>`** in NoteEditor.vue, orphaned when the "Links / Linked
from" footer was cut. `vue-tsc --noEmit` type-checks the SCRIPT block and never
parses the template, so the typecheck lane passed it and `vite build` caught it
two workflows later. Worth remembering: a green typecheck says nothing about
template structure.

I also pushed step 2 without waiting for ad21eac to go terminal, which is what
let the Rust break travel a commit further than it should have.

Each fix comes with the check that would have caught it: a scan for stacked
serde attributes and called-but-undefined fns across every .rs, and a tag
balance pass over every .vue. Both are clean.
2026-08-22 13:25:26 -04:00
bvandeusen c46a4a7709 A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.

Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.

The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.

Two things that fell out of the merge rather than being aimed at:

- The Keep importer was DISCARDING `textContent` whenever a note also had
  `listContent`, because the target could only hold one. Both survive now, and
  the test says so.
- Markdown export wrote the body OR the checklist. It writes both.

Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.

The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
2026-08-22 12:53:53 -04:00
bvandeusen 229076c82d sync: stop deleting a note's checklist items because it isn't a "list"
`_apply_note_items` didn't ignore items on a non-list note — it deleted them.
That was survivable only because nothing in the product could produce a note
holding both a body and items.

M13 makes exactly that the normal shape: a checklist is something a note HAS,
not something a note IS. Against that shape this guard is a data-loss path — the
first sync after adding a checklist to a note would wipe it.

Landing it before the UI that can create the state, so there is never a window
where the two disagree. `kind` itself, and the rest of the merge, follow.
2026-08-22 12:45:14 -04:00
bvandeusen ad21eac5bc editor: drop the adapter import that went with backlinks
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
CI & Build / Build & push image (push) Failing after 22s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 30s
Desktop (Tauri) / Update manifest (push) Skipped
`repo` reached the editor for exactly two calls — `repo.notes.backlinks` and
`repo.notes.linkSearch` — and both left with the linking system. vue-tsc runs
with noUnusedLocals, so one stale import failed the whole shared-frontend build
and took both desktop lanes down with it (TS6133).

My local sweep checked for dangling *references*; it never checked the inverse,
that every import still has one. It does now, across all fifteen files that
removal touched — `repo` was the only one.
2026-08-22 12:44:33 -04:00
bvandeusen bc22f8e249 Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00
bvandeusen 982d24c83b links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.

That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.

`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.

`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.

The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.

The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).

The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
2026-08-22 11:02:39 -04:00
bvandeusen bacedea8a3 tests: seed the throttle counters on the clock the routes actually read
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 19s
The three route tests stamped their pre-loaded hits at t=0..9 through the
injected clock, then called a route that reads `time.monotonic()`. Against a
trailing window those hits are fifteen minutes stale on arrival, so they were
pruned before they could refuse anything, the request carried on to the database
that this suite doesn't have, and the assertion read `500 == 429`.

The window's own tests keep the injected clock — they pass the same one to both
sides, which is what makes them deterministic and instant. Only the tests that
hand off to a route need the real one.
2026-08-21 22:03:19 -04:00
bvandeusen b6152ec18b server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is
reachable by everyone, and nothing in front of it was counting.

Three credential routes — /login, /register and /device-login — now throttle.
Every attempt is counted against BOTH the account and the calling address, and
either can refuse it. The account key is the one that matters and the one that
cannot be forged: it stops stuffing against a known email no matter how many
addresses the attempts arrive from. The address key bounds one source spraying
many accounts, and is best-effort by nature — behind a proxy it comes from
X-Forwarded-For, which a caller can set to anything if the app is exposed
directly. That is exactly why it isn't the only key.

The check runs BEFORE the password is verified, which is the other half of what
this protects. bcrypt is deliberately slow; an unauthenticated caller who can
trigger it without limit has a CPU exhaustion primitive as well as a guessing
one. Sliding rather than fixed windows, because a fixed one lets twice the limit
through across a boundary. Bucket count is capped so a rotating forged header
can't turn the limiter into the exhaustion it prevents.

A sign-in against an email with no account now spends a real bcrypt against a
throwaway hash first. Without it "no such account" returned in microseconds
while a wrong password took ~100ms, which is a reliable oracle for which emails
are registered here.

Every response carries a CSP with script-src 'self', object-src 'none' and
frame-ancestors 'none', plus nosniff, a referrer policy and a permissions
policy. The app has no inline and no third-party scripts, so this concedes
nothing; the exceptions are honest — inline STYLE (Vue writes it itself for
v-show and the FLIP), and remote images (a link preview renders the og:image of
an arbitrary host, over either scheme, since a LAN install is served over http).
HSTS only where the request already arrived over TLS, and scoped to the one
host: no includeSubDomains, no preload, neither of which is this app's to
commit.

X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's
Secure flag and HSTS are the same question, and answering it twice is how the
two drift apart.

docs/public-hosting.md is the rest of it: the four things only the operator can
do (close registration, terminate TLS and forward the scheme, stop publishing
the app port, back up the attachment volume as well as the database), and an
honest list of what the app does NOT have — no email verification, no password
reset, no second factor, no per-user quota, no audit log. Those aren't blockers
for an instance whose accounts are people you know. They're the reason not to
leave signups open to strangers.
2026-08-21 22:01:37 -04:00
bvandeusen 16f86bef93 web: make the board usable on a phone, not just reachable
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The controls a card carries were always-visible overlays on a touch device —
correct as far as it went (task 2697: a finger cannot hover, and the pill is
the only way to pin or archive), but they were still absolutely positioned, so
they sat ON the note's own title. A card reading "thought sync tauri app"
rendered as "ught sync tauri app" with the grip parked over the first three
characters, and the four-icon pill covering the right half of the first line.

Placement is now CSS's decision. One element each, two placements: where a
pointer can hover they lift out of flow into the floating top-corner pills they
have always been; where nothing can hover they stay in flow as a footer row,
which cannot overlap anything by construction. Keyed on hover rather than
width, for the same reason `.hover-reveal` already is — a narrow window on a
laptop still hovers, a wide tablet still doesn't. The colour popover moved
inside the action set so it follows it, and opens into the card from either
end.

The header was sharing one phone-width row between a menu button, the logo, the
lens name, a search field and four icons; everything in it was truncated, the
lens down to "N…" and the search box to an empty pill. It wraps now, so search
takes its own line below sm, and account / settings / sign-out move into the
drawer where there is room to name them rather than guess at a glyph. One input,
moved by CSS — duplicating it would have meant two `searchInput` refs and a `/`
shortcut that focuses the wrong one.

Also closes the other half of task 2706, which was waiting on a device to look
at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding
that makes it safe (sides on body, top on the sticky header, bottom on the
board and the drawer), and `100dvh` behind an @supports so the app box follows
the visual viewport when the keyboard opens instead of the layout viewport.
Both halves in one change, as that task insisted.

And the composer no longer tells a phone to "Press Enter".
2026-08-21 21:55:46 -04:00
89 changed files with 2996 additions and 2226 deletions
+10
View File
@@ -39,6 +39,16 @@ POSTGRES_PASSWORD=
# 127.0.0.1 so only the proxy can talk to it.
#THOUGHTSYNC_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
# reverse proxy terminating HTTPS) and belongs there because it is something you may
# need to change while the server is running, alongside the sign-in limits.
# How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`.
#THOUGHTSYNC_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created.
#POSTGRES_USER=thoughtsync
+74 -1
View File
@@ -184,7 +184,80 @@ jobs:
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Run tests
run: /opt/venv/bin/python -m pytest tests/ -q
# DB-free by design. Anything needing a real Postgres is marked `integration`
# and runs in the job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
# for the first time when the operator's container started — 26 revisions, none of
# them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it.
#
# Runs for visibility and does NOT gate the build, matching the `test` lane and
# FabledScribe's equivalent job.
#
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
# derives the service-container name from the truncated job display name, and the
# discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
services:
postgres:
# Same image the production compose runs, so the schema is proven against the
# Postgres it will actually meet.
image: postgres:16-alpine
env:
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: thoughtsync_test
options: >-
--health-cmd "pg_isready -U thoughtsync"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Create virtual environment
run: uv venv /opt/venv
# Same install as the unit lane — the two must agree on versions, or
# "unit green, integration red" stops being a signal about the code.
- name: Install package with dev deps
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Integration suite (resolve service IP, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
# no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
try:
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
break
except OSError:
time.sleep(1)
else:
sys.exit("postgres did not become reachable")
PY
# Real migrations build the schema, never metadata.create_all (rule 82) —
# testing a schema no deployment has ever seen would prove nothing. This
# step IS the migration test: a broken revision fails the job here.
/opt/venv/bin/alembic upgrade head
/opt/venv/bin/python -m pytest tests/ -v -m integration
build:
name: Build & push image
Generated
+1 -1
View File
@@ -4189,7 +4189,7 @@ dependencies = [
[[package]]
name = "thoughtsync-desktop"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"log",
"serde",
+5
View File
@@ -101,6 +101,11 @@ Then open `http://<host>:5000` and register — **the first account becomes the
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
`:<git-sha>` (immutable, for pinning / rollback).
- **Putting it on the public internet:** there are four things to do first — close
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
port, and back up the attachment volume as well as the database. See
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
hardens on its own and what it deliberately doesn't.
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
@@ -0,0 +1,67 @@
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
that doesn't exist yet is a supported way to create one.
The backfill is safe to run bluntly because note_links is DERIVED data — every row
is recomputed from the source body on the next save regardless.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0023"
down_revision = "0022"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_links",
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_note_links_target",
"note_links",
"notes",
["target_id"],
["id"],
# A deleted target un-resolves its inbound links rather than deleting them:
# the link text is still in the source's body, and it should read as pointing
# at something that isn't there — which is also what lets it re-resolve if a
# note of that name appears again.
ondelete="SET NULL",
)
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
# Resolve what can be resolved right now, scoped to the source's owner so a link
# can never bind to another user's note.
op.execute(
"""
UPDATE note_links AS nl
SET target_id = t.id
FROM notes AS src, notes AS t
WHERE nl.source_id = src.id
AND t.owner_id = src.owner_id
AND t.deleted_at IS NULL
AND lower(btrim(t.display_title)) = nl.target_norm
AND t.id <> src.id
"""
)
def downgrade() -> None:
op.drop_index("ix_note_links_target_id", table_name="note_links")
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
op.drop_column("note_links", "target_id")
+54
View File
@@ -0,0 +1,54 @@
"""drop note_links — [[wiki-links]] are removed (note 2897)
Revision ID: 0024
Revises: 0023
Create Date: 2026-08-22
ThoughtSync is an intermediary surface for capture and recall; a linking system is
organization, which is not what it is for. Backlinks, the graph and the name index
went with it.
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
than deleted. It shipped in an image and may already be applied, and removing an
applied revision would strand a database's alembic_version pointer. So the column is
dropped here along with the table it lived on, and the history stays honest about the
fact that it existed for a day.
No down-migration data concern: note_links was always DERIVED from note bodies. The
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
lost by this.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0024"
down_revision = "0023"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("note_links")
def downgrade() -> None:
op.create_table(
"note_links",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"source_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"target_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("target_norm", sa.Text(), nullable=False),
)
op.create_index("ix_note_links_target", "note_links", ["target_norm"])
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
+35
View File
@@ -0,0 +1,35 @@
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
Revision ID: 0025
Revises: 0024
Create Date: 2026-08-22
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
ordinary child table keyed by note_id, serialization always emitted `items` whatever
the kind, and the Android editor already toggled between the two losslessly. The
storage has modelled "a body plus optional checkable items" the whole time; only the
gates forbade it.
Nothing is lost. Items were already rows in their own table, and a note that was
`kind = 'list'` keeps every one of them — it just stops being a different sort of
thing from the note next to it.
"""
from alembic import op
import sqlalchemy as sa
revision = "0025"
down_revision = "0024"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "kind")
def downgrade() -> None:
# server_default so existing rows get a value; every note comes back as 'text',
# which is right — a restored note with items would previously have hidden its
# body, and there is no record of which ones were once lists.
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
+82
View File
@@ -0,0 +1,82 @@
"""drop notes.title and note_revisions.title — a note's name is its first line
Revision ID: 0026
Revises: 0025
Create Date: 2026-08-22
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
non-empty line of that body, falling back to its first checklist item. There is no
separate field to type into, and `display_title` (already persisted, already what
search results and export filenames read) carries the name.
## The search vector has to be rebuilt, not just left alone
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
column depends on, and even if it didn't, the weighting would be wrong. So it is
dropped and recreated over `display_title` instead, which keeps the original
intent: the note's NAME ranks above the rest of its body.
Rebuilding a stored generated column re-computes every row, and the GIN index is
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
before running this against something large.
## What happens to existing titles
Nothing preserves them, deliberately: `display_title` was already derived from the
title when one was set, so every note keeps the NAME it had. What is lost is the
distinction between "this note has an explicit title" and "this note's first line is
its name" — which is the distinction being removed.
Imports are the exception and are handled in code, not here: a Keep note's title, or
one in an export taken before this, is folded in as the note's first body line rather
than dropped (see `_create_imported_note`).
"""
from alembic import op
import sqlalchemy as sa
revision = "0026"
down_revision = "0025"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Order matters: the generated column depends on `title`, so it goes first.
op.execute("DROP INDEX IF EXISTS ix_notes_search")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
op.drop_column("notes", "title")
op.drop_column("note_revisions", "title")
op.execute(
"""
ALTER TABLE notes ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED
"""
)
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_notes_search")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
# Comes back empty. The text is not gone — it is the first line of every body —
# but which notes once had an explicit title is not recorded anywhere.
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
op.execute(
"""
ALTER TABLE notes ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED
"""
)
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
@@ -226,8 +226,8 @@ private fun App(
ComposeSheet(
saving = board.state.saving,
onDismiss = { composing = false },
onSave = { kind, title, content ->
board.create(kind, title, content)
onSave = { content ->
board.create(content)
composing = false
},
)
@@ -50,9 +50,6 @@ sealed interface Destination {
) : Destination
}
/** What kind of thing the compose sheet is making. */
enum class DraftKind { NOTE, LIST }
/** Everything the board renders from, in one immutable snapshot. */
data class BoardState(
val destination: Destination = Destination.Notes,
@@ -197,20 +194,15 @@ class BoardViewModel(
* Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over.
*/
fun create(
kind: DraftKind,
title: String,
content: String,
) {
val cleanTitle = title.trim()
fun create(content: String) {
val cleanContent = content.trim()
if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return
if (cleanContent.isEmpty()) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(kind, cleanTitle, cleanContent)) }
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top
// of the board, and a full re-query would cost a round trip to
// tell us what we already know. Skipped when the board is not
@@ -281,34 +273,13 @@ class BoardViewModel(
EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError()
// Text is the only edit that batches: title and body are typed
// together and saved together on close, so they cost one write and
// one revision snapshot rather than two of each.
// Saved on close rather than per keystroke, so a session of typing
// costs one write and one revision snapshot.
is EditorAction.SaveText ->
mutate {
it.updateNote(
id,
listOf(
// An emptied title CLEARS the column rather than
// storing "". The core derives `display_title` from
// the first body line when the title is null, so the
// difference is whether an untitled note is nameable
// or blank — exactly what `ClearTitle` exists for.
if (action.title.isBlank()) {
NoteEdit.ClearTitle
} else {
NoteEdit.Title(action.title.trim())
},
NoteEdit.Body(action.body),
),
)
}
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
EditorAction.ToggleKind ->
edit(id, NoteEdit.Kind(if (note.kind == KIND_LIST) KIND_TEXT else KIND_LIST))
// Pinning re-sorts the board rather than emptying it, and on a phone
// you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open.
@@ -331,6 +302,10 @@ class BoardViewModel(
null
}
// An empty first item: the checklist editor appears the moment the note
// has one, and an empty row is what someone can type straight into.
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) }
@@ -467,26 +442,8 @@ private fun query(
labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
private fun draft(
kind: DraftKind,
title: String,
content: String,
): NoteDraft =
when (kind) {
// Body left to carry the text; the core derives display_title from its
// first line when no title was given, so a captured thought is nameable
// without making the user name it.
DraftKind.NOTE ->
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, kind = null, items = null)
// One line per item. At CAPTURE time the whole list is already in your
// head, so typing it in one go beats a tap between each row; the editor
// has the per-row control for when the list is being revised instead.
DraftKind.LIST ->
NoteDraft(
title = title,
body = "",
color = DEFAULT_COLOR,
kind = KIND_LIST,
items = content.lines().map { it.trim() }.filter { it.isNotEmpty() },
)
}
private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is
// findable without anyone being asked to name it. A checklist is added afterwards,
// in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -58,28 +57,26 @@ import com.fabledsword.thoughtsync.R
fun ComposeSheet(
saving: Boolean,
onDismiss: () -> Unit,
onSave: (DraftKind, String, String) -> Unit,
onSave: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this.
var kind by rememberSaveable { mutableStateOf(DraftKind.NOTE) }
var title by rememberSaveable { mutableStateOf("") }
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
val written = title.isNotBlank() || content.isNotBlank()
val leave = { if (written) onSave(kind, title, content) else onDismiss() }
val written = content.isNotBlank()
val leave = { if (written) onSave(content) else onDismiss() }
// Land in the body, not the title. Most captures are a thought, not a titled
// document, and making someone tab past an optional field is the difference
// between "under a second" and not.
// Straight into the one field there is. A capture is a thought, and every field
// someone has to tab past is the difference between "under a second" and not —
// which is why the title field is gone rather than merely skipped (M13 step 3).
LaunchedEffect(Unit) { contentFocus.requestFocus() }
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
// + and then got distracted should find the composer where they left it; the
// only reason to act here is that there is something to lose.
FlushOnStop { if (written) onSave(kind, title, content) }
FlushOnStop { if (written) onSave(content) }
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column(
@@ -91,43 +88,20 @@ fun ComposeSheet(
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = kind == DraftKind.NOTE,
onClick = { kind = DraftKind.NOTE },
label = { Text(stringResource(R.string.compose_kind_note)) },
)
FilterChip(
selected = kind == DraftKind.LIST,
onClick = { kind = DraftKind.LIST },
label = { Text(stringResource(R.string.compose_kind_list)) },
)
}
PlainTextField(
value = title,
onValueChange = { title = it },
hint = R.string.compose_title_hint,
singleLine = true,
)
// No note/list switch any more: there is one thing to capture. A
// checklist is added to a note in the editor, once there is a note.
PlainTextField(
value = content,
onValueChange = { content = it },
modifier = Modifier.focusRequester(contentFocus),
hint =
if (kind == DraftKind.LIST) {
R.string.compose_list_hint
} else {
R.string.compose_body_hint
},
hint = R.string.compose_body_hint,
minLines = MIN_CONTENT_LINES,
)
SheetActions(
canSave = !saving && written,
onDiscard = onDismiss,
onSave = { onSave(kind, title, content) },
onSave = { onSave(content) },
)
}
}
@@ -22,7 +22,6 @@ sealed interface EditorAction {
data object DismissError : EditorAction
data class SaveText(
val title: String,
val body: String,
) : EditorAction
@@ -31,13 +30,13 @@ sealed interface EditorAction {
) : EditorAction
/**
* Note checklist.
* Give this note a checklist.
*
* Only `kind` changes: the body text and any existing items both stay where
* they are, so switching back and forth is lossless and a mis-tap costs
* nothing.
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
* so nothing moves and nothing is swapped: the body stays exactly where it is and
* the note gains a first, empty item for someone to type into.
*/
data object ToggleKind : EditorAction
data object AddChecklist : EditorAction
data class SetPinned(
val pinned: Boolean,
@@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.BottomAppBar
@@ -84,17 +83,18 @@ fun EditorBottomBar(
contentDescription = stringResource(R.string.editor_reminder),
)
}
IconButton(onClick = { onAction(EditorAction.ToggleKind) }) {
val list = note.kind == KIND_LIST
// Adds the first checklist item, which is what makes the checklist
// editor appear. Hidden once the note already has one — there is nothing
// left to add that the checklist's own "+" row doesn't do better.
if (note.items.isEmpty()) {
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
Icon(
if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List,
contentDescription =
stringResource(
if (list) R.string.editor_make_note else R.string.editor_make_list,
),
Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist),
)
}
}
}
Box(modifier = Modifier.weight(1f))
@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -50,23 +49,10 @@ fun NoteCard(
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// A title only renders when one was actually set. `displayTitle` is
// derived from the first body line when it wasn't, so printing both would
// show the same text twice.
note.title?.takeIf { it.isNotBlank() }?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
}
if (note.kind == KIND_LIST) {
Checklist(items = note.items)
} else if (note.body.isNotBlank()) {
// Body then checklist, in order — a note can carry both (M13 step 2), and
// nothing above them: the first line of the body IS the note's name, at the
// same weight as the rest of it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
Text(
text = note.body,
style = MaterialTheme.typography.bodyMedium,
@@ -74,10 +60,14 @@ fun NoteCard(
overflow = TextOverflow.Ellipsis,
)
}
if (note.items.isNotEmpty()) {
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
Checklist(items = note.items)
}
// A note with no title, no body and no items still has to occupy the
// board legibly — otherwise it reads as a rendering bug.
if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) {
// A note with no body and no items still has to occupy the board legibly —
// otherwise it reads as a rendering bug.
if (note.body.isBlank() && note.items.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
@@ -30,7 +30,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
@@ -62,7 +61,6 @@ fun NoteEditorScreen(
// Keyed by note id: the editor is reused across notes, and without the key the
// second note opened would show the first one's text.
var title by remember(note.id) { mutableStateOf(note.title.orEmpty()) }
var body by remember(note.id) { mutableStateOf(note.body) }
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
@@ -77,8 +75,8 @@ fun NoteEditorScreen(
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it.
val flush = {
if (!readOnly && (title != note.title.orEmpty() || body != note.body)) {
onAction(EditorAction.SaveText(title, body))
if (!readOnly && body != note.body) {
onAction(EditorAction.SaveText(body))
}
}
val leave = {
@@ -142,17 +140,9 @@ fun NoteEditorScreen(
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
}
EditorField(
value = title,
onValueChange = { title = it },
hint = R.string.editor_title_hint,
enabled = !readOnly,
bold = true,
)
if (note.kind == KIND_LIST) {
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
} else {
// One field. A note is its body; its NAME is that body's first line, so
// there is nothing separate to type into and nothing to render bolder
// than the line beneath it (M13 steps 3 and 4).
EditorField(
value = body,
onValueChange = { body = it },
@@ -160,6 +150,11 @@ fun NoteEditorScreen(
enabled = !readOnly,
minLines = MIN_BODY_LINES,
)
// Below the body, not instead of it, and only once the note has items —
// the toolbar's add-checklist action is what puts the first one there.
if (note.items.isNotEmpty()) {
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
}
if (note.labels.isNotEmpty()) {
@@ -250,11 +245,15 @@ private fun EditorOverlays(
}
/**
* The title and body fields.
* The note's body field.
*
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
* the note's colour, and a filled field would draw a second surface over the first
* and turn a note into a form.
*
* One weight throughout. The first line is the note's name, but it is not a
* different KIND of text from the line after it, and typing it should not feel like
* filling in a header.
*/
@Composable
private fun EditorField(
@@ -262,7 +261,6 @@ private fun EditorField(
onValueChange: (String) -> Unit,
@StringRes hint: Int,
enabled: Boolean,
bold: Boolean = false,
minLines: Int = 1,
) {
PlainTextField(
@@ -270,16 +268,8 @@ private fun EditorField(
onValueChange = onValueChange,
hint = hint,
enabled = enabled,
// The title is one line by contract — it is a name, and a name that wraps
// has become a body. The body itself never is.
singleLine = bold,
minLines = minLines,
textStyle =
if (bold) {
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
} else {
MaterialTheme.typography.bodyLarge
},
textStyle = MaterialTheme.typography.bodyLarge,
)
}
@@ -1,16 +0,0 @@
package com.fabledsword.thoughtsync.ui
/**
* The core's `kind` vocabulary, which the UI has to match exactly.
*
* Shared rather than repeated because it was already living in three places — the
* card deciding whether to draw checkboxes, the editor deciding which field to
* show, and the view model deciding what to create — and a typo in any one of them
* would silently render a checklist as a paragraph rather than fail.
*
* Strings and not an enum: this is a value the STORE owns, arriving from a server
* that may be newer than this client, and an unrecognised kind has to fall through
* to "render it as a note" rather than throw.
*/
internal const val KIND_TEXT = "text"
internal const val KIND_LIST = "list"
+1 -7
View File
@@ -10,11 +10,7 @@
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<string name="compose_kind_note">Note</string>
<string name="compose_kind_list">List</string>
<string name="compose_title_hint">Title</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_list_hint">One item per line</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</string>
@@ -41,14 +37,12 @@
<!-- Editor -->
<string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string>
<string name="editor_title_hint">Title</string>
<string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Note</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_make_list">Make a checklist</string>
<string name="editor_make_note">Switch to a note</string>
<string name="editor_more">More actions</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
+25 -45
View File
@@ -568,12 +568,10 @@ mod tests {
dir.to_string_lossy().into_owned()
}
fn draft(title: &str, body: &str) -> NoteDraft {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
title: title.to_string(),
body: body.to_string(),
color: "default".to_string(),
kind: None,
items: None,
}
}
@@ -588,62 +586,50 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries", "milk"))
.create_note(draft("Groceries\nmilk"))
.expect("create should succeed");
assert_eq!(created.title.as_deref(), Some("Groceries"));
assert_eq!(created.body, "milk");
assert_eq!(created.body, "Groceries\nmilk");
let fetched = app
.get_note(created.id.clone())
.expect("get should succeed");
assert_eq!(fetched.id, created.id);
// The NAME is the first line — there is no title field to have set (M13 step 3).
assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok();
}
/// A body-only note still has to be nameable — that is what `display_title` is
/// for, and the Android board relies on it exactly as the desktop does.
/// Every note has to be nameable — that is what `display_title` is for, and the
/// Android board relies on it exactly as the desktop does.
#[test]
fn body_only_notes_still_have_a_display_title() {
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("", "just a thought"))
.create_note(draft("just a thought"))
.expect("create should succeed");
assert_eq!(created.title, None);
assert_eq!(created.display_title, "just a thought");
std::fs::remove_dir_all(&dir).ok();
}
/// Clearing a field and setting one are different edits, and the difference has
/// to survive the trip through the patch object.
/// The hole that made removing the title unsafe until checklists stopped being
/// their own kind of thing: a note with no body text still needs a name.
#[test]
fn edits_can_both_set_and_clear_a_title() {
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("First", "body")).expect("create");
let renamed = app
.update_note(
note.id.clone(),
vec![NoteEdit::Title {
value: "Second".to_string(),
}],
)
.expect("rename");
assert_eq!(renamed.title.as_deref(), Some("Second"));
let cleared = app
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
.expect("clear");
assert_eq!(
cleared.title, None,
"ClearTitle must null the column, not set it to an empty string — the \
distinction is why NoteEdit is a list rather than a struct of options"
);
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
assert_eq!(created.display_title, "milk");
std::fs::remove_dir_all(&dir).ok();
}
@@ -674,10 +660,8 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
title: "Packing".to_string(),
body: String::new(),
body: "Packing".to_string(),
color: "default".to_string(),
kind: Some("list".to_string()),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
@@ -730,7 +714,7 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip", "book the ferry #travel"))
.create_note(draft("Trip\nbook the ferry #travel"))
.expect("create");
assert_eq!(
note.labels.len(),
@@ -769,7 +753,7 @@ mod tests {
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone())
.expect("delete forever");
@@ -786,7 +770,7 @@ mod tests {
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back", "")).expect("create");
let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None);
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
@@ -812,9 +796,7 @@ mod tests {
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Water the plants", ""))
.expect("create");
let note = app.create_note(draft("Water the plants")).expect("create");
let armed = app
.update_note(
@@ -851,9 +833,7 @@ mod tests {
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
// invisibly on a note with no reminder.
let once = app
.create_note(draft("Post the letter", ""))
.expect("create");
let once = app.create_note(draft("Post the letter")).expect("create");
app.update_note(
once.id.clone(),
vec![NoteEdit::RemindAt {
+21 -49
View File
@@ -29,13 +29,11 @@ use thoughtsync_core::sync::state as core_state;
#[derive(Debug, Clone, uniffi::Record)]
pub struct Note {
pub id: String,
pub title: Option<String>,
/// Title if set, else the first body line — always present, so a body-only note
/// is still nameable. Derived by the core, never stored.
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub kind: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -130,11 +128,9 @@ impl From<core_models::Note> for Note {
// Exhaustive on purpose — see the module header.
let core_models::Note {
id,
title,
display_title,
body,
color,
kind,
position,
pinned,
archived,
@@ -151,11 +147,9 @@ impl From<core_models::Note> for Note {
} = value;
Note {
id,
title,
display_title,
body,
color,
kind,
position,
pinned,
archived,
@@ -294,7 +288,6 @@ pub struct NoteQuery {
pub struct NoteFacets {
pub q: Option<String>,
pub color: Option<String>,
pub kind: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
@@ -324,7 +317,6 @@ impl From<NoteFacets> for core_models::Facets {
let NoteFacets {
q,
color,
kind,
label,
has_reminder,
has_attachment,
@@ -334,7 +326,6 @@ impl From<NoteFacets> for core_models::Facets {
core_models::Facets {
q,
color,
kind,
label,
has_reminder,
has_attachment,
@@ -347,31 +338,18 @@ impl From<NoteFacets> for core_models::Facets {
/// A new note.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub title: String,
pub body: String,
/// "default" unless the user picked a colour.
pub color: String,
pub kind: Option<String>,
/// Checklist lines, for `kind = "checklist"`.
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>,
}
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft {
title,
body,
color,
kind,
items,
} = value;
core_models::NoteCreateInput {
title,
body,
color,
kind,
items,
}
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
}
}
@@ -379,17 +357,14 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
///
/// A LIST of these rather than a struct of optional fields, because the core's patch
/// semantics distinguish three states — leave alone, set to a value, and clear to
/// null — and Kotlin has no way to express the third with a nullable field. `title:
/// null` in a data class is indistinguishable from `title` unset, so the editor
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
/// gets a sealed class it can `when` over exhaustively.
/// null — and Kotlin has no way to express the third with a nullable field.
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Title { value: String },
ClearTitle,
Body { value: String },
Color { value: String },
Kind { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
@@ -408,11 +383,8 @@ impl NoteEdit {
fn entry(self) -> (&'static str, serde_json::Value) {
use serde_json::Value;
match self {
NoteEdit::Title { value } => ("title", Value::String(value)),
NoteEdit::ClearTitle => ("title", Value::Null),
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Kind { value } => ("kind", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
@@ -425,8 +397,8 @@ impl NoteEdit {
/// Fold a list of edits into the single patch object the store applies.
///
/// Later edits win on a repeated key, which is what a caller batching "set title,
/// then clear title" would expect.
/// Later edits win on a repeated key, which is what a caller batching "set a
/// reminder, then clear it" would expect.
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
let mut map = serde_json::Map::new();
for edit in edits {
@@ -707,14 +679,14 @@ mod tests {
#[test]
fn a_set_and_a_clear_are_different_patch_entries() {
let set = patch_from(vec![NoteEdit::Title {
value: "x".to_string(),
let set = patch_from(vec![NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
}]);
assert_eq!(set["title"], serde_json::json!("x"));
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
assert!(
cleared["title"].is_null(),
cleared["remind_at"].is_null(),
"a clear must reach the store as JSON null — an absent key means \
'leave alone', which is a different instruction"
);
@@ -730,11 +702,11 @@ mod tests {
#[test]
fn later_edits_win_on_a_repeated_field() {
let patch = patch_from(vec![
NoteEdit::Title {
value: "first".to_string(),
NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
},
NoteEdit::ClearTitle,
NoteEdit::ClearRemindAt,
]);
assert!(patch["title"].is_null());
assert!(patch["remind_at"].is_null());
}
}
+63
View File
@@ -73,6 +73,43 @@ entirely on `ci-python:3.14`.
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
`python3 -m zipfile -e`.
## The integration lane
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the
operator's container started — 26 revisions, none of them ever executed by CI — and
the schema the migrations build had never been checked against the models that read
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
watching.
Copied from FabledScribe's `integration` job, which had already solved the awkward
parts. Three of them are family rules for a reason:
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service
container's name from the truncated job DISPLAY name, and the discovery step filters
`docker ps` by it. A spaced or underscored name breaks the filter.
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
`postgres:16-alpine`, matching the production compose, so the schema is proven against
the Postgres it will actually meet. The schema comes from **real migrations, never
`metadata.create_all`** (rule 82): testing a schema no deployment has ever seen proves
nothing, and that `alembic upgrade head` step IS the migration test — a broken revision
fails the job there, before it can fail a container start.
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
BEFORE each test rather than after, so a failure leaves its rows behind to look at.
Like `test`, it runs for visibility and does **not** gate the build.
There is no local way to run it — that would mean standing up Postgres on the
workstation, which rule 12 reserves for an explicit request. This lane is verified in
CI.
## Desktop (Tauri) lane — separate workflow
The Tauri desktop client (`desktop/`) builds in its own workflow,
@@ -305,11 +342,37 @@ matched CI run 3931's byte for byte. Same image, same lockfile, same units.
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
whenever the space is wanted.
**Run these on every Rust-touching push, not just the ones that feel risky.** Four
consecutive failures across M13's removals — a private `fn` deleted along with the
`pub fn` above it, an orphaned `#[serde]` attribute left where a field was removed,
and a test pinning a protocol version literal — were all caught by these three
commands in under a minute each, after CI had already found them the slow way. A
removal is exactly the kind of change that looks safe and isn't: nothing in the
Python or TypeScript lanes compiles Rust, so a break can travel several commits
before the first lane that does gets to it.
**Don't infer formatting from existing code.** Several lines in `local/store.rs`
exceed 100 characters and survive only because rustfmt cannot break a string
literal — copying that shape caused one of four consecutive fmt-only CI failures,
which is what this whole section exists to prevent.
## Checking the frontend lane before pushing
Same technique, same authorisation, same reason — and it covers a gap the Rust gate
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE
passes the typecheck lane and fails `vite build` in a different workflow. `npm run
build` runs both, which is exactly what the desktop lanes run.
```
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
```
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs
already run on, not a separate one. Delete `frontend/node_modules` and `frontend/dist`
afterwards; both are gitignored, but neither belongs in a working tree that never
builds locally otherwise.
## The desktop lockfile
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
+7 -54
View File
@@ -1,38 +1,14 @@
//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of
//! what the server computes on save. Pure string scanning (no regex dependency),
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts):
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
//! frontend's inline rules (see frontend notes/markdown.ts):
//!
//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to
//! compute backlinks at query time (links are derived, never stored/synced).
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`.
//!
//! Both dedupe case-insensitively, preserving first-seen order.
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`.
pub fn extract_links(body: &str) -> Vec<String> {
let bytes = body.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(rel) = body[i + 2..].find("]]") {
let inner = &body[i + 2..i + 2 + rel];
// Mirror the frontend's `[^[\]]+`: no stray brackets inside.
if !inner.contains('[') && !inner.contains(']') {
let t = inner.trim();
if !t.is_empty() {
push_unique(&mut out, t);
}
}
i += 2 + rel + 2;
continue;
}
}
i += 1;
}
out
}
//! Dedupes case-insensitively, preserving first-seen order.
//!
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
//! capture-and-recall surface, and a linking system is organization.
/// Extract every `#tag` name (without the leading `#`) from `body`.
pub fn extract_tags(body: &str) -> Vec<String> {
@@ -73,28 +49,6 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
mod tests {
use super::*;
#[test]
fn links_basic_and_trim() {
assert_eq!(
extract_links("see [[ Alpha ]] and [[Beta]]"),
vec!["Alpha", "Beta"]
);
}
#[test]
fn links_dedupe_case_insensitive_first_seen() {
assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]);
}
#[test]
fn links_ignore_malformed_and_nested_brackets() {
assert_eq!(
extract_links("[[a[b]] [[]] [ [x] ] plain"),
Vec::<String>::new()
);
assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]);
}
#[test]
fn tags_basic() {
assert_eq!(
@@ -116,7 +70,6 @@ mod tests {
#[test]
fn empty_body() {
assert!(extract_links("").is_empty());
assert!(extract_tags("").is_empty());
}
}
+3 -17
View File
@@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Note {
pub id: String,
pub title: Option<String>,
/// title if set, else the note's first body line — always present, so body-only
/// notes are still nameable and `[[link]]`-able. Derived, never stored.
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present, so every note has something to be called. Derived at read time,
/// never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub kind: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -72,7 +71,6 @@ pub struct LinkPreview {
#[derive(Serialize)]
pub struct NoteRevision {
pub id: String,
pub title: Option<String>,
pub body: String,
pub created_at: Option<String>,
}
@@ -94,12 +92,6 @@ pub struct TitleEntry {
pub title: String,
}
#[derive(Serialize)]
pub struct Backlink {
pub id: String,
pub title: String,
}
#[derive(Serialize)]
pub struct SavedFilter {
pub id: String,
@@ -135,15 +127,11 @@ fn default_color() -> String {
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -168,8 +156,6 @@ pub struct Facets {
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+8 -8
View File
@@ -93,8 +93,8 @@ mod tests {
let when = Utc::now() - age;
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)",
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES (?1, 'B', ?2, ?2, 1, ?2)",
rusqlite::params![id, stamped],
)
.expect("insert");
@@ -149,8 +149,8 @@ mod tests {
fn an_untrashed_note_is_never_swept() {
let conn = db();
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed)
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
"INSERT INTO notes (id, body, created_at, updated_at, trashed)
VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
[],
)
.expect("insert");
@@ -163,8 +163,8 @@ mod tests {
// "Age unknown" must never resolve to "delete it".
let conn = db();
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
[],
)
.expect("insert");
@@ -179,8 +179,8 @@ mod tests {
let conn = db();
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)",
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES ('server', 'B', ?1, ?1, 1, ?1)",
rusqlite::params![stamped],
)
.expect("insert");
+31 -2
View File
@@ -1,7 +1,8 @@
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
//! offline note can later sync 1:1 with the server. Each syncable row carries local
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md.
//! `#tags` are NOT stored as such (derived at query time into labels), matching
//! docs/sync.md.
//!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
@@ -13,7 +14,7 @@ CREATE TABLE notes (
title TEXT,
body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list'
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
@@ -159,6 +160,26 @@ CREATE TABLE prefs (
);
"#;
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
// a note IS — the column was a mode flag with no enum and no constraint behind it,
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
// 'list' keeps every one of its items.
//
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
const SCHEMA_V6: &str = r#"
ALTER TABLE notes DROP COLUMN kind;
"#;
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
// its NAME is the first non-empty line of that body, falling back to its first item —
// derived at read time, never stored (see store::display_title).
//
// note_revisions loses its copy for the same reason: a revision snapshots a body.
const SCHEMA_V7: &str = r#"
ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title;
"#;
/// Bring the database up to the latest schema. Idempotent.
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
@@ -183,5 +204,13 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V5)?;
conn.execute_batch("PRAGMA user_version = 5;")?;
}
if version < 6 {
conn.execute_batch(SCHEMA_V6)?;
conn.execute_batch("PRAGMA user_version = 6;")?;
}
if version < 7 {
conn.execute_batch(SCHEMA_V7)?;
conn.execute_batch("PRAGMA user_version = 7;")?;
}
Ok(())
}
+65 -150
View File
@@ -24,30 +24,26 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// title if non-empty, else the first non-blank body line — always a string.
fn display_title(title: Option<&str>, body: &str) -> String {
if let Some(t) = title {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
/// The note's NAME: its first non-blank body line, else its first checklist item.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// Pure, and given the items rather than fetching them: every caller has already
/// loaded them, so a query here would be a second trip for something already in hand.
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
return line.to_string();
}
}
body.lines()
.map(str::trim)
.find(|l| !l.is_empty())
items
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
}
fn normalize_title(raw: &str) -> Option<String> {
let t = raw.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
fn escape_like(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
@@ -139,33 +135,29 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
let dt = display_title(title.as_deref(), &body);
let body: String = r.get(1)?;
Ok(Note {
id: r.get(0)?,
title,
display_title: dt,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(3)?,
kind: r.get(4)?,
position: r.get(5)?,
pinned: r.get(6)?,
archived: r.get(7)?,
trashed: r.get(8)?,
deleted_at: r.get(13)?,
remind_at: r.get(9)?,
recurrence: r.get(10)?,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
previews: Vec::new(),
created_at: r.get(11)?,
updated_at: r.get(12)?,
created_at: r.get(9)?,
updated_at: r.get(10)?,
})
},
)?;
@@ -173,6 +165,8 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
note.items = load_items(conn, id)?;
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
Ok(note)
}
@@ -268,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
if let Some(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
let pat = format!("%{}%", escape_like(text));
binds.push(pat.clone());
binds.push(pat);
@@ -277,10 +271,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
sql.push_str(" AND color = ?");
binds.push(c.to_string());
}
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND kind = ?");
binds.push(k.to_string());
}
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -326,23 +316,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
// Names come from `load_note` rather than from a bare row, because a note whose
// body is empty is named by its first checklist item — which a row here doesn't
// have. The command palette reads this; correctness beats one query per note at
// personal scale.
let ids: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter()
.map(|id| {
let note = load_note(conn, id)?;
Ok(TitleEntry {
id: r.get(0)?,
title: display_title(title.as_deref(), &body),
id: note.id,
title: note.display_title,
})
})?;
rows.collect()
})
.collect()
}
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
let pat = format!("%{}%", escape_like(q));
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
)?;
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
@@ -350,77 +348,20 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
ids.iter().map(|id| load_note(conn, id)).collect()
}
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
let target: String = {
let (t, b): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
display_title(t.as_deref(), &b)
};
if target.is_empty() {
return Ok(Vec::new());
}
let mut stmt =
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
let rows = stmt.query_map([id], |r| {
let nid: String = r.get(0)?;
let t: Option<String> = r.get(1)?;
let b: String = r.get(2)?;
Ok((nid, t, b))
})?;
let mut out = Vec::new();
for row in rows {
let (nid, t, b) = row?;
if derive::extract_links(&b)
.iter()
.any(|l| l.eq_ignore_ascii_case(&target))
{
out.push(Backlink {
id: nid,
title: display_title(t.as_deref(), &b),
});
}
}
Ok(out)
}
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
let ql = q.trim().to_lowercase();
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let id: String = r.get(0)?;
let t: Option<String> = r.get(1)?;
let b: String = r.get(2)?;
Ok((id, t, b))
})?;
let mut out = Vec::new();
for row in rows {
let (id, t, b) = row?;
let dt = display_title(t.as_deref(), &b);
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
out.push(TitleEntry { id, title: dt });
}
}
Ok(out)
}
// ---- notes: write -----------------------------------------------------------
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
let id = new_id();
let ts = now();
let title = normalize_title(&input.title);
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
params![id, title, input.body, input.color, kind, position, ts],
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, input.body, input.color, position, ts],
)?;
if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() {
@@ -434,25 +375,12 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
load_note(conn, &id)
}
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
let input = NoteCreateInput {
title: title.to_string(),
body: String::new(),
color: "default".to_string(),
kind: None,
items: None,
};
create_note(conn, &input)
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let (title, body): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
conn.execute(
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![new_id(), id, title, body, now()],
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, body, now()],
)?;
Ok(())
}
@@ -463,20 +391,13 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit title/body once if either is being changed (version history).
if obj.contains_key("title") || obj.contains_key("body") {
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
}
for (k, v) in obj {
match k.as_str() {
"title" => {
let norm = v.as_str().and_then(normalize_title);
conn.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
params![norm, id],
)?;
}
"body" => {
let body = v.as_str().unwrap_or("");
conn.execute(
@@ -490,11 +411,6 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
}
}
"kind" => {
if let Some(s) = v.as_str() {
conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?;
}
}
"pinned" => {
if let Some(b) = v.as_bool() {
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
@@ -730,28 +646,27 @@ pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<(
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
let mut stmt = conn
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([id], |r| {
Ok(NoteRevision {
id: r.get(0)?,
title: r.get(1)?,
body: r.get(2)?,
created_at: r.get(3)?,
body: r.get(1)?,
created_at: r.get(2)?,
})
})?;
rows.collect()
}
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
let (title, body): (Option<String>, String) = conn.query_row(
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
let body: String = conn.query_row(
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
params![rev_id, id],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| r.get(0),
)?;
snapshot_revision(conn, id)?;
conn.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
params![title, body, id],
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, &body)?;
touch(conn, id)?;
+12 -8
View File
@@ -19,11 +19,11 @@
use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 1;
pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1;
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it.
@@ -344,13 +344,17 @@ mod tests {
fn server_info_tolerates_unknown_and_absent_fields() {
// Forward compatibility: a NEWER server sending fields we've never heard of
// must not break the handshake.
let info: ServerInfo = serde_json::from_str(
r#"{"site_name":"S","sync_protocol_version":1,
"min_client_protocol_version":1,
// Versions come from the constants, not literals: this test is about unknown
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
// to v2 — for a reason that has nothing to do with what it checks.
let body = format!(
r#"{{"site_name":"S","sync_protocol_version":{v},
"min_client_protocol_version":{v},
"sync_features":["notes","labels","attachments","tombstones","revisions"],
"some_future_field":{"nested":true}}"#,
)
.expect("unknown fields are ignored");
"some_future_field":{{"nested":true}}}}"#,
v = CLIENT_PROTOCOL_VERSION,
);
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
assert_eq!(evaluate(&info), Compatibility::Ok);
}
+2 -8
View File
@@ -240,15 +240,13 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
"INSERT INTO notes (id, body, color, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
body = excluded.body,
color = excluded.color,
kind = excluded.kind,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
@@ -261,10 +259,8 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
dirty = 0",
params![
note.id,
note.title,
note.body,
note.color,
note.kind,
note.position,
note.pinned,
note.archived,
@@ -498,10 +494,8 @@ mod tests {
fn note(id: &str, revision: i64) -> wire::Note {
wire::Note {
id: id.to_string(),
title: Some("Title".into()),
body: "Body".into(),
color: "default".into(),
kind: "text".into(),
position: 0,
pinned: false,
archived: false,
+13 -27
View File
@@ -62,15 +62,11 @@ pub struct Change {
pub op: &'static str,
pub edited_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
@@ -99,10 +95,8 @@ impl Change {
id,
op: "delete",
edited_at,
title: None,
body: None,
color: None,
kind: None,
pinned: None,
archived: None,
trashed: None,
@@ -200,9 +194,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
name: Some(r.get(1)?),
color: Some(r.get(2)?),
edited_at: r.get(3)?,
title: None,
body: None,
kind: None,
pinned: None,
archived: None,
trashed: None,
@@ -237,10 +229,8 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
/// field-to-column mapping stays readable at the call site.
struct NoteRow {
title: Option<String>,
body: String,
color: String,
kind: String,
position: i64,
pinned: bool,
archived: bool,
@@ -253,24 +243,22 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row(
"SELECT title, body, color, kind, position, pinned, archived, trashed,
"SELECT body, color, position, pinned, archived, trashed,
remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1",
params![id],
|r| {
Ok(NoteRow {
title: r.get(0)?,
body: r.get(1)?,
color: r.get(2)?,
kind: r.get(3)?,
position: r.get(4)?,
pinned: r.get::<_, i64>(5)? != 0,
archived: r.get::<_, i64>(6)? != 0,
trashed: r.get::<_, i64>(7)? != 0,
remind_at: r.get(8)?,
recurrence: r.get(9)?,
created_at: r.get(10)?,
updated_at: r.get(11)?,
body: r.get(0)?,
color: r.get(1)?,
position: r.get(2)?,
pinned: r.get::<_, i64>(3)? != 0,
archived: r.get::<_, i64>(4)? != 0,
trashed: r.get::<_, i64>(5)? != 0,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
created_at: r.get(8)?,
updated_at: r.get(9)?,
})
},
)
@@ -309,10 +297,8 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
// The local `updated_at` IS the client's edit time, which is what the
// server's last-write-wins comparison runs against.
edited_at: row.updated_at,
title: row.title,
body: Some(row.body),
color: Some(row.color),
kind: Some(row.kind),
pinned: Some(row.pinned),
archived: Some(row.archived),
trashed: Some(row.trashed),
@@ -535,9 +521,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
"INSERT INTO notes (id, body, color, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'T', 'B', 'default', 'text', 0, 0, 0, 0,
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty],
)
-8
View File
@@ -24,13 +24,9 @@ pub struct ChangesPage {
pub struct Note {
pub id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default)]
pub position: i64,
#[serde(default)]
@@ -157,10 +153,6 @@ fn default_color() -> String {
"default".to_string()
}
fn default_kind() -> String {
"text".to_string()
}
fn default_mime() -> String {
"application/octet-stream".to_string()
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "thoughtsync-desktop"
version = "0.1.0"
version = "0.2.0"
description = "ThoughtSync desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"]
edition = "2021"
-24
View File
@@ -67,12 +67,6 @@ pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result<Note, S
store::create_note(&conn, &input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_create_titled(title: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::create_titled(&conn, &title).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
@@ -196,24 +190,6 @@ pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
store::titles(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_search(q: String, db: State<'_, Db>) -> Result<Vec<Note>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::search(&conn, &q).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_backlinks(id: String, db: State<'_, Db>) -> Result<Vec<Backlink>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::backlinks(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_link_search(q: String, db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::link_search(&conn, &q).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
-4
View File
@@ -102,7 +102,6 @@ pub fn run() {
commands::local::notes_list,
commands::local::notes_get,
commands::local::notes_create,
commands::local::notes_create_titled,
commands::local::notes_update,
commands::local::notes_complete_reminder,
commands::local::notes_snooze_reminder,
@@ -120,9 +119,6 @@ pub fn run() {
commands::local::notes_restore_revision,
commands::local::notes_reminders,
commands::local::notes_titles,
commands::local::notes_search,
commands::local::notes_backlinks,
commands::local::notes_link_search,
commands::local::labels_list,
commands::local::labels_create,
commands::local::labels_rename,
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "ThoughtSync",
"mainBinaryName": "thoughtsync",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.fabledsword.thoughtsync",
"build": {
"frontendDist": "../../frontend/dist",
+21 -3
View File
@@ -71,10 +71,28 @@ services:
# Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR),
# not configurable — mount it or lose every image on container recreation.
- thoughtsync-data:/var/thoughtsync
# WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is
# different for each — the default serves the first.
#
# 1. LAN, no proxy (the default). Binds every interface so your phone and your
# desktop can reach the server. This is what makes a self-hosted install work
# out of the box, and it is why the default is NOT the locked-down value: a
# server only reachable from the machine it runs on is not hardened, it is
# broken.
#
# 2. Reverse proxy in Docker, on this network (Traefik discovering the container,
# an nginx container, etc). DELETE the `ports:` block below entirely. The proxy
# reaches the app over the compose network without any port being published,
# and publishing one is a second, unauthenticated way in that bypasses the
# proxy — including whatever the proxy is doing about TLS and auth.
#
# 3. Reverse proxy on the HOST (not in Docker). Set THOUGHTSYNC_BIND=127.0.0.1 in
# .env, so the port exists but only the host itself can reach it.
#
# If you are exposing this to the internet, you want 2 or 3. Leaving it at 1
# means the app is reachable directly on port 5000, past everything your proxy
# does.
ports:
# Default binds every interface, which is what lets desktop clients on the LAN
# reach it. Behind a reverse proxy, set THOUGHTSYNC_BIND=127.0.0.1 so only the
# proxy can talk to it.
- "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000"
healthcheck:
# python rather than curl: the runtime image is python:3.12-slim and carries no
+142
View File
@@ -0,0 +1,142 @@
# Putting ThoughtSync on the public internet
ThoughtSync is built to run on a LAN and works fine there with no ceremony. Exposing
it changes the threat model: anyone can now reach the login form, and any account is
one guessed password away from someone's whole note history.
This is what the app does about that on its own, and the four things it cannot do for
you.
## Do these five things first
**1. Check registration is closed.** On a fresh instance this now takes care of
itself: the first account created becomes the admin *and* closes registration behind
it, so there is no window between "my account exists" and "I remembered to turn it
off". A brand-new instance is never locked out of itself, and never left open either.
**Instances that predate this still need one manual flip.** The close fires when the
first account is created, so a server whose admin already existed keeps whatever
`allow_registration` was set to — which was **on** by default. Check **Settings →
Access → Allow new registrations** before exposing an instance you have been running
on a LAN.
To let someone else in, turn it back on, have them register, turn it off. There is no
invite system yet, so that is the mechanism.
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
HTTPS. It looks at `X-Forwarded-Proto`, so the proxy has to set it:
```
# Traefik does this automatically. For nginx:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Without that header the app assumes plain HTTP and leaves the cookie unmarked — the
conservative choice, since forcing `Secure` on an HTTP install stops the browser from
ever sending the cookie back and silently breaks login.
Once a browser has seen HSTS from your hostname it will refuse plain HTTP there for a
year, even if the header stops. That is the point of it, but it is worth knowing
before you put a hostname behind TLS temporarily.
**3. Tell it how many proxies are in front of it.** **Settings → Security → Trusted
proxy hops**, which defaults to `1` — one reverse proxy terminating TLS. Behind a CDN
as well (Cloudflare in front of your proxy) set it to `2`. It applies immediately; no
restart.
This decides which entry of `X-Forwarded-For` is believed, and it is a security
setting rather than a preference. The header grows left to right as a request
traverses, so the rightmost entries are the ones your own infrastructure wrote and
anything a caller forged sits to the left of them. Counting in from the right by the
number of proxies you actually run means a forged prefix can never be selected. Set it
too HIGH and it starts trusting entries no proxy of yours wrote; too low and several
callers share one rate-limit bucket, which is merely inconvenient.
**4. Stop reaching the app except through the proxy.** The default compose binds
`0.0.0.0:5000` so LAN clients can reach it directly — which is right for a LAN install
and wrong the moment there is a proxy in front, because it leaves a second way in that
bypasses everything the proxy does.
Which fix depends on where your proxy runs:
- **Proxy in Docker** (Traefik discovering the container, an nginx container): delete
the `ports:` block from `docker-compose.yml`. The proxy reaches the app over the
compose network; no published port is needed at all, and this is the safest of the
two because there is no host port to reach even from the host.
- **Proxy on the host**: set `THOUGHTSYNC_BIND=127.0.0.1` in `.env`, so the port
exists but only the host itself can use it.
To check which you have: `docker compose ps` shows the published ports, and
`curl http://<your-lan-ip>:5000/api/health` from another machine tells you whether the
app is still answering around the proxy. It should not be.
**5. Have a backup that includes the files.** Attachments are files on the
`thoughtsync-data` volume, not rows — a `pg_dump` restores notes whose images are all
gone. Back up both:
```
docker compose exec -T db pg_dump -U thoughtsync thoughtsync > notes.sql
docker run --rm -v thoughtsync-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz -C /d .
```
## What the app already does
- **The credential endpoints are throttled.** `/api/auth/login`, `/api/auth/register`
and `/api/auth/device-login` count attempts against both the account and the calling
address, and answer `429` with a `Retry-After` once either is over budget. The
numbers live in **Settings → Security** — ten failed sign-ins per account per
fifteen minutes and five sign-ups per address per hour by default — and a change
applies to the next attempt rather than the next deploy. The account-keyed limit is the one that holds when the address is forged.
Checked *before* the password is verified, so a throttled attempt costs no bcrypt:
hashing is deliberately slow, and an unauthenticated caller who can trigger it
without limit has a CPU-exhaustion primitive as well as a guessing one.
- **A failed sign-in takes the same time whether or not the account exists.** No
timing oracle for which emails are registered here.
- **Every response carries a CSP** with `script-src 'self'`, `object-src 'none'` and
`frame-ancestors 'none'`, plus `nosniff`, a referrer policy and a permissions
policy. The app has no inline or third-party scripts, so this costs nothing.
- **Link unfurling is SSRF-hardened.** Every hop is resolved and every resolved
address must be publicly routable before a socket is opened, and the connection is
made to the vetted IP so a rebind between check and connect cannot slip through. A
note containing `http://192.168.1.1/` cannot make your server probe your network.
- **Attachments never render inline unless they are a known raster image.** Anything
else — an SVG, an HTML file — is served `Content-Disposition: attachment`, so a file
on a note shared with you can't run script in your session.
- **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in
for CSRF protection: a `Lax` cookie is not sent on a cross-site POST.
## What it does not do
Know these before you decide who gets an account.
- **No email verification and no password reset.** `email_verified` exists on the user
row and nothing sets it. A forgotten password needs a hand on the database.
- **No second factor.** A password is the whole of it.
- **No per-user storage quota.** Any account can upload attachments until the volume
is full. `max_attachment_mb` caps a single file, not a total.
- **No audit TABLE.** Credential events — sign-ins, failures, throttle trips, new
accounts, device tokens issued — are written to the application log and readable
with `docker compose logs app`, which is enough to see whether anyone is knocking.
They are not queryable, not retained beyond the container's log rotation, and not
attributable after the fact.
- **No invites.** Adding a second person means re-opening registration while they
sign up, then closing it again. There is no per-person token, no expiry, and no
record of who invited whom.
None of these are hard blockers for an instance whose accounts are you and people you
know. They are the reason not to hand out open registration to strangers.
## The Android client
The app allows plain HTTP so a self-hosted server on a LAN is usable at all — Android
blocks cleartext by default from API 28, and `http://192.168.1.10:8000` is exactly the
case ThoughtSync is built for. Over the public internet, link the phone to the
**HTTPS** hostname. The sync screen shows a warning before any credential field
whenever the address it probed was `http://`; on a public network that warning means
what it says.
The APK the server hands out is signed with the project release key, and the in-app
updater installs over the existing app only because the signature matches. A build
from anywhere else will not install over it.
+4 -4
View File
@@ -122,9 +122,9 @@ as `?since=`. `since=0` (or absent) is a **full initial sync**.
so pulling the note re-syncs the whole thing.
- **Label** — the label *catalog* (name + color) syncs as its own entity so a
rename/recolor/delete propagates independently of notes.
- **Derived, NOT synced:** `[[wiki-links]]` and `#tags` are parsed from the note
body. Clients recompute them locally; the server recomputes them on push. They
never travel over the wire.
- **Derived, NOT synced:** `#tags` are parsed from the note body. Clients recompute
them locally; the server recomputes them on push. They never travel over the wire.
(`[[wiki-links]]` were derived the same way until they were removed — note 2897.)
- **Attachment blobs** sync by id over the existing upload/download routes (see
Attachments below); only their metadata rides the delta feed.
@@ -201,7 +201,7 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
- **Whole-note semantics.** A note upsert carries the client's *full* current
state (not a partial patch) — the server overwrites all scalar fields, replaces
items, and sets manual label memberships from `label_ids` (tag-sourced labels
are re-derived from the body). `[[links]]`/`#tags` are recomputed server-side.
are re-derived from the body). `#tags` are recomputed server-side.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: true`.
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- `viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to
anything other than 0. It opts the page into drawing under the notch and the
gesture bar, so it is only safe alongside the padding that keeps content out
of them — see style.css (sides), the sticky header (top) and the board
(bottom). The two halves ship together, deliberately: turning this on alone
is worse than leaving it off (task 2706). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#F5C518" />
<meta
name="description"
+2 -6
View File
@@ -6,7 +6,7 @@
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
// yet (account auth, device linking, attachment upload, URL unfurl, file import) —
// those reject with a clear message rather than silently failing; the board, editor,
// capture, search, filters, labels, checklists and reminders all work fully offline.
// capture, filters, labels, checklists and reminders all work fully offline.
import { invoke } from "../desktop/bridge";
import type { Note, NoteRevision } from "../stores/notes";
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type { Backlink, DeviceToken, ImportResult, Repo } from "./repo";
import type { DeviceToken, ImportResult, Repo } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
@@ -51,7 +51,6 @@ export const local: Repo = {
list: (query) => invoke<Note[]>("notes_list", { query }),
get: (id) => invoke<Note>("notes_get", { id }),
create: (input) => invoke<Note>("notes_create", { input }),
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
@@ -72,9 +71,6 @@ export const local: Repo = {
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
reminders: () => invoke<Note[]>("notes_reminders"),
titles: () => invoke<TitleEntry[]>("notes_titles"),
search: (q) => invoke<Note[]>("notes_search", { q }),
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
},
savedFilters: {
+2 -12
View File
@@ -10,7 +10,7 @@
// stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteKind, NoteRevision } from "../stores/notes";
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices";
@@ -32,16 +32,14 @@ export interface NoteListQuery {
}
export interface NoteCreateInput {
title: string;
body: string;
color: NoteColor;
kind?: NoteKind;
items?: string[];
}
// The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial<
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
@@ -49,10 +47,6 @@ export interface ChecklistItemChanges {
checked?: boolean;
}
export interface Backlink {
id: string;
title: string;
}
export interface ImportResult {
source: string;
@@ -97,7 +91,6 @@ export interface NotesRepo {
list(query: NoteListQuery): Promise<Note[]>;
get(id: string): Promise<Note>;
create(input: NoteCreateInput): Promise<Note>;
createTitled(title: string): Promise<Note>;
update(id: string, changes: NoteChanges): Promise<Note>;
completeReminder(id: string): Promise<Note>;
snoozeReminder(id: string, minutes: number): Promise<Note>;
@@ -118,9 +111,6 @@ export interface NotesRepo {
restoreRevision(id: string, revId: string): Promise<Note>;
reminders(): Promise<Note[]>;
titles(): Promise<TitleEntry[]>;
search(q: string): Promise<Note[]>;
backlinks(id: string): Promise<Backlink[]>;
linkSearch(q: string): Promise<TitleEntry[]>;
}
export interface SavedFiltersRepo {
-7
View File
@@ -13,7 +13,6 @@ import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type {
Backlink,
DeviceToken,
ImportResult,
NoteChanges,
@@ -33,7 +32,6 @@ function notesQuery(q: NoteListQuery): string {
for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color);
if (q.facets?.kind) params.set("kind", q.facets.kind);
if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
@@ -80,7 +78,6 @@ export const rest: Repo = {
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
get: (id) => api.get<Note>(`/api/notes/${id}`),
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
createTitled: (title) => api.post<Note>("/api/notes", { title, body: "" }),
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
@@ -102,10 +99,6 @@ export const rest: Repo = {
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
backlinks: async (id) => (await api.get<{ backlinks: Backlink[] }>(`/api/notes/${id}/backlinks`)).backlinks,
linkSearch: async (q) =>
(await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`)).results,
},
savedFilters: {
+87 -32
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels";
@@ -49,7 +49,6 @@ const shortcuts = [
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
{ label: "Search", keys: ["/"] },
{ label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Go to Timeline", keys: ["g", "t"] },
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
@@ -121,11 +120,6 @@ function onKeydown(e: KeyboardEvent) {
void router.push("/");
return;
}
if (e.key === "g") {
e.preventDefault();
void router.push("/graph");
return;
}
if (e.key === "r") {
e.preventDefault();
void router.push("/reminders");
@@ -183,21 +177,48 @@ function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
// The board lenses — the routes a search can happen *within*. Searching while looking
// at Trash should search Trash, not silently move you.
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
/**
* Search is a FACET, not a destination.
*
* It used to navigate to a `/search` view backed by a different endpoint with no
* facets at all — so the one screen you landed on when you searched was the one
* screen where you could not also narrow by tag, which is precisely what tags are
* for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
* already there, and the same AND-ed query serves both.
*
* Existing facets are preserved, so "filter by #grocery, then search" and the reverse
* both work.
*/
function onSearch(value: string) {
searchText.value = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const q = searchText.value.trim();
if (q) router.push({ name: "search", query: { q } });
else if (route.name === "search") router.push("/");
const onBoard = BOARD_ROUTES.has(String(route.name));
const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
if (q) query.q = q;
else delete query.q;
void router.push({ path: onBoard ? route.path : "/", query });
}, 250);
}
// Clear the search box when navigating to a non-search view.
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather
// than holding its own copy — which is also what keeps it in step with the Filters
// panel's Clear button and with a saved view opened from the sidebar.
watch(
() => route.query.q,
(q) => {
searchText.value = typeof q === "string" ? q : "";
},
{ immediate: true },
);
watch(
() => route.name,
(name) => {
if (name !== "search") searchText.value = "";
() => {
drawer.value = false;
},
);
@@ -206,8 +227,8 @@ watch(
* What to call the lens currently in view.
*
* Keyed off the route name rather than each view declaring its own title, so the
* label sits in one place and can't go missing (the board and search never had one)
* or drift in styling (timeline, reminders and graph each had their own h1).
* label sits in one place and can't go missing (the board never had one)
* or drift in styling (timeline and reminders each had their own h1).
*
* A label lens is named by the label itself — "Groceries" is what the user came
* looking for; "Label" would tell them nothing they didn't already know.
@@ -218,14 +239,10 @@ const lensName = computed<string>(() => {
return "Archive";
case "trash":
return "Trash";
case "search":
return "Search";
case "timeline":
return "Timeline";
case "reminders":
return "Reminders";
case "graph":
return "Graph";
case "label":
// The store may not have loaded yet on a deep link; fall back rather than
// flashing an empty slot.
@@ -252,10 +269,18 @@ async function signOut() {
>
Skip to notes
</a>
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
under the status bar / notch, and this is the one box that sits there a
sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
it costs nothing on a desktop or a flat-topped phone. -->
<header
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 pt-[env(safe-area-inset-top,0px)] backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
>
<div class="flex items-center gap-3 px-4 py-3">
<!-- Wraps, so the search field can take a line of its own on a narrow screen.
In one row it was sharing ~360px with a menu button, the logo, the lens
name and four icons, which left every one of them truncated the lens read
as "N…" and the search box as an empty pill. -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<button
type="button"
class="icon-btn sm:hidden"
@@ -286,7 +311,7 @@ async function signOut() {
page you navigated to — so it sits in the bar that never moves, beside
the app name, and stays in one place while everything beneath it
re-filters. Replaces the per-view <h1>s, which sat in a different spot
in each view and were absent entirely on the board and in search. -->
in each view and were absent entirely on the board. -->
<span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
<!-- The separator only makes sense next to the app name, which is itself
hidden on narrow screens. There, the lens name simply takes the space
@@ -295,19 +320,24 @@ async function signOut() {
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
</span>
<div class="flex flex-1 justify-center">
<!-- `order-last w-full` drops this onto its own line below sm (a full-width
flex item forces the wrap); from sm it returns to the middle of the row.
ONE input either way, moved by CSS rather than duplicated the `/`
shortcut focuses `searchInput`, and two of those would be one ref too
many. -->
<div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
<input
ref="searchInput"
:value="searchText"
type="search"
placeholder="Search notes…"
aria-label="Search notes"
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand sm:py-1.5 sm:text-sm dark:border-neutral-700 dark:bg-neutral-900"
@input="onSearch(($event.target as HTMLInputElement).value)"
/>
</div>
<div class="flex shrink-0 items-center gap-3">
<div class="ml-auto flex shrink-0 items-center gap-1 sm:ml-0 sm:gap-3">
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@@ -341,7 +371,7 @@ async function signOut() {
<RouterLink
v-if="!desktopApp"
to="/account"
class="icon-btn"
class="icon-btn hidden sm:inline-flex"
title="Linked devices"
aria-label="Linked devices"
>
@@ -350,7 +380,7 @@ async function signOut() {
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="icon-btn"
class="icon-btn hidden sm:inline-flex"
title="Settings"
aria-label="Settings"
>
@@ -364,7 +394,7 @@ async function signOut() {
<button
v-if="!desktopApp"
type="button"
class="icon-btn"
class="icon-btn hidden sm:inline-flex"
title="Sign out"
aria-label="Sign out"
@click="signOut"
@@ -383,16 +413,13 @@ async function signOut() {
@click="drawer = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 dark:border-neutral-800 dark:bg-neutral-950"
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
:class="drawer ? 'translate-x-0' : ''"
>
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
<Icon name="note" /> Notes
</RouterLink>
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
<Icon name="graph" /> Graph
</RouterLink>
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
@@ -469,6 +496,34 @@ async function signOut() {
<Icon name="download" /> Export
</a>
<ImportNotes />
<!-- Account, settings and sign-out, for the screens where they are NOT in
the header. Four icons plus a search field never fit one phone-width
row, and the header is the wrong place to lose: it holds the only way
back to the board. Here they get room to be named instead of guessed
at from a glyph.
Hidden from sm up, where the header carries them again so they are
in exactly one place at any width, never both. -->
<div
v-if="!desktopApp"
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
>
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
<Icon name="device" /> Linked devices
</RouterLink>
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="nav-link"
:class="route.name === 'settings' ? 'nav-link-active' : ''"
>
<Icon name="settings" /> Settings
</RouterLink>
<button type="button" class="nav-link w-full text-left" @click="signOut">
<Icon name="logout" /> Sign out
</button>
</div>
</nav>
</aside>
@@ -481,7 +536,7 @@ async function signOut() {
BoardView, so keying on the route would remount it blanking the board
and refetching, which is precisely the page-change feeling this is meant
to remove. Unkeyed, Vue only transitions when the component TYPE changes
(board search timeline graph), and moving between the board's own
(board timeline reminders), and moving between the board's own
lenses stays an in-place reflow that NoteGrid animates. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }">
@@ -42,7 +42,6 @@ const commands = computed<Row[]>(() => {
const list: Row[] = [
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
{ id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
+2 -26
View File
@@ -10,8 +10,8 @@ import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
// + has-attachment + kind + created-date range. The URL query IS the state, so a
// A dead-simple facet bar over the board: color + labels + has-reminder
// + has-attachment + created-date range. The URL query IS the state, so a
// filtered board is a shareable lens and a saved view is just a link.
const route = useRoute();
const router = useRouter();
@@ -35,9 +35,6 @@ function clearAll() {
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function setKind(k: "text" | "list") {
patch({ kind: facets.value.kind === k ? undefined : k });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
@@ -50,13 +47,6 @@ function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
}
let qTimer: ReturnType<typeof setTimeout> | undefined;
function onQ(e: Event) {
const v = (e.target as HTMLInputElement).value;
clearTimeout(qTimer);
qTimer = setTimeout(() => patch({ q: v.trim() || undefined }), 300);
}
function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value;
patch({ created_after: v ? `${v}T00:00:00` : undefined });
@@ -121,14 +111,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<input
type="search"
:value="facets.q ?? ''"
placeholder="Search text…"
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@input="onQ"
/>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
@@ -164,12 +146,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment
</button>
<button type="button" :class="[chipBase, facets.kind === 'list' ? chipOn : chipOff]" @click="setKind('list')">
Lists
</button>
<button type="button" :class="[chipBase, facets.kind === 'text' ? chipOn : chipOff]" @click="setKind('text')">
Notes
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
-1
View File
@@ -16,7 +16,6 @@ const paths: Record<string, string> = {
check: '<path d="M20 6 9 17l-5-5"/>',
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
+31 -7
View File
@@ -1,12 +1,24 @@
<script setup lang="ts">
import type { LinkPreview } from "../stores/notes";
defineProps<{ preview: LinkPreview; removable?: boolean }>();
/**
* A fetched link preview, in one of two sizes.
*
* `compact` is a single row — favicon-less, one line of title, the site name — for a
* URL mentioned *inside* a note that has its own text. The note is the thing; the
* link is a footnote to it.
*
* Full size is for a note that is NOTHING but a URL. There the link IS the note, and
* a compact strip would be a card with nothing on it.
*/
defineProps<{ preview: LinkPreview; removable?: boolean; compact?: boolean }>();
defineEmits<{ (e: "remove"): void }>();
</script>
<template>
<div class="group/lp relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700">
<div
class="group/lp relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700"
>
<a
:href="preview.url"
target="_blank"
@@ -19,16 +31,28 @@ defineEmits<{ (e: "remove"): void }>();
alt=""
loading="lazy"
decoding="async"
class="h-auto w-24 shrink-0 self-stretch object-cover"
class="h-auto shrink-0 self-stretch object-cover"
:class="compact ? 'w-12' : 'w-24'"
/>
<div class="min-w-0 flex-1 px-3 py-2">
<p v-if="preview.site_name" class="truncate text-[11px] uppercase tracking-wide text-neutral-400">
<div class="min-w-0 flex-1" :class="compact ? 'px-2 py-1.5' : 'px-3 py-2'">
<p
v-if="preview.site_name"
class="truncate uppercase tracking-wide text-neutral-400"
:class="compact ? 'text-[10px]' : 'text-[11px]'"
>
{{ preview.site_name }}
</p>
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
<p
class="truncate font-medium text-neutral-800 dark:text-neutral-100"
:class="compact ? 'text-xs' : 'text-sm'"
>
{{ preview.title || preview.url }}
</p>
<p v-if="preview.description" class="mt-0.5 line-clamp-2 text-xs text-neutral-500 dark:text-neutral-400">
<!-- The description is the first thing to go when there is no room for it. -->
<p
v-if="preview.description && !compact"
class="mt-0.5 line-clamp-2 text-xs text-neutral-500 dark:text-neutral-400"
>
{{ preview.description }}
</p>
</div>
+4 -29
View File
@@ -1,42 +1,17 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useNotesStore } from "../stores/notes";
import { useTitlesStore } from "../stores/titles";
import type { InlineToken } from "../notes/markdown";
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
defineProps<{ tokens: InlineToken[] }>();
const router = useRouter();
const notes = useNotesStore();
const titles = useTitlesStore();
// A [[wiki-link]] on the card: resolve the title and open the target note (creating
// it first if it doesn't exist), via the board's ?open=<id> mechanism.
async function follow(title: string) {
await titles.load();
let hit = titles.resolve(title);
if (!hit) {
const created = await notes.createTitled(title);
await titles.reload();
hit = { id: created.id, title: created.title ?? title };
}
void router.push({ path: "/", query: { open: hit.id } });
}
</script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
spaces are preserved and no extra spaces are introduced. -->
<template
><template v-for="(t, i) in tokens" :key="i"
><span
v-if="t.type === 'link'"
role="link"
tabindex="0"
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
@click.stop="follow(t.value)"
@keydown.enter.stop.prevent="follow(t.value)"
>{{ t.value }}</span
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
><strong v-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
><code
v-else-if="t.type === 'code'"
+104 -56
View File
@@ -54,6 +54,46 @@ const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));
// How much of a note the CARD shows. Android has always clamped to 8
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
// produce a card taller than the screen and push everything else off the board.
//
// It matters more now that the title is gone (M13 step 4). The first line used to be
// the thing your eye caught; with one weight throughout, an unbounded card is just a
// wall, and the note next to it is the one you were looking for.
//
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
// exactly, and skips parsing a body the card was never going to show.
const PREVIEW_LINES = 8;
// --- Links ------------------------------------------------------------------
//
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
// showing the raw URL underneath a card that already says where it goes is saying the
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
// gets a compact strip at the bottom instead.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
/** The preview for a lone-URL note — null while it is still being fetched, or if it
* could never be fetched at all. */
const loneUrlPreview = computed(() => {
if (!isLoneUrl.value) return null;
const url = props.note.body.trim();
return props.note.previews.find((p) => p.url === url) ?? null;
});
const bodyPreview = computed(() => {
const lines = props.note.body.split("\n");
if (lines.length <= PREVIEW_LINES) return props.note.body;
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
});
const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
@@ -186,27 +226,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
]"
:data-note-id="note.id"
>
<!-- Drag handle: reorder is gated behind this grip so a normal click or tap
never starts a drag. Board views only (reorderable). Still a pointer-only
affordance there is no keyboard equivalent, hence tabindex="-1".
`touch-none` hands the gesture to us instead of the browser's scrolling,
and `hover-reveal` keeps the grip on screen where hovering is impossible
(it is now reachable there, which it was not under native drag-and-drop). -->
<button
v-if="canDrag()"
type="button"
tabindex="-1"
class="hover-reveal pointer-events-none absolute left-1.5 top-1.5 z-10 flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
title="Drag to reorder"
aria-label="Drag to reorder"
@pointerdown="onGripDown"
@pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
<img
v-if="firstImage"
:src="firstImage.url"
@@ -227,43 +246,46 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</span>
</div>
<div v-if="note.previews.length" class="mb-2 flex flex-col gap-2">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
</div>
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
focusable div; text notes keep a semantic button. -->
<template v-if="note.kind === 'list'">
<!-- One render path: every note is a body plus, maybe, checkable items.
A focusable div rather than a <button>, because a checklist nests interactive
controls and those cannot live inside a button and the card is the same
shape whether or not it happens to carry items today. -->
<div
role="button"
tabindex="0"
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
or if it never does the URL itself stands in, so the card is never
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" />
</div>
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
</template>
<button
v-else
type="button"
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
@click="emit('open', note)"
<p
v-if="!note.body && !note.items.length && !note.attachments.length"
class="text-sm italic text-neutral-400"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="note.body" />
</div>
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
Empty note
</p>
</button>
</div>
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own
words rather than stacked on top of them. They were above the body until
M13 — which put a stranger's headline where the note's first line should be. -->
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div>
<NoteChecklist
v-if="note.items.length"
:class="note.body ? 'mt-2' : ''"
:note-id="note.id"
:items="note.items"
@click="emit('open', note)"
/>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
@@ -342,14 +364,36 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</span>
</div>
<!-- Toolbar overlays the card's top-right on hover/focus as a floating pill
(window-control style) instead of reserving a permanent row so at rest
the card is content-sized with even padding, not text pinned to the top
above an empty strip.
`hover-reveal`: on a device that can't hover this is the ONLY way to pin,
colour or archive a note, so there it stays visible (see style.css). -->
<!-- The card's controls: the drag grip, then the action set.
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
them where the device can actually use them. Where a pointer hovers they lift
out of flow into the floating top-corner pills they have always been; where
nothing can hover they stay here, in flow, as a footer row. The alternative
(a permanently visible overlay) sat on top of the note's own title, which is
what a phone was showing.
`hover-reveal` still handles the visibility half see style.css. -->
<div class="note-actions">
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
drag. Board views only (reorderable). A pointer-only affordance there is
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
to us instead of the browser's scrolling. -->
<button
v-if="canDrag()"
type="button"
tabindex="-1"
class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
title="Drag to reorder"
aria-label="Drag to reorder"
@pointerdown="onGripDown"
@pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
<div
class="hover-reveal pointer-events-none absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
>
<template v-if="note.trashed">
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
@@ -371,6 +415,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
@@ -408,11 +453,12 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<Icon name="trash" />
</button>
</template>
</div>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="absolute right-1.5 top-11 z-20 flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
@@ -426,4 +472,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
/>
</div>
</div>
</div>
</div>
</template>
+56 -325
View File
@@ -1,9 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { repo } from "../adapters";
import { useNotesStore } from "../stores/notes";
import { useConfigStore } from "../stores/config";
import { useTitlesStore, type TitleEntry } from "../stores/titles";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
@@ -26,43 +23,39 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const config = useConfigStore();
const titles = useTitlesStore();
const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? props.initialBody);
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
const backlinks = ref<{ id: string; title: string }[]>([]);
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
title: props.note?.title ?? null,
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
const hasContent = computed(() => body.value.trim() !== "");
// Rich features need a saved note; in compose they light up once there's content.
const richEnabled = computed(() => !isCreate.value || hasContent.value);
// A synthetic note for compose mode (before anything is persisted), so the shared
// template can read attachments/items/kind/remind_at uniformly.
// template can read attachments/items/remind_at uniformly.
const draftNote = computed<Note>(() => ({
id: "",
title: title.value.trim() || null,
display_title: "",
body: body.value,
color: color.value,
kind: createKind.value,
position: 0,
pinned: false,
archived: false,
@@ -82,42 +75,34 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value,
);
// Only edit-mode list notes render the interactive checklist; compose-list types
// lines into the textarea (they become items on create).
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
const bodyPlaceholder = computed(() =>
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
// The checklist renders once the note has items, or once someone has asked for one.
// It sits BELOW the body rather than instead of it — a note can carry both, which is
// the whole point of the merge.
//
// Items need a persisted note to hang off, so this is a rich action like attaching a
// file: in compose it waits for the draft to be saved.
const showChecklist = computed(
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value),
);
const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
() => props.note,
(n) => {
noteId.value = n?.id ?? null;
title.value = n?.title ?? "";
body.value = n?.body ?? "";
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
let created: Note;
if (createKind.value === "list") {
const items = body.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
body.value = ""; // the lines moved into checklist items
} else {
created = await notes.create({ title: title.value, body: body.value, color: color.value });
}
const created = await notes.create({ body: body.value, color: color.value });
noteId.value = created.id;
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
baseline.value = { body: created.body, color: created.color as NoteColor };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -143,13 +128,13 @@ async function flush(): Promise<void> {
return;
}
const b = baseline.value;
const nextBody = showChecklist.value ? b.body : body.value;
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
const nextBody = body.value;
const changed = nextBody !== b.body || color.value !== b.color;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
} finally {
saving.value = false;
}
@@ -157,13 +142,11 @@ async function flush(): Promise<void> {
function resetCompose(): void {
noteId.value = null;
title.value = "";
body.value = "";
color.value = "default";
labelList.value = [];
createKind.value = "text";
baseline.value = { title: null, body: "", color: "default" };
linkMenu.value = false;
checklistOpen.value = false;
baseline.value = { body: "", color: "default" };
uploadError.value = "";
}
@@ -242,22 +225,7 @@ function onBackdropMousedown(): void {
void dismiss();
}
async function loadBacklinks(): Promise<void> {
if (!noteId.value) {
backlinks.value = [];
return;
}
try {
backlinks.value = await repo.notes.backlinks(noteId.value);
} catch {
backlinks.value = [];
}
}
watch(() => noteId.value, loadBacklinks);
onMounted(async () => {
void titles.load();
void loadBacklinks();
await nextTick();
const el = bodyInput.value;
el?.focus();
@@ -265,92 +233,6 @@ onMounted(async () => {
if (el) el.selectionStart = el.selectionEnd = el.value.length;
});
// ---- outgoing links (edit mode) ----
const outgoingLinks = computed(() => {
const re = /\[\[([^[\]]+)\]\]/g;
const seen = new Set<string>();
const out: { title: string; id: string | null }[] = [];
let match: RegExpExecArray | null;
while ((match = re.exec(body.value)) !== null) {
const t = match[1].trim();
const key = t.toLowerCase();
if (t && !seen.has(key)) {
seen.add(key);
out.push({ title: t, id: titles.resolve(t)?.id ?? null });
}
}
return out;
});
async function openLink(link: { title: string; id: string | null }) {
if (link.id) {
emit("navigate", link.id);
return;
}
const created = await notes.createTitled(link.title);
await titles.reload();
emit("navigate", created.id);
}
// ---- [[ link autocomplete in the body textarea ----
const linkMenu = ref(false);
const linkQuery = ref("");
const linkStart = ref(-1);
const linkSelected = ref(0);
const linkMatches = ref<TitleEntry[]>([]);
let linkTimer: ReturnType<typeof setTimeout> | undefined;
function refreshLinkMatches() {
if (linkTimer) clearTimeout(linkTimer);
const q = linkQuery.value.trim();
linkTimer = setTimeout(async () => {
try {
const results = await repo.notes.linkSearch(q);
linkMatches.value = results.filter((r) => r.id !== noteId.value).slice(0, 8);
} catch {
linkMatches.value = [];
}
linkSelected.value = 0;
}, 120);
}
function onBodyInput() {
const el = bodyInput.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
const text = body.value.slice(0, caret);
const open = text.lastIndexOf("[[");
if (open === -1) {
linkMenu.value = false;
return;
}
const between = text.slice(open + 2);
if (between.includes("]") || between.includes("\n")) {
linkMenu.value = false;
return;
}
linkQuery.value = between;
linkStart.value = open;
linkSelected.value = 0;
linkMenu.value = true;
refreshLinkMatches();
}
function insertLink(t: string) {
const el = bodyInput.value;
const caret = el?.selectionStart ?? body.value.length;
const before = body.value.slice(0, linkStart.value);
const after = body.value.slice(caret);
const insertion = `[[${t}]]`;
body.value = before + insertion + after;
linkMenu.value = false;
const pos = before.length + insertion.length;
void nextTick(() => {
el?.focus();
el?.setSelectionRange(pos, pos);
});
}
function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
@@ -358,31 +240,6 @@ function onBodyKeydown(e: KeyboardEvent) {
void commitAndContinue();
return;
}
if (!linkMenu.value || linkMatches.value.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
linkSelected.value = Math.max(linkSelected.value - 1, 0);
} else if (e.key === "Enter" || e.key === "Tab") {
const m = linkMatches.value[linkSelected.value];
if (m) {
e.preventDefault();
insertLink(m.title);
}
} else if (e.key === "Escape") {
// Close only the menu — don't let Esc bubble to the frame's close/commit.
e.preventDefault();
e.stopPropagation();
linkMenu.value = false;
}
}
function onTitleEnter(e: KeyboardEvent) {
e.preventDefault();
if (e.shiftKey && isCreate.value) void commitAndContinue();
else bodyInput.value?.focus();
}
// ---- reminder ----
@@ -426,29 +283,16 @@ function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
async function toggleKind() {
if (isCreate.value) {
createKind.value = createKind.value === "list" ? "text" : "list";
bodyInput.value?.focus();
return;
}
const id = noteId.value as string;
if (liveNote.value.kind === "list") {
await notes.setKind(id, "text");
return;
}
const lines = body.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) await notes.addItem(id, line);
if (lines.length > 0) {
body.value = "";
await notes.saveEdit(id, { title: title.value, body: "", color: color.value });
baseline.value = { title: title.value.trim() || null, body: "", color: color.value };
}
await notes.setKind(id, "list");
// ---- add a checklist ----
//
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
// body and gains a place to put items. Persists the draft first for the same reason
// attaching a file does — an item needs a note to belong to.
async function addChecklist() {
if (checklistOpen.value) return;
const id = await ensureDraft();
if (!id) return;
checklistOpen.value = true;
}
// ---- attachments ----
@@ -477,41 +321,12 @@ async function uploadFile(file: File) {
}
}
// ---- link previews (URL unfurl) ----
const unfurling = ref<string | null>(null); // the URL currently being fetched
const unfurlError = ref("");
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed.
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g;
const detectedUrls = computed(() => {
const out: string[] = [];
for (const m of body.value.matchAll(URL_RE)) {
const u = m[1].replace(/[.,;:!?]+$/, "");
if (!out.includes(u)) out.push(u);
}
return out;
});
const previewedUrls = computed(() => new Set(liveNote.value.previews.map((p) => p.url)));
const unpreviewedUrls = computed(() => detectedUrls.value.filter((u) => !previewedUrls.value.has(u)));
async function addPreview(url: string) {
const id = await ensureDraft();
if (!id) return;
unfurling.value = url;
unfurlError.value = "";
try {
await notes.unfurl(id, url);
} catch (e) {
unfurlError.value = (e as { error?: string }).error ?? "Couldn't fetch a preview for that link.";
} finally {
unfurling.value = null;
}
}
function shortUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// ---- link previews ----
//
// Nothing to trigger any more: the server unfurls a note's URLs in the background
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
// is left here is removing one you don't want — the editor is the only place with
// room to offer that, and the card deliberately doesn't.
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
@@ -556,10 +371,9 @@ async function restoreRevisionAt(revId: string) {
const id = noteId.value;
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
title.value = updated.title ?? "";
body.value = updated.body;
color.value = updated.color;
baseline.value = { title: updated.title, body: updated.body, color: updated.color };
baseline.value = { body: updated.body, color: updated.color };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
@@ -567,9 +381,7 @@ function revLabel(iso: string | null): string {
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function revPreview(rev: NoteRevision): string {
const t = (rev.title ?? "").trim();
const b = rev.body.trim().replace(/\s+/g, " ");
const s = t && b ? `${t}${b}` : t || b;
const s = rev.body.trim().replace(/\s+/g, " ");
if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}` : s;
}
@@ -651,7 +463,7 @@ function revPreview(rev: NoteRevision): string {
</div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
<!-- Link previews: stored preview cards + one "Preview <domain>" per detected URL -->
<!-- Fetched automatically after each save; removable here and nowhere else. -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview
v-for="p in liveNote.previews"
@@ -661,64 +473,23 @@ function revPreview(rev: NoteRevision): string {
@remove="notes.deletePreview(liveNote.id, p.id)"
/>
</div>
<div
v-if="config.enableUrlUnfurl && !liveNote.trashed && unpreviewedUrls.length"
class="flex flex-wrap gap-1.5"
>
<button
v-for="u in unpreviewedUrls"
:key="u"
type="button"
class="inline-flex items-center gap-1 rounded-full border border-neutral-200 px-2 py-0.5 text-xs text-neutral-500 hover:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
:disabled="unfurling === u"
@click="addPreview(u)"
>
<Icon name="link" />
{{ unfurling === u ? "Fetching…" : `Preview ${shortUrl(u)}` }}
</button>
</div>
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
<input
v-model="title"
type="text"
placeholder="Title (optional)"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter="onTitleEnter"
/>
<div v-if="!showChecklist" class="relative">
<textarea
ref="bodyInput"
v-model="body"
rows="8"
:placeholder="bodyPlaceholder"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onBodyInput"
@keydown="onBodyKeydown"
/>
<ul
v-if="linkMenu && linkMatches.length"
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<li v-for="(m, i) in linkMatches" :key="m.id">
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
:class="
i === linkSelected
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
"
@mousemove="linkSelected = i"
@mousedown.prevent="insertLink(m.title)"
>
<span class="truncate">{{ m.title }}</span>
</button>
</li>
</ul>
</div>
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
<!-- Below the body, not instead of it. -->
<NoteChecklist
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/>
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
@@ -786,44 +557,6 @@ function revPreview(rev: NoteRevision): string {
</button>
</div>
<div
v-if="!isCreate && (outgoingLinks.length || backlinks.length)"
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<div v-if="outgoingLinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="link in outgoingLinks"
:key="link.title"
type="button"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
link.id
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
"
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
@click="openLink(link)"
>
{{ link.title }}<span v-if="!link.id" class="opacity-60"></span>
</button>
</div>
</div>
<div v-if="backlinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="b in backlinks"
:key="b.id"
type="button"
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
@click="emit('navigate', b.id)"
>
{{ b.title }}
</button>
</div>
</div>
</div>
<div
@@ -865,13 +598,12 @@ function revPreview(rev: NoteRevision): string {
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="!liveNote.trashed"
v-if="richEnabled && !liveNote.trashed && !showChecklist"
type="button"
class="icon-btn"
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
:aria-pressed="isListMode"
@click="toggleKind"
title="Add a checklist"
aria-label="Add a checklist"
@click="addChecklist"
>
<Icon name="checkbox" />
</button>
@@ -947,6 +679,5 @@ function revPreview(rev: NoteRevision): string {
</div>
</div>
</div>
</div>
</Transition>
</template>
+2 -2
View File
@@ -8,8 +8,8 @@ import { captureMorphOrigin } from "./useEditorMorph";
//
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
// - `list` is the host's local note array, tried first when resolving a navigated
// [[wiki-link]] target before falling back to the store, then a fetch — so views
// that keep their own list (reminders, timeline, search, graph) still resolve
// note id before falling back to the store, then a fetch — so views
// that keep their own list (reminders, timeline, search) still resolve
// locally without duplicating the lookup.
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
const notes = useNotesStore();
-4
View File
@@ -17,8 +17,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
const kind = one(q.kind);
if (kind === "text" || kind === "list") f.kind = kind;
if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true;
@@ -33,7 +31,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {};
if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.kind) q.kind = f.kind;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
@@ -47,7 +44,6 @@ export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
if (f.kind) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
+13 -10
View File
@@ -2,12 +2,12 @@
// stays plain text; this only formats what's shown. We render the parsed tree as
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is
// left as plain text and never mistaken for a heading.
// code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
// space after `#`, so a #tag (no space) is left as plain text and never mistaken for
// a heading.
export interface InlineToken {
type: "text" | "bold" | "italic" | "code" | "link";
type: "text" | "bold" | "italic" | "code";
value: string;
}
@@ -19,9 +19,13 @@ export interface Block {
value?: string;
}
// Order matters: links + code are matched before emphasis so their contents aren't
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1).
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
// bold (**) before italic (*). Emphasis does not nest (v1).
//
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = [];
@@ -31,9 +35,8 @@ export function parseInline(text: string): InlineToken[] {
while ((m = INLINE_RE.exec(text)) !== null) {
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
const raw = m[0];
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() });
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length;
}
-2
View File
@@ -20,8 +20,6 @@ const router = createRouter({
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
],
+5 -22
View File
@@ -5,14 +5,11 @@ import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
export type NoteKind = "text" | "list";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
// view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets {
q?: string;
color?: string;
kind?: NoteKind;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
@@ -55,23 +52,20 @@ export interface LinkPreview {
site_name: string | null;
}
// A past version of a note's title+body (version history).
// A past version of a note's body (version history).
export interface NoteRevision {
id: string;
title: string | null;
body: string;
created_at: string | null;
}
export interface Note {
id: string;
title: string | null;
// The note's display NAME: explicit title, else its first body line (server-derived).
// Every note has one, so body-only notes are still nameable + [[link]]-able.
// The note's NAME: its first body line, else its first checklist item
// (server-derived). Every note has one, so every note has something to be called.
display_title: string;
body: string;
color: NoteColor;
kind: NoteKind;
position: number;
pinned: boolean;
archived: boolean;
@@ -138,10 +132,8 @@ export const useNotesStore = defineStore("notes", () => {
}
async function create(input: {
title: string;
body: string;
color: NoteColor;
kind?: NoteKind;
items?: string[];
}): Promise<Note> {
const note = await repo.notes.create(input);
@@ -152,7 +144,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
@@ -165,10 +157,9 @@ export const useNotesStore = defineStore("notes", () => {
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
const saveEdit = (id: string, changes: { body: string; color: NoteColor }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id));
@@ -224,12 +215,6 @@ export const useNotesStore = defineStore("notes", () => {
}
}
async function createTitled(title: string): Promise<Note> {
const created = await repo.notes.createTitled(title);
reconcile(created);
return created;
}
async function reorder(orderedIds: string[]): Promise<void> {
// Optimistically assign positions matching the backend (total - index), sort,
// then persist.
@@ -290,7 +275,6 @@ export const useNotesStore = defineStore("notes", () => {
setPinned,
setArchived,
setColor,
setKind,
setReminder,
setRecurrence,
completeReminder,
@@ -306,7 +290,6 @@ export const useNotesStore = defineStore("notes", () => {
deletePreview,
importNotes,
fetchOne,
createTitled,
reorder,
trash,
restore,
+7 -7
View File
@@ -7,7 +7,12 @@ export interface TitleEntry {
title: string;
}
// Owner's {id,title} index, used to resolve [[wiki-links]] client-side.
// Owner's {id, name} index of every non-trashed note.
//
// Outlived [[wiki-links]] (note 2897), which is what it was originally built for,
// because the command palette lists it so someone can jump straight to a note by
// name. That is recall, which is what this app is for; `resolve()` went with the
// links.
export const useTitlesStore = defineStore("titles", () => {
const items = ref<TitleEntry[]>([]);
const loaded = ref(false);
@@ -23,10 +28,5 @@ export const useTitlesStore = defineStore("titles", () => {
await load();
}
function resolve(title: string): TitleEntry | null {
const norm = title.trim().toLowerCase();
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
}
return { items, loaded, load, reload, resolve };
return { items, loaded, load, reload };
});
+101
View File
@@ -8,6 +8,35 @@ body,
height: 100%;
}
/* Track the VISUAL viewport, not the layout viewport.
*
* On a phone the two diverge the moment the on-screen keyboard opens or the URL bar
* retracts: `100%` keeps the taller layout viewport, which slides the sticky header
* off screen and can park a focused field underneath the keyboard. `dvh` is the unit
* that follows the box actually being shown.
*
* Same three selectors rather than a fourth rule, so the box model is unchanged and
* only the number moves; behind @supports so a browser without `dvh` keeps the
* `100%` above instead of falling back to nothing. */
@supports (height: 100dvh) {
html,
body,
#app {
height: 100dvh;
}
}
/* Landscape on a notched phone puts the cutout down one SIDE of the page, and with
* `viewport-fit=cover` (index.html) the page is drawing under it. Applied to body so
* every view inherits it, rather than each container remembering; the insets are 0
* on every device that has no cutout, and 0 in portrait, so this costs nothing where
* it isn't needed. Top and bottom are NOT done here — a sticky header and a scrolling
* board need them in their own boxes, not on the page. */
body {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
body {
@apply bg-neutral-50 text-neutral-900 antialiased;
}
@@ -61,6 +90,78 @@ body {
}
}
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set.
* ONE element each, in TWO placements, because "always visible" and "floating over
* the card's top corners" cannot both be true without the controls sitting on top of
* the note's own words.
*
* That is exactly what a phone got. `.hover-reveal` above made the pills permanent
* where nothing can hover (task 2697, correctly — they are the only way to pin or
* archive), but they were still absolutely positioned, so they covered the title:
* "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked on
* the first three characters.
*
* So the default — no hover — is a footer row IN FLOW, which cannot overlap anything
* by construction. A pointer that can hover lifts them back out into the floating
* pills they have always been, where they cost no vertical space and appear only on
* approach. `display: contents` drops the flex row itself out of the way so each
* child positions against the card.
*
* Keyed on hover rather than width, for the reason `.hover-reveal` already gives: a
* narrow window on a laptop still hovers, and a wide tablet still doesn't. */
.note-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.125rem;
margin-top: 0.5rem;
}
.note-grip {
/* Pushes the action set to the far edge, so the two read as opposite ends of a
* footer rather than as a clump. */
margin-right: auto;
}
.note-actions-set {
position: relative; /* the colour popover anchors here in both placements */
}
@media (hover: hover) {
.note-actions {
display: contents;
}
.note-grip {
position: absolute;
left: 0.375rem;
top: 0.375rem;
z-index: 10;
margin-right: 0;
}
.note-actions-set {
position: absolute;
right: 0.375rem;
top: 0.375rem;
}
}
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
+12 -1
View File
@@ -258,16 +258,27 @@ async function onDrop(targetId: string) {
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<!-- `pb-[…safe-area-inset-bottom]`: the board is the last thing on the page, so
it is what ends up underneath the gesture bar once `viewport-fit=cover` lets
the page draw there. Added to the existing py-6 rather than replacing it. -->
<div class="mx-auto w-full max-w-6xl px-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-6">
<button
v-if="isMainBoard"
type="button"
class="mx-auto mb-4 block w-full max-w-xl rounded-xl border border-dashed border-neutral-300 px-4 py-2.5 text-center text-sm text-neutral-400 transition hover:border-neutral-400 hover:text-neutral-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:border-neutral-500"
@click="ui.requestCompose()"
>
<!-- Two ways of saying the same thing, because the keyboard half is a lie on a
phone: there is no Enter key until something is already focused, and the
box has always been tappable. `hidden sm:inline` / `sm:hidden` rather than
a JS pointer check this is presentation, and it should be right in the
first paint rather than after one. -->
<span class="hidden sm:inline">
Press
<kbd class="mx-0.5 rounded border border-neutral-300 px-1 text-xs dark:border-neutral-600">Enter</kbd>
or start typing to add a note
</span>
<span class="sm:hidden">Tap to add a note</span>
</button>
<FilterBar v-if="isMainBoard" />
-420
View File
@@ -1,420 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client";
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import NoteEditor from "../components/NoteEditor.vue";
type NodeKind = "note" | "label";
interface GNode {
id: string;
title: string;
color: string;
kind: NodeKind;
labelId?: string;
x: number;
y: number;
vx: number;
vy: number;
}
interface GEdge {
source: string;
target: string;
kind?: string;
}
const WIDTH = 1000;
const HEIGHT = 700;
const router = useRouter();
const allNodes = ref<GNode[]>([]);
const edges = ref<GEdge[]>([]);
const loading = ref(true);
const error = ref("");
// Editor host: resolve a node id against the store, else fetch (shared controller).
const { editing, close: closeEditor, navigate } = useNoteEditor();
// Unlinked notes float in the space by default — the graph is a gentle overview,
// not a links-only surface. Label hubs are on by default so tags cluster notes.
const showAll = ref(true);
const showLabels = ref(true);
const svgRef = ref<SVGSVGElement | null>(null);
const gRef = ref<SVGGElement | null>(null);
// Pan/zoom applied to the inner <g>.
const scale = ref(1);
const tx = ref(0);
const ty = ref(0);
let frame = 0;
let raf = 0;
// Label membership edges drop out when the label hubs are hidden.
const visibleEdges = computed(() =>
showLabels.value ? edges.value : edges.value.filter((e) => e.kind !== "label"),
);
const connectedIds = computed(() => {
const s = new Set<string>();
for (const e of visibleEdges.value) {
s.add(e.source);
s.add(e.target);
}
return s;
});
// Hide label hubs when toggled off; otherwise show everything (unlinked notes
// float too) unless "show unlinked" is off, in which case keep only connected nodes.
const activeNodes = computed(() =>
allNodes.value.filter((n) => {
if (n.kind === "label" && !showLabels.value) return false;
if (showAll.value) return true;
return connectedIds.value.has(n.id);
}),
);
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
const edgeLines = computed(() => {
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
for (const e of visibleEdges.value) {
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
const s = byId.get(e.source);
const t = byId.get(e.target);
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
}
return out;
});
function fill(color: string): string {
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
}
async function loadGraph() {
loading.value = true;
error.value = "";
try {
const res = await api.get<{
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
edges: GEdge[];
}>("/api/graph");
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const count = Math.max(res.nodes.length, 1);
allNodes.value = res.nodes.map((n, i) => {
const angle = (i / count) * Math.PI * 2;
return {
id: n.id,
title: n.title,
color: n.color,
kind: n.kind,
labelId: n.label_id,
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
vx: 0,
vy: 0,
};
});
edges.value = res.edges;
} catch (e) {
error.value = (e as { error?: string }).error ?? "Couldn't load the graph.";
allNodes.value = [];
edges.value = [];
} finally {
loading.value = false;
}
reheat();
}
function reheat() {
frame = 0;
cancelAnimationFrame(raf);
raf = 0;
if (activeNodes.value.length) simulate();
}
function simulate() {
const list = activeNodes.value;
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const byId = new Map(list.map((n) => [n.id, n]));
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const a = list[i];
const b = list[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
let d2 = dx * dx + dy * dy;
if (d2 < 0.01) {
d2 = 0.01;
dx = Math.random();
dy = Math.random();
}
const d = Math.sqrt(d2);
const force = 6000 / d2;
const fx = (dx / d) * force;
const fy = (dy / d) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
for (const e of visibleEdges.value) {
const s = byId.get(e.source);
const t = byId.get(e.target);
if (!s || !t) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
// Label-membership springs sit a touch longer so hubs ring their notes.
const rest = e.kind === "label" ? 150 : 130;
const diff = (d - rest) * 0.02;
const fx = (dx / d) * diff;
const fy = (dy / d) * diff;
s.vx += fx;
s.vy += fy;
t.vx -= fx;
t.vy -= fy;
}
for (const n of list) {
if (n === dragNode) {
n.vx = 0;
n.vy = 0;
continue; // pinned to the cursor while dragging
}
n.vx += (cx - n.x) * 0.002;
n.vy += (cy - n.y) * 0.002;
n.vx *= 0.85;
n.vy *= 0.85;
n.x += n.vx;
n.y += n.vy;
}
frame++;
// Keep running while cooling, or indefinitely while a node is being dragged.
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
}
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
let dragNode: GNode | null = null;
let dragMoved = false;
let downPos = { x: 0, y: 0 };
let panning = false;
let panLast = { x: 0, y: 0 };
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
const ctm = el?.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
}
function onNodeDown(n: GNode, e: MouseEvent) {
e.stopPropagation();
dragNode = n;
dragMoved = false;
downPos = { x: e.clientX, y: e.clientY };
reheat();
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function onBgDown(e: MouseEvent) {
panning = true;
panLast = toLocal(svgRef.value, e);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function onMove(e: MouseEvent) {
if (dragNode) {
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
const p = toLocal(gRef.value, e);
dragNode.x = p.x;
dragNode.y = p.y;
} else if (panning) {
const p = toLocal(svgRef.value, e);
tx.value += p.x - panLast.x;
ty.value += p.y - panLast.y;
panLast = p;
}
}
function onUp() {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
const node = dragNode;
const moved = dragMoved;
dragNode = null;
panning = false;
// A press without a drag is a click.
if (node && !moved) clickNode(node);
}
function clickNode(n: GNode) {
// Label hub → jump to that label's board lens (one space, many lenses).
if (n.kind === "label" && n.labelId) {
void router.push(`/label/${n.labelId}`);
return;
}
void navigate(n.id);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const vb = toLocal(svgRef.value, e);
const gx = (vb.x - tx.value) / scale.value;
const gy = (vb.y - ty.value) / scale.value;
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
tx.value = vb.x - gx * scale.value;
ty.value = vb.y - gy * scale.value;
}
function resetView() {
scale.value = 1;
tx.value = 0;
ty.value = 0;
}
function toggleAll() {
showAll.value = !showAll.value;
reheat();
}
function toggleLabels() {
showLabels.value = !showLabels.value;
reheat();
}
onMounted(loadGraph);
onBeforeUnmount(() => {
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
});
</script>
<template>
<div class="flex h-full flex-col p-4">
<!-- Titled by the shell's persistent lens name (task 1913). -->
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
<div class="flex items-center gap-3 text-sm">
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
<input type="checkbox" class="accent-brand" :checked="showLabels" @change="toggleLabels" />
Show labels
</label>
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
Show unlinked notes
</label>
<button
type="button"
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="resetView"
>
Reset view
</button>
</div>
</div>
<AsyncState
:loading="loading"
:error="error || undefined"
error-title="Couldn't load the graph"
@retry="loadGraph"
>
<div v-if="allNodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
<p class="mt-1 text-sm text-neutral-400">
Create notes and link them with
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
</p>
</div>
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Nothing connected yet</h2>
<p class="mt-1 text-sm text-neutral-400">
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, add
<span class="font-mono text-brand-700 dark:text-brand">#tags</span>, or
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
show all notes
</button>.
</p>
</div>
<div
v-else
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
>
<svg
ref="svgRef"
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
class="h-full w-full cursor-grab select-none touch-none"
preserveAspectRatio="xMidYMid meet"
@mousedown="onBgDown"
@wheel="onWheel"
>
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
<line
v-for="(l, i) in edgeLines"
:key="`e${i}`"
:x1="l.x1"
:y1="l.y1"
:x2="l.x2"
:y2="l.y2"
class="stroke-neutral-300 dark:stroke-neutral-700"
:stroke-width="l.label ? 1 : 1.5"
:stroke-dasharray="l.label ? '3 3' : undefined"
/>
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
<circle
v-if="n.kind === 'label'"
:cx="n.x"
:cy="n.y"
r="12"
:fill="fill(n.color)"
fill-opacity="0.9"
class="stroke-neutral-50 dark:stroke-neutral-950"
stroke-width="3"
/>
<circle
v-else
:cx="n.x"
:cy="n.y"
r="8"
:fill="fill(n.color)"
class="stroke-neutral-50 dark:stroke-neutral-950"
stroke-width="1.5"
/>
<text
:x="n.x"
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
text-anchor="middle"
:class="
n.kind === 'label'
? 'fill-neutral-700 text-[12px] font-semibold dark:fill-neutral-100'
: 'fill-neutral-600 text-[12px] dark:fill-neutral-300'
"
>
{{ n.title }}
</text>
</g>
</g>
</svg>
</div>
</AsyncState>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="navigate" />
</template>
</div>
</template>
-48
View File
@@ -1,48 +0,0 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRoute } from "vue-router";
import { repo } from "../adapters";
import { useNoteList } from "../composables/useNoteList";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import EmptyState from "../components/EmptyState.vue";
import NoteGrid from "../components/NoteGrid.vue";
import NoteEditor from "../components/NoteEditor.vue";
const route = useRoute();
const query = computed(() => (typeof route.query.q === "string" ? route.query.q : ""));
const noMatchSubtitle = computed(() => `Nothing found for "${query.value}".`);
const { items: results, loading, error, load: run } = useNoteList(async () => {
const q = query.value.trim();
if (!q) return [];
return repo.notes.search(q);
}, "Search failed.");
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
list: () => results.value,
onClose: run, // reflect any edits made from a result
});
watch(query, run, { immediate: true });
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
<template v-if="query"
>Results for <span class="font-semibold text-neutral-800 dark:text-neutral-200">{{ query }}</span></template
>
<template v-else>Type in the search box to find your notes.</template>
</p>
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't search" @retry="run">
<EmptyState v-if="query && results.length === 0" title="No matches" :subtitle="noMatchSubtitle" />
<NoteGrid v-else-if="results.length" :notes="results" @open="openEditor" />
</AsyncState>
</div>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
</template>
</template>
+10
View File
@@ -12,6 +12,10 @@ interface SettingItem {
label: string;
description: string;
group: string;
// Ints only, and nullable: the server sends the registry's bounds so the number
// input can refuse an out-of-range value before the round trip.
minimum: number | null;
maximum: number | null;
}
const config = useConfigStore();
@@ -131,10 +135,16 @@ onMounted(load);
:checked="Boolean(it.value)"
@change="it.value = ($event.target as HTMLInputElement).checked"
/>
<!-- min/max come from the registry. The server rejects out-of-range
values regardless — this is so the browser says so first, rather than
letting someone type a hop count that would disable a protection and
only learn about it from an error banner. -->
<input
v-else-if="it.type === 'int'"
:id="it.key"
type="number"
:min="it.minimum ?? undefined"
:max="it.maximum ?? undefined"
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
:value="Number(it.value)"
@input="it.value = Number(($event.target as HTMLInputElement).value)"
+7 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "thoughtsync"
version = "0.1.0"
version = "0.2.0"
description = "Self-hosted personal thought-capture web app (FabledSword family)"
requires-python = ">=3.12"
dependencies = [
@@ -29,6 +29,12 @@ where = ["src"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
# The unit lane runs `-m "not integration"`; the integration lane runs `-m integration`
# against a real Postgres. Registered here so an unmarked typo fails loudly instead of
# quietly landing a test in neither lane.
markers = [
"integration: needs a live Postgres — CI's integration job, not the unit lane",
]
[tool.ruff]
line-length = 120
+1 -1
View File
@@ -1,3 +1,3 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
__version__ = "0.1.0"
__version__ = "0.2.0"
+85 -8
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
import asyncio
import logging
import mimetypes
import os
import secrets
from contextlib import suppress
from datetime import timedelta
from quart import Quart, has_request_context, jsonify, request, send_from_directory
from quart import Quart, jsonify, send_from_directory
from quart.sessions import SecureCookieSessionInterface
from . import __version__
@@ -15,15 +16,27 @@ from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
from .db import session_scope
from .graph import bp as graph_bp
from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .proxy import is_https
from .retention import run_sweeper
from .saved_filters import bp as saved_filters_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key
from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings_api import bp as settings_bp
from .sync import bp as sync_bp, protocol_advertisement
# Without this, `logger.info` from this package goes nowhere: hypercorn configures its
# own access/error loggers and leaves the root logger at WARNING, so the credential
# events in auth.py would be invisible in `docker compose logs` — which is exactly
# where they are meant to be read until an audit table exists (task 2939).
#
# `force=False` (the default) so a host that has already configured logging keeps its
# own setup; LOG_LEVEL lets an operator turn it up without a code change.
logging.basicConfig(
level=os.environ.get("THOUGHTSYNC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
# `.webmanifest` isn't in every base image's mime map; register it so the PWA
@@ -42,10 +55,7 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
"""
def get_cookie_secure(self, app: Quart) -> bool:
if not has_request_context():
return False
forwarded = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower()
return forwarded == "https" or request.is_secure
return is_https()
def create_app() -> Quart:
@@ -68,7 +78,6 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(graph_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp)
@@ -85,6 +94,11 @@ def create_app() -> Quart:
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
except (ValueError, TypeError, KeyError):
pass
# The security settings the throttle and the proxy trust read on hot
# paths. Cached rather than queried per request; until this runs they
# hold their registry defaults, which is the correct behaviour for a
# server that has not finished starting.
await refresh_live(db)
# Expire old trash in the background (retention.py). One task per process is
# correct because the image serves with a single hypercorn worker (Dockerfile);
# if that ever gains `--workers`, this needs a lock so N workers don't each
@@ -101,6 +115,69 @@ def create_app() -> Quart:
with suppress(asyncio.CancelledError):
await task
@app.after_request
async def _security_headers(response):
"""Headers a publicly-reachable instance should be sending.
None of these change how the app behaves for a legitimate caller; they narrow
what a browser will do if something else goes wrong.
The CSP is the substantive one. `script-src 'self'` means that even if some
future path did manage to reflect user text into the page, the browser would
refuse to run it — the app has no inline scripts and no third-party scripts,
so nothing legitimate is given up. The exceptions are honest ones:
- `style-src 'unsafe-inline'` — Vue writes inline styles itself (`v-show`
toggling display, TransitionGroup's FLIP setting transforms). Inline
STYLE is not an execution primitive the way inline script is.
- `img-src https: http:` — link previews render the remote og:image of
whatever was linked, which is an arbitrary host by definition. Both
schemes, because a LAN install is served over http and would otherwise
lose every preview image; on an https instance the browser blocks the
http ones as mixed content anyway, so naming it concedes nothing.
- `blob:`/`data:` — attachment previews and the desktop's blob URI scheme.
`frame-ancestors 'none'` replaces the older X-Frame-Options and is what stops
the app being framed for clickjacking; `form-action 'self'` stops a form from
being pointed at another origin.
"""
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"base-uri 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https: http:; "
"font-src 'self' data:; "
"media-src 'self' blob:; "
"connect-src 'self'",
)
# Content-type sniffing turns a file we said was text into whatever the bytes
# look like. The attachment route already sets this; every other response
# deserves it too.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Note titles and label names end up in the URL of a search or a label lens,
# and a full Referer would hand them to any site a link preview points at.
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# Nothing here uses a camera, a microphone or a location, so nothing embedded
# in a page should be able to ask on its behalf.
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()"
)
# HSTS only where the request already arrived over TLS — the same detection
# the session cookie uses. Sending it on a plain-HTTP LAN install would tell
# the browser to refuse the only scheme that install serves.
#
# Scoped to this host: no `includeSubDomains` and no `preload`, both of which
# commit domains this app does not own. A browser still remembers the policy
# for up to a year after the header stops being sent, which is the point of
# it — worth knowing before putting a hostname behind TLS temporarily.
if is_https():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
return response
@app.get("/api/health")
async def health():
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
+131 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import functools
import logging
import uuid
from datetime import datetime, timezone
@@ -11,11 +12,27 @@ from .common import iso
from .db import session_scope
from .models.device_token import DeviceToken
from .models.user import User
from .security import generate_token, hash_password, hash_token, verify_password
from .settings import get_setting
from .proxy import client_address
from .ratelimit import (
register_by_address,
sign_in_by_account,
sign_in_by_address,
)
from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password
from .settings import get_setting, set_settings
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
# Every credential event goes to the app log — there is no audit TABLE yet (see task
# 2939), and until there is, `docker compose logs` is the only way to know whether
# anyone is knocking. That matters most in exactly the window this was written for: a
# freshly-exposed instance.
#
# The attempted email is included deliberately. It is the operator's own server, and
# "somebody failed a login" without saying against WHICH account tells you nothing you
# can act on. Passwords, obviously, never appear.
logger = logging.getLogger(__name__)
SESSION_KEY = "user_id"
MIN_PASSWORD_LEN = 8
DEVICE_NAME_CAP = 100
@@ -106,6 +123,53 @@ def require_admin(fn):
return wrapper
def _throttled(retry_after: int):
"""The 429 every throttled credential route returns.
Deliberately says nothing about WHICH limit was hit or how many attempts are
left — that would tell someone probing whether the email they guessed exists.
`Retry-After` is standard and is the one thing a legitimate client (or person)
genuinely needs.
"""
logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after)
return (
jsonify({"error": "too many attempts — try again shortly"}),
429,
{"Retry-After": str(retry_after)},
)
def _sign_in_block(email: str) -> int | None:
"""Seconds to wait before this sign-in may be attempted, or None to proceed.
Checked BEFORE the password is verified, so a throttled attempt costs no bcrypt
— which is the other half of what this protects: hashing is deliberately slow,
and an unauthenticated caller who can trigger it without limit has a CPU
exhaustion primitive, not just a guessing one.
"""
address = client_address()
waits = [
sign_in_by_address.retry_after(address),
sign_in_by_account.retry_after(email) if email else None,
]
live = [w for w in waits if w is not None]
return max(live) if live else None
def _sign_in_failed(email: str) -> None:
address = client_address()
sign_in_by_address.record(address)
if email:
sign_in_by_account.record(email)
def _sign_in_succeeded(email: str) -> None:
"""Clear the account's history on success. The address keeps its count: one
correct password does not vouch for the other attempts from there."""
if email:
sign_in_by_account.forget(email)
@bp.post("/register")
async def register():
data = await request.get_json(silent=True) or {}
@@ -120,12 +184,21 @@ async def register():
if not display_name:
display_name = email.split("@", 1)[0]
# Counted by attempt rather than by failure: a rejected registration still cost a
# round trip and a uniqueness check, and on an instance with signups open the
# thing worth bounding is how fast accounts can appear at all.
wait = register_by_address.retry_after(client_address())
if wait is not None:
return _throttled(wait)
register_by_address.record(client_address())
async with session_scope() as db:
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
is_first = user_count == 0
# The first account bootstraps the admin and is always allowed, even when
# registration is otherwise closed.
if not is_first and not await get_setting(db, "allow_registration"):
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
return jsonify({"error": "registration is closed"}), 403
existing = await db.scalar(select(User).where(User.email == email))
if existing is not None:
@@ -137,10 +210,27 @@ async def register():
is_admin=is_first,
)
db.add(user)
if is_first:
# Registration CLOSES the moment the instance has an owner.
#
# Not "defaults closed" — that would still need the first person to get in
# somehow. Closed as a CONSEQUENCE of the admin account existing, which is
# the only formulation with no open window in it. Leaving the setting on
# meant the gap between "my account exists" and "I remembered to turn it
# off in Settings" was wide open, and on a public host that gap is the
# entire exposure — it starts the moment DNS resolves.
#
# An admin who wants a second person turns it back on in Settings → Access,
# adds them, and turns it off. Crude until invites exist, but it is a
# deliberate act rather than a default nobody chose.
await set_settings(db, {"allow_registration": False})
await db.commit()
await db.refresh(user)
session[SESSION_KEY] = str(user.id)
session.permanent = True
logger.info(
"account created email=%s admin=%s from=%s", email, is_first, client_address()
)
return jsonify(_serialize_user(user)), 201
@@ -150,12 +240,28 @@ async def login():
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
wait = _sign_in_block(email)
if wait is not None:
return _throttled(wait)
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
if user is None or not user.password_hash:
# Hash anyway. Without this, "no such account" returns in microseconds
# while a wrong password takes bcrypt's deliberate ~100ms, and the
# difference is a reliable oracle for which emails have accounts here.
dummy_verify(password)
_sign_in_failed(email)
logger.warning("sign-in failed (no such account) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
if not verify_password(password, user.password_hash):
_sign_in_failed(email)
logger.warning("sign-in failed (bad password) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
_sign_in_succeeded(email)
session[SESSION_KEY] = str(user.id)
session.permanent = True
logger.info("sign-in ok email=%s from=%s", email, client_address())
return jsonify(_serialize_user(user))
@@ -210,11 +316,32 @@ async def device_login():
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "email and password are required"}), 400
# Same budget as the web sign-in, and the SAME counters — this route hands out a
# long-lived bearer token, so leaving it unthrottled would just move the guessing
# here from /login.
wait = _sign_in_block(email)
if wait is not None:
return _throttled(wait)
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
if user is None or not user.password_hash:
dummy_verify(password)
_sign_in_failed(email)
logger.warning("device-login failed (no such account) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
if not verify_password(password, user.password_hash):
_sign_in_failed(email)
logger.warning("device-login failed (bad password) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401
_sign_in_succeeded(email)
row, token = await _issue_device_token(db, user.id, data.get("name") or "")
# A device token outlives the session that made it, so its creation is the
# most consequential thing on this blueprint.
logger.info(
"device token issued email=%s device=%s from=%s", email, row.name, client_address()
)
await db.commit()
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
+1
View File
@@ -48,3 +48,4 @@ class Config:
def secret_key_env(cls) -> str | None:
"""Optional break-glass override for the cookie-signing secret."""
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import func, select
from sqlalchemy.orm import aliased
from .auth import login_required
from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import Note
from .models.note_link import NoteLink
bp = Blueprint("graph", __name__, url_prefix="/api/graph")
@bp.get("")
@login_required
async def get_graph():
"""Spatial view of the owner's non-trashed notes.
Nodes are two kinds:
- notes (kind="note") — every non-trashed note; each carries its first
label's color for tinting.
- labels (kind="label", id "label:<uuid>") — every label actually attached
to a live note, acting as a clustering HUB so tagged notes gravitate
together even without wiki-links between them.
Edges are two kinds:
- wiki-links (kind="link") — resolved [[links]] (note_links.target_norm
matched to a note's normalized display_title).
- membership (kind="label") — each note → each of its label hubs.
The frontend toggles labels + unlinked notes; the graph is a light auxiliary
lens, not a focal surface.
"""
source = aliased(Note)
target = aliased(Note)
edge_stmt = (
select(source.id, target.id)
.select_from(NoteLink)
.join(source, source.id == NoteLink.source_id)
.join(target, func.lower(func.trim(target.display_title)) == NoteLink.target_norm)
.where(
source.owner_id == g.user_id,
source.deleted_at.is_(None),
target.owner_id == g.user_id,
target.deleted_at.is_(None),
source.id != target.id,
)
)
async with session_scope() as db:
rows = (await db.execute(edge_stmt)).all()
edges = []
seen: set = set()
for src_id, tgt_id in rows:
key = (src_id, tgt_id)
if key in seen:
continue
seen.add(key)
edges.append({"source": str(src_id), "target": str(tgt_id), "kind": "link"})
# Note ↔ label membership: one row per (note, label) for the owner's
# non-trashed notes. Drives both the note-color tint (first label by name)
# and the label-hub nodes + membership edges.
label_rows = (
await db.execute(
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
.join(Label, Label.id == NoteLabel.label_id)
.join(Note, Note.id == NoteLabel.note_id)
.where(
Label.owner_id == g.user_id,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
)
.order_by(Label.name)
)
).all()
first_color: dict = {}
label_nodes: dict = {}
for note_id, label_id, label_name, label_color in label_rows:
first_color.setdefault(note_id, label_color)
hub_id = f"label:{label_id}"
if hub_id not in label_nodes:
label_nodes[hub_id] = {
"id": hub_id,
"title": f"#{label_name}",
"color": label_color or "default",
"kind": "label",
"label_id": str(label_id),
}
edges.append({"source": str(note_id), "target": hub_id, "kind": "label"})
note_rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
).all()
nodes = [
{
"id": str(n.id),
"title": n.display_title or "Untitled",
"color": first_color.get(n.id, "default"),
"kind": "note",
}
for n in note_rows
]
nodes.extend(label_nodes.values())
return jsonify({"nodes": nodes, "edges": edges})
-1
View File
@@ -10,7 +10,6 @@ from . import ( # noqa: F401
note,
note_attachment,
note_item,
note_link,
note_link_preview,
note_revision,
saved_filter,
+5 -8
View File
@@ -38,16 +38,15 @@ class Note(Base):
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
# The note's display NAME: explicit title if set, else the first non-empty body
# line (see notes.derive_display_title). Persisted + normalized-matched so every
# note — even a body-only one — is nameable, searchable, graphable, and
# [[wiki-link]]-able without forcing the user to type a title.
# The note's NAME: its first non-empty body line, else its first checklist item
# (see notes.derive_display_title). There is no title field to prefer — a note is
# a body plus optional items, and this is simply the first thing written in it.
# Persisted so search results and export filenames have something to say, and so
# the full-text vector can weight it above the rest of the body.
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
# 'text' (freeform body) or 'list' (a checklist of note_items).
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
# Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
@@ -74,11 +73,9 @@ class Note(Base):
def serialize(self) -> dict:
return {
"id": str(self.id),
"title": self.title,
"display_title": self.display_title,
"body": self.body,
"color": self.color,
"kind": self.kind,
"position": self.position,
"pinned": self.pinned,
"archived": self.archived,
+5 -1
View File
@@ -11,7 +11,11 @@ from . import Base
class NoteItem(Base):
"""A single checklist item within a note (only used when note.kind == 'list')."""
"""A single checklist item on a note.
Any note can have them. There is no note "kind" gating this — a checklist is
something a note HAS, not something a note IS (M13 step 2).
"""
__tablename__ = "note_items"
-23
View File
@@ -1,23 +0,0 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteLink(Base):
"""A [[wiki-link]] from a source note to a target title (normalized). Resolved
to a target note by matching target_norm against lower(trim(note.title))."""
__tablename__ = "note_links"
__table_args__ = (Index("ix_note_links_target", "target_norm"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
target_norm: Mapped[str] = mapped_column(Text(), nullable=False)
+3 -4
View File
@@ -11,9 +11,9 @@ from . import Base
class NoteRevision(Base):
"""A point-in-time snapshot of a note's title+body, written on each edit that
changes either — so an accidental overwrite can be viewed and restored. Only
title+body are versioned in v1 (not items/attachments/labels)."""
"""A point-in-time snapshot of a note's body, written on each edit that changes
it — so an accidental overwrite can be viewed and restored. Only the body is
versioned (not items/attachments/labels)."""
__tablename__ = "note_revisions"
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
@@ -22,6 +22,5 @@ class NoteRevision(Base):
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+1 -1
View File
@@ -13,7 +13,7 @@ from . import Base
class SavedFilter(Base):
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the
GET /api/notes query (q/color/kind/labels/has_reminder/has_attachment/date range)."""
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range)."""
__tablename__ = "saved_filters"
+50 -163
View File
@@ -1,7 +1,7 @@
"""Notes API (the `/api/notes` blueprint).
The bulk of the shared logic lives in cohesive sibling modules — serialization
(`serialize`), wiki-links/tags (`links`), recurring reminders (`recurrence`),
(`serialize`), #tags (`tags`), recurring reminders (`recurrence`),
small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and
`bp` is defined in `_bp` so every module can import it without a cycle.
@@ -19,7 +19,7 @@ import zipfile
from datetime import datetime, timedelta, timezone
from quart import Response, g, jsonify, request, send_file
from sqlalchemy import case, func, literal_column, select
from sqlalchemy import func, literal_column, select
from ..acl import visible_to_user
from ..auth import login_required
@@ -32,19 +32,18 @@ from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link import NoteLink
from ..models.note_link_preview import NoteLinkPreview
from ..models.note_revision import NoteRevision
from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note
from ..settings import get_setting
from ..unfurl_queue import schedule as schedule_unfurls
from ..unfurl import UnfurlError, unfurl
from ._bp import bp
from .helpers import (
ALLOWED_IMAGE_MIMES,
VALID_FILTERS,
_attachment_ext,
_escape_like,
_get_owned,
_header_filename,
_safe_filename,
@@ -65,13 +64,9 @@ from .import_export import (
_read_import_specs,
_usec_to_dt,
)
from .links import (
from .tags import (
_reconcile_tags,
_rename_inbound_links,
_rewrite_links,
parse_link_titles,
parse_tags,
rewrite_link_title,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
@@ -82,16 +77,11 @@ __all__ = [
"is_empty_note",
"parse_list_items",
"parse_tags",
"parse_link_titles",
"rewrite_link_title",
"normalize_color",
"normalize_recurrence",
"next_occurrence",
"_reconcile_tags",
"_rename_inbound_links",
"_rewrite_links",
"_serialize_notes",
"_escape_like",
"_safe_filename",
"_attachment_ext",
"_header_filename",
@@ -112,7 +102,6 @@ async def list_notes():
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
label_params = request.args.getlist("label")
color = request.args.get("color")
kind = request.args.get("kind")
has_reminder = coerce_bool(request.args.get("has_reminder"))
has_attachment = coerce_bool(request.args.get("has_attachment"))
query_text = (request.args.get("q") or "").strip()
@@ -136,10 +125,6 @@ async def list_notes():
if color not in NOTE_COLORS:
return json_error("invalid color", 400)
stmt = stmt.where(Note.color == color)
if kind is not None:
if kind not in ("text", "list"):
return json_error("invalid kind", 400)
stmt = stmt.where(Note.kind == kind)
if has_reminder:
stmt = stmt.where(Note.remind_at.is_not(None))
if has_attachment:
@@ -155,8 +140,10 @@ async def list_notes():
return json_error("invalid created_before", 400)
stmt = stmt.where(Note.created_at < before_dt)
if query_text:
# Full-text match over title+body (generated tsvector, migration 0005),
# ranked — so the facet bar's text box searches, not just filters.
# Full-text match over the note's name + body (generated tsvector,
# migrations 0005/0026), ranked. This is the ONLY text search now: the
# separate facet-less `/search` route was removed because landing on it
# was the one place you could not also narrow by tag (note 2930).
tsquery = func.websearch_to_tsquery("english", query_text)
search_col = literal_column("notes.search_vector")
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
@@ -170,30 +157,6 @@ async def list_notes():
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/search")
@login_required
async def search_notes():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"notes": []})
async with session_scope() as db:
tsquery = func.websearch_to_tsquery("english", q)
# search_vector is a generated column (migration 0005), not mapped on the ORM.
search_col = literal_column("notes.search_vector")
stmt = (
select(Note)
.where(
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.deleted_at.is_(None),
search_col.op("@@")(tsquery),
)
.order_by(func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc())
.limit(100)
)
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/reminders")
@login_required
async def list_reminders():
@@ -289,11 +252,9 @@ async def export_notes():
payload["notes"].append(
{
"id": str(n.id),
"title": n.title,
"display_title": n.display_title,
"body": n.body,
"color": n.color,
"kind": n.kind,
"pinned": n.pinned,
"archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
@@ -380,9 +341,13 @@ async def import_notes():
@bp.get("/titles")
@login_required
async def list_titles():
# Owner's non-trashed notes, keyed by their display NAME (explicit title or
# first body line) — the index the frontend uses to resolve + autocomplete
# [[wiki-links]]. Every note has a name now, so body-only notes are linkable too.
"""Owner's non-trashed notes, keyed by their display NAME.
Survived the removal of [[wiki-links]] (note 2897) because it was serving two
different things, and only one of them was linking. This is what the command
palette lists so someone can jump to a note by name — which is recall, the thing
this app is actually for. The `[[` autocomplete that also read it is gone.
"""
async with session_scope() as db:
rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
@@ -392,73 +357,6 @@ async def list_titles():
)
@bp.get("/link-search")
@login_required
async def link_search():
# Autocomplete source for [[wiki-links]]: match the query against a note's display
# NAME *or* its BODY, so you can link by recalling any phrase — not just the name.
# Substring ILIKE (good for partial-word typing, deterministic, fine at personal
# scale; the FTS index still powers the heavier /search). Name matches rank above
# body-only matches, and a name prefix above a mid-name substring. Empty q → recent.
q = (request.args.get("q") or "").strip()
async with session_scope() as db:
base = select(Note).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.display_title != ""
)
if not q:
stmt = base.order_by(Note.updated_at.desc()).limit(10)
else:
esc = _escape_like(q)
name_hit = Note.display_title.ilike(f"%{esc}%", escape="\\")
stmt = (
base.where(name_hit | Note.body.ilike(f"%{esc}%", escape="\\"))
.order_by(
case((name_hit, 0), else_=1),
case((Note.display_title.ilike(f"{esc}%", escape="\\"), 0), else_=1),
Note.updated_at.desc(),
)
.limit(10)
)
rows = (await db.scalars(stmt)).all()
return jsonify({"results": [{"id": str(n.id), "title": n.display_title} for n in rows]})
@bp.get("/<note_id>/backlinks")
@login_required
async def note_backlinks(note_id: str):
nid = parse_uuid(note_id)
if nid is None:
return not_found()
async with session_scope() as db:
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None:
return not_found()
if not note.display_title:
return jsonify({"backlinks": []})
norm = note.display_title.strip().lower()
sources = (
await db.scalars(
select(Note)
.join(NoteLink, NoteLink.source_id == Note.id)
.where(
NoteLink.target_norm == norm,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
Note.id != nid,
)
)
).all()
seen: set = set()
out = []
for n in sources:
if n.id not in seen:
seen.add(n.id)
out.append({"id": str(n.id), "title": n.display_title})
return jsonify({"backlinks": out})
@bp.post("/reorder")
@login_required
async def reorder_notes():
@@ -486,20 +384,32 @@ async def reorder_notes():
return jsonify({"ok": True})
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
"""The note's display name, consulting its checklist only when the body is silent.
`item_texts` short-circuits the query for callers that already hold the items
(create, import). Everyone else pays one narrow SELECT, and only when the body
produced nothing — which is the uncommon case.
"""
name = derive_display_title(note.body)
if name:
return name
if item_texts is not None:
return derive_display_title("", item_texts[0] if item_texts else None)
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
return derive_display_title("", first)
@bp.post("")
@login_required
async def create_note():
data = await request.get_json(silent=True) or {}
title = data.get("title") if isinstance(data.get("title"), str) else ""
body = data.get("body") if isinstance(data.get("body"), str) else ""
kind = data.get("kind") if data.get("kind") in ("text", "list") else "text"
# A checklist note's "content" is its items, not the body — so it's non-empty
# when it has a title or at least one item (quick-add can create one in one shot).
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
if kind == "list":
if not (title.strip() or item_texts):
return json_error("note is empty", 400)
elif is_empty_note(title, body):
# Items are accepted on ANY note — a checklist is something a note HAS.
item_texts = parse_list_items(data.get("items"))
if is_empty_note(body, item_texts):
return json_error("note is empty", 400)
async with session_scope() as db:
# New notes go to the top of the manual order.
@@ -508,13 +418,10 @@ async def create_note():
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
clean_title = title.strip() or None
note = Note(
owner_id=g.user_id,
title=clean_title,
display_title=derive_display_title(clean_title, body),
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
body=body,
kind=kind,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
)
@@ -522,10 +429,12 @@ async def create_note():
await db.flush() # assign note.id before writing items/links
for pos, text in enumerate(item_texts):
db.add(NoteItem(note_id=note.id, text=text, position=pos))
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
# After the commit, never before it: the note is saved and the response is
# about to go out. Any link previews arrive on a later read.
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note)), 201
@@ -552,18 +461,11 @@ async def update_note(note_id: str):
note = await _get_owned(db, note_id)
if note is None:
return not_found()
old_display = note.display_title
old_title = note.title
old_body = note.body
if "title" in data:
title = data["title"] if isinstance(data["title"], str) else ""
note.title = title.strip() or None
if "body" in data and isinstance(data["body"], str):
note.body = data["body"]
if "color" in data:
note.color = normalize_color(data["color"])
if "kind" in data and data["kind"] in ("text", "list"):
note.kind = data["kind"]
if "pinned" in data:
note.pinned = bool(data["pinned"])
if "archived" in data:
@@ -580,31 +482,22 @@ async def update_note(note_id: str):
note.remind_at = remind_dt
if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"])
# Recompute the display name (explicit title, else first body line) whenever
# the title or body may have changed.
if "title" in data or "body" in data:
note.display_title = derive_display_title(note.title, note.body)
if "body" in data:
await _rewrite_links(db, note)
note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note)
# The display NAME changing — via an explicit title OR the first body line —
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
# case/whitespace changes, which still resolve).
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
# Version history: snapshot the PRE-edit title+body whenever either changed.
if note.title != old_title or note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
# Version history: snapshot the PRE-edit body whenever it changed.
if note.body != old_body:
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.commit()
await db.refresh(note)
if note.body != old_body:
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note))
def _serialize_revision(rev: NoteRevision) -> dict:
return {
"id": str(rev.id),
"title": rev.title,
"body": rev.body,
"created_at": iso(rev.created_at),
}
@@ -641,20 +534,14 @@ async def restore_revision(note_id: str, rev_id: str):
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
if rev is None:
return not_found()
if note.title == rev.title and note.body == rev.body:
if note.body == rev.body:
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
# the revision — with the same title/body ripple as a normal edit.
old_display = note.display_title
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
# the revision — with the same body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body
note.display_title = derive_display_title(note.title, note.body)
await _rewrite_links(db, note)
note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note)
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
+18 -15
View File
@@ -19,22 +19,30 @@ VALID_FILTERS = {"active", "archived", "trash"}
DISPLAY_TITLE_CAP = 200
def derive_display_title(title: str | None, body: str | None) -> str:
"""The note's display NAME: the explicit title if set, else the first non-empty
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
body-only note is still nameable/searchable/linkable — the user never has to type
a title. Deterministic (literal first line, no AI)."""
if title and title.strip():
return title.strip()[:DISPLAY_TITLE_CAP]
def derive_display_title(body: str | None, first_item: str | None = None) -> str:
"""The note's display NAME: the first non-empty line of the body, else the first
checklist item's text (both trimmed and length-capped).
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
optional items, and its name is simply the first thing written in it. Persisted as
notes.display_title so search results and export filenames have something to say.
The item fallback is what step 2 bought: a note that is only a checklist would
otherwise have no name at all, which is exactly the hole that made removing the
title unsafe before checklists stopped being their own kind of thing.
Deterministic — a literal first line, never generated.
"""
for line in (body or "").splitlines():
stripped = line.strip()
if stripped:
return stripped[:DISPLAY_TITLE_CAP]
return ""
return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
def is_empty_note(title: str | None, body: str | None) -> bool:
return not (title or "").strip() and not (body or "").strip()
def is_empty_note(body: str | None, items: list | None = None) -> bool:
"""Nothing worth keeping: no body text and no checklist items."""
return not (body or "").strip() and not items
def parse_list_items(raw: object) -> list[str]:
@@ -74,11 +82,6 @@ async def _get_owned(db, note_id: str) -> Note | None:
)
def _escape_like(s: str) -> str:
"""Escape LIKE wildcards so user input matches literally (escape char = \\)."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
+29 -23
View File
@@ -28,7 +28,7 @@ from .helpers import (
derive_display_title,
is_empty_note,
)
from .links import _find_or_create_label, _reconcile_tags, _rewrite_links
from .tags import _find_or_create_label, _reconcile_tags
from .recurrence import normalize_recurrence
@@ -36,8 +36,6 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
"""One note as a human-readable Markdown file with a small frontmatter block.
The authoritative machine format is notes.json; this is for reading/portability."""
fm = ["---"]
if note.title:
fm.append(f"title: {note.title}")
fm.append(f"display_name: {note.display_title}")
if labels:
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
@@ -52,11 +50,15 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
fm.append(f"updated: {note.updated_at.isoformat() if note.updated_at else ''}")
fm.append("---")
fm.append("")
if note.kind == "list":
# Body and checklist are no longer alternatives — a note can carry both, so both
# are written, body first, with a blank line between them when there is.
if note.body:
fm.append(note.body)
if items:
if note.body:
fm.append("")
for it in items:
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
else:
fm.append(note.body)
return "\n".join(fm) + "\n"
@@ -100,7 +102,6 @@ def _native_spec(n: dict) -> dict:
return {
"title": n.get("title"),
"body": n.get("body") or "",
"kind": n.get("kind"),
"color": n.get("color"),
"pinned": bool(n.get("pinned")),
"archived": bool(n.get("archived")),
@@ -128,8 +129,10 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
"""Normalize one Google Keep note (Takeout <note>.json) into the common import
spec. `keep_dir` is the note JSON's folder, used to resolve attachment paths."""
list_content = kn.get("listContent") if isinstance(kn.get("listContent"), list) else []
is_list = bool(list_content)
body = kn.get("textContent") or "" if not is_list else ""
# Keep's own notes are one or the other, but its text was being DISCARDED whenever
# a note also had list content, because the target model could only hold one.
# It can hold both now, so both are kept.
body = kn.get("textContent") or ""
# Keep stores link annotations (e.g. shared URLs) separately from the text —
# fold any URLs into the body so the content survives the move.
urls = [
@@ -155,7 +158,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
return {
"title": kn.get("title"),
"body": body,
"kind": "list" if is_list else "text",
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
"pinned": bool(kn.get("isPinned")),
"archived": bool(kn.get("isArchived")),
@@ -272,24 +274,30 @@ async def _create_imported_note(
db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
) -> bool:
"""Insert one imported note plus its items/labels/attachments, reusing the same
display-title derivation + tag/link reconciliation as create_note. Returns False
(nothing written) when the spec is empty."""
title = (spec.get("title") or "").strip() or None
name derivation + tag reconciliation as create_note. Returns False (nothing
written) when the spec is empty."""
body = spec.get("body") or ""
kind = spec.get("kind") if spec.get("kind") in ("text", "list") else "text"
# An imported title becomes the note's FIRST BODY LINE.
#
# ThoughtSync has no title field any more (M13 step 3), but the things people
# import from do — Keep notes carry one, and so does any export taken before this.
# Dropping it would silently lose text someone wrote; folding it into the body puts
# it exactly where a name now lives, so the note comes in named the way it was.
# Skipped when the body already opens with that line, so re-importing an export
# this code produced doesn't stack duplicates.
title = (spec.get("title") or "").strip()
if title and body.lstrip().split("\n", 1)[0].strip() != title:
body = f"{title}\n{body}" if body.strip() else title
items = spec.get("items") or []
if kind == "list":
if not (title or any((it.get("text") or "").strip() for it in items)):
return False
elif is_empty_note(title, body):
item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t]
if is_empty_note(body, item_texts):
return False
note = Note(
owner_id=owner_id,
title=title,
display_title=derive_display_title(title, body),
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
body=body,
kind=kind,
color=normalize_color(spec.get("color")),
pinned=bool(spec.get("pinned")),
archived=bool(spec.get("archived")),
@@ -310,7 +318,6 @@ async def _create_imported_note(
db.add(note)
await db.flush() # assign note.id before items/labels/attachments/links
if kind == "list":
for pos, it in enumerate(items):
text = (it.get("text") or "").strip()
if text:
@@ -333,6 +340,5 @@ async def _create_imported_note(
if isinstance(att, dict):
_import_attachment(db, note, zf, att, budget)
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
return True
@@ -1,17 +1,20 @@
"""[[wiki-links]] and #tags — parsing note bodies and keeping the derived
note_links / tag-sourced note_labels rows in sync with the text. Manual (picker)
labels are NOT touched here (see the labeling module)."""
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
module).
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
this app is an intermediary surface for capture and recall, and a linking system is
organization, which is not what it is for. A file called links.py holding no links
would have been exactly the kind of drift that removal was meant to end.
"""
from __future__ import annotations
import re
from sqlalchemy import delete, func, select
from sqlalchemy import func, select
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_link import NoteLink
_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
# A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
@@ -37,25 +40,6 @@ def parse_tags(body: str | None) -> list[str]:
return out
def parse_link_titles(body: str | None) -> list[str]:
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
if not body:
return []
out: list[str] = []
for match in _LINK_RE.finditer(body):
norm = match.group(1).strip().lower()
if norm and norm not in out:
out.append(norm)
return out
async def _rewrite_links(db, note: Note) -> None:
"""Replace a note's outgoing wiki-links from its current body."""
await db.execute(delete(NoteLink).where(NoteLink.source_id == note.id))
for norm in parse_link_titles(note.body):
db.add(NoteLink(source_id=note.id, target_norm=norm))
async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar(
@@ -89,38 +73,3 @@ async def _reconcile_tags(db, note: Note) -> None:
if lid not in attached_ids:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid)
def rewrite_link_title(body: str | None, old_norm: str, new_title: str) -> str:
"""Repoint every [[token]] whose normalized form == old_norm to [[new_title]]."""
if not body:
return body or ""
def _sub(match: re.Match) -> str:
return f"[[{new_title}]]" if match.group(1).strip().lower() == old_norm else match.group(0)
return _LINK_RE.sub(_sub, body)
async def _rename_inbound_links(db, renamed: Note, old_title: str, new_title: str) -> None:
"""Rewrite [[old title]] references (and their link rows) in every note that
links to the renamed note, so its backlinks survive the title change."""
old_norm = old_title.strip().lower()
sources = (
await db.scalars(
select(Note)
.join(NoteLink, NoteLink.source_id == Note.id)
.where(
NoteLink.target_norm == old_norm,
Note.owner_id == renamed.owner_id,
Note.deleted_at.is_(None),
)
)
).all()
seen: set = set()
for source in sources:
if source.id in seen:
continue
seen.add(source.id)
source.body = rewrite_link_title(source.body, old_norm, new_title)
await _rewrite_links(db, source)
+74
View File
@@ -0,0 +1,74 @@
"""Reading what the proxies in front of this app say about a request.
Two headers carry information the app cannot see for itself — who the client is
(`X-Forwarded-For`) and whether they arrived over TLS (`X-Forwarded-Proto`) — and both
are trusted by the same rule, so the rule lives in one place. Writing it twice is
precisely how issue 2183 happened: two places holding one decision, and only one of
them updated.
## The rule
A forwarding header grows LEFT to RIGHT. Each hop appends what IT saw, so the
rightmost entries are the ones our own infrastructure wrote, and anything a caller
sent arrives to the LEFT of those.
That inverts the intuitive reading. The leftmost entry is nominally "the original
client" — and is exactly the one a caller can forge, by sending the header themselves.
So we count in from the right by the number of proxies we actually run
(the **Trusted proxy hops** setting, default 1), and a forged prefix can never be
selected no matter how much of it there is.
Too HIGH a hop count is the dangerous direction: it starts believing entries no proxy
of ours wrote. Too low just means several callers share a bucket. So when the header
is shorter than configured — fewer proxies than expected — we fall back to the socket
address rather than reaching further left.
"""
from __future__ import annotations
from quart import has_request_context, request
from .settings import live
def trusted_entry(header: str, hops: int) -> str | None:
"""The nth-from-the-right entry of a forwarding header, or None if there isn't one.
Pure, so the trust boundary is testable without a request context.
"""
if hops <= 0:
return None
entries = [part.strip() for part in header.split(",") if part.strip()]
if len(entries) < hops:
return None
return entries[-hops]
def forwarded_for(header: str, remote_addr: str | None, hops: int) -> str:
"""The client address a proxy chain vouches for, else this connection's peer."""
entry = trusted_entry(header, hops)
return (entry or remote_addr or "unknown")[:64] # bounded: becomes a dict key
def client_address() -> str:
"""The caller's address, as far as the deployment's own proxies vouch for it."""
return forwarded_for(
request.headers.get("X-Forwarded-For", ""),
request.remote_addr,
live("trusted_proxy_hops"),
)
def is_https() -> bool:
"""Whether this request reached us over TLS — directly, or via a trusted proxy.
Shared by the session cookie's `Secure` flag and by HSTS, because they are the same
question. Read with the same hop count as the address: a caller who sets
`X-Forwarded-Proto: https` on a plain-HTTP request puts it to the left of whatever
our proxy appended, so it is not what gets read.
"""
if not has_request_context():
return False
if request.is_secure:
return True
entry = trusted_entry(request.headers.get("X-Forwarded-Proto", ""), live("trusted_proxy_hops"))
return (entry or "").lower() == "https"
+134
View File
@@ -0,0 +1,134 @@
"""Throttling for the endpoints that a public deployment leaves exposed.
Only the credential endpoints are rate-limited: login, register, and the native
device-link exchange. Everything else already needs a session or a device token to
reach, so an attacker has to get through one of these three first.
## Why in-process is enough here, and where that stops being true
State lives in module-level dicts, so it is per-process. That is correct for how
this image actually serves — one hypercorn worker (see the Dockerfile, and the
same assumption the trash sweeper documents in app.py). If that ever gains
``--workers N``, each worker would keep its own counters and the effective limit
would multiply by N; the fix then is a shared store (the Postgres connection is
already there), not a bigger number here.
## Two keys, on purpose
Every attempt is counted against BOTH the account being tried and the address it
came from, and either one can refuse it:
- **The account** is the key that matters, and the key that cannot be forged. It
is what stops credential stuffing against one known email, no matter how many
addresses the attempts arrive from.
- **The address** bounds the damage from one source spraying many accounts. It is
read from ``X-Forwarded-For``, counting in from the RIGHT by
the **Trusted proxy hops** setting so that only entries our own proxies wrote are
believed — a forged header lands to the left of those and is never selected. It is
still the weaker of the two keys, because it depends on that setting matching the
deployment; the account key depends on nothing.
Counting is by failure for the sign-in routes and by attempt for registration: a
correct password should never move someone closer to being locked out, but every
registration is a row in the users table whether it succeeds or not.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Callable
from .settings import live
# Never let the bookkeeping become the denial of service: an attacker rotating an
# address could otherwise mint an unbounded number of buckets. Well above any real
# deployment's distinct-caller count, so a legitimate instance never reaches it; when
# it is reached the oldest buckets are dropped, which at worst forgives some attempts.
#
# Not a setting: it protects the limiter from itself rather than the app from a
# caller, and there is no operator judgment to apply to it.
MAX_BUCKETS = 10_000
class SlidingWindow:
"""Counts events per key over a trailing window.
Sliding rather than a fixed window because a fixed one lets twice the limit
through across a boundary — 10 at 14:59 and 10 at 15:00 — which for a login
limiter is the difference between the number meaning something and not.
The limit and window are SUPPLIERS, not values, so an admin saving a new number in
Settings takes effect on the next attempt instead of the next deploy. They are read
per call, which is a dict lookup — the settings cache never touches the database.
"""
def __init__(self, limit: Callable[[], int], window_s: Callable[[], float]) -> None:
self._limit = limit
self._window_s = window_s
self._hits: dict[str, deque[float]] = {}
@property
def limit(self) -> int:
return self._limit()
@property
def window_s(self) -> float:
return self._window_s()
def _prune(self, key: str, now: float) -> deque[float]:
hits = self._hits.get(key)
if hits is None:
hits = deque()
# Insertion-ordered, so the first key is the least recently created.
if len(self._hits) >= MAX_BUCKETS:
self._hits.pop(next(iter(self._hits)), None)
self._hits[key] = hits
cutoff = now - self.window_s
while hits and hits[0] <= cutoff:
hits.popleft()
return hits
def retry_after(self, key: str, now: float | None = None) -> int | None:
"""Seconds until `key` may try again, or None while it is still under the
limit. Read-only — it does not count as an attempt."""
now = time.monotonic() if now is None else now
hits = self._prune(key, now)
if len(hits) < self.limit:
return None
# The window frees up when its OLDEST hit falls out of it.
return max(1, int(hits[0] + self.window_s - now) + 1)
def record(self, key: str, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
self._prune(key, now).append(now)
def forget(self, key: str) -> None:
"""Drop a key's history. Used after a successful sign-in, so someone who
fumbled a password twice and then got it right starts clean rather than
carrying those two for the next quarter of an hour."""
self._hits.pop(key, None)
def clear(self) -> None:
self._hits.clear()
def _minutes(key: str) -> Callable[[], float]:
return lambda: float(live(key)) * 60.0
sign_in_by_account = SlidingWindow(
lambda: live("signin_limit_per_account"), _minutes("signin_window_minutes")
)
sign_in_by_address = SlidingWindow(
lambda: live("signin_limit_per_address"), _minutes("signin_window_minutes")
)
register_by_address = SlidingWindow(
lambda: live("register_limit_per_address"), _minutes("register_window_minutes")
)
def reset_all() -> None:
"""Drop every counter. For tests — nothing in the app calls this."""
for window in (sign_in_by_account, sign_in_by_address, register_by_address):
window.clear()
-3
View File
@@ -33,7 +33,6 @@ from .models.label import NoteLabel
from .models.note import Note
from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link import NoteLink
from .models.note_link_preview import NoteLinkPreview
from .models.note_revision import NoteRevision
from .settings import get_setting
@@ -88,10 +87,8 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
note.title = None
note.body = ""
note.display_title = ""
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
-1
View File
@@ -19,7 +19,6 @@ NAME_CAP = 100
_ALLOWED_PARAM_KEYS = {
"q",
"color",
"kind",
"label", # matches the repeatable ?label= query param (stored as an array)
"has_reminder",
"has_attachment",
+17
View File
@@ -14,6 +14,23 @@ def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], bcrypt.gensalt()).decode("utf-8")
# A real bcrypt hash of a value nobody can present, used only to spend the time a
# verification would have. Computed once at import — generating it per call would
# cost a gensalt+hash on top of the checkpw and make the "no such user" path SLOWER
# than the real one, which is the same oracle pointing the other way.
_DUMMY_HASH = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt())
def dummy_verify(password: str) -> None:
"""Burn one password verification against a throwaway hash.
For the sign-in path when the email has no account: it makes "no such user" cost
what "wrong password" costs, so response time stops answering the question of
which emails are registered here.
"""
bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], _DUMMY_HASH)
def verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], password_hash.encode("utf-8"))
+133 -2
View File
@@ -19,6 +19,13 @@ class SettingDef:
label: str
description: str
group: str
# Ints only. Enforced server-side in validate_updates and passed to the UI so the
# number input carries them too. These exist because several of the security
# values have ranges where a typo is not merely wrong but dangerous — a proxy hop
# count of 50 would trust anything a caller sent, and a sign-in limit of 0 would
# lock every account out permanently.
minimum: int | None = None
maximum: int | None = None
# The source of truth for every user-facing setting. Add a row here and it appears
@@ -32,7 +39,8 @@ REGISTRY: list[SettingDef] = [
"bool",
True,
"Allow new registrations",
"When off, only existing users can sign in. The first account is always allowed.",
"When off, only existing users can sign in. Closes itself once the first "
"account exists — turn it back on only while you're adding someone.",
"Access",
),
SettingDef(
@@ -69,6 +77,80 @@ REGISTRY: list[SettingDef] = [
"The server contacts the linked site; private/internal addresses are always blocked.",
"Links",
),
# --- Security -----------------------------------------------------------------
#
# Read on paths too hot for a database round trip (the credential throttle checks
# them BEFORE opening a connection, which is the point of checking a throttle
# before doing expensive work), so they are cached — see `live()` below.
SettingDef(
"trusted_proxy_hops",
"int",
1,
"Trusted proxy hops",
"How many proxies sit in front of this server. 1 for a single reverse proxy "
"terminating HTTPS; 2 if a CDN like Cloudflare sits in front of that; 0 if "
"the app is exposed directly. This decides which entry of X-Forwarded-For is "
"believed — set it TOO HIGH and a visitor can forge their own address and "
"slip the sign-in limits below.",
"Security",
minimum=0,
maximum=10,
),
SettingDef(
"signin_limit_per_account",
"int",
10,
"Failed sign-ins per account",
"How many failures one account tolerates within the window before it stops "
"answering. Comfortably above mistyping a password, far below anything that "
"makes guessing worth attempting.",
"Security",
minimum=1,
maximum=1000,
),
SettingDef(
"signin_limit_per_address",
"int",
50,
"Failed sign-ins per address",
"The same, counted per visitor address instead of per account — it bounds one "
"source trying many accounts. Wider, because one address is legitimately many "
"people: a household, an office, a phone on carrier NAT.",
"Security",
minimum=1,
maximum=10000,
),
SettingDef(
"signin_window_minutes",
"int",
15,
"Sign-in window (minutes)",
"The trailing period both sign-in limits are counted over.",
"Security",
minimum=1,
maximum=1440,
),
SettingDef(
"register_limit_per_address",
"int",
5,
"Sign-ups per address",
"How many accounts one address may create within its window. Counted per "
"attempt rather than per failure — each one is a row either way.",
"Security",
minimum=1,
maximum=1000,
),
SettingDef(
"register_window_minutes",
"int",
60,
"Sign-up window (minutes)",
"The trailing period the sign-up limit is counted over.",
"Security",
minimum=1,
maximum=10080,
),
]
_BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY}
@@ -152,11 +234,52 @@ async def get_admin_settings(db) -> list[dict]:
"label": d.label,
"description": d.description,
"group": d.group,
"minimum": d.minimum,
"maximum": d.maximum,
}
)
return result
# Settings the app must be able to read WITHOUT awaiting a database.
#
# The credential throttle consults these before opening a connection — deliberately,
# because a refused attempt is supposed to cost nothing, and the proxy hop count is
# needed to know who is even asking. A per-request query would undo both.
#
# Seeded from the registry defaults so the app works before (and without) a database —
# unit tests construct it with no Postgres at all — then refreshed from the DB at boot
# and again whenever an admin saves. Same live-update contract `session_ttl_days`
# already has in settings_api.py.
_LIVE_KEYS = (
"trusted_proxy_hops",
"signin_limit_per_account",
"signin_limit_per_address",
"signin_window_minutes",
"register_limit_per_address",
"register_window_minutes",
)
_live: dict[str, Any] = {k: _BY_KEY[k].default for k in _LIVE_KEYS}
def live(key: str) -> Any:
"""The cached value of a hot setting. Synchronous, never touches the database."""
return _live[key]
async def refresh_live(db) -> None:
"""Re-read the hot settings into the cache. Called at boot and after every save."""
for key in _LIVE_KEYS:
_live[key] = await get_setting(db, key)
def reset_live() -> None:
"""Back to registry defaults. For tests — nothing in the app calls this."""
for key in _LIVE_KEYS:
_live[key] = _BY_KEY[key].default
def validate_updates(updates: dict) -> tuple[dict, str | None]:
"""Coerce/validate a {key: value} dict against the registry. Returns
(clean_values, error_message). An unknown key or a bad int is rejected."""
@@ -167,9 +290,17 @@ def validate_updates(updates: dict) -> tuple[dict, str | None]:
return {}, f"unknown setting: {key}"
if defn.type == "int":
try:
clean[key] = int(val)
n = int(val)
except (ValueError, TypeError):
return {}, f"{defn.label} must be a whole number"
# Rejected rather than clamped: silently accepting a number and storing a
# different one is how somebody ends up believing a protection is set to
# something it is not.
if defn.minimum is not None and n < defn.minimum:
return {}, f"{defn.label} must be at least {defn.minimum}"
if defn.maximum is not None and n > defn.maximum:
return {}, f"{defn.label} must be at most {defn.maximum}"
clean[key] = n
elif defn.type == "bool":
clean[key] = _coerce_bool(val)
else:
+5 -1
View File
@@ -6,7 +6,7 @@ from quart import Blueprint, current_app, jsonify, request
from .auth import require_admin
from .db import session_scope
from .settings import get_admin_settings, set_settings, validate_updates
from .settings import get_admin_settings, refresh_live, set_settings, validate_updates
bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@@ -35,6 +35,10 @@ async def update_settings():
async with session_scope() as db:
await set_settings(db, clean)
await db.commit()
# Re-read the cached security values so a saved limit or hop count applies to
# the very next request. Unconditional: cheap, and a conditional here would be
# one more place that has to know which keys are hot.
await refresh_live(db)
result = await get_admin_settings(db)
# Apply the live-tunable knob without a restart (rule 25).
+52 -21
View File
@@ -28,8 +28,6 @@ from .models.note_item import NoteItem
from .models.note_revision import NoteRevision
from .notes import (
_reconcile_tags,
_rename_inbound_links,
_rewrite_links,
_serialize_notes,
derive_display_title,
normalize_color,
@@ -37,6 +35,7 @@ from .notes import (
)
from .retention import purge_note
from .serialize import serialize_label_sync
from .unfurl_queue import schedule as schedule_unfurls
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
@@ -57,8 +56,14 @@ MAX_PUSH = 1000 # per-batch change cap
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch
# that hard-blocks older clients, so additive changes must leave it alone.
SYNC_PROTOCOL_VERSION = 1
MIN_CLIENT_PROTOCOL_VERSION = 1
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
# both and would read back notes carrying neither.
#
# One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2.
SYNC_PROTOCOL_VERSION = 2
MIN_CLIENT_PROTOCOL_VERSION = 2
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name
# here rather than a min-version bump, so a newer client meeting an older server
@@ -192,11 +197,8 @@ def client_wins(client_edited_at: datetime | None, server_edited_at: datetime |
def _assign_note_fields(note: Note, ch: dict) -> None:
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
whole-note, not a partial patch — the client sends its authoritative version)."""
title = ch.get("title")
note.title = (title or "").strip() or None if isinstance(title, str) else None
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
note.color = normalize_color(ch.get("color"))
note.kind = ch["kind"] if ch.get("kind") in ("text", "list") else "text"
note.pinned = bool(ch.get("pinned"))
note.archived = bool(ch.get("archived"))
if ch.get("trashed"):
@@ -210,13 +212,41 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
note.position = ch["position"]
async def _apply_note_items(db, note: Note, ch: dict) -> None:
"""Replace the note's checklist items with the client's (items sync inline)."""
if note.kind != "list":
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
return
def _first_item_text(ch: dict) -> str:
"""The first non-blank checklist item in a pushed change, or "".
Read straight from the payload rather than the database because the note's name is
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
empty is named by its first item (M13 step 3).
"""
items = ch.get("items")
if not isinstance(items, list):
return ""
for it in items:
if isinstance(it, dict):
text = (it.get("text") or "").strip()
if text:
return text
return ""
async def _apply_note_items(db, note: Note, ch: dict) -> None:
"""Replace the note's checklist items with the client's (items sync inline).
Applies to ANY note. This used to delete every item when the note wasn't
`kind == "list"`, which was survivable only because nothing could produce a note
holding both a body and items. M13 makes that the normal shape — a checklist is
something a note HAS, not something a note IS — and against that shape the old
guard was a data-loss path: the first sync after adding a checklist to a note
would have wiped it.
Removed ahead of the UI that can create the state, deliberately, so there is no
window in which the two disagree.
"""
items = ch.get("items")
if not isinstance(items, list):
# Absent means "not telling us", not "empty". A client that omits the key
# leaves what the server has; only an explicit [] clears it.
return
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
for pos, it in enumerate(items):
@@ -282,24 +312,25 @@ async def _apply_note(db, ch: dict) -> dict:
elif note.purged_at is not None:
note.purged_at = None # client re-created/edited → clear the tombstone
old_title, old_body, old_display = note.title, note.body, note.display_title
old_body = note.body
_assign_note_fields(note, ch)
note.display_title = derive_display_title(note.title, note.body)
note.display_title = derive_display_title(note.body, _first_item_text(ch))
if edited_at is not None:
note.updated_at = edited_at
# Non-destructive LWW: snapshot the overwritten server title+body into history.
if not creating and (note.title != old_title or note.body != old_body):
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
# Non-destructive LWW: snapshot the overwritten server body into history.
if not creating and note.body != old_body:
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch)
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch)
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
await db.flush()
await db.refresh(note, ["sync_revision"])
# A note pushed from a linked client gets the same link previews as one typed into
# the web app — the client picks them up on its next pull. Scheduled rather than
# awaited: a push batch must not wait on somebody else's website.
if creating or note.body != old_body:
schedule_unfurls(note.id, note.body)
return {
"id": str(nid),
"entity": "note",
+127
View File
@@ -0,0 +1,127 @@
"""Unfurling a note's URLs in the background, after the note is already saved.
## Why this is not done inline
Capture speed is the product. Unfurling is a 5-second-timeout network call to a host
nobody controls, and a note must persist the instant someone stops typing — so the
save returns first and the preview catches up. A person who pastes a link and closes
the composer has already done the thing they came to do.
## Why it is on the server rather than in each client
The server sees every note that reaches it, from all three surfaces, so detection and
fetching live in one place instead of three. A linked desktop or Android client pushes
its note and picks the preview up on the next pull; an unlinked one has no server to
ask and simply has no preview until it links, which is the honest consequence of being
offline rather than a gap to paper over.
## What it deliberately does not do
Fail loudly. A preview that could not be fetched is not an error the person needs —
the note is fine, it just has no card. The link is still in the body, still clickable,
still searchable.
"""
from __future__ import annotations
import asyncio
import logging
import re
import uuid
from sqlalchemy import select
from .db import session_scope
from .models.note import Note
from .models.note_link_preview import NoteLinkPreview
from .settings import get_setting
from .unfurl import UnfurlError, unfurl
logger = logging.getLogger(__name__)
# Matches the frontend's detector (NoteEditor.vue) so both surfaces agree on what
# counts as a link. Trailing sentence punctuation is stripped below rather than in the
# pattern — a URL can legitimately end in most of these characters, just not when the
# sentence does.
_URL_RE = re.compile(r"(https?://[^\s<>\"'\])]+)")
# Per note, per save. A body pasted full of links should not turn into a burst of
# outbound requests; nobody is reading forty preview cards on one card anyway.
MAX_URLS_PER_NOTE = 5
# Background tasks are only weakly referenced by the event loop, so without a strong
# reference here a task can be garbage-collected mid-flight. Discarded on completion.
_running: set[asyncio.Task] = set()
def detect_urls(body: str | None) -> list[str]:
"""Distinct http(s) URLs in a note body, in order, trailing punctuation trimmed."""
out: list[str] = []
for match in _URL_RE.finditer(body or ""):
url = match.group(1).rstrip(".,;:!?")
if url and url not in out:
out.append(url)
return out
async def _fetch_and_store(note_id: uuid.UUID, url: str) -> None:
"""Unfurl one URL and cache it against the note. Silent on every failure."""
try:
preview = await unfurl(url)
except UnfurlError as e:
# Expected and uninteresting: a dead link, a private address, a non-page.
logger.debug("no preview for %s: %s", url, e)
return
except Exception:
logger.warning("unexpected failure unfurling %s", url, exc_info=True)
return
async with session_scope() as db:
# The note may have been deleted or the URL removed while the fetch was in
# flight, so re-check rather than assuming the world held still.
note = await db.scalar(select(Note).where(Note.id == note_id, Note.deleted_at.is_(None)))
if note is None or url not in detect_urls(note.body):
return
row = await db.scalar(
select(NoteLinkPreview).where(NoteLinkPreview.note_id == note_id, NoteLinkPreview.url == url)
)
if row is None:
row = NoteLinkPreview(note_id=note_id, url=url)
db.add(row)
row.title = preview["title"]
row.description = preview["description"]
row.image_url = preview["image_url"]
row.site_name = preview["site_name"]
await db.commit()
async def _unfurl_new_urls(note_id: uuid.UUID, body: str) -> None:
async with session_scope() as db:
if not await get_setting(db, "enable_url_unfurl"):
return
cached = set(
(
await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note_id))
).all()
)
fresh = [u for u in detect_urls(body) if u not in cached][:MAX_URLS_PER_NOTE]
for url in fresh:
await _fetch_and_store(note_id, url)
def schedule(note_id: uuid.UUID, body: str | None) -> None:
"""Queue an unfurl pass for a note that was just written. Returns immediately.
Safe to call on every save: it re-reads what is already cached and does nothing
when there is nothing new, so an edit that doesn't touch the links costs one
cheap query on a background task rather than a fetch.
"""
if not body or not detect_urls(body):
return
try:
task = asyncio.create_task(_unfurl_new_urls(note_id, body))
except RuntimeError:
# No running loop — a script or a test calling the write path directly. The
# note is saved either way; only the preview is skipped.
return
_running.add(task)
task.add_done_callback(_running.discard)
+416
View File
@@ -0,0 +1,416 @@
"""The real-Postgres lane (family rule 6).
Everything else in this suite is deliberately DB-free, which means the schema the
migrations build has never been checked against the models that read it. That gap is
what this file closes, and it is not theoretical: M13 dropped three columns and
rebuilt a generated column, and until now `alembic upgrade head` ran for the first
time when the operator's container started.
Marked `integration` and excluded from the unit lane by `-m "not integration"`, so a
workstation without Postgres runs the rest of the suite unchanged.
The schema comes from real migrations, never `metadata.create_all` (rule 82) — the
point is to test what actually ships, and `create_all` would build a schema no
deployment has ever seen.
"""
from __future__ import annotations
import uuid
import pytest
import pytest_asyncio
from sqlalchemy import select, text
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.note import Note
from thoughtsync.models.note_item import NoteItem
from thoughtsync.models.user import User
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview
from thoughtsync.sync import _apply_note_items
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_items, note_revisions, note_labels, note_link_previews, labels, users"
@pytest_asyncio.fixture
async def db():
"""A session against the migrated database, wiped before each test.
Wiped BEFORE rather than after so a failed test leaves its rows behind to look at.
"""
async with session_scope() as session:
await session.execute(text(f"TRUNCATE {_TABLES} RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await dispose_engine()
@pytest_asyncio.fixture
async def app_client(db):
"""A test client against the real app, over the migrated database.
The credential throttle is process-global and its counters outlive a single
test, so they are cleared here — otherwise a suite that registers a few times
starts handing out 429s for reasons that have nothing to do with the test.
"""
ratelimit.reset_all()
yield create_app().test_client()
ratelimit.reset_all()
@pytest_asyncio.fixture
async def owner(db):
"""A user to hang notes off — `notes.owner_id` is a real foreign key."""
user = User(email=f"{uuid.uuid4().hex}@example.test", display_name="Integration")
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def test_the_migrated_schema_matches_the_models(db, owner):
"""The check that has never run: insert through the ORM, read it back.
A column the models expect and the migrations never created — or the reverse —
fails right here, instead of when a container starts.
"""
note = Note(owner_id=owner.id, body="a thought", display_title="a thought")
db.add(note)
await db.commit()
await db.refresh(note)
found = await db.scalar(select(Note).where(Note.id == note.id))
assert found is not None
assert found.body == "a thought"
assert found.display_title == "a thought"
async def test_the_dropped_columns_are_actually_gone(db):
"""M13 dropped three. If a migration silently no-opped, this is where it shows."""
cols = set(
(
await db.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'notes'")
)
)
.scalars()
.all()
)
assert "title" not in cols, "notes.title should have gone in 0026"
assert "kind" not in cols, "notes.kind should have gone in 0025"
assert "display_title" in cols and "body" in cols
rev_cols = set(
(
await db.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'note_revisions'")
)
)
.scalars()
.all()
)
assert "title" not in rev_cols, "note_revisions.title should have gone in 0026"
tables = set(
(await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")))
.scalars()
.all()
)
assert "note_links" not in tables, "note_links should have gone in 0024"
async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
"""0026 had to drop and recreate a STORED GENERATED column.
Postgres refuses to drop a column another generated column depends on, so getting
this wrong doesn't produce a subtly wrong ranking — it produces a migration that
won't run at all. Worth proving the replacement actually indexes something.
"""
note = Note(owner_id=owner.id, body="ferry tickets\nbook before friday", display_title="ferry tickets")
db.add(note)
await db.commit()
hit = await db.scalar(
text(
"SELECT count(*) FROM notes "
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
).bindparams(q="ferry")
)
assert hit == 1
# The NAME is weight A and the body weight B, which is what makes a name match
# rank above a body-only one. Both must be in the vector at all.
body_only = await db.scalar(
text(
"SELECT count(*) FROM notes "
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
).bindparams(q="friday")
)
assert body_only == 1
async def test_a_note_keeps_both_its_body_and_its_items(db, owner):
"""The shape M13 step 2 made normal: a note HAS a checklist, it isn't one."""
note = Note(owner_id=owner.id, body="weekend shop", display_title="weekend shop")
db.add(note)
await db.flush()
db.add_all(
[
NoteItem(note_id=note.id, text="milk", position=0),
NoteItem(note_id=note.id, text="eggs", position=1),
]
)
await db.commit()
items = (
await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position))
).all()
assert [i.text for i in items] == ["milk", "eggs"]
assert (await db.scalar(select(Note.body).where(Note.id == note.id))) == "weekend shop"
async def test_sync_no_longer_deletes_items_from_a_note_with_a_body(db, owner):
"""The data-loss path step 2 removed, pinned against a real database.
`_apply_note_items` used to delete every item when the note wasn't `kind = "list"`.
Nothing can produce that state any more, but this is the regression that would
have silently eaten a checklist, and it deserves a test that would catch its
return.
"""
note = Note(owner_id=owner.id, body="packing", display_title="packing")
db.add(note)
await db.flush()
db.add(NoteItem(note_id=note.id, text="socks", position=0))
await db.commit()
# A change that says nothing about items must LEAVE them alone — absent means
# "not telling us", not "empty".
await _apply_note_items(db, note, {"body": "packing"})
await db.commit()
assert (await db.scalar(select(NoteItem.text).where(NoteItem.note_id == note.id))) == "socks"
# An explicit list replaces them.
await _apply_note_items(db, note, {"items": [{"text": "charger", "checked": True}]})
await db.commit()
rows = (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
assert [(r.text, r.checked) for r in rows] == [("charger", True)]
async def test_a_note_with_only_items_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe until step 2 closed it."""
note = Note(owner_id=owner.id, body="", display_title="")
db.add(note)
await db.flush()
db.add(NoteItem(note_id=note.id, text="milk", position=0))
await db.commit()
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
note.display_title = derive_display_title(note.body, first)
await db.commit()
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner, monkeypatch):
"""The background pass, run inline so the assertions are deterministic.
The network is stubbed — this is about what reaches the DATABASE, not about
parsing someone's OpenGraph tags (unfurl.py's own tests cover that). What matters
here is the part only a real database can show: the unique constraint holding, the
upsert going to the right row, and a second pass not re-fetching.
"""
note = Note(
owner_id=owner.id,
body="read https://example.com/a and https://example.com/b",
display_title="read https://example.com/a and https://example.com/b",
)
db.add(note)
await db.commit()
calls: list[str] = []
async def fake_unfurl(url):
calls.append(url)
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, note.body)
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
assert {r.url for r in rows} == {"https://example.com/a", "https://example.com/b"}
assert all(r.title.startswith("T ") for r in rows)
# A second pass over an unchanged body fetches nothing — the whole reason
# `schedule` is safe to call on every save.
calls.clear()
await _unfurl_new_urls(note.id, note.body)
assert calls == []
async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, monkeypatch):
"""A slow fetch must not resurrect a link the person deleted mid-flight."""
note = Note(owner_id=owner.id, body="https://example.com/gone", display_title="x")
db.add(note)
await db.commit()
async def fake_unfurl(url):
# Simulate the body changing while the request was in the air.
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
note.body = "changed my mind"
await db.commit()
await _unfurl_new_urls(note.id, "https://example.com/gone")
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
assert rows == [], "a preview was stored for a URL the note no longer contains"
async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
"""The detector and the storage path read the same body the same way."""
body = "one https://example.com/x. two (https://example.com/y) three"
assert detect_urls(body) == ["https://example.com/x", "https://example.com/y"]
note = Note(owner_id=owner.id, body=body, display_title="one")
db.add(note)
await db.commit()
async def fake_unfurl(url):
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, body)
stored = {
r for r in (await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note.id))).all()
}
assert stored == set(detect_urls(body))
async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
"""The gap this removes: registration was open between "my account exists" and
"I remembered to turn it off", and on a public host that gap starts at DNS.
Runs against a real database because it is the interaction between two writes —
the user row and the settings row — inside one transaction.
"""
# The instance is empty (the fixture truncated it), so this is the first account:
# allowed unconditionally, and it becomes the admin.
first = await app_client.post(
"/api/auth/register",
json={"email": "owner@example.test", "password": "a-long-enough-password"},
)
assert first.status_code == 201
assert (await first.get_json())["is_admin"] is True
# …and the door shut behind it.
async with session_scope() as fresh:
assert await get_setting(fresh, "allow_registration") is False
second = await app_client.post(
"/api/auth/register",
json={"email": "stranger@example.test", "password": "a-long-enough-password"},
)
assert second.status_code == 403
# Re-opening it deliberately still works — that is how a second person gets in
# until invites exist.
async with session_scope() as fresh:
await set_settings(fresh, {"allow_registration": True})
await fresh.commit()
third = await app_client.post(
"/api/auth/register",
json={"email": "invited@example.test", "password": "a-long-enough-password"},
)
assert third.status_code == 201
assert (await third.get_json())["is_admin"] is False
async def test_security_settings_are_live_and_bounded(app_client, db):
"""The security values are settings now, not constants — so saving one has to take
effect without a restart, and a dangerous value has to be refused.
Real database because the whole point is the round trip: write through the admin
API, re-read into the cache the throttle consults, observe the new number.
"""
# An admin to authenticate as. First account, so it is allowed and becomes admin.
reset_live()
created = await app_client.post(
"/api/auth/register",
json={"email": "admin@example.test", "password": "a-long-enough-password"},
)
assert created.status_code == 201
# Defaults are what the registry says.
async with session_scope() as fresh:
await refresh_live(fresh)
assert live("trusted_proxy_hops") == 1
assert live("signin_limit_per_account") == 10
# A value that would disable the protection is REFUSED, not clamped — storing a
# different number than the one typed is how somebody ends up believing a limit
# is set to something it is not.
bad = await app_client.patch("/api/settings", json={"signin_limit_per_account": 0})
assert bad.status_code == 400
assert "at least" in (await bad.get_json())["error"]
# …and so is a hop count that would trust anything a caller sent.
bad_hops = await app_client.patch("/api/settings", json={"trusted_proxy_hops": 99})
assert bad_hops.status_code == 400
# A legitimate change applies to the cache the throttle reads, immediately.
ok = await app_client.patch(
"/api/settings", json={"signin_limit_per_account": 3, "trusted_proxy_hops": 2}
)
assert ok.status_code == 200
assert live("signin_limit_per_account") == 3
assert live("trusted_proxy_hops") == 2
# And it is persisted, not just cached.
async with session_scope() as fresh:
assert await get_setting(fresh, "trusted_proxy_hops") == 2
reset_live()
async def test_the_security_group_reaches_the_admin_ui(app_client, db):
"""Every security value has to be visible and editable, which is the whole reason
they moved out of the environment."""
created = await app_client.post(
"/api/auth/register",
json={"email": "admin2@example.test", "password": "a-long-enough-password"},
)
assert created.status_code == 201
resp = await app_client.get("/api/settings")
assert resp.status_code == 200
rows = (await resp.get_json())["settings"]
security = {r["key"]: r for r in rows if r["group"] == "Security"}
assert set(security) == {
"trusted_proxy_hops",
"signin_limit_per_account",
"signin_limit_per_address",
"signin_window_minutes",
"register_limit_per_address",
"register_window_minutes",
}
# The UI renders a number input from these, and it cannot offer a safe range it
# was never told about.
for row in security.values():
assert row["type"] == "int"
assert row["minimum"] is not None and row["maximum"] is not None
assert row["description"], f"{row['key']} has no description to explain itself"
+61 -69
View File
@@ -5,9 +5,9 @@ import pytest
from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.unfurl_queue import detect_urls
from thoughtsync.notes import (
_attachment_ext,
_escape_like,
_header_filename,
_keep_spec,
_native_spec,
@@ -19,10 +19,8 @@ from thoughtsync.notes import (
next_occurrence,
normalize_color,
normalize_recurrence,
parse_link_titles,
parse_list_items,
parse_tags,
rewrite_link_title,
)
@@ -39,9 +37,9 @@ def test_all_note_routes_registered(app):
expected = {
f"notes.{name}"
for name in (
"list_notes", "search_notes", "list_reminders", "complete_reminder",
"list_notes", "list_reminders", "complete_reminder",
"snooze_reminder", "export_notes", "import_notes", "list_titles",
"link_search", "note_backlinks", "reorder_notes", "create_note",
"reorder_notes", "create_note",
"get_note", "update_note", "list_revisions", "restore_revision",
"set_note_labels", "add_item", "update_item", "delete_item",
"reorder_items", "upload_attachment", "get_attachment",
@@ -54,9 +52,10 @@ def test_all_note_routes_registered(app):
def test_is_empty_note():
assert is_empty_note(None, None)
assert is_empty_note("", " ")
assert not is_empty_note("title", "")
assert not is_empty_note("", "body")
assert is_empty_note(" ", [])
assert not is_empty_note("body")
# A note that is only a checklist is not empty — it just has nothing in its body.
assert not is_empty_note("", ["milk"])
def test_normalize_color():
@@ -72,9 +71,9 @@ def test_palette_has_core_colors():
def test_serialize_shape():
n = Note(title="t", body="b", color="blue", pinned=True, archived=False)
n = Note(body="b", color="blue", pinned=True, archived=False)
s = n.serialize()
assert s["title"] == "t"
assert "title" not in s # there is no title field any more (M13 step 3)
assert s["body"] == "b"
assert s["color"] == "blue"
assert s["pinned"] is True
@@ -118,51 +117,41 @@ async def test_reorder_requires_auth(app):
assert resp.status_code == 401
def test_parse_link_titles():
titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again")
assert titles == ["alpha", "beta"]
# [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
# the name index and the `[[` autocomplete. So are the two helpers that used to keep
# links alive across a rename, and the id-binding that briefly replaced them. Nothing
# here asserts their absence — `test_all_note_routes_registered` below is what would
# notice a route coming back, and the removal is one commit rather than a fossil.
def test_parse_link_titles_empty():
assert parse_link_titles(None) == []
assert parse_link_titles("no links here") == []
def test_rewrite_link_title():
body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]"
assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]"
def test_rewrite_link_title_noop():
assert rewrite_link_title("", "alpha", "Gamma") == ""
assert rewrite_link_title(None, "alpha", "Gamma") == ""
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here"
def test_derive_display_title_explicit_wins():
assert derive_display_title("My Title", "some body line") == "My Title"
assert derive_display_title(" Padded ", "body") == "Padded"
def test_derive_display_title_from_first_body_line():
assert derive_display_title(None, "first line\nsecond line") == "first line"
assert derive_display_title("", " spaced first \nnext") == "spaced first"
def test_derive_display_title_is_the_first_body_line():
assert derive_display_title("first line\nsecond line") == "first line"
assert derive_display_title(" spaced first \nnext") == "spaced first"
# leading blank/whitespace lines are skipped to the first line with content
assert derive_display_title(None, "\n \nreal line\nmore") == "real line"
# a whitespace-only title falls through to the body
assert derive_display_title(" ", "body wins") == "body wins"
assert derive_display_title("\n \nreal line\nmore") == "real line"
def test_derive_display_title_falls_back_to_the_first_item():
# What step 2 bought: a note that is only a checklist still has a name. Without
# this it would have none at all, which is why the title could not go first.
assert derive_display_title("", "milk") == "milk"
assert derive_display_title(" \n ", " eggs ") == "eggs"
# The body still wins when it has anything to say.
assert derive_display_title("shopping", "milk") == "shopping"
def test_derive_display_title_empty():
assert derive_display_title(None, None) == ""
assert derive_display_title("", "") == ""
assert derive_display_title(" ", " \n ") == ""
assert derive_display_title(None) == ""
assert derive_display_title("") == ""
assert derive_display_title(" \n ", None) == ""
assert derive_display_title(" \n ", " ") == ""
def test_derive_display_title_caps_length():
long = "x" * 300
assert derive_display_title(None, long) == "x" * 200
assert derive_display_title(long, "body") == "x" * 200
assert derive_display_title(long) == "x" * 200
# the item fallback is capped on the same rule
assert derive_display_title("", long) == "x" * 200
def test_parse_tags():
@@ -182,14 +171,6 @@ def test_parse_list_items():
assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"]
def test_escape_like():
# LIKE wildcards in user input must be neutralized so they match literally.
assert _escape_like("100%") == "100\\%"
assert _escape_like("a_b") == "a\\_b"
assert _escape_like("c:\\path") == "c:\\\\path"
assert _escape_like("plain") == "plain"
def test_parse_dt():
# A full ISO instant round-trips (used to validate the Timeline date range).
d = parse_dt("2026-07-19T12:30:00+00:00")
@@ -211,18 +192,6 @@ async def test_titles_requires_auth(app):
assert resp.status_code == 401
async def test_link_search_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/link-search?q=hi")
assert resp.status_code == 401
async def test_graph_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/graph")
assert resp.status_code == 401
async def test_reminders_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/reminders")
@@ -354,9 +323,13 @@ def test_usec_to_dt():
assert _usec_to_dt(None) is None
def test_keep_spec_list_note():
def test_keep_spec_list_note_keeps_its_text_too():
# Keep's own notes carry one or the other, but its textContent used to be
# DISCARDED whenever a note also had listContent, because a note could only be
# one kind. A note holds both now, so nothing is dropped on the way in.
kn = {
"title": "Groceries",
"textContent": "for the weekend",
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
"labels": [{"name": "shopping"}],
"color": "TEAL",
@@ -367,7 +340,7 @@ def test_keep_spec_list_note():
"userEditedTimestampUsec": 1600000100000000,
}
spec = _keep_spec(kn, "Takeout/Keep")
assert spec["kind"] == "list"
assert spec["body"] == "for the weekend"
assert spec["color"] == "teal"
assert spec["pinned"] is True
assert spec["archived"] is False
@@ -385,7 +358,6 @@ def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
}
spec = _keep_spec(kn, "Takeout/Keep")
assert spec["kind"] == "text"
assert "https://example.com" in spec["body"]
assert spec["color"] == "orange"
# attachment path is resolved relative to the note JSON's folder
@@ -397,7 +369,6 @@ def test_native_spec_roundtrip_fields():
"title": "T",
"body": "b",
"color": "blue",
"kind": "text",
"pinned": True,
"archived": False,
"created_at": "2026-07-19T00:00:00+00:00",
@@ -406,6 +377,8 @@ def test_native_spec_roundtrip_fields():
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
}
spec = _native_spec(n)
# The spec still CARRIES a title — an export taken before M13 has one, and
# _create_imported_note folds it into the body rather than dropping it.
assert spec["title"] == "T"
assert spec["body"] == "b"
assert spec["color"] == "blue"
@@ -414,3 +387,22 @@ def test_native_spec_roundtrip_fields():
assert spec["created_at"].year == 2026
assert spec["labels"] == ["x"]
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]
def test_detect_urls_finds_each_link_once_in_order():
body = "see https://example.com/a and https://example.com/b\nand https://example.com/a again"
assert detect_urls(body) == ["https://example.com/a", "https://example.com/b"]
def test_detect_urls_trims_sentence_punctuation():
# A URL can end in most punctuation; a SENTENCE containing one usually doesn't.
assert detect_urls("read https://example.com/page.") == ["https://example.com/page"]
assert detect_urls("(see https://example.com/x)") == ["https://example.com/x"]
# …but a path that legitimately ends in a slash or a dash keeps it.
assert detect_urls("https://example.com/dir/") == ["https://example.com/dir/"]
def test_detect_urls_ignores_non_http():
assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == []
assert detect_urls(None) == []
assert detect_urls("") == []
+73
View File
@@ -0,0 +1,73 @@
"""The proxy trust boundary.
The whole security property is "a caller cannot forge their own address", and it rests
on counting in from the RIGHT of the header rather than the left. These are the cases
that tell the two apart — pure functions, no request context, no database.
"""
from thoughtsync.proxy import forwarded_for, trusted_entry
from thoughtsync.settings import live
PEER = "10.0.0.1" # the socket address: our own proxy, or the caller when unproxied
def test_default_is_one_hop():
# One reverse proxy terminating TLS — this deployment, and the only shape that is
# safe to assume. A wrong default here is a silent security bug, not a preference.
#
# Read through live() rather than the registry: live() is what proxy.py actually
# calls, and it is seeded from the defaults at import time so the value is right
# before the first database read. A boot that never reached the DB must still
# count one hop, not zero.
assert live("trusted_proxy_hops") == 1
def test_no_proxy_ignores_the_header_entirely():
# hops=0 says nothing in front of us appends anything, so the header can only be
# something a caller invented.
assert forwarded_for("1.2.3.4", PEER, 0) == PEER
def test_one_hop_reads_what_our_proxy_wrote():
assert forwarded_for("203.0.113.7", PEER, 1) == "203.0.113.7"
def test_a_forged_prefix_is_never_selected():
# THE test. A caller sends `X-Forwarded-For: 1.2.3.4`; our proxy appends the
# address it actually saw. Reading from the left would hand the caller a fresh
# rate-limit bucket for every value they invent.
assert forwarded_for("1.2.3.4, 203.0.113.7", PEER, 1) == "203.0.113.7"
# …and padding it doesn't help either.
assert forwarded_for("a, b, c, d, 203.0.113.7", PEER, 1) == "203.0.113.7"
def test_two_hops_sees_past_a_cdn():
# Cloudflare appended the real client; our proxy appended Cloudflare.
assert forwarded_for("203.0.113.7, 172.16.0.5", PEER, 2) == "203.0.113.7"
assert forwarded_for("1.2.3.4, 203.0.113.7, 172.16.0.5", PEER, 2) == "203.0.113.7"
def test_a_short_header_falls_back_rather_than_reaching_left():
# Fewer proxies than configured. Reaching further left would start believing
# entries no proxy of ours wrote, so the safe direction is the socket address —
# at worst several callers share one bucket.
assert forwarded_for("203.0.113.7", PEER, 2) == PEER
assert forwarded_for("", PEER, 1) == PEER
def test_malformed_headers_do_not_crash_or_leak_empties():
assert forwarded_for(",,,", PEER, 1) == PEER
assert forwarded_for(" , 203.0.113.7 , ", PEER, 1) == "203.0.113.7"
def test_the_key_is_length_bounded():
# It becomes a dict key in the limiter; an unbounded header must not become an
# unbounded allocation.
assert len(forwarded_for("x" * 5000, PEER, 1)) <= 64
def test_trusted_entry_reports_absence_rather_than_guessing():
# `is_https` needs to tell "no trusted entry" apart from "an entry saying http",
# which is why this returns None rather than a default.
assert trusted_entry("", 1) is None
assert trusted_entry("https", 0) is None
assert trusted_entry("http, https", 1) == "https"
+145
View File
@@ -0,0 +1,145 @@
"""The credential throttle. DB-free, like the rest of this suite.
The window itself is exercised directly with an injected clock, so nothing here
sleeps: a 15-minute window tested in real time is a test nobody runs twice.
The routes are exercised only as far as they get WITHOUT a database — a throttled
request returns 429 before any session is opened, which is the whole point of
checking the limit before the password. The happy path can't be reached here and is
not pretended at.
"""
import time
import pytest
from thoughtsync import ratelimit
from thoughtsync.settings import live
from thoughtsync.app import create_app
from thoughtsync.ratelimit import SlidingWindow
def window(limit: int, window_s: float) -> SlidingWindow:
"""A fixed-value window. The real ones read their numbers from the settings cache
so an admin's change applies immediately; these tests are about the counting, not
about where the numbers come from."""
return SlidingWindow(lambda: limit, lambda: window_s)
@pytest.fixture(autouse=True)
def _clean_counters():
ratelimit.reset_all()
yield
ratelimit.reset_all()
@pytest.fixture
def app():
return create_app()
def test_under_the_limit_is_not_blocked():
w = window(3, 60)
for i in range(3):
assert w.retry_after("k", now=i) is None
w.record("k", now=i)
assert w.retry_after("k", now=3) is not None
def test_window_slides_rather_than_resetting():
w = window(2, 60)
w.record("k", now=0)
w.record("k", now=30)
assert w.retry_after("k", now=31) is not None
# The 0s hit falls out at t=60, which frees exactly one slot — the 30s hit is
# still inside the window, so this is a slide and not a reset.
assert w.retry_after("k", now=61) is None
w.record("k", now=61)
assert w.retry_after("k", now=62) is not None
def test_retry_after_points_past_the_oldest_hit():
w = window(1, 100)
w.record("k", now=10)
wait = w.retry_after("k", now=40)
# The hit at t=10 leaves the window at t=110, i.e. 70s away. Rounded up, never
# under-reported — a client that waits exactly this long must not be refused
# again.
assert wait is not None
assert 70 <= wait <= 72
assert w.retry_after("k", now=40 + wait) is None
def test_keys_are_counted_separately():
w = window(1, 60)
w.record("a", now=0)
assert w.retry_after("a", now=1) is not None
assert w.retry_after("b", now=1) is None
def test_forget_clears_one_key():
w = window(1, 60)
w.record("a", now=0)
w.record("b", now=0)
w.forget("a")
assert w.retry_after("a", now=1) is None
assert w.retry_after("b", now=1) is not None
def test_bucket_count_is_bounded(monkeypatch):
# An attacker rotating a forged X-Forwarded-For must not be able to grow this
# dict without limit — the limiter cannot become the exhaustion it prevents.
monkeypatch.setattr(ratelimit, "MAX_BUCKETS", 8)
w = window(5, 60)
for i in range(50):
w.record(f"addr-{i}", now=i)
assert len(w._hits) <= 8
async def test_login_starts_refusing(app):
client = app.test_client()
body = {"email": "someone@example.com", "password": "wrong-password"}
# Pre-load the account's counter to its limit rather than posting that many
# times: every real attempt would need a database to reach the password check.
#
# On the REAL clock, not an injected one. The window is trailing, so hits stamped
# at t=0..9 are fifteen minutes stale the moment the route reads
# `time.monotonic()` and get pruned before they can refuse anything.
now = time.monotonic()
for _ in range(live("signin_limit_per_account")):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
resp = await client.post("/api/auth/login", json=body)
assert resp.status_code == 429
assert resp.headers.get("Retry-After")
# The refusal says nothing about whether that account exists.
assert "someone@example.com" not in (await resp.get_data(as_text=True))
async def test_device_login_shares_the_account_counter(app):
client = app.test_client()
now = time.monotonic()
for _ in range(live("signin_limit_per_account")):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
resp = await client.post(
"/api/auth/device-login",
json={"email": "someone@example.com", "password": "wrong-password"},
)
# Same budget as /login — otherwise guessing just moves to the route that hands
# out a long-lived bearer token.
assert resp.status_code == 429
async def test_register_is_throttled_by_address(app):
client = app.test_client()
now = time.monotonic()
for _ in range(live("register_limit_per_address")):
ratelimit.register_by_address.record("203.0.113.9", now=now)
resp = await client.post(
"/api/auth/register",
json={"email": "new@example.com", "password": "a-long-enough-password"},
# One entry, so with the default single trusted hop this IS the address the
# limiter keys on. The forged-prefix cases live in test_proxy.py.
headers={"X-Forwarded-For": "203.0.113.9"},
)
assert resp.status_code == 429
+57
View File
@@ -0,0 +1,57 @@
"""Headers every response carries once this is reachable from the internet.
Asserted on /api/health because it is the one route that needs no database and no
session — the headers are set in an after_request hook, so any response proves the
hook, and this suite has no Postgres.
"""
import pytest
from thoughtsync.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_content_security_policy_locks_scripts_to_self(app):
resp = await app.test_client().get("/api/health")
csp = resp.headers["Content-Security-Policy"]
assert "script-src 'self'" in csp
# The two that matter most if anything ever reflects user text into the page.
assert "object-src 'none'" in csp
assert "frame-ancestors 'none'" in csp
# No blanket unsafe-inline for SCRIPT — style is the only place it's conceded.
assert "script-src 'self' 'unsafe-inline'" not in csp
async def test_link_preview_images_are_still_allowed(app):
resp = await app.test_client().get("/api/health")
csp = resp.headers["Content-Security-Policy"]
# A preview renders the og:image of an arbitrary host; both schemes, because a
# LAN install is served over http.
assert "img-src" in csp
assert "https:" in csp
assert "http:" in csp
async def test_sniffing_and_referrer_are_pinned(app):
resp = await app.test_client().get("/api/health")
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
assert "camera=()" in resp.headers["Permissions-Policy"]
async def test_no_hsts_on_plain_http(app):
# A plain-HTTP LAN install must not be told to refuse the only scheme it serves.
resp = await app.test_client().get("/api/health")
assert "Strict-Transport-Security" not in resp.headers
async def test_hsts_when_a_proxy_terminated_tls(app):
resp = await app.test_client().get("/api/health", headers={"X-Forwarded-Proto": "https"})
hsts = resp.headers["Strict-Transport-Security"]
assert "max-age=" in hsts
# Scoped to this host: neither of these commits domains the app doesn't own.
assert "includeSubDomains" not in hsts
assert "preload" not in hsts