← All posts

Plainva 0.6.7: the anchor that changed every time

August 20, 2026

Every reconnect handed out a new account id. That is the whole bug, and it took a vault full of duplicated tasks to see it.

One question, asked too late

Plainva mirrors tasks from Google, Microsoft and CalDAV into notes. Each of those notes carries an anchor in its frontmatter saying which remote task it stands for, so the next sync recognises it instead of creating it again. The anchor held three things, and one of them was the local account id — the identifier Plainva assigns when you connect an account.

Reconnect that same account and it gets a new one. Nothing else about the account changes; the id does. From that moment no anchor matched its task any more.

That alone would have been survivable, because the reconciler has a second way to recognise a task: a state row recording what was synced last. But that row is keyed the same way, and it is not the file — it lives in the index. So the reconciler’s single question, “is there a state row for this task?”, answered no after a reconnect, no on a second device that never had one, and no after any index rebuild. Three quite different situations, one answer, one consequence: import it again.

The fix is in two halves. The anchor now carries what survives a reconnect — the provider plus the verified account identity — and keeps writing the old field so an older build can still read it, but never compares it. And before creating anything, the reconciler asks your notes: one query across the index rather than reading every note once per task. If a note already holds that task, it is adopted.

Where several notes claim the same task — and a vault that has been through a few reconnects does — one is adopted and the rest are counted, not deleted. Which of your duplicates is the real one is not a question a sync should answer on its own. The order is deliberately not alphabetical, by the way: Steuern einreichen 2.md sorts before Steuern einreichen.md, so the empty copy would beat the note with the work in it.

Deleting, in both directions

Until now, deleting a task note left the task at the provider. That was defensible while the anchor was unreliable, and indefensible once it was not.

A confirmed deletion now takes the task with it, after eight seconds, with Undo in the notice. It reuses the queue built for undoing a sent mail — with the opposite rule at the end of a session. Mail flushes, because the safe outcome of an interrupted send is that the message goes. Here it cancels, because the safe outcome of an interrupted deletion is that the task still exists.

A merely missing file still deletes nothing. A file can go missing for reasons that have nothing to do with intent — a half-finished sync, a folder moved outside the app — and none of them are a reason to delete something at a provider.

Both of these are desktop features. The phone can create a task at a provider but does not mirror them, so nothing changes there. It is written down in the parity catalogue rather than left implied.

Two things surfaced while building it. IPimTarget.deleteTask did not exist — all three providers could create and update tasks, none could delete one. And a sync cycle inside the eight-second window would have written a tombstone that outlives the undo, which would have left the restored note an orphan forever.

Twenty tasks, twenty clicks

A database could only ever do one row. Twenty tasks to mark done meant twenty clicks; ten entries to delete meant ten questions.

The click was already taken on both shells — the inline editor on the desktop, opening the note on the phone — so selection needed a surface of its own. It got a checkbox column that appears on hover, and the press-and-hold sheet whose first named entry is now “Select multiple”.

Deleting many was nearly free; both chains already took arrays. One value for many rows is the actual build: bounded concurrency, progress, cancel, and an honest partial-failure report. Tags, lists, multi-select and relations are deliberately excluded — there, “set all to X” means every existing value disappears, which is not bulk editing but data loss behind one click.

Shift-click for a range is desktop-only on purpose. A finger has no modifier key, and a two-tap sequence — “from here to there” — would be a gesture that exists nowhere else in the app.

The folder that left with the account

The cloud folder was a field of the credentials. No secret, but stored in the keychain slot — so removing an account took it along. The newly connected account found nothing to adopt, fell back to its built-in default, and created that folder. The vault then synced into a fresh, empty remote while the original sat untouched beside it.

The one way back — setting the folder again — was itself broken twice over: the field is read-only, and the picker asked for a refresh token that an account deliberately does not have any more. Three locks, one cause.

Nothing was lost, and that is provable rather than hopeful: the pull side holds deletions back as soon as more than ten and more than 20% of known files go missing from a listing.

The folder now lives in the per-vault settings and survives the removal of an account, a newly created folder says so, and the picker asks the same broker the sync uses. Verifying that turned up something nobody had reported: OneDrive and Dropbox created their root folder silently on both shells. The hook that announces a created folder existed only on one target type.

Signing in, on the phone

A Google calendar account said “sign-in expired” immediately after signing in, and no re-sign-in helped.

The phone asked the shared account token first, always. The fresh grant had been written to the service slot; the account slot was read. And Google cannot widen a grant on refresh — the token comes back with exactly the scopes the consent gave it. The desktop has had a guard against precisely this since 30 July, with a comment describing the symptom word for word. It now lives in one place both shells call.

The way out was walled off too: the offer to unify an account asked whether an account token exists, so it disappeared exactly when it was needed. It now asks whether the token carries the services.

Dates, and one thing worth knowing

{{date:dddd, D. MMMM YYYY}} wrote “Friday, 31. July 2026” into a German vault. Templates render dates in the app’s language now, and {{daily+1:Tomorrow}} can carry a label.

The choice is a separate function rather than a parameter, and that is the interesting part. formatMoment has always carried a note saying it is deliberately not localised, with a sound reason: the file name of a daily note must match the format it was created with, or yesterday’s notes stop being found. But it has two callers, and for the second one — a date written into the body of a note — the reason does not hold. A locale argument is something a caller forgets, and forgetting it in the file-name path is the one mistake with lasting consequences.

So: dddd and MMMM produce different words than before, A/a becomes “vorm./nachm.” in German, and the ww/w week numbers follow the locale — the ISO variants WW/W do not move. The file name stays English. Notes you have already written are untouched; text is text.

The rest

Attachments open, folders import. Thanks to @davi-jorge-art for a report whose error message named four problems at once — .md appended to an .mp3, “Error creating” instead of opening, %20 left encoded, a path guard firing on a legitimate ../. It is one bug: nothing resolved the path. The correct resolver had existed since July and lived in the reading view only; the editor sent every non-http markdown link down the wiki path, whose miss branch correctly offers to create a missing note. Both views ask the same rule now. Separately, the importer’s folder mode never worked, for any source — the wizard checked that you had picked a folder and displayed its path, and nothing ever read it.

Three quieter repairs on the phone. Importing settings writes a journal before it changes anything, as a file rather than a preference, because being cleared from the background is the normal case on a phone and a preference written asynchronously would not be there at the moment of the kill. A crash no longer costs the keystrokes you typed while a save was in flight. And deleting a vault takes its secrets with it — the service slots of every account, the drafts, the journals, the master-key cache.

Under the hood: h2 0.4.16 closes RUSTSEC-2026-0258, reachable through reqwest in the sync path.

Download

Plainva 0.6.7 is on the releases page for Windows, macOS and Linux; the desktop app updates itself. Android testers get it through the internal track, iOS through TestFlight.