rename: the server is Inkwell — package, env vars, image, compose, export marker
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s

Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:

- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
  commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
  CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
  thoughtsync-named release assets, because the lanes that publish them are
  renamed in steps 3 and 4
- exports are written with app "inkwell"

Two deliberate exceptions, both because data rides on them:

- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
  INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
  volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
  rename are backups. Tested both ways

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:18:49 -04:00
co-authored by Claude Opus 5.5
parent f806e35d41
commit a706644455
78 changed files with 231 additions and 168 deletions
+24 -5
View File
@@ -1,4 +1,4 @@
# ThoughtSync production settings. Copy to `.env` and edit: # Inkwell production settings. Copy to `.env` and edit:
# #
# cp .env.example .env # cp .env.example .env
# #
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
# #
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing, # NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
# check which branch it actually landed on before assuming a bug. # check which branch it actually landed on before assuming a bug.
#THOUGHTSYNC_TAG=latest #INKWELL_TAG=latest
# The host port the app is published on. # The host port the app is published on.
#THOUGHTSYNC_PORT=5000 #INKWELL_PORT=5000
# Which interface to bind. The default (all interfaces) is what lets desktop # Which interface to bind. The default (all interfaces) is what lets desktop
# clients on your network reach the server. Behind a reverse proxy, set this to # clients on your network reach the server. Behind a reverse proxy, set this to
# 127.0.0.1 so only the proxy can talk to it. # 127.0.0.1 so only the proxy can talk to it.
#THOUGHTSYNC_BIND=0.0.0.0 #INKWELL_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var — # 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 # Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
@@ -47,12 +47,31 @@ POSTGRES_PASSWORD=
# How much the app says. Credential events (sign-ins, failures, throttles, new # How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with # accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`. # `docker compose logs app`.
#THOUGHTSYNC_LOG_LEVEL=INFO #INKWELL_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything # Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created. # that already exists — the volume keeps whatever the first run created.
#POSTGRES_USER=inkwell
#POSTGRES_DB=inkwell
# --- Upgrading from ThoughtSync ---------------------------------------------
#
# Inkwell was called ThoughtSync, and a deployment installed under that name has
# its data in volumes and a database named for it. Point at them instead of
# renaming anything. Leaving these unset on such a deployment starts Inkwell
# against EMPTY volumes; the old data is untouched, but you would not see it.
#
# Use the full names `docker volume ls` prints — compose prefixes them with the
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
#
# And the database identity the first start created, which a volume keeps:
#POSTGRES_USER=thoughtsync #POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync #POSTGRES_DB=thoughtsync
#
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
# names are no longer read.
# --- a note on HTTPS -------------------------------------------------------- # --- a note on HTTPS --------------------------------------------------------
# #
+5 -5
View File
@@ -54,7 +54,7 @@ permissions:
env: env:
REGISTRY: git.fabledsword.com REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync IMAGE: git.fabledsword.com/bvandeusen/inkwell
jobs: jobs:
# Should this push build an image now, or is the Android lane about to publish a # Should this push build an image now, or is the Android lane about to publish a
@@ -215,11 +215,11 @@ jobs:
# Postgres it will actually meet. # Postgres it will actually meet.
image: postgres:16-alpine image: postgres:16-alpine
env: env:
POSTGRES_USER: thoughtsync POSTGRES_USER: inkwell
POSTGRES_PASSWORD: ci_integration POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: thoughtsync_test POSTGRES_DB: inkwell_test
options: >- options: >-
--health-cmd "pg_isready -U thoughtsync" --health-cmd "pg_isready -U inkwell"
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
@@ -243,7 +243,7 @@ jobs:
test -n "$PG" test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP" test -n "$PG_IP"
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test" export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) — # 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. # no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY' /opt/venv/bin/python - "$PG_IP" <<'PY'
+3 -3
View File
@@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH # Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
# points at /app/src so the runtime imports this source tree (with static/ present), # points at /app/src so the runtime imports this source tree (with static/ present),
# not the pip-installed copy. # not the pip-installed copy.
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/ COPY --from=build-frontend /build/dist/ src/inkwell/static/
COPY alembic.ini . COPY alembic.ini .
COPY alembic/ alembic/ COPY alembic/ alembic/
@@ -41,7 +41,7 @@ COPY alembic/ alembic/
# that step never ran. An image with no clients — or with some and not others — is # that step never ran. An image with no clients — or with some and not others — is
# a supported state: the server advertises what it has and the web UI hides the # a supported state: the server advertises what it has and the web UI hides the
# rest (client_dist.py). # rest (client_dist.py).
COPY client/ src/thoughtsync/client/ COPY client/ src/inkwell/client/
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
@@ -52,4 +52,4 @@ EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly # Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention # slow/unready database from crash-looping the container. Family convention
# (rule 82): schema is built by real migrations, never metadata.create_all. # (rule 82): schema is built by real migrations, never metadata.create_all.
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+2 -2
View File
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
## Layout ## Layout
``` ```
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db) src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`) alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest) tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
@@ -28,7 +28,7 @@ Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
```sh ```sh
pip install -e ".[dev]" pip install -e ".[dev]"
alembic upgrade head alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
``` ```
Frontend (proxies `/api` to `:5000`): Frontend (proxies `/api` to `:5000`):
+2 -2
View File
@@ -5,8 +5,8 @@ script_location = %(here)s/alembic
prepend_sys_path = . src prepend_sys_path = . src
path_separator = os path_separator = os
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py). # Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
[loggers] [loggers]
+3 -3
View File
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
from thoughtsync.models import Base from inkwell.models import Base
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
config = context.config config = context.config
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
config.set_main_option( config.set_main_option(
"sqlalchemy.url", "sqlalchemy.url",
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")), os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
) )
target_metadata = Base.metadata target_metadata = Base.metadata
+1 -1
View File
@@ -1,6 +1,6 @@
//! Recurring-reminder math: where a reminder goes when it is marked done. //! Recurring-reminder math: where a reminder goes when it is marked done.
//! //!
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept //! A deliberate port of the server's `src/inkwell/notes/recurrence.py`, kept
//! behaviourally identical rather than merely similar. The same note can be //! behaviourally identical rather than merely similar. The same note can be
//! completed from the web (server code) or from the desktop and Android (this //! completed from the web (server code) or from the desktop and Android (this
//! code), and the two must land on the same instant — otherwise completing a //! code), and the two must land on the same instant — otherwise completing a
+11 -11
View File
@@ -14,15 +14,15 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: thoughtsync POSTGRES_USER: inkwell
POSTGRES_PASSWORD: thoughtsync POSTGRES_PASSWORD: inkwell
POSTGRES_DB: thoughtsync POSTGRES_DB: inkwell
volumes: volumes:
- thoughtsync-dev-db:/var/lib/postgresql/data - inkwell-dev-db:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"] test: ["CMD-SHELL", "pg_isready -U inkwell"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -34,22 +34,22 @@ services:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:thoughtsync@db:5432/thoughtsync INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:inkwell@db:5432/inkwell
PYTHONPATH: /app/src PYTHONPATH: /app/src
volumes: volumes:
- ./pyproject.toml:/app/pyproject.toml - ./pyproject.toml:/app/pyproject.toml
- ./alembic.ini:/app/alembic.ini - ./alembic.ini:/app/alembic.ini
- ./alembic:/app/alembic - ./alembic:/app/alembic
- ./src:/app/src - ./src:/app/src
- thoughtsync-dev-data:/var/thoughtsync - inkwell-dev-data:/var/inkwell
ports: ports:
- "5000:5000" - "5000:5000"
# Install deps, wait for the DB, run migrations, then serve with live reload. # Install deps, wait for the DB, run migrations, then serve with live reload.
command: > command: >
sh -c "pip install --quiet -e . && sh -c "pip install --quiet -e . &&
python -m thoughtsync.dbwait && python -m inkwell.dbwait &&
alembic upgrade head && alembic upgrade head &&
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --reload" hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --reload"
web: web:
image: node:22-alpine image: node:22-alpine
@@ -67,5 +67,5 @@ services:
command: sh -c "npm install && npm run dev -- --host 0.0.0.0" command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
volumes: volumes:
thoughtsync-dev-db: inkwell-dev-db:
thoughtsync-dev-data: inkwell-dev-data:
+27 -21
View File
@@ -1,4 +1,4 @@
# ThoughtSync — PRODUCTION stack (app + Postgres). # Inkwell — PRODUCTION stack (app + Postgres).
# #
# This is the default compose file: `docker compose up -d` runs a real deployment # This is the default compose file: `docker compose up -d` runs a real deployment
# from the published image. Development lives in docker-compose.dev.yml (hot-reload, # from the published image. Development lives in docker-compose.dev.yml (hot-reload,
@@ -10,15 +10,15 @@
# Per family rule 12 the agent does NOT start this — run it yourself. # Per family rule 12 the agent does NOT start this — run it yourself.
# #
# Upgrades: docker compose pull && docker compose up -d # Upgrades: docker compose pull && docker compose up -d
# Rollback: set THOUGHTSYNC_TAG to a commit sha in .env, then the same two commands. # Rollback: set INKWELL_TAG to a commit sha in .env, then the same two commands.
# Every push publishes an immutable :<sha> image for exactly this. # Every push publishes an immutable :<sha> image for exactly this.
# #
# Schema migrations run automatically at container start (see the Dockerfile CMD), # Schema migrations run automatically at container start (see the Dockerfile CMD),
# so an upgrade is just a pull and a restart. Take a backup first anyway: # so an upgrade is just a pull and a restart. Take a backup first anyway:
# #
# docker compose exec -T db pg_dump -U thoughtsync thoughtsync > backup.sql # docker compose exec -T db pg_dump -U inkwell inkwell > backup.sql
# #
# Attachments are files, not rows — they live in the `thoughtsync-data` volume and # Attachments are files, not rows — they live in the `inkwell-data` volume and
# a pg_dump does NOT contain them. Back up both or you'll restore notes whose images # a pg_dump does NOT contain them. Back up both or you'll restore notes whose images
# are gone. # are gone.
@@ -27,22 +27,21 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-thoughtsync} POSTGRES_USER: ${POSTGRES_USER:-inkwell}
# No default on purpose. A production compose that ships a known password is # No default on purpose. A production compose that ships a known password is
# how self-hosted databases end up in search engines; compose fails fast here # how self-hosted databases end up in search engines; compose fails fast here
# instead, with the message below. # instead, with the message below.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example}
POSTGRES_DB: ${POSTGRES_DB:-thoughtsync} POSTGRES_DB: ${POSTGRES_DB:-inkwell}
volumes: volumes:
# Volume names kept from the previous compose file so an existing deployment # Which volume this resolves to is set at the bottom of this file, and an
# upgrades in place. Renaming them would silently start against an empty # existing deployment overrides it from .env rather than renaming anything.
# database while the old one sat there, orphaned and looking like data loss. - inkwell-db:/var/lib/postgresql/data
- thoughtsync-db:/var/lib/postgresql/data
# Deliberately NOT published to the host. The app reaches Postgres over the # Deliberately NOT published to the host. The app reaches Postgres over the
# compose network; exposing 5432 only widens the attack surface. If you need # compose network; exposing 5432 only widens the attack surface. If you need
# psql, `docker compose exec db psql -U thoughtsync` gets you there without it. # psql, `docker compose exec db psql -U inkwell` gets you there without it.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-thoughtsync} -d ${POSTGRES_DB:-thoughtsync}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-inkwell} -d ${POSTGRES_DB:-inkwell}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -56,9 +55,9 @@ services:
max-file: "3" max-file: "3"
app: app:
# :latest tracks `main`. Set THOUGHTSYNC_TAG=dev in .env to follow the # :latest tracks `main`. Set INKWELL_TAG=dev in .env to follow the
# development line instead, or a commit sha to pin exactly. # development line instead, or a commit sha to pin exactly.
image: git.fabledsword.com/bvandeusen/thoughtsync:${THOUGHTSYNC_TAG:-latest} image: git.fabledsword.com/bvandeusen/inkwell:${INKWELL_TAG:-latest}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -66,11 +65,11 @@ services:
environment: environment:
# The only required application setting. Everything else a person might want # The only required application setting. Everything else a person might want
# to tune lives in the admin Settings UI, backed by the database (rule 25). # to tune lives in the admin Settings UI, backed by the database (rule 25).
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-thoughtsync}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-thoughtsync} INKWELL_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-inkwell}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-inkwell}
volumes: volumes:
# Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR), # Uploaded attachments. /var/inkwell is fixed in the app (Config.DATA_DIR),
# not configurable — mount it or lose every image on container recreation. # not configurable — mount it or lose every image on container recreation.
- thoughtsync-data:/var/thoughtsync - inkwell-data:/var/inkwell
# WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is # WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is
# different for each — the default serves the first. # different for each — the default serves the first.
# #
@@ -86,14 +85,14 @@ services:
# and publishing one is a second, unauthenticated way in that bypasses the # and publishing one is a second, unauthenticated way in that bypasses the
# proxy — including whatever the proxy is doing about TLS and auth. # 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 # 3. Reverse proxy on the HOST (not in Docker). Set INKWELL_BIND=127.0.0.1 in
# .env, so the port exists but only the host itself can reach it. # .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 # 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 # means the app is reachable directly on port 5000, past everything your proxy
# does. # does.
ports: ports:
- "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000" - "${INKWELL_BIND:-0.0.0.0}:${INKWELL_PORT:-5000}:5000"
healthcheck: healthcheck:
# python rather than curl: the runtime image is python:3.12-slim and carries no # python rather than curl: the runtime image is python:3.12-slim and carries no
# HTTP client binary. Hits the app's own /api/health. # HTTP client binary. Hits the app's own /api/health.
@@ -113,5 +112,12 @@ services:
logging: *logging logging: *logging
volumes: volumes:
thoughtsync-db: # Named explicitly so the name is one a person can type, and overridable so a
thoughtsync-data: # deployment that predates the rename to Inkwell keeps the volumes it already has
# (see "Upgrading from ThoughtSync" in .env.example). Pointing these at the wrong
# name does not fail: it starts against an EMPTY database and the old one sits
# there untouched, which looks exactly like data loss.
inkwell-db:
name: ${INKWELL_DB_VOLUME:-inkwell-db}
inkwell-data:
name: ${INKWELL_DATA_VOLUME:-inkwell-data}
+1 -1
View File
@@ -20,7 +20,7 @@ an older release than the desktop app for months. So the wire protocol is
versioned **separately from either program's release version**, and each side versioned **separately from either program's release version**, and each side
declares two numbers: what it speaks, and the oldest counterpart it accepts. declares two numbers: what it speaks, and the oldest counterpart it accepts.
| | server (`src/thoughtsync/sync.py`) | client (`desktop/src-tauri/src/sync/compat.rs`) | | | server (`src/inkwell/sync.py`) | client (`desktop/src-tauri/src/sync/compat.rs`) |
|---|---|---| |---|---|---|
| speaks | `SYNC_PROTOCOL_VERSION` | `CLIENT_PROTOCOL_VERSION` | | speaks | `SYNC_PROTOCOL_VERSION` | `CLIENT_PROTOCOL_VERSION` |
| accepts down to | `MIN_CLIENT_PROTOCOL_VERSION` | `MIN_SERVER_PROTOCOL_VERSION` | | accepts down to | `MIN_CLIENT_PROTOCOL_VERSION` | `MIN_SERVER_PROTOCOL_VERSION` |
+1 -1
View File
@@ -75,7 +75,7 @@ export function parseInline(text: string): InlineToken[] {
} }
// A checklist item: the third implementation of one grammar, alongside // A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference // core/src/local/derive.rs and src/inkwell/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304). // between any two of them is a checklist that changes shape when it syncs (M304).
// //
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`. // `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
+10 -10
View File
@@ -37,7 +37,7 @@ case "$channel" in dev|stable) : ;; *)
esac esac
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}" SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}" REPO="${GITHUB_REPOSITORY:-bvandeusen/inkwell}"
# The channel's release tag, not its name — `dev` lives on `dev-rolling` # The channel's release tag, not its name — `dev` lives on `dev-rolling`
# (packaging/channel-tag.sh, Scribe #2184). # (packaging/channel-tag.sh, Scribe #2184).
tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")" tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")"
@@ -88,15 +88,15 @@ echo "==> Collecting the $channel clients"
# Its sidecar is published whole by the Android lane — an APK keeps its version in # Its sidecar is published whole by the Android lane — an APK keeps its version in
# a binary AXML manifest, so the values are recorded where they were already known. # a binary AXML manifest, so the values are recorded where they were already known.
# Copied verbatim rather than rebuilt here. # Copied verbatim rather than rebuilt here.
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" && if fetch "$BASE/thoughtsync.apk" "$dest/inkwell.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then fetch "$BASE/thoughtsync-android.json" "$dest/inkwell-android.json"; then
echo " android $(field "$dest/thoughtsync-android.json" version_name)" echo " android $(field "$dest/inkwell-android.json" version_name)"
else else
# Both or neither. Half a pair is worse than none: the server would read a # Both or neither. Half a pair is worse than none: the server would read a
# sidecar describing an APK that is not there, or an APK it cannot state a # sidecar describing an APK that is not there, or an APK it cannot state a
# version for. # version for.
echo "::warning::No Android client on the $channel channel — this image ships without one." echo "::warning::No Android client on the $channel channel — this image ships without one."
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json" rm -f "$dest/inkwell.apk" "$dest/inkwell-android.json"
fi fi
# --- desktop ----------------------------------------------------------------- # --- desktop -----------------------------------------------------------------
@@ -132,10 +132,10 @@ bake_desktop() {
# #
# `<platform id>|<published name>|<name on disk>` # `<platform id>|<published name>|<name on disk>`
for row in \ for row in \
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \ "linux-deb|ThoughtSync_${key}_amd64.deb|inkwell.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \ "linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|inkwell.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \ "linux-appimage|ThoughtSync_${key}_amd64.AppImage|inkwell.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe" "windows|ThoughtSync_${key}_x64-setup.exe|inkwell-setup.exe"
do do
id="${row%%|*}"; rest="${row#*|}" id="${row%%|*}"; rest="${row#*|}"
remote="${rest%%|*}"; local_name="${rest#*|}" remote="${rest%%|*}"; local_name="${rest#*|}"
@@ -158,7 +158,7 @@ bake_desktop() {
fi fi
fi fi
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key" sidecar "$dest/$local_name" "$dest/inkwell-$id.json" "$name" "$key"
echo " $id $(bytes "$dest/$local_name") bytes" echo " $id $(bytes "$dest/$local_name") bytes"
done done
} }
+1 -1
View File
@@ -32,7 +32,7 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}" SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}" REPO="${GITHUB_REPOSITORY:-bvandeusen/inkwell}"
artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}" artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
+1 -1
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "thoughtsync" name = "inkwell"
version = "0.2.0" version = "0.2.0"
description = "Self-hosted personal thought-capture web app (FabledSword family)" description = "Self-hosted personal thought-capture web app (FabledSword family)"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -1,4 +1,4 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family).""" """Inkwell — self-hosted personal thought-capture web app (FabledSword family)."""
# PACKAGING METADATA, and nothing else. Not the version any running server reports. # PACKAGING METADATA, and nothing else. Not the version any running server reports.
# #
@@ -32,7 +32,7 @@ from .sync import bp as sync_bp, protocol_advertisement
# `force=False` (the default) so a host that has already configured logging keeps its # `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. # own setup; LOG_LEVEL lets an operator turn it up without a code change.
logging.basicConfig( logging.basicConfig(
level=os.environ.get("THOUGHTSYNC_LOG_LEVEL", "INFO").upper(), level=os.environ.get("INKWELL_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s", format="%(asctime)s %(levelname)s %(name)s %(message)s",
) )
@@ -38,8 +38,8 @@ wins — would mean replacing one client silently retracts the other four.
Two files, and both must be present: Two files, and both must be present:
- the artifact, at a FIXED name (`thoughtsync.deb`, not - the artifact, at a FIXED name (`inkwell.deb`, not
`ThoughtSync_2026.08.30.0307_amd64.deb`) `Inkwell_2026.08.30.0307_amd64.deb`)
- its sidecar, `{version_name, version_code, size, sha256}` - its sidecar, `{version_name, version_code, size, sha256}`
**Fixed names, and the version only in the sidecar.** A version-stamped filename **Fixed names, and the version only in the sidecar.** A version-stamped filename
@@ -53,7 +53,7 @@ an APK keeps it in a binary AXML manifest Python cannot parse without the Androi
build tools, and a `.deb` or an AppImage would each need a different unpacker. CI build tools, and a `.deb` or an AppImage would each need a different unpacker. CI
writes the sidecar where the real value is already known. writes the sidecar where the real value is already known.
The AppImage needs a THIRD file, `thoughtsync.AppImage.sig`. That is the minisign The AppImage needs a THIRD file, `inkwell.AppImage.sig`. That is the minisign
signature the desktop updater verifies before replacing the running binary, and a signature the desktop updater verifies before replacing the running binary, and a
bundle that cannot be verified cannot be offered as an update — so a missing bundle that cannot be verified cannot be offered as an update — so a missing
signature makes the AppImage absent rather than merely unsigned. signature makes the AppImage absent rather than merely unsigned.
@@ -132,31 +132,31 @@ PLATFORMS: tuple[Platform, ...] = (
# exact names, CI bakes them in under them, and clients in the field poll # exact names, CI bakes them in under them, and clients in the field poll
# `/api/client/android`. Renaming them to match the pattern below would # `/api/client/android`. Renaming them to match the pattern below would
# buy tidiness and strand every installed phone. # buy tidiness and strand every installed phone.
artifact="thoughtsync.apk", artifact="inkwell.apk",
sidecar="thoughtsync-android.json", sidecar="inkwell-android.json",
mimetype="application/vnd.android.package-archive", mimetype="application/vnd.android.package-archive",
), ),
Platform( Platform(
id="linux-deb", id="linux-deb",
label="Debian / Ubuntu", label="Debian / Ubuntu",
artifact="thoughtsync.deb", artifact="inkwell.deb",
sidecar="thoughtsync-linux-deb.json", sidecar="inkwell-linux-deb.json",
mimetype="application/vnd.debian.binary-package", mimetype="application/vnd.debian.binary-package",
code_is_int=False, code_is_int=False,
), ),
Platform( Platform(
id="linux-pacman", id="linux-pacman",
label="Arch / CachyOS", label="Arch / CachyOS",
artifact="thoughtsync.pkg.tar.zst", artifact="inkwell.pkg.tar.zst",
sidecar="thoughtsync-linux-pacman.json", sidecar="inkwell-linux-pacman.json",
mimetype="application/zstd", mimetype="application/zstd",
code_is_int=False, code_is_int=False,
), ),
Platform( Platform(
id="linux-appimage", id="linux-appimage",
label="Other Linux (AppImage)", label="Other Linux (AppImage)",
artifact="thoughtsync.AppImage", artifact="inkwell.AppImage",
sidecar="thoughtsync-linux-appimage.json", sidecar="inkwell-linux-appimage.json",
# No registered type for an AppImage, and guessing one buys nothing: it is # No registered type for an AppImage, and guessing one buys nothing: it is
# served as an attachment either way, and octet-stream is the answer that # served as an attachment either way, and octet-stream is the answer that
# cannot be wrong. # cannot be wrong.
@@ -167,8 +167,8 @@ PLATFORMS: tuple[Platform, ...] = (
Platform( Platform(
id="windows", id="windows",
label="Windows", label="Windows",
artifact="thoughtsync-setup.exe", artifact="inkwell-setup.exe",
sidecar="thoughtsync-windows.json", sidecar="inkwell-windows.json",
mimetype="application/vnd.microsoft.portable-executable", mimetype="application/vnd.microsoft.portable-executable",
code_is_int=False, code_is_int=False,
), ),
@@ -7,26 +7,26 @@ from pathlib import Path
class Config: class Config:
"""Bootstrap configuration. """Bootstrap configuration.
For a basic install, ``THOUGHTSYNC_DATABASE_URL`` is the ONLY required env var — For a basic install, ``INKWELL_DATABASE_URL`` is the ONLY required env var —
every other tunable lives in the DB-backed Settings UI (rule 25). The only other every other tunable lives in the DB-backed Settings UI (rule 25). The only other
env var is an optional "break-glass" item: env var is an optional "break-glass" item:
- ``THOUGHTSYNC_SECRET_KEY`` — optional override for the cookie-signing secret. - ``INKWELL_SECRET_KEY`` — optional override for the cookie-signing secret.
If unset, a key is generated and persisted in the DB (see If unset, a key is generated and persisted in the DB (see
``thoughtsync.settings.load_or_create_secret_key``), so sessions survive ``inkwell.settings.load_or_create_secret_key``), so sessions survive
restarts with no volume required. restarts with no volume required.
Uploaded media lives under ``DATA_DIR`` — a fixed, authoritative path Uploaded media lives under ``DATA_DIR`` — a fixed, authoritative path
(``/var/thoughtsync``), intentionally NOT configurable (a mutable data path only (``/var/inkwell``), intentionally NOT configurable (a mutable data path only
invites breakage). Mount a volume there if you want uploads to persist across invites breakage). Mount a volume there if you want uploads to persist across
container recreation; a text-notes-only install never writes to it. container recreation; a text-notes-only install never writes to it.
""" """
DATA_DIR = "/var/thoughtsync" DATA_DIR = "/var/inkwell"
DATABASE_URL = os.environ.get( DATABASE_URL = os.environ.get(
"THOUGHTSYNC_DATABASE_URL", "INKWELL_DATABASE_URL",
"postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync", "postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell",
) )
@classmethod @classmethod
@@ -50,5 +50,5 @@ class Config:
@classmethod @classmethod
def secret_key_env(cls) -> str | None: def secret_key_env(cls) -> str | None:
"""Optional break-glass override for the cookie-signing secret.""" """Optional break-glass override for the cookie-signing secret."""
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None return os.environ.get("INKWELL_SECRET_KEY") or None
@@ -12,7 +12,7 @@ from . import Base
class Group(Base): class Group(Base):
"""A named set of users. Sharing a resource with a group grants it to every """A named set of users. Sharing a resource with a group grants it to every
member (see thoughtsync.acl.visible_to_user).""" member (see inkwell.acl.visible_to_user)."""
__tablename__ = "groups" __tablename__ = "groups"
@@ -10,7 +10,7 @@ from . import Base
class Setting(Base): class Setting(Base):
"""A single persisted key/value setting. Values are JSON-encoded text; the """A single persisted key/value setting. Values are JSON-encoded text; the
typed defaults + metadata live in the code registry (thoughtsync.settings), so typed defaults + metadata live in the code registry (inkwell.settings), so
an empty table means "all defaults" (rule 26).""" an empty table means "all defaults" (rule 26)."""
__tablename__ = "settings" __tablename__ = "settings"
@@ -6,7 +6,7 @@ small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and 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. `bp` is defined in `_bp` so every module can import it without a cycle.
External callers import from `thoughtsync.notes` (see `__all__`); those names are External callers import from `inkwell.notes` (see `__all__`); those names are
re-exported here so the package is a drop-in replacement for the old module.""" re-exported here so the package is a drop-in replacement for the old module."""
from __future__ import annotations from __future__ import annotations
@@ -237,7 +237,7 @@ async def export_notes():
).all() ).all()
payload: dict = { payload: dict = {
"app": "thoughtsync", "app": "inkwell",
"version": 1, "version": 1,
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"labels": [{"name": lb.name, "color": lb.color} for lb in all_labels], "labels": [{"name": lb.name, "color": lb.color} for lb in all_labels],
@@ -278,7 +278,7 @@ async def export_notes():
data, data,
headers={ headers={
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="thoughtsync-export-{stamp}.zip"', "Content-Disposition": f'attachment; filename="inkwell-export-{stamp}.zip"',
}, },
) )
@@ -15,7 +15,7 @@ THE GRAMMAR IS SHARED. Three implementations exist and they have to agree, becau
difference between any two of them is a checklist that changes shape when it syncs: difference between any two of them is a checklist that changes shape when it syncs:
core/src/local/derive.rs the native clients (desktop + Android) core/src/local/derive.rs the native clients (desktop + Android)
src/thoughtsync/notes/checklist.py this file, the server src/inkwell/notes/checklist.py this file, the server
frontend/src/notes/markdown.ts the browser frontend/src/notes/markdown.ts the browser
optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`, optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
@@ -1,5 +1,5 @@
"""Export/import helpers (the non-route logic). Export renders each note as Markdown; """Export/import helpers (the non-route logic). Export renders each note as Markdown;
import normalizes a ThoughtSync export OR a Google Keep Takeout zip into a common spec import normalizes an Inkwell export OR a Google Keep Takeout zip into a common spec
and materializes notes — with a decompression-size budget so an import zip bomb can't and materializes notes — with a decompression-size budget so an import zip bomb can't
exhaust memory/disk.""" exhaust memory/disk."""
from __future__ import annotations from __future__ import annotations
@@ -58,7 +58,13 @@ def _note_markdown(note: Note, labels: list) -> str:
return "\n".join(fm) + "\n" return "\n".join(fm) + "\n"
# --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip --- # --- Import: Inkwell's own export (round-trip) OR a Google Keep Takeout zip ---
# The `app` value an export's notes.json carries. "thoughtsync" is what every export
# written before the rename to Inkwell says (Scribe note 5071), and those zips are
# people's backups, so they keep importing. That is the one reason the old name is
# still read here; nothing writes it any more.
NATIVE_APP_MARKERS = ("inkwell", "thoughtsync")
# Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its # Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its
# filename when the source didn't record one (Keep usually does; be defensive). # filename when the source didn't record one (Keep usually does; be defensive).
@@ -75,7 +81,7 @@ def _usec_to_dt(usec: object) -> datetime | None:
def _native_spec(n: dict) -> dict: def _native_spec(n: dict) -> dict:
"""Normalize one note from a ThoughtSync export's notes.json into the common """Normalize one note from an Inkwell export's notes.json into the common
import spec consumed by _create_imported_note.""" import spec consumed by _create_imported_note."""
return { return {
"title": n.get("title"), "title": n.get("title"),
@@ -186,9 +192,10 @@ class _ImportBudget:
def _read_import_specs(zf: zipfile.ZipFile, budget: _ImportBudget) -> tuple[list[dict], str]: def _read_import_specs(zf: zipfile.ZipFile, budget: _ImportBudget) -> tuple[list[dict], str]:
"""Detect the archive format and return (specs, source). A ThoughtSync export """Detect the archive format and return (specs, source). An Inkwell export
is recognized by its notes.json (app == thoughtsync); otherwise each Keep-shaped is recognized by its notes.json (app in NATIVE_APP_MARKERS); otherwise each
<note>.json is imported. Returns ([], "") when nothing importable is found.""" Keep-shaped <note>.json is imported. Returns ([], "") when nothing importable
is found."""
names = zf.namelist() names = zf.namelist()
for name in names: for name in names:
if posixpath.basename(name) == "notes.json": if posixpath.basename(name) == "notes.json":
@@ -196,9 +203,9 @@ def _read_import_specs(zf: zipfile.ZipFile, budget: _ImportBudget) -> tuple[list
doc = json.loads(budget.read(zf, name)) doc = json.loads(budget.read(zf, name))
except (ValueError, KeyError): except (ValueError, KeyError):
continue continue
if isinstance(doc, dict) and doc.get("app") == "thoughtsync": if isinstance(doc, dict) and doc.get("app") in NATIVE_APP_MARKERS:
specs = [_native_spec(n) for n in (doc.get("notes") or []) if isinstance(n, dict)] specs = [_native_spec(n) for n in (doc.get("notes") or []) if isinstance(n, dict)]
return specs, "thoughtsync" return specs, "inkwell"
keep_specs: list[dict] = [] keep_specs: list[dict] = []
keep_keys = ("textContent", "listContent", "isPinned", "isArchived", "isTrashed", "userEditedTimestampUsec") keep_keys = ("textContent", "listContent", "isPinned", "isArchived", "isTrashed", "userEditedTimestampUsec")
@@ -255,7 +262,7 @@ async def _create_imported_note(
body = spec.get("body") or "" body = spec.get("body") or ""
# An imported title becomes the note's FIRST BODY LINE. # An imported title becomes the note's FIRST BODY LINE.
# #
# ThoughtSync has no title field any more (M13 step 3), but the things people # Inkwell 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. # 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 # 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. # it exactly where a name now lives, so the note comes in named the way it was.
+2 -2
View File
@@ -1,12 +1,12 @@
import pytest import pytest
from thoughtsync.config import Config from inkwell.config import Config
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolated_data_dir(tmp_path, monkeypatch): def _isolated_data_dir(tmp_path, monkeypatch):
"""Keep any media writes on an isolated temp dir rather than the fixed """Keep any media writes on an isolated temp dir rather than the fixed
/var/thoughtsync. DB-free unit tests never actually hit it (create_app takes its /var/inkwell. DB-free unit tests never actually hit it (create_app takes its
signing key from env-or-random, not a file), but this stays defensive.""" signing key from env-or-random, not a file), but this stays defensive."""
monkeypatch.setattr(Config, "DATA_DIR", str(tmp_path / "data")) monkeypatch.setattr(Config, "DATA_DIR", str(tmp_path / "data"))
yield yield
+2 -2
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from thoughtsync.acl import visible_to_user from inkwell.acl import visible_to_user
from thoughtsync.models.user import User from inkwell.models.user import User
def test_visible_to_user_builds_owner_or_shared_predicate(): def test_visible_to_user_builds_owner_or_shared_predicate():
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
@pytest.fixture @pytest.fixture
+2 -2
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
@pytest.fixture @pytest.fixture
@@ -61,7 +61,7 @@ async def test_no_version_in_the_environment_reports_unknown(monkeypatch):
happens to hold, so bumping the packaging version cannot make this pass for the happens to hold, so bumping the packaging version cannot make this pass for the
wrong reason. wrong reason.
""" """
from thoughtsync import __version__ from inkwell import __version__
monkeypatch.delenv("APP_VERSION", raising=False) monkeypatch.delenv("APP_VERSION", raising=False)
reported = await reported_version() reported = await reported_version()
+14 -10
View File
@@ -3,9 +3,9 @@ from pathlib import Path
import pytest import pytest
from thoughtsync import client_dist from inkwell import client_dist
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.client_dist import ( from inkwell.client_dist import (
APK_NAME, APK_NAME,
BY_ID, BY_ID,
MANIFEST_NAME, MANIFEST_NAME,
@@ -15,7 +15,7 @@ from thoughtsync.client_dist import (
release, release,
releases, releases,
) )
from thoughtsync.config import Config from inkwell.config import Config
# DB-free, like the rest of this suite — the test lane runs no Postgres. That is # DB-free, like the rest of this suite — the test lane runs no Postgres. That is
# why the advertisement is asserted through `advertisement()` rather than through # why the advertisement is asserted through `advertisement()` rather than through
@@ -61,7 +61,7 @@ def coded(platform_id: str, value: int):
def _empty_baked_client(tmp_path, monkeypatch): def _empty_baked_client(tmp_path, monkeypatch):
"""Point the baked-in copy at an empty directory. """Point the baked-in copy at an empty directory.
In a source checkout `src/thoughtsync/client/` does not exist, so these tests In a source checkout `src/inkwell/client/` does not exist, so these tests
would pass anyway — but only by accident of where they are run. A built image would pass anyway — but only by accident of where they are run. A built image
has real clients there, and a test that silently depends on which tree it is in has real clients there, and a test that silently depends on which tree it is in
is one that will eventually lie. is one that will eventually lie.
@@ -118,13 +118,17 @@ def test_every_platform_has_its_own_filenames():
assert not set(artifacts) & set(sidecars) assert not set(artifacts) & set(sidecars)
def test_the_android_names_are_the_ones_already_published(): def test_the_android_names_are_the_ones_the_image_build_writes():
"""Pinned because renaming them is a tidy-up that strands every installed phone. """Pinned against `packaging/fetch-clients.sh`, which writes the APK and its
sidecar under these names when it bakes them into the image. The two are a pair
with nothing checking them against each other, so a rename on one side reads as
"no Android client" on the other rather than as an error.
The Android lane publishes these exact names and CI bakes them in under them. Phones never ask for these names; they poll `/api/client/android`. The names
changed once, with the rename to Inkwell (Scribe note 5071).
""" """
assert APK_NAME == "thoughtsync.apk" assert APK_NAME == "inkwell.apk"
assert MANIFEST_NAME == "thoughtsync-android.json" assert MANIFEST_NAME == "inkwell-android.json"
# --- absence is an ordinary answer ------------------------------------------- # --- absence is an ordinary answer -------------------------------------------
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
@pytest.fixture @pytest.fixture
+17 -17
View File
@@ -22,20 +22,20 @@ import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy import select, text from sqlalchemy import select, text
from thoughtsync import ratelimit from inkwell import ratelimit
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.db import dispose_engine, session_scope from inkwell.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel from inkwell.models.label import NoteLabel
from thoughtsync.models.note import Note from inkwell.models.note import Note
from thoughtsync.models.user import User from inkwell.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags from inkwell.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings from inkwell.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked from inkwell.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title from inkwell.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview from inkwell.models.note_link_preview import NoteLinkPreview
from thoughtsync.models.note_revision import NoteRevision from inkwell.models.note_revision import NoteRevision
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot from inkwell.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls from inkwell.unfurl_queue import _unfurl_new_urls, detect_urls
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -305,7 +305,7 @@ async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner,
calls.append(url) calls.append(url)
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"} return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl) monkeypatch.setattr("inkwell.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, note.body) await _unfurl_new_urls(note.id, note.body)
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"] assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
@@ -331,7 +331,7 @@ async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, mo
# Simulate the body changing while the request was in the air. # Simulate the body changing while the request was in the air.
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None} return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl) monkeypatch.setattr("inkwell.unfurl_queue.unfurl", fake_unfurl)
note.body = "changed my mind" note.body = "changed my mind"
await db.commit() await db.commit()
@@ -352,7 +352,7 @@ async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
async def fake_unfurl(url): async def fake_unfurl(url):
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None} return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl) monkeypatch.setattr("inkwell.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, body) await _unfurl_new_urls(note.id, body)
stored = { stored = {
+3 -3
View File
@@ -2,9 +2,9 @@ import uuid
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.models.label import Label from inkwell.models.label import Label
from thoughtsync.serialize import serialize_label from inkwell.serialize import serialize_label
@pytest.fixture @pytest.fixture
+35 -8
View File
@@ -1,12 +1,15 @@
import io
import json
import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.common import coerce_bool, parse_dt from inkwell.common import coerce_bool, parse_dt
from thoughtsync.colors import NOTE_COLORS from inkwell.colors import NOTE_COLORS
from thoughtsync.models.note import Note from inkwell.models.note import Note
from thoughtsync.notes.checklist import ( from inkwell.notes.checklist import (
append_item, append_item,
parse_items, parse_items,
remove_item, remove_item,
@@ -14,13 +17,15 @@ from thoughtsync.notes.checklist import (
set_item_text, set_item_text,
strip_marker, strip_marker,
) )
from thoughtsync.unfurl_queue import detect_urls from inkwell.unfurl_queue import detect_urls
from thoughtsync.notes import ( from inkwell.notes import (
_ImportBudget,
_attachment_ext, _attachment_ext,
_header_filename, _header_filename,
_keep_spec, _keep_spec,
_native_spec, _native_spec,
_note_markdown, _note_markdown,
_read_import_specs,
_safe_filename, _safe_filename,
_slugify, _slugify,
_usec_to_dt, _usec_to_dt,
@@ -32,7 +37,8 @@ from thoughtsync.notes import (
parse_list_items, parse_list_items,
parse_tags, parse_tags,
) )
from thoughtsync.notes.tags import split_body_tags from inkwell.notes.import_export import NATIVE_APP_MARKERS
from inkwell.notes.tags import split_body_tags
@pytest.fixture @pytest.fixture
@@ -672,3 +678,24 @@ def test_native_spec_of_a_pre_m304_export_still_carries_its_items():
# items at all. # items at all.
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]} old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}] assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]
def _export_zip(app_marker: str) -> zipfile.ZipFile:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("notes.json", json.dumps({"app": app_marker, "notes": [{"body": "kept"}]}))
return zipfile.ZipFile(io.BytesIO(buf.getvalue()))
@pytest.mark.parametrize("marker", ["inkwell", "thoughtsync"])
def test_an_export_imports_under_either_name(marker):
# "thoughtsync" is what every export written before the rename to Inkwell says.
# Those zips are backups, so they have to keep restoring as native exports rather
# than falling through to the Keep importer, which would find nothing in them.
specs, source = _read_import_specs(_export_zip(marker), _ImportBudget())
assert source == "inkwell"
assert [s["body"] for s in specs] == ["kept"]
def test_new_exports_are_written_as_inkwell():
assert NATIVE_APP_MARKERS[0] == "inkwell"
+2 -2
View File
@@ -4,8 +4,8 @@ The whole security property is "a caller cannot forge their own address", and it
on counting in from the RIGHT of the header rather than the left. These are the cases 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. that tell the two apart — pure functions, no request context, no database.
""" """
from thoughtsync.proxy import forwarded_for, trusted_entry from inkwell.proxy import forwarded_for, trusted_entry
from thoughtsync.settings import live from inkwell.settings import live
PEER = "10.0.0.1" # the socket address: our own proxy, or the caller when unproxied PEER = "10.0.0.1" # the socket address: our own proxy, or the caller when unproxied
+4 -4
View File
@@ -12,10 +12,10 @@ import time
import pytest import pytest
from thoughtsync import ratelimit from inkwell import ratelimit
from thoughtsync.settings import live from inkwell.settings import live
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.ratelimit import SlidingWindow from inkwell.ratelimit import SlidingWindow
def window(limit: int, window_s: float) -> SlidingWindow: def window(limit: int, window_s: float) -> SlidingWindow:
+2 -2
View File
@@ -2,14 +2,14 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from thoughtsync.retention import ( from inkwell.retention import (
SWEEP_BATCH, SWEEP_BATCH,
SWEEP_INTERVAL_SECONDS, SWEEP_INTERVAL_SECONDS,
SWEEP_STARTUP_DELAY_SECONDS, SWEEP_STARTUP_DELAY_SECONDS,
expired_before, expired_before,
sweep_expired_trash, sweep_expired_trash,
) )
from thoughtsync.settings import REGISTRY, get_public_config, validate_updates from inkwell.settings import REGISTRY, get_public_config, validate_updates
NOW = datetime(2026, 7, 26, 12, 0, tzinfo=timezone.utc) NOW = datetime(2026, 7, 26, 12, 0, tzinfo=timezone.utc)
+2 -2
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.saved_filters import clean_params from inkwell.saved_filters import clean_params
@pytest.fixture @pytest.fixture
+1 -1
View File
@@ -1,4 +1,4 @@
from thoughtsync.security import generate_token, hash_password, hash_token, verify_password from inkwell.security import generate_token, hash_password, hash_token, verify_password
def test_password_roundtrip(): def test_password_roundtrip():
+1 -1
View File
@@ -6,7 +6,7 @@ hook, and this suite has no Postgres.
""" """
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
@pytest.fixture @pytest.fixture
+1 -1
View File
@@ -1,4 +1,4 @@
from thoughtsync.settings import REGISTRY, validate_updates from inkwell.settings import REGISTRY, validate_updates
def test_registry_has_expected_keys(): def test_registry_has_expected_keys():
+2 -2
View File
@@ -2,8 +2,8 @@ from datetime import datetime, timezone
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.sync import ( from inkwell.sync import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
MAX_LIMIT, MAX_LIMIT,
MIN_CLIENT_PROTOCOL_VERSION, MIN_CLIENT_PROTOCOL_VERSION,
+2 -2
View File
@@ -2,8 +2,8 @@ import ipaddress
import pytest import pytest
from thoughtsync.app import create_app from inkwell.app import create_app
from thoughtsync.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url from inkwell.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url
@pytest.fixture @pytest.fixture