Changeset 812fea9 in Klonkt


Ignore:
Timestamp:
08/06/2026 02:20:19 PM (5 weeks ago)
Author:
roboburr <roboburr@…>
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)
Message:

Elke bijlage zegt waar hij voor was (shaer-pmr, vervolg)

Gevonden bij een oefenherstel: ik vergeleek dertien kolommen, noemde het een
schone rondgang, en vergeleek daarna ALLE kolommen. Toen bleken er drie dingen
weg te vallen.

COVER de bytes zaten in het archief, maar nergens stond dat ze de

cover waren. cover_image_url kwam als null terug, dus de post
stond zonder cover.

C2S-BIJLAGE idem: het audiobestand en zijn poster zaten erin, maar

c2s_attachments werd niet hersteld, dus de speler verdween.

SUB-SECONDE de import zette tijdstempels om naar SQL-notatie en verloor de

milliseconden. Twee posts in dezelfde seconde staan dan in
willekeurige volgorde.

Elke bijlage draagt nu een shaer:role -- cover, coverVideo, inline, c2s, poster,
track -- en een poster wijst met shaer:posterFor naar de bijlage waar hij bij
hoort. De importer bouwt cover_image_url, cover_video_url en c2s_attachments
daaruit terug. Een rol is geen versiering: zonder rol heeft het archief wel de
bytes maar niet het feit dat ze de cover WAREN, en dat zie je pas als je alle
kolommen vergelijkt.

Tijdstempels gaan nu ongewijzigd de database in. Klonkt schrijft zelf in twee
spellingen; het archief normaliseert naar ISO. Het MOMENT overleeft de rondgang,
de spelling niet -- nu ook zo gedocumenteerd in plaats van stilzwijgend.

Rondgang op echte beta-data: geen verschil meer over 29 kolommen, met de tijden
op moment vergeleken in plaats van op tekst. 21 tests. Suite 514/514.

Files:
4 edited

Legend:

Unmodified
Added
Removed
  • docs/EXPORT-FORMAT.md

    r58cfe5f r812fea9  
    9999| `summary` | `posts.content_warning` | AS2 summary is the content warning |
    100100| `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. |
    102102| `updated` | `posts.updated_at` | omitted when equal to `published` |
    103103| `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` |
    105105| `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
    106106| `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
     
    170170| `missing` | it existed, we know where, we do not have it | the original absolute URL |
    171171
     172Every 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
     182The role is not decoration. Without it the archive holds the bytes but not the
     183fact that they *were the cover*, and the post comes back without one — which is
     184invisible until you compare every column, not a handful.
     185
    172186`url` for an included attachment is a **container-relative path**, not a URL. An
    173187importer must rewrite it. This is the one place where the archive deviates from
  • src/services/ArchiveExportService.js

    r58cfe5f r812fea9  
    8181  const uit = [];
    8282  const zie = new Set();
    83   const voegToe = (url, name) => {
     83  const voegToe = (url, name, rol, extra = {}) => {
    8484    const u = String(url || '').trim();
    8585    if (!u || zie.has(u)) return;
    8686    zie.add(u);
    87     uit.push({ url: u, name: name || null });
     87    uit.push({ url: u, name: name || null, rol, ...extra });
    8888  };
    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');
    9297  try {
    9398    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');
    95100      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
    96101      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
    97102      // 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 });
    99104    }
    100105  } catch { /* kapotte kolom blokkeert de export niet */ }
     
    103108    try {
    104109      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');
    106111    } catch { /* geen audio-tabellen: niets te doen */ }
    107112  }
     
    251256          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
    252257          'shaer:sha256': hash,
     258          'shaer:role': ref.rol,
     259          'shaer:posterFor': ref.posterFor || undefined,
    253260        });
    254261      } else {
     
    261268          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
    262269          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
     270          'shaer:role': ref.rol,
     271          'shaer:posterFor': ref.posterFor || undefined,
    263272        });
    264273      }
  • src/services/ArchiveImportService.js

    r58cfe5f r812fea9  
    2828
    2929const 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.
     33const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
    3134
    3235// ── Inlezen ───────────────────────────────────────────────────────
     
    220223    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
    221224     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)
    223226    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
    224227     @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')`);
    226229  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
    227230    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
     
    242245      const o = s.obj;
    243246      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);
    244260      insPost.run({
    245261        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
    246262        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
    247263        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,
    249267        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
    250268        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']),
    253271        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
    254272        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
  • test/archive-import.test.js

    r58cfe5f r812fea9  
    292292});
    293293
     294test('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
     325test('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
    294338test('zonder manifest is het geen archief', () => {
    295339  const files = new Map(ARCHIEF.files);
Note: See TracChangeset for help on using the changeset viewer.