Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ curl -X POST https://rssamplifier.com/api/discover \
Every author page carries `<link rel="openprofile">` 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.

Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/lib/authorProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,16 @@ 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();
const built = authorProfile({
person,
feeds,
topicsByFeed,
firstByFeed,
base,
overrides: profile?.overrides ?? null,
});
Expand Down
23 changes: 7 additions & 16 deletions apps/web/src/lib/openprofile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand All @@ -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<string, string[]>,
* firstByFeed?: Map<string, string>,
* 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 5 additions & 9 deletions apps/web/test/openprofile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]));
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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 });
Expand Down Expand Up @@ -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`);
});
25 changes: 0 additions & 25 deletions packages/db/src/profiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, string>>}
*/
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
Expand Down
6 changes: 1 addition & 5 deletions packages/db/test/profiles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Loading