A backup that writes itself into a folder the user picked.
Shared by bildhaft, mitreden, vorlaut-editor and wochenwerk. MIT.
A folder inside Dropbox, iCloud Drive or Nextcloud is already synced by software the user installed on purpose. Writing a file there is the whole of the cloud story: no account, no OAuth client, no token to refresh, no server of ours, and nothing that has to appear in a privacy notice beyond the sentence the user already understands — it goes in the folder you chose.
That is the entire idea. Everything in this package is plumbing under it.
Chromium on the desktop. Nowhere else.
showDirectoryPicker is absent from Safari and Firefox on every platform, and
absent from every browser on Android, Chrome included — Android has no
system picker that maps onto the API.
So this is a convenience for whoever prepares content on a laptop. It is not
the backup story for a tablet, and a product must not present it as one: keep
the ordinary download button, and show this only when Sicherung.supported.
The package holds no database, no store, and no reference to anything a
product keeps. The produce callback is the only way data enters it.
That is structural, not a promise in a comment — there is no code path here
that could read a symbol, a filename index or a cached image, because there is
nothing here to read them from. test/sicherung.test.ts asserts the public
surface as an allow-list so a new inlet cannot arrive unnoticed.
It matters because of METACOM: that collection is licensed per person, and nothing derived from a user's folder may leave the browser — not even a list of filenames. A folder inside Dropbox is somewhere else. So the rule for every consumer is one line long, and easy to hold because the inlet is one function:
Pass the audited export. Never a raw dump of a database.
import { Sicherung } from '@lautstark/sicherung';
const backup = new Sicherung({
app: 'bildhaft',
produce: () => exportEverything(), // the audited export, nothing else
});
backup.subscribe(drawStatus);
await backup.restore(); // no prompt; safe at startup
chooseButton.onclick = () => backup.choose(); // needs a user gesture
confirmButton.onclick = () => backup.confirm(); // needs a user gesture
onEveryEdit(() => backup.schedule()); // debouncedThe Ablage is the one interface in this family that crosses a machine
boundary: a calendar on a laptop writes, a board on a wall reads, and the two
are separate deployments that can be weeks apart. Everything else that crosses
such a boundary carries a version — a Sicherung has one in its envelope, an
.obz is a documented format. This did not, because it reads as "our database,
only on disk". It is a wire format, and it has one now.
const store = new Ablage({ app: 'wochenwerk', kinds: KINDS, version: 2 });
// after a read:
if (store.shape.sawNewer) say('Der Kalender ist neuer als diese Anzeige.');write stamps v on every record. shape answers what this build writes and
whether anything read so far was written by a newer one. Absent means 1, so the
folders already in the field keep reading — a household whose calendar emptied
over a missing field would be the worst possible outcome of adding one.
Reading a newer record does not refuse it. The package remembers and the
product decides: refusing to draw is a product's judgement about its own screen,
not a folder's. Raise version when the shape changes in a way an older build
would misread — not when a field is added that an older build can ignore, which
is most of them.
writeFile puts a picture or a recording beside a record, and remove takes it
along — which means listing the kind's folder to find it, because nothing writes
its name down. On a share that listing is the slow part of a delete. A product
says which kinds can hold files, and remove lists only those:
const store = new Ablage({ app: 'wochenwerk', kinds: KINDS, files: [] });Absent means every kind can, as before. A kind left out refuses writeFile.
app names two things at once: the subtree inside the chosen folder, and the
key the folder handle is remembered under. That is right for a product's own
records and wrong for anything the household owns rather than a product — a
vocabulary, say, which four products would all want to read. Asking for a second
subtree meant asking the household to pick the same folder twice, under two
names that look alike.
follows separates the two: keep your own subtree, borrow the folder somebody
already chose.
const mine = new Ablage({ app: 'bildhaft', kinds: ['sammlungen', 'saetze'] });
const shared = new Ablage({ app: 'wortschatz', kinds: ['woerterbuch'], follows: 'bildhaft' });
await mine.restore();
await shared.restore(); // idle already, and never a promptHaushalt/
bildhaft/ ← mine
wortschatz/ ← shared, and what another product would follow too
A follower cannot choose(), forget() or nest() — all three throw. They
answer "which folder", and that answer belongs to whoever asked the person for
it: a follower choosing or nesting would move the leader's store and a follower
forgetting would drop it. None is a state a caller could recover from by
reading a status afterwards, so it is loud, and it is a wiring mistake made once
at construction. Everything else — restore, read, write, watch, conflicts — works
as it does anywhere.
store.follows names the leader, and actionsFor and both panels read it, so a
follower is never offered a button that would throw.
app |
Which product. Namespaces the stored folder. |
follows |
Ablage only. Borrow that app's remembered folder; keep your own subtree. |
stem |
Filename stem. Defaults to app. |
produce |
Returns the payload. The only inlet. |
keep |
Dated copies to keep. Default 14. |
settle |
Debounce before writing, ms. Default 4000. |
bildhaft-aktuell.json always the newest
bildhaft-2026-08-23.json one per day, pruned to `keep`
Dropbox keeps versions of its own, but a folder on a plain disk does not, and the dated copies are the half that has to work without a sync client under it.
restore(), choose(), confirm(), forget() and save() all resolve to
the current Status, and subscribe() reports every change.
| kind | means |
|---|---|
unsupported |
No picker in this browser. Show the download button instead. |
off |
Supported, no folder chosen. |
needs-permission |
Set up, and writing nothing until the user clicks. |
idle |
Chosen, permitted, current. |
saving |
Writing now. |
failed |
Last attempt threw. The folder is kept. |
Every status that has a folder carries lastWrite, including the failing ones.
Use it.
A stored handle survives a restart, but the browser may want it re-confirmed,
and confirming needs a click nobody is there to give at startup. So the state
that matters is not broken — it is needs-permission: set up, looks fine,
writing nothing. A backup that silently stops is worse than no backup,
because it manufactures confidence. "Letzte Sicherung: vor 11 Tagen" is the
only line that actually tells somebody whether they are safe.
The three products each drew this panel and each wrote out the same table of
what to offer in which state. That table is a behavioural contract, and three
copies with nothing asserting they agree is the arrangement where one of them
quietly stops offering a way out of failed. It lives here now.
import { actionsFor, ago } from '@lautstark/sicherung/ui';
for (const action of actionsFor(backup, status)) {
panel.append(button(t(`folder_${action.id}`), action.primary, action.run));
}needsAttention(status) says whether a state is one the person has to act
on - needs-permission and failed, the two in which nothing is being written
and nothing resumes by itself. A panel draws those as a warning rather than as
prose; see conventions.md §3.7 in @lautstark/design.
actionsFor(backup, status) returns { id, primary, run } — id is one of
choose, confirm, retry, forget. It is a key, not a label: the
product supplies its own word for it.
| status | offers |
|---|---|
off |
choose |
needs-permission |
confirm, forget |
failed |
retry, forget |
idle |
forget |
saving, unsupported |
nothing |
ago(at, locale, now?) turns a lastWrite into "vor 3 Minuten". It builds its
formatter per call and must never cache one: a product that changes
language without reloading would go on being answered in the language the
reader has just left, and the string would stay well-formed the whole time.
ui.ts above stops at the states and the actions. That was the right place to
stop for as long as one product drew the panel; it was not, once four had.
import { backupPanel } from '@lautstark/sicherung/backup-panel';
const panel = backupPanel({
backup,
say: notify,
lang: 'de',
headline: (text) => { dataPanel.state.textContent = text; },
});
if (panel) container.append(panel.node);
// and when the container goes:
panel?.dispose();null where the browser has no picker — the ordinary download button beside it
is the whole offer there, and a tablet must not be shown a backup story it
cannot have.
It carries the words, in German and English, and the markup. That is a
departure from /ui's rule and the same one ablage-panel already made, for
the same reason: four products writing their own sentences is four chances to
disagree, and by the time they were read side by side they had. What each copy
had that the others did not:
| headline | dispose |
English quotes | |
|---|---|---|---|
| bildhaft, 211 lines | ✓ | ✓ | ✓ |
| vorlaut-editor, 170 | — | — | n/a |
| mitreden, 161 | — | — | German „ “ |
| druckwerk, 112 (deleted since) | — | — | n/a |
| wochenwerk | no panel at all |
The headline is the line a panel's own summary carries, so „Daten" stops being
the one section whose state you have to unfold it to learn — and it keeps being
written apart from looks like it is, which is the distinction the whole
module exists for. dispose is the subscription: three of the four never
unsubscribed, so every reopen of a settings dialog left another listener
painting a node nobody could see.
What stays with the product is what only it knows: which Sicherung this is,
where a spoken sentence goes, and which heading to put the state line in.
It returns no text and no DOM.
No text, because bildhaft has no t() — an argued position, not an omission:
it turns German sentences into pictograms, so an English shell would front a
program that only understands German input. A shared module that rendered
words would force on it exactly the indirection it exists to refuse.
No DOM, because the two shapes genuinely differ — bildhaft builds a node and hands it back, mitreden and vorlaut paint into markup their page already has. Product layout is identity (design.md §4.4), and a helper serving both would be a configuration object pretending to be a component.
It imports nothing at runtime. Both of its imports are types, so the built
dist/ui.js has no imports at all — the inlet rule in index.ts is held one
layer further out by the compiler rather than by a promise.
The dot's attribute takes status.kind verbatim:
line.setAttribute('data-state', status.kind);No mapping table in the product. A mapping is three chances to disagree about
what failed looks like, invisible until it matters, and @lautstark/design
styles these kinds by name. An unknown kind falls back to the grey off dot
and reads as deliberately not set up — which is why a new Status kind is a
major for the CSS as well as for this package.
Since 1.17. The vanilla exports above stay until no consumer is left; these are beside them, not instead of them.
<script>
import BackupPanel from '@lautstark/sicherung/svelte/BackupPanel';
import AblagePanel from '@lautstark/sicherung/svelte/AblagePanel';
</script>
<BackupPanel {backup} say={notify} {lang} headline={(text) => state = text} />
<AblagePanel {store} {adopt} {changed} say={notify} {lang}>
{#snippet below()}<MyOwnExportButton />{/snippet}
</AblagePanel>Same options, same emitted markup, same words — WORDS is imported from the
vanilla module rather than copied, because a second table in the same package
is the drift that module exists to have ended. @lautstark/design's
conventions.md §6.8 is the specification; what follows is every place they are
deliberately not the same.
langis a prop. The vanilla backup panel takes a thunk and reads it on every paint, because mitreden changes language without reloading. That is what a vanilla module has instead of reactivity; here the framework has it.- No
refreshand nodispose. Both panels hold their status throughsubscribein an$effectthat returns the unsubscribe. TheAblagepanel gains that where the vanilla one has nothing to dispose: a component can be unmounted while a folder is being adopted, which a node a page keeps cannot. BackupPanelrenders nothing where the browser has no picker, which is the vanilla function'snull.belowis a snippet, where the vanilla option is() => (Node | null)[].
Nothing is imported from @lautstark/design at runtime — a provider package
does not gain it as a dependency (§6.0). The panels emit the class vocabulary
and design draws it, which is what test/css-contract.test.ts holds them to,
in every state, vanilla and Svelte alike.
What a product says when the database it found was not one it can read: every record still there, untouched, and nothing may happen next until the person holding them has them in a file.
<script>
import { Rescuing } from '@lautstark/sicherung/svelte/rescuing';
const s = new Rescuing(dump.version, countRecords(dump), words, {
save: () => downloadJson(asFile(dump), 'rettung'),
discard: () => discardEverything(),
again: () => boot(),
});
s.close = sheet.close; // the host's sheet; this package has none
</script>The words are the product's, because a shared component carries no German; the
file's name is the product's too. saved is the whole enforcement of handed
their data before anything is destroyed — the discard cannot be pressed until
the download has been through.
The discard closes the sheet after the await, not before it. Both products
this comes from wrote the other order in one of their two copies, and it costs
them the failure path: with the sheet closed there is no region left to say
Fehlgeschlagen into and the boot has already restarted. This is a behaviour
change and not a port, and it is the reason discarded — the flag a host's
onClose reads to know a dismissal from a discard — is a plain field and not
the in-flight going, which is false again after a failure.
The components ship as raw .svelte and are compiled in your build. That works
because every entry declares the svelte export condition, which is what keeps
the package out of dependency pre-bundling; without it a build still succeeds
and dev mode silently compiles a second copy of every component and of the rune
module. svelte is a peer dependency, ^5.
A git tag is the release; see RELEASING.md.