From 3a9d00a2c6f54d0e5d91b11f9243ebdffb569fa2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 04:39:10 +0000 Subject: [PATCH] A show's Broadcast section no longer claims a Since the feed cannot know A feed is a window on a show, the last fifty or hundred episodes, and the oldest item the directory holds says when that window starts, not when the show began. Live, a podcast running for years read "Since: 2026-06", which is exactly the false claim OpenBroadcast forbids a platform to make. The key is now left to the owner to write; the query that fed it goes with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S7yeJUHGxA4P5N74xnsRPQ --- README.md | 3 ++- apps/web/src/lib/authorProfile.js | 4 +--- apps/web/src/lib/openprofile.js | 23 +++++++---------------- apps/web/test/openprofile.test.js | 14 +++++--------- packages/db/src/profiles.js | 25 ------------------------- packages/db/test/profiles.test.js | 6 +----- 6 files changed, 16 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index c44110b..d0d982c 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ curl -X POST https://rssamplifier.com/api/discover \ Every author page carries `` to `/authors/{slug}/openprofile.md`, the person as one portable file: identity block, Accounts (their `rel="me"` links), Topics (their feeds' subjects), and a Broadcast section ([OpenBroadcast](https://logicsrc.com/openbroadcast)) for every -podcast or show they publish, with only the facts their own feed states. The file never fills in +podcast or show they publish, with only the facts their own feed states (not `Since`: a feed is a +window on a show, and the oldest episode the directory holds is not when the show began). The file never fills in what the person did not say: no `Seeking`, `Pays`, `Charges`, no Guest section, and no email even when the API republishes one. diff --git a/apps/web/src/lib/authorProfile.js b/apps/web/src/lib/authorProfile.js index e1e5791..c8d22c0 100644 --- a/apps/web/src/lib/authorProfile.js +++ b/apps/web/src/lib/authorProfile.js @@ -25,10 +25,9 @@ export async function loadAuthorProfile(slug) { const feeds = person.feeds ?? []; const feedIds = feeds.map((f) => String(f.id)); - const [profile, topicsByFeed, firstByFeed] = await Promise.all([ + const [profile, topicsByFeed] = await Promise.all([ profiles.profileForAuthor(client, String(person.id)), profiles.keywordsForFeeds(client, feedIds), - profiles.firstPublishedAt(client, feedIds), ]); const base = siteUrl(); @@ -36,7 +35,6 @@ export async function loadAuthorProfile(slug) { person, feeds, topicsByFeed, - firstByFeed, base, overrides: profile?.overrides ?? null, }); diff --git a/apps/web/src/lib/openprofile.js b/apps/web/src/lib/openprofile.js index 96d6bbb..054dd94 100644 --- a/apps/web/src/lib/openprofile.js +++ b/apps/web/src/lib/openprofile.js @@ -20,6 +20,12 @@ import { * Guest section: those are the person's to write, through the overlay, and * absent means unstated. * + * `Since` is deliberately not written either. A feed is a window on a show, + * the last fifty or hundred episodes, and the oldest item the directory holds + * says when that window starts, not when the show did. A podcast running since + * 2019 would read "Since: 2026-06", which is exactly the false claim the spec + * forbids; the owner can write the year themselves. + * * Email is deliberately not in the generated file even when the table has it. * The API republishes an address the author published; the profile is a * document other directories will copy, and the owner adds `Email` to it @@ -68,18 +74,6 @@ function label(network) { return names[network] ?? network.charAt(0).toUpperCase() + network.slice(1); } -/** - * `YYYY-MM` from an ISO stamp, or null when it does not parse. - * - * @param {string|null|undefined} iso - * @returns {string|null} - */ -export function yearMonth(iso) { - if (!iso) return null; - const m = /^(\d{4})-(\d{2})/.exec(String(iso)); - return m ? `${m[1]}-${m[2]}` : null; -} - /** * The generated document, before the owner's corrections. * @@ -89,12 +83,11 @@ export function yearMonth(iso) { * feeds: Array<{ id: string, slug: string, title: string, kind?: string|null, role?: string|null, * feed_url?: string|null, site_url?: string|null, language?: string|null }>, * topicsByFeed?: Map, - * firstByFeed?: Map, * base: string, * }} input * @returns {import('@profullstack/openprofile').OpenProfileDoc} */ -export function generateAuthorProfile({ person, feeds, topicsByFeed, firstByFeed, base }) { +export function generateAuthorProfile({ person, feeds, topicsByFeed, base }) { const links = (person.links ?? []).filter((l) => l.network !== 'email'); const handleLink = HANDLE_NETWORKS.map((n) => links.find((l) => l.network === n && l.handle)).find( Boolean, @@ -136,7 +129,6 @@ export function generateAuthorProfile({ person, feeds, topicsByFeed, firstByFeed Show: f.title, Kind: BROADCAST_KINDS.get(String(f.kind)) ?? null, Language: f.language ?? null, - Since: yearMonth(firstByFeed?.get(String(f.id))), Feed: f.feed_url ?? null, Listen: f.site_url ?? `${base}/${encodeURIComponent(String(f.slug))}`, Topics: (topicsByFeed?.get(String(f.id)) ?? []).slice(0, 8).join(', ') || null, @@ -150,7 +142,6 @@ export function generateAuthorProfile({ person, feeds, topicsByFeed, firstByFeed Show: shows[0].title, Kind: BROADCAST_KINDS.get(String(shows[0].kind)) ?? null, Language: shows[0].language ?? null, - Since: yearMonth(firstByFeed?.get(String(shows[0].id))), Feed: shows[0].feed_url ?? null, Listen: shows[0].site_url ?? `${base}/${encodeURIComponent(String(shows[0].slug))}`, Topics: (topicsByFeed?.get(String(shows[0].id)) ?? []).slice(0, 8).join(', ') || null, diff --git a/apps/web/test/openprofile.test.js b/apps/web/test/openprofile.test.js index d38e09d..26bed62 100644 --- a/apps/web/test/openprofile.test.js +++ b/apps/web/test/openprofile.test.js @@ -9,7 +9,6 @@ import { overridesFromBody, overridesFromForm, profileUrl, - yearMonth, } from '../src/lib/openprofile.js'; import { claimVerdict, isOwner, linksBack } from '../src/lib/profileAuth.js'; import { principalFromToken } from '../src/lib/openaccess.js'; @@ -57,10 +56,9 @@ const TOPICS = new Map([ ['f1', ['history', 'mathematics']], ['f2', ['mathematics', 'writing']], ]); -const FIRST = new Map([['f1', '2024-11-15T00:00:00.000Z']]); test('the generated file says what the author published, and nothing they did not', () => { - const doc = generateAuthorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE }); + const doc = generateAuthorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, base: BASE }); assert.equal(doc.name, 'Ada Lovelace'); const identity = Object.fromEntries(doc.identity.map((e) => [e.key, e.value])); @@ -84,12 +82,12 @@ test('the generated file says what the author published, and nothing they did no Show: 'The Analytical Engine', Kind: 'podcast', Language: 'en', - Since: '2024-11', Feed: 'https://ada.example/podcast/feed.xml', Listen: 'https://ada.example/podcast', Topics: 'history, mathematics', }); assert.equal('Seeking' in shows[0], false); + assert.equal('Since' in shows[0], false, 'the feed window is not when the show started'); assert.equal(doc.sections.some((s) => s.name === 'guest'), false, 'no Guest section unless the person wrote one'); const links = doc.sections.find((s) => s.name === 'links'); @@ -98,7 +96,7 @@ test('the generated file says what the author published, and nothing they did no test('two owned shows become ### groups in one Broadcast section', () => { const feeds = [FEEDS[0], { ...FEEDS[2], role: 'owner' }]; - const doc = generateAuthorProfile({ person: ADA, feeds, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE }); + const doc = generateAuthorProfile({ person: ADA, feeds, topicsByFeed: TOPICS, base: BASE }); const shows = broadcasts(doc); assert.deepEqual(shows.map((s) => s.Show), ['The Analytical Engine', 'Somebody Elses Show']); assert.equal(shows[1].Listen, `${BASE}/other-show`, 'a show with no site listens on its directory page'); @@ -110,7 +108,7 @@ test('the overlay wins per part, the rest stays generated, and the file round-tr identity: { Email: 'ada@example.com', Location: 'London', Avatar: null }, sections: { guest: '- **Available**: yes\n- **Expertise**: early computing', links: 'none' }, }; - const { markdown, doc } = authorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE, overrides }); + const { markdown, doc } = authorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, base: BASE, overrides }); assert.equal(doc.headline, 'Countess, programmer.'); const identity = Object.fromEntries(doc.identity.map((e) => [e.key, e.value])); assert.equal(identity.Email, 'ada@example.com', 'the owner chose to publish it'); @@ -125,7 +123,7 @@ test('the overlay wins per part, the rest stays generated, and the file round-tr }); test('a PUT body as Markdown becomes the whole overlay; as JSON it is a patch', () => { - const generated = generateAuthorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, firstByFeed: FIRST, base: BASE }); + const generated = generateAuthorProfile({ person: ADA, feeds: FEEDS, topicsByFeed: TOPICS, base: BASE }); const edited = '# Ada Lovelace\n\n- Kind: person\n- Web: https://ada.example\n\nCountess.\n\n## Guest\n\n- **Available**: yes\n'; const fromMd = overridesFromBody({ contentType: 'text/markdown; charset=utf-8', text: edited, existing: null, generated }); @@ -235,7 +233,5 @@ test('an OpenAccess token yields a principal with scopes and an email when the h }); test('small helpers', () => { - assert.equal(yearMonth('2024-11-15T00:00:00Z'), '2024-11'); - assert.equal(yearMonth(null), null); assert.equal(profileUrl(BASE, 'ada lovelace'), `${BASE}/authors/ada%20lovelace/openprofile.md`); }); diff --git a/packages/db/src/profiles.js b/packages/db/src/profiles.js index 74a73c7..f33aa73 100644 --- a/packages/db/src/profiles.js +++ b/packages/db/src/profiles.js @@ -143,31 +143,6 @@ export async function profilesForUser(db, userId) { return /** @type {any} */ (rows); } -/** - * When each feed first published, as `feed_id -> ISO date`, for the `Since` - * key of a Broadcast section. Feeds with no dated item are absent. - * - * @param {Client} db - * @param {string[]} feedIds - * @returns {Promise>} - */ -export async function firstPublishedAt(db, feedIds) { - const out = new Map(); - if (feedIds.length === 0) return out; - const { rows } = await db.execute({ - sql: `select feed_id, min(published_at) as first_at - from feed_items - where feed_id in (${feedIds.map(() => '?').join(',')}) - and published_at is not null and published_at <> '' - group by feed_id`, - args: feedIds, - }); - for (const row of rows) { - if (row.first_at) out.set(String(row.feed_id), String(row.first_at)); - } - return out; -} - /** * The topics of several feeds at once, strongest first per feed, as * `feed_id -> keyword[]`. What the Topics of a profile and the `Topics` key of diff --git a/packages/db/test/profiles.test.js b/packages/db/test/profiles.test.js index f5321d3..d6bcbe6 100644 --- a/packages/db/test/profiles.test.js +++ b/packages/db/test/profiles.test.js @@ -68,11 +68,7 @@ test('no row means no profile: the generated file stands', async () => { assert.equal(await profiles.profileForAuthor(db, ada.id), null); }); -test('the Since and Topics inputs come from the feeds themselves', async () => { - const first = await profiles.firstPublishedAt(db, [String(podcast.id), String(blog.id)]); - assert.equal(first.get(String(podcast.id)), '2024-11-15T00:00:00.000Z'); - assert.equal(first.has(String(blog.id)), false, 'a feed with no dated item is absent'); - +test('the Topics inputs come from the feeds themselves', async () => { const topics = await profiles.keywordsForFeeds(db, [String(podcast.id), String(blog.id)]); assert.deepEqual(topics.get(String(podcast.id)), ['history', 'mathematics'], 'category first, then by count'); assert.deepEqual(topics.get(String(blog.id)), ['mathematics']);