Skip to content
LautstarkPublic

About

Keeps a browser-held backup written to a folder the user picked, so a cleared cache is not the end of the work.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

sicherung

A backup that writes itself into a folder the user picked.

Shared by bildhaft, mitreden, vorlaut-editor and wochenwerk. MIT.

Why a folder and not a cloud account

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.

Where it works

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.

What it cannot do

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.

Use

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());  // debounced

The shape a record was written in

The 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.

Which kinds hold files

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.

A compartment several products share

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 prompt
Haushalt/
  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.

Options

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.

What lands in the folder

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.

Status

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.

Say how old the last copy is

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.

Drawing the panel — @lautstark/sicherung/ui

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.

Drawing the backup panel — @lautstark/sicherung/backup-panel

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.

What this module deliberately is not

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 one rule that is not code

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.

The same panels in Svelte — @lautstark/sicherung/svelte/*

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.

  • lang is 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 refresh and no dispose. Both panels hold their status through subscribe in an $effect that returns the unsubscribe. The Ablage panel 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.
  • BackupPanel renders nothing where the browser has no picker, which is the vanilla function's null.
  • below is 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.

The rescue sheet — ./svelte/Rescue, ./svelte/RescueBody, ./svelte/RescueFoot

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.

Tooling, if you are consuming these

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.

Releasing

A git tag is the release; see RELEASING.md.

About

Keeps a browser-held backup written to a folder the user picked, so a cleared cache is not the end of the work.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages