Changeset 812fea9 in Klonkt
- Timestamp:
- 08/06/2026 02:20:19 PM (5 weeks ago)
- Branches:
- main
- Children:
- 7aa3140
- Parents:
- 58cfe5f
- git-author:
- Robin <roboburr@…> (08/06/2026 02:20:09 PM)
- git-committer:
- roboburr <roboburr@…> (08/06/2026 02:20:19 PM)
- Files:
-
- 4 edited
-
docs/EXPORT-FORMAT.md (modified) (2 diffs)
-
src/services/ArchiveExportService.js (modified) (4 diffs)
-
src/services/ArchiveImportService.js (modified) (3 diffs)
-
test/archive-import.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
docs/EXPORT-FORMAT.md
r58cfe5f r812fea9 99 99 | `summary` | `posts.content_warning` | AS2 summary is the content warning | 100 100 | `sensitive` | `posts.nsfw` | | 101 | `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC |101 | `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC. Klonkt stores timestamps in two spellings (`YYYY-MM-DD HH:MM:SS` and full ISO); the archive normalises to ISO. The **instant** survives a round trip, the spelling does not. | 102 102 | `updated` | `posts.updated_at` | omitted when equal to `published` | 103 103 | `url` | `<origin>/<slug>` | the human permalink | 104 | `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media) |104 | `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media); each carries a `shaer:role` | 105 105 | `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` | 106 106 | `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` | … … 170 170 | `missing` | it existed, we know where, we do not have it | the original absolute URL | 171 171 172 Every attachment also carries **`shaer:role`**, saying what it was for: 173 174 | role | restored to | 175 |---|---| 176 | `cover` / `coverVideo` | `posts.cover_image_url` / `cover_video_url` | 177 | `inline` | already referenced from `content` | 178 | `c2s` | an entry in `posts.c2s_attachments` | 179 | `poster` | the `poster` of the `c2s` entry named in `shaer:posterFor` | 180 | `track` | the file behind a `[[track:id]]`, linked from `shaer:audio` | 181 182 The role is not decoration. Without it the archive holds the bytes but not the 183 fact that they *were the cover*, and the post comes back without one — which is 184 invisible until you compare every column, not a handful. 185 172 186 `url` for an included attachment is a **container-relative path**, not a URL. An 173 187 importer must rewrite it. This is the one place where the archive deviates from -
src/services/ArchiveExportService.js
r58cfe5f r812fea9 81 81 const uit = []; 82 82 const zie = new Set(); 83 const voegToe = (url, name ) => {83 const voegToe = (url, name, rol, extra = {}) => { 84 84 const u = String(url || '').trim(); 85 85 if (!u || zie.has(u)) return; 86 86 zie.add(u); 87 uit.push({ url: u, name: name || null });87 uit.push({ url: u, name: name || null, rol, ...extra }); 88 88 }; 89 voegToe(post.cover_image_url, post.cover_alt); 90 voegToe(post.cover_video_url, post.cover_alt); 91 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1]); 89 // De ROL is niet decoratief. Zonder rol staat er in het archief wel een 90 // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan 91 // komt de post na een herstel zonder cover en zonder speler terug. Gevonden 92 // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een 93 // handjevol. 94 voegToe(post.cover_image_url, post.cover_alt, 'cover'); 95 voegToe(post.cover_video_url, post.cover_alt, 'coverVideo'); 96 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline'); 92 97 try { 93 98 for (const a of JSON.parse(post.c2s_attachments || '[]')) { 94 voegToe(a && a.url, a && a.name );99 voegToe(a && a.url, a && a.name, 'c2s'); 95 100 // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die 96 101 // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat 97 102 // pas op bij de export van echte data. 98 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null );103 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url }); 99 104 } 100 105 } catch { /* kapotte kolom blokkeert de export niet */ } … … 103 108 try { 104 109 const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]); 105 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title );110 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track'); 106 111 } catch { /* geen audio-tabellen: niets te doen */ } 107 112 } … … 251 256 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`, 252 257 'shaer:sha256': hash, 258 'shaer:role': ref.rol, 259 'shaer:posterFor': ref.posterFor || undefined, 253 260 }); 254 261 } else { … … 261 268 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined, 262 269 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig, 270 'shaer:role': ref.rol, 271 'shaer:posterFor': ref.posterFor || undefined, 263 272 }); 264 273 } -
src/services/ArchiveImportService.js
r58cfe5f r812fea9 28 28 29 29 const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); 30 const sqlTijd = (iso) => { const t = Date.parse(iso); return isNaN(t) ? null : new Date(t).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); }; 30 // De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de 31 // sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige 32 // volgorde. Klonkt schrijft zelf ook ISO in deze kolommen. 33 const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null); 31 34 32 35 // ── Inlezen ─────────────────────────────────────────────────────── … … 220 223 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url, 221 224 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language, 222 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, origin_server)225 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server) 223 226 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url, 224 227 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language, 225 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, 'import')`);228 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`); 226 229 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions 227 230 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at) … … 242 245 const o = s.obj; 243 246 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null)); 247 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het 248 // bestand er wel, maar komt de post zonder cover en zonder speler terug -- 249 // en dat zie je pas als je alle kolommen vergelijkt. 250 const bijlagen = Array.isArray(o.attachment) ? o.attachment : []; 251 const padVan = (a) => (a ? padVanOrigineel(a['shaer:originalUrl']) : null); 252 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r); 253 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => { 254 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a)); 255 return { 256 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined, 257 poster: poster ? padVan(poster) : undefined, 258 }; 259 }).filter((a) => a.url); 244 260 insPost.run({ 245 261 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id, 246 262 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null, 247 263 status: o['shaer:status'] || 'draft', 248 cover_image_url: null, cover_alt: o['shaer:coverAlt'] || null, cover_video_url: null, 264 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null, 265 cover_video_url: padVan(metRol('coverVideo')), 266 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null, 249 267 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post', 250 268 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null, 251 published_at: sqlTijd(o.published), created_at: sqlTijd(o.published), updated_at: sqlTijd(o.updated || o.published),252 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: sqlTijd(o['shaer:publishAt']),269 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published), 270 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']), 253 271 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0, 254 272 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null, -
test/archive-import.test.js
r58cfe5f r812fea9 292 292 }); 293 293 294 test('de cover en de speler komen terug, niet alleen hun bestanden', () => { 295 // Gevonden bij het oefenherstel, door ALLE kolommen te vergelijken in plaats 296 // van een handjevol: de bytes zaten in het archief, maar nergens stond dat ze 297 // de cover waren. De post kwam zonder cover en zonder speler terug. 298 leeg(); 299 fs.mkdirSync(path.join(MEDIA, 'c'), { recursive: true }); 300 for (const [n, b] of [['cov.jpg', 'cover'], ['op.m4a', 'audio'], ['op.png', 'poster']]) { 301 fs.writeFileSync(path.join(MEDIA, 'c', n), Buffer.from(b)); 302 } 303 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, cover_image_url, cover_alt, c2s_attachments) 304 VALUES ('rijk','s1','rijke-post','u1','Rijk','<p>x</p>','published','2026-08-05 10:00:00', 305 '/media/c/cov.jpg','de cover', ?)`) 306 .run(JSON.stringify([{ url: '/media/c/op.m4a', mediaType: 'audio/mp4', name: 'opname.m4a', poster: '/media/c/op.png' }])); 307 308 const arch = AX.buildArchive('me', { exportedAt: 'X' }); 309 const rollen = JSON.parse(arch.files.get('posts/rijk.json').toString()).attachment.map((a) => a['shaer:role']); 310 assert.deepEqual(rollen.sort(), ['c2s', 'cover', 'poster'], 'elke bijlage zegt waar hij voor was'); 311 312 db.prepare('DELETE FROM posts').run(); 313 fs.rmSync(path.join(MEDIA, 'c'), { recursive: true, force: true }); 314 AI.importArchive(arch.files, { slug: 'me' }); 315 316 const p = db.prepare("SELECT * FROM posts WHERE id = 'rijk'").get(); 317 assert.equal(p.cover_image_url, '/media/c/cov.jpg', 'zonder dit staat de post zonder cover terug'); 318 assert.equal(p.cover_alt, 'de cover'); 319 const c2s = JSON.parse(p.c2s_attachments); 320 assert.equal(c2s[0].url, '/media/c/op.m4a'); 321 assert.equal(c2s[0].poster, '/media/c/op.png', 'de poster hoort weer bij zijn opname'); 322 assert.ok(fs.existsSync(path.join(MEDIA, 'c', 'cov.jpg'))); 323 }); 324 325 test('het MOMENT van een tijdstempel overleeft, ook uit SQL-notatie', () => { 326 // Klonkt schrijft in twee spellingen. Het archief normaliseert naar ISO; het 327 // moment blijft, de spelling niet. Dat is gedocumenteerd gedrag, geen verlies. 328 leeg(); 329 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at) 330 VALUES ('tijd','s1','tijd','u1','T','<p>x</p>','published','2026-07-01 12:56:10')`).run(); 331 const arch = AX.buildArchive('me', { exportedAt: 'X' }); 332 db.prepare('DELETE FROM posts').run(); 333 AI.importArchive(arch.files, { slug: 'me' }); 334 const p = db.prepare("SELECT published_at FROM posts WHERE id = 'tijd'").get(); 335 assert.equal(Date.parse(p.published_at), Date.parse('2026-07-01T12:56:10Z')); 336 }); 337 294 338 test('zonder manifest is het geen archief', () => { 295 339 const files = new Map(ARCHIEF.files);
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)