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 8s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Two things, one of which I got wrong in a place that outlives the session. **The claim.** Migration 0027's docstring said "The Google Keep import is genuine content on this instance, not fixtures." That is not true and I had no basis for it. Note 2916's headline is the opposite — "there is no work that anyone has done that isn't test data" — and its clause about imports is CONDITIONAL: text arriving from another app would be real, and any import path has to treat it that way. I read a rule about how import code must behave as a fact about what is in the database, then repeated it in a migration that will be read long after anyone remembers this week. The operator has never run the importer. They did not know it existed. Nothing about the migration changes. Content-preserving was cheap and is right for anything that rewrites somebody's text — and it is what the rule will demand the day an import does happen. Only the reason recorded in the file was wrong, and a wrong reason in a migration is how a later decision gets made on a false premise. **The delete.** Removing the FIRST checklist row asked to focus `index - 1`, which is -1, so nothing took focus and the keyboard stayed up over a list with no cursor in it. It now focuses whichever row takes the deleted one's place, which also does the right thing when the deleted row was the only one — `withoutIndex` leaves a fresh empty block behind, and that block is what gets the caret. Found by reading the path the operator said they were about to test, rather than by waiting for them to find it.
129 lines
5.9 KiB
Python
129 lines
5.9 KiB
Python
"""fold note_items into the note body and drop the table
|
|
|
|
Revision ID: 0027
|
|
Revises: 0026
|
|
Create Date: 2026-08-24
|
|
|
|
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
|
|
goes. The reason is positional, not cosmetic: a row had a position in a table and no
|
|
position in the text, so a separate list could only ever render AFTER the prose. With
|
|
the items in the body, a list can sit between two paragraphs — which is the thing that
|
|
could not be built before and no amount of restyling would have delivered.
|
|
|
|
## This migration rewrites note bodies
|
|
|
|
Every note that has items gets its body appended to. The rules below are strict
|
|
because rewriting somebody's text deserves it — not, as an earlier draft of this
|
|
docstring claimed, because this instance holds imported Google Keep notes. It does
|
|
not; note 2916's headline is that nothing here is anyone's work but the operator's
|
|
test data. What 2916 actually says about imports is conditional — text arriving from
|
|
another app WOULD be real, and any import path has to treat it that way — and the
|
|
importer this migration shares a format with is one nobody here has run.
|
|
|
|
Careful was still the right call. It cost little, and the same care is what the rule
|
|
demands the day someone does import something:
|
|
|
|
* Rows are read BEFORE the table is dropped, in this one transaction.
|
|
* The existing body is never rewritten, only appended to.
|
|
* The layout — a blank line between prose and the list, nothing between consecutive
|
|
items — is byte-for-byte what `_note_markdown` has always exported and what
|
|
`derive::append_item` produces on every client. All three landing on the same text
|
|
is what lets the clients migrate their own SQLite stores independently and still
|
|
agree with the server, with no sync required to reconcile them.
|
|
|
|
## The fold is inlined on purpose
|
|
|
|
`notes/checklist.py` has this same function and this migration deliberately does not
|
|
import it. A migration has to keep producing what it produced the day it ran; if the
|
|
app's spacing rule ever changes, this file must not change with it.
|
|
|
|
## `updated_at` is left alone, and that is load-bearing
|
|
|
|
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
|
|
more than the first. Every client folds the same rows the same way, so the new body is
|
|
news to nobody. And a client holding an UNPUSHED body edit still has the newer
|
|
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
|
|
of the migration silently winning.
|
|
|
|
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
|
|
migrated note becomes pullable once. That is wanted: it is what makes a client whose
|
|
local fold somehow differed converge on the server's text.
|
|
|
|
## The downgrade is not a true inverse, and says so
|
|
|
|
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
|
|
every item is still there as text, which is where this migration put it — but the old
|
|
code would show those notes as prose with no checklist. A faithful inverse is not
|
|
possible: once the items are lines, nothing distinguishes a line this migration wrote
|
|
from one somebody typed, and a downgrade that guessed would eat hand-written task
|
|
lists. The real rollback is a database restore.
|
|
|
|
Recreating the table is not decoration, though. Migration 0015's downgrade runs
|
|
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
|
|
covers the trigger, not the table — against a missing table that statement errors. So
|
|
this is what keeps the migration chain runnable all the way back down.
|
|
"""
|
|
import re
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
|
|
revision = "0027"
|
|
down_revision = "0026"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
_TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$")
|
|
|
|
|
|
def _append_item(body: str, text: str, checked: bool) -> str:
|
|
mark = "x" if checked else " "
|
|
text = (text or "").strip()
|
|
line = f"- [{mark}] {text}" if text else f"- [{mark}]"
|
|
trimmed = (body or "").rstrip("\n")
|
|
if not trimmed.strip():
|
|
return line
|
|
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
|
|
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
|
|
|
|
|
|
def upgrade():
|
|
bind = op.get_bind()
|
|
rows = bind.execute(
|
|
sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at")
|
|
).fetchall()
|
|
|
|
grouped: dict = {}
|
|
for note_id, text, checked in rows:
|
|
grouped.setdefault(note_id, []).append((text, bool(checked)))
|
|
|
|
for note_id, items in grouped.items():
|
|
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
|
|
# An item whose note is already gone has nothing to fold into. The foreign key
|
|
# should make this impossible; skipping costs nothing and failing here would
|
|
# leave the database half-migrated.
|
|
if body is None:
|
|
continue
|
|
for text, checked in items:
|
|
body = _append_item(body, text, checked)
|
|
bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id})
|
|
|
|
op.drop_table("note_items")
|
|
|
|
|
|
def downgrade():
|
|
# Column-for-column as migration 0006 created it, index name included: 0015's
|
|
# downgrade names both the table and its trigger, so a near-enough copy is not
|
|
# good enough.
|
|
op.create_table(
|
|
"note_items",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("text", sa.Text(), nullable=False),
|
|
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
|
)
|
|
op.create_index("ix_note_items_note", "note_items", ["note_id"])
|