Changeset 0688b5f in Klonkt


Ignore:
Timestamp:
07/01/2026 07:06:45 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
1c2dcba
Parents:
d18c60e
Message:

feat(fediverse): post language → AS2 contentMap

A post can now carry a language; it federates as an AS2 contentMap (a BCP-47-keyed
copy of the content, alongside plain content) so Mastodon's timeline language filter
and translate button work. Editor gets a language picker (defaults to the author's
UI language).

  • src/config/database.js — posts.language column.
  • src/services/ActivityPubService.js — buildNote emits contentMap { <lang>: content } when the post has a valid BCP-47 language.
  • src/routes/posts.js — capture/validate/store language on create/save (default = the author's current language) and pass it to the federation hooks.
  • src/services/Scheduler.js — carry language when a scheduled post goes live.
  • src/views/pages/post-edit.ejs — language <select> (18 common languages).
  • src/services/i18n.js — pedit.f_language + pedit.language_hint (nl/en/de).
  • test/activitypub-as2.test.js — allow contentMap/nameMap/summaryMap; don't treat a language-map's keys as vocab terms; kitchen-sink post now sets a language.
  • test/post-language.test.js — contentMap shape, no-language, invalid-code = ignored.
  • CHANGELOG(.nl/.de).md — "Set a post's language" under Unreleased.

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

Files:
1 added
10 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    rd18c60e r0688b5f  
    77
    88### Hinzugefügt
     9- **Sprache eines Beitrags festlegen.** Wähle, in welcher Sprache du einen Beitrag geschrieben hast —
     10  im Fediverse funktionieren damit der Sprachfilter der Timeline und die Übersetzen-Schaltfläche.
    911- **Alt-Text für Bilder.** Gib deinem Titelbild eine Beschreibung (Inline-Bilder behalten ihren
    1012  eigenen Alt-Text) — sie föderiert ins Fediverse und lässt Screenreader das Bild beschreiben.
  • CHANGELOG.md

    rd18c60e r0688b5f  
    77
    88### Added
     9- **Set a post's language.** Choose the language you wrote a post in — on the fediverse it enables
     10  timeline language filtering and the translate button.
    911- **Alt text for images.** Give your cover image a description (and inline images keep their own
    1012  alt text) — it federates to the fediverse and lets screen readers describe the picture.
  • CHANGELOG.nl.md

    rd18c60e r0688b5f  
    77
    88### Toegevoegd
     9- **Stel de taal van een post in.** Kies in welke taal je een post schreef — op de fediverse
     10  werkt daarmee het taalfilter van de tijdlijn en de vertaal-knop.
    911- **Alt-tekst voor afbeeldingen.** Geef je cover een beschrijving (en inline-afbeeldingen behouden
    1012  hun eigen alt-tekst) — die federeert mee naar de fediverse en laat schermlezers de afbeelding beschrijven.
  • src/config/database.js

    rd18c60e r0688b5f  
    9090  ensureColumn('posts', 'cover_video_url', 'TEXT');        // muted loop MP4 for an animated cover (Safari-smooth)
    9191  ensureColumn('posts', 'cover_alt', 'TEXT');              // alt text / description for the cover (a11y → AS2 attachment `name`)
     92  ensureColumn('posts', 'language', 'TEXT');               // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
    9293  ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
    9394  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
  • src/routes/posts.js

    rd18c60e r0688b5f  
    247247  const cw = (req.body.content_warning || '').trim().slice(0, 200);
    248248  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
     249  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
    249250
    250251  // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
     
    285286    INSERT INTO posts (
    286287      id, site_id, slug, author_id, title, content, excerpt,
    287       status, cover_image_url, cover_video_url, cover_alt, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
     288      status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
    288289      created_at, updated_at, published_at
    289     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     290    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    290291  `).run(
    291292    postId, site.id, finalSlug, req.session.user.id,
    292293    title || finalSlug, cleanContent, excerpt || '',
    293     finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
     294    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
    294295    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    295296    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
     
    313314      ActivityPubService.deliverCreate(site, {
    314315        id: postId, slug: finalSlug, title: title || finalSlug,
    315         content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
     316        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
    316317        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    317318      }).catch(() => { /* best-effort */ });
     
    381382  const cw = (req.body.content_warning || '').trim().slice(0, 200);
    382383  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
     384  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
    383385  const newSlug = req.body.slug;
    384386  const action = req.body.action || 'save';
     
    424426    UPDATE posts SET
    425427      title = ?, content = ?, excerpt = ?, status = ?,
    426       cover_image_url = ?, cover_video_url = ?, cover_alt = ?, pinned = ?, tags = ?,
     428      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
    427429      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
    428430      slug = ?, published_at = ?, updated_at = ?
     
    430432  `).run(
    431433    title, cleanContent, excerpt, finalStatus,
    432     cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
     434    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
    433435    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    434436    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
     
    456458    const apPost = {
    457459      id: post.id, slug: finalSlug, title: title || finalSlug,
    458       content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
     460      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
    459461      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    460462    };
  • src/services/ActivityPubService.js

    rd18c60e r0688b5f  
    400400  // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
    401401  if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
     402  // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
     403  // language from its key for the timeline language filter + the translate button. Emitted
     404  // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
     405  if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
    402406  // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
    403407  // reuses the note's content/addressing/tags, then swaps the type and strips media.
  • src/services/Scheduler.js

    rd18c60e r0688b5f  
    1515  try {
    1616    const due = db.prepare(`
    17       SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
     17      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.language, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
    1818             p.published_at, p.publish_at, p.created_at, u.username
    1919      FROM posts p JOIN users u ON u.id = p.author_id
     
    3838          ActivityPubService.deliverCreate(site, {
    3939            id: p.id, slug: p.slug, title: p.title || p.slug,
    40             content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt,
     40            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt, language: p.language,
    4141            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
    4242          }).catch(() => { /* best-effort */ });
  • src/services/i18n.js

    rd18c60e r0688b5f  
    639639    'pedit.f_cover_alt': 'Alt-tekst (beschrijving)',
    640640    'pedit.cover_alt_placeholder': 'Beschrijf de afbeelding voor schermlezers',
     641    'pedit.f_language': 'Taal',
     642    'pedit.language_hint': 'voor het fediverse-taalfilter',
    641643    'pedit.cover_upload_btn': 'Upload nieuwe cover',
    642644    'pedit.s_content': 'Content',
     
    15591561    'pedit.f_cover_alt': 'Alt text (description)',
    15601562    'pedit.cover_alt_placeholder': 'Describe the image for screen readers',
     1563    'pedit.f_language': 'Language',
     1564    'pedit.language_hint': 'for the fediverse language filter',
    15611565    'pedit.cover_upload_btn': 'Upload new cover',
    15621566    'pedit.s_content': 'Content',
     
    24792483    'pedit.f_cover_alt': 'Alt-Text (Beschreibung)',
    24802484    'pedit.cover_alt_placeholder': 'Beschreibe das Bild für Screenreader',
     2485    'pedit.f_language': 'Sprache',
     2486    'pedit.language_hint': 'für den Fediverse-Sprachfilter',
    24812487    'pedit.cover_upload_btn': 'Neues Titelbild hochladen',
    24822488    'pedit.s_content': 'Inhalt',
  • src/views/pages/post-edit.ejs

    rd18c60e r0688b5f  
    8787        <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
    8888        <small class="pe-hint" style="opacity:.7"><%- t('pedit.excerpt_hint') %></small>
     89      </label>
     90      <% var _postLang = (typeof post.language !== 'undefined' && post.language) ? post.language : (typeof lang !== 'undefined' ? lang : 'en'); %>
     91      <label class="pe-field">
     92        <span><%= t('pedit.f_language') %> <small><%= t('pedit.language_hint') %></small></span>
     93        <select name="language" id="pe-language" style="max-width:220px">
     94          <% [['en','English'],['nl','Nederlands'],['de','Deutsch'],['fr','Français'],['es','Español'],['it','Italiano'],['pt','Português'],['pl','Polski'],['ru','Русский'],['uk','Українська'],['sv','Svenska'],['da','Dansk'],['nb','Norsk'],['fi','Suomi'],['tr','Türkçe'],['ja','日本語'],['zh','中文'],['ar','العربية']].forEach(function (l) { %>
     95            <option value="<%= l[0] %>" <%= l[0] === _postLang ? 'selected' : '' %>><%= l[1] %></option>
     96          <% }); %>
     97        </select>
    8998      </label>
    9099    </section>
  • test/activitypub-as2.test.js

    rd18c60e r0688b5f  
    2727  'published', 'updated', 'attributedTo', 'inReplyTo', 'replies',
    2828  'attachment', 'tag', 'icon', 'image', 'duration',
     29  'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary
    2930  'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
    3031  'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
     
    4748      keys.add(k);
    4849      if (k === 'type' && typeof v === 'string') keys.add(v);
     50      if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms
    4951      collect(v, keys);
    5052    }
     
    7072  id: 'p1', slug: 'hello', title: 'Hi', content: '<p>hello #music</p>',
    7173  nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']),
    72   cover_image_url: '/media/c.webp',
     74  cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en',
    7375  published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
    7476};
Note: See TracChangeset for help on using the changeset viewer.