source: Klonkt/docs/EXPORT-FORMAT.md@ 58cfe5f

main
Last change on this file since 58cfe5f was 58cfe5f, checked in by roboburr <roboburr@…>, 5 weeks ago

De importer (shaer-pmr)

ArchiveImportService leest het formaat terug, plus een CLI
(scripts/import-archive.mjs) met droogloop en --overwrite. De vier eisen uit het
document staan expliciet in de code:

VERSIE EERST een hogere onbekende formatVersion wordt in zijn GEHEEL

geweigerd; half lezen ziet eruit alsof het gelukt is

IDENTITEIT gelijke origin -> AP-ids behouden, anders nieuwe ids met een

waarschuwing. De importer stelt dat zelf vast; een verkeerd
antwoord publiceert objecten onder een id dat je niet beheert

NIETS STILS ontbrekende media worden geteld en gemeld, en een archief dat

zijn eigen bestand mist is een APARTE melding -- dat is een
kapot archief, geen verdwenen media

GEEN UITZENDING geen Update de fediverse in

Botsingsregel, gedocumenteerd en niet per post gegokt: een bestaande post met
hetzelfde id of dezelfde slug wordt OVERGESLAGEN. Dat maakt de import idempotent
en zorgt dat je nooit per ongeluk vernietigt wat er al staat. --overwrite doet
het wel, expliciet.

Media gaan terug naar hun oorspronkelijke pad onder /media, want de content van
de post wijst daarheen; dat pad is site-relatief en werkt dus ook op een ander
domein. Checksum wordt gecontroleerd voordat er iets wordt weggeschreven, en een
pad buiten MEDIA_ROOT nooit. Gehoste audio krijgt zijn media- en audio_tracks-rij
terug, anders valt [[track:]] op niets terug -- daarvoor draagt shaer:audio nu
een shaer:media-verwijzing, die in het formaat ontbrak.

readable/ wordt nooit gelezen, en daar is een test voor: vul het met onzin en er
verandert niets.

De zip-lezer kent ook deflate. Een lezer die alleen onze eigen store-only zip
aankan is geen uitwisselformaat maar een prive-doosje.

DERDE VONDST UIT ECHTE DATA vandaag: bij de rondgang op beta bleek poll_json.closed
weg te vallen. Een poll die je vroegtijdig sluit met een einddatum in de toekomst
stond na een herstel weer open. AS2 kent daar "closed" voor op een Question; nu
beide kanten op.

Rondgang op echte beta-data: 4 posts, 3 media, 1 antwoordenarchief, ids behouden,
en na de fix geen enkel kolomverschil meer. 19 tests. Suite 512/512.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 12.8 KB
Line 
1# Klonkt Content Archive, format version 1
2
3A portable archive of **your own content**: posts, their media, and — as a
4read-only historical record — the replies other people wrote underneath them.
5It can be exported from one Klonkt and imported into another, and it stays
6readable when Klonkt is gone.
7
8This is the format specification. It is written before the exporter and the
9importer exist, on purpose: without a written format each of them writes down
10its own assumptions, and then there are two sources of truth instead of one.
11
12## What this is not
13
14| | |
15|---|---|
16| **Storage backup** (`shaer-190t`) | A byte-level zip of the storage directory, including password hashes, sessions and the per-actor private RSA keys. A backup for Klonkt-to-Klonkt restore. Never hand it to anyone. |
17| **GDPR data export** (`shaer-0j2`) | Personal data for inspection. Different scope, different audience. |
18| **This format** | Your content, portable, importable, reproducible, and free of every credential. |
19
20They may share serialisation code. They do not share a purpose, and they must
21not quietly become the same file.
22
23## Container layout
24
25```
26manifest.json format version, origin, counts, checksums
27posts/<post-id>.json ONE post — canonical, the only thing import reads
28replies/<post-id>.json replies received under that post — read-only archive
29media/<sha256>.<ext> the bytes, content-addressed
30readable/<slug>.md rendered for humans — derived, NEVER read on import
31```
32
33Delivered as a zip. `<post-id>` is Klonkt's own post id, which is also the tail
34of the ActivityPub object id (`<origin>/ap/notes/<post-id>`). Using it as the
35filename means the identity is visible in the tree and no character escaping is
36needed. The slug lives inside the file, where it can change without renaming
37anything.
38
39## manifest.json
40
41```json
42{
43 "formatVersion": 1,
44 "generator": "klonkt/1.6.0",
45 "exportedAt": "2026-08-06T13:00:00Z",
46 "origin": "https://boiert.eu",
47 "actor": "https://boiert.eu/ap/users/boiert",
48 "site": { "slug": "boiert", "title": "Boiert" },
49 "counts": { "posts": 412, "replies": 1180, "media": 233, "mediaMissing": 7 },
50 "files": { "posts/abc123.json": "<sha256>", "media/<sha256>.jpg": "<sha256>" }
51}
52```
53
54`origin` is the field the importer cannot work without. It decides whether the
55ActivityPub ids may be kept (see [Import rules](#import-rules)). Everything else
56in the manifest is verification and reporting.
57
58`exportedAt` lives **only** here. It must not appear in any post file, or two
59exports of unchanged content would differ.
60
61## posts/&lt;post-id&gt;.json
62
63An ActivityStreams 2.0 object carrying the post **as authored**, with Klonkt's
64own fields in the `shaer:` namespace.
65
66### It is not what we federate, and that is deliberate
67
68`buildNote()` produces a *projection* for the fediverse, and that projection is
69lossy by design:
70
71- the title is baked into the content as a bold first line, because Mastodon
72 ignores a Note's `name`;
73- a paid post federates a **teaser only**, never the body;
74- `<img>` is stripped from the content and moved into `attachment`, because
75 Mastodon drops inline images;
76- when a post carries an external embed the image attachments are suppressed, so
77 the link card wins.
78
79Exporting that projection would mean archiving a paid post as its teaser. So the
80archive carries the authoring truth instead, and the differences are:
81
82| | federated (`buildNote`) | archive |
83|---|---|---|
84| type | always `Note` | `Article` when the post has a title, else `Note` |
85| title | inside `content` | `name` |
86| paid post body | teaser | full content |
87| inline images | stripped from `content` | left in `content`, and also listed in `attachment` |
88
89### Fields
90
91| AS2 / `shaer:` | source | notes |
92|---|---|---|
93| `id` | `<origin>/ap/notes/<posts.id>` | the federation identity; see import rules |
94| `type` | `posts.title` present? | `Article` / `Note` |
95| `attributedTo` | site actor | |
96| `name` | `posts.title` | plain text, not HTML |
97| `content` | `posts.content` | authored HTML, shorthand intact |
98| `contentMap` | `posts.language` | omitted when the post has no language |
99| `summary` | `posts.content_warning` | AS2 summary is the content warning |
100| `sensitive` | `posts.nsfw` | |
101| `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC |
102| `updated` | `posts.updated_at` | omitted when equal to `published` |
103| `url` | `<origin>/<slug>` | the human permalink |
104| `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media) |
105| `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
106| `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
107| `quoteUrl`, `shaer:quoteActor` | `posts.quote_uri`, `quote_actor` | FEP-044f |
108| `shaer:slug` | `posts.slug` | |
109| `shaer:status` | `posts.status` | `draft` posts are yours too and are exported |
110| `shaer:excerpt` | `posts.excerpt` | |
111| `shaer:type` | `posts.type` | |
112| `shaer:pinned`, `shaer:noindex`, `shaer:fanOnly` | idem | |
113| `shaer:paid`, `shaer:paidMinCents` | idem | **see the warning below** |
114| `shaer:apVisibility` | `posts.ap_visibility` | |
115| `shaer:publishAt` | `posts.publish_at` | a scheduled post keeps its schedule |
116| `shaer:coverAlt` | `posts.cover_alt` | |
117| `shaer:viewCount` | `posts.view_count` | optional; a restore without it loses history |
118| `shaer:audio` | `audio_tracks` rows referenced from the content | title, artist, duration, credit, license, external links, `shaer:ref` (the `[[track:id]]` it belongs to) and `shaer:media` (which attachment holds its file) |
119
120**Deliberately not exported:** `yjs_binary` (collaborative editor state — large,
121regenerable, and meaningless elsewhere), `content_rendered` (derived from
122`content`; exporting it would create a second truth that can drift),
123`origin_server`, and every id that belongs to a foreign server.
124
125> **Warning — a paid post exports in the clear.** The archive contains the full
126> body of paywalled posts, because an archive that silently drops your own
127> content is worse than useless. An export of a site with paid posts must be
128> handled like the content itself.
129
130### Klonkt shorthand inside `content`
131
132`content` may contain Klonkt shorthand that other software will not understand:
133
134```
135[[track:<id>]] a hosted audio track → see shaer:audio
136[[album:<id>]] an album of tracks
137[[playlist:<id>]] a playlist
138[[embed:<url>]] an external player (Spotify, YouTube, SoundCloud, …)
139```
140
141They are left in place, because resolving them at export would destroy the
142authored source. A foreign consumer should either strip them or resolve them
143using `shaer:audio`. `readable/<slug>.md` has them rendered.
144
145## Media
146
147Media are content-addressed by the SHA-256 of the bytes, so two exports of the
148same content produce the same filenames and duplicates collapse on their own.
149
150An attachment has three possible states, and the format must be able to say
151which one applies. Two are not enough: at Boiert we will know that a picture
152existed and where it lived, without having the bytes. An empty attachment list
153would be a lie, and a reference to a missing file would be a broken archive.
154
155```json
156{
157 "type": "Image",
158 "mediaType": "image/jpeg",
159 "name": "alt text",
160 "url": "media/9f86d0….jpg",
161 "shaer:availability": "included",
162 "shaer:originalUrl": "https://boiert.eu/media/2026/cover.jpg",
163 "shaer:sha256": "9f86d0…"
164}
165```
166
167| `shaer:availability` | meaning | `url` |
168|---|---|---|
169| `included` | the bytes are in `media/` | container-relative path |
170| `missing` | it existed, we know where, we do not have it | the original absolute URL |
171
172`url` for an included attachment is a **container-relative path**, not a URL. An
173importer must rewrite it. This is the one place where the archive deviates from
174strict AS2, and it is the price of carrying bytes at all.
175
176An importer must **count and report** every `missing` attachment. It must never
177silently drop one: a post whose picture has vanished should say so.
178
179## replies/&lt;post-id&gt;.json
180
181An AS2 `OrderedCollection` of the replies received under that post, as received.
182
183```json
184{
185 "type": "OrderedCollection",
186 "shaer:archive": true,
187 "shaer:inReplyTo": "https://boiert.eu/ap/notes/abc123",
188 "orderedItems": [ { "id": "https://mstdn.social/users/x/statuses/1", "…": "…" } ]
189}
190```
191
192This is **other people's content**. Three rules follow, and none is optional:
193
1941. It is never re-delivered, produces no notifications and no push. It is
195 history, not traffic.
1962. Their ids belong to their servers. We carry them as they are and never claim
197 them.
1983. Because of this section an export is not purely "your data". That is exactly
199 why this format and the GDPR export (`shaer-0j2`) have different scopes and
200 must not silently become the same file.
201
202`shaer:archive: true` is what an importer keys on. An importer that does not
203understand this section must skip it, never read it as own content.
204
205## readable/&lt;slug&gt;.md
206
207Markdown with YAML front matter, rendered from the canonical file: title, date,
208content with the shorthand resolved, images as links into `media/`.
209
210It exists so the archive survives Klonkt itself. It is **derived** and travels
211in one direction only — the importer never reads it. This is not tidiness: an
212archive whose Markdown and JSON are both read is an archive with two sources of
213truth, and they will drift.
214
215## Versioning
216
217`formatVersion` is an integer in `manifest.json`.
218
219- Adding a property does **not** bump it. Readers must ignore properties they do
220 not know.
221- Changing or removing the meaning of a property bumps it.
222- An importer that meets a `formatVersion` **higher than it knows must refuse the
223 whole archive** and say so. It must never read it partially: a half-understood
224 restore is worse than no restore, because it looks like it worked.
225
226## Import rules
227
228**Identity.** Compare `manifest.origin` with the importing site's own origin.
229
230- Equal → **keep the ActivityPub ids**. This is what makes a restore a restore:
231 the boosts, likes and replies that already point at those ids find their post
232 again.
233- Different → **mint new ids**, and keep the original `url` as a historical
234 reference. Keeping foreign ids would mean publishing objects under an id you
235 do not control; remote servers resolve such an id at its original host, and it
236 is a forgery surface besides.
237
238The importer determines this itself. It is not a question for the user, because
239a wrong answer cannot be taken back.
240
241**Consequences of keeping ids.** The post id is part of the identity, so the
242importer must be able to *set* the primary key rather than let it be generated.
243That is a different write path from creating a post, and it must not be reachable
244outside the import.
245
246**No `Update` broadcast.** If the restored content differs from what other
247servers cached long ago, their copy stays stale. Sending `Update` activities
248would fix that, but it is a broadcast to the whole fediverse and must never be a
249side effect of an import. Separate, deliberate action.
250
251**Dry run.** The importer offers a mode that reports what would happen —
252including the missing-media count — before anything is written.
253
254**Idempotent.** Importing the same archive twice produces no duplicates. On a
255collision with an existing id the importer follows one documented rule (skip,
256overwrite, or refuse and report); it never guesses per post.
257
258## Reproducibility
259
260Two exports of unchanged content must be **byte-identical except for
261`manifest.exportedAt`**, which is by definition the moment of export. Everything
262else — every post file, every media file, and the `files` checksum map — must
263match. That is what makes an archive verifiable and a diff meaningful: if the
264checksum map is equal, nothing changed.
265
266- JSON: keys sorted, two-space indent, LF, trailing newline.
267- Posts ordered by `published`, then by `id` for ties.
268- No timestamps inside post files; `exportedAt` lives only in the manifest.
269- Zip entries in sorted path order, with a fixed modification time.
270- Media names derive from the bytes, so they never depend on export order.
271
272## Worked example: the hardest post
273
274The format is tested against the most demanding post Klonkt can produce, not the
275easiest: a **poll** with **attachments**, a **quote card**, **custom emoji**, a
276**content warning**, and **paid** access.
277
278- poll → `type: "Question"` with `oneOf`/`anyOf` and `endTime`; the votes are
279 federation state, not content, and are not exported;
280- attachments → `attachment[]`, each with its own availability;
281- quote → `quoteUrl` + `shaer:quoteActor` (FEP-044f);
282- custom emoji → `tag[]` entries of `toot:Emoji`, whose icons are media like any
283 other and are content-addressed the same way;
284- content warning → `summary` + `sensitive: true`;
285- paid → full `content` plus `shaer:paid`, with the warning above.
286
287A post that survives this round trip unchanged is the acceptance test for
288slices 2 and 3.
Note: See TracBrowser for help on using the repository browser.