feat(content): add source-based multilingual revisions

This commit is contained in:
Christian Johannesen 2026-10-18 17:21:34 +02:00
parent 00700b593a
commit eaf237a502
5 changed files with 83 additions and 16 deletions

View file

@ -1,35 +1,36 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { topicRevisionInputSchema, type TopicDetail, type TopicListItem, type TopicRevisionInput } from '@tech-library/contracts'; import { topicRevisionInputSchema, type Language, type TopicDetail, type TopicListItem, type TopicRevisionInput } from '@tech-library/contracts';
const parse = <T>(value: string): T => JSON.parse(value) as T; const parse = <T>(value: string): T => JSON.parse(value) as T;
export class LibraryRepository { export class LibraryRepository {
constructor(private readonly db: Database.Database) {} constructor(private readonly db: Database.Database) {}
listTopics({ q = '', domain, type, page = 1, pageSize = 24 }: { q?: string; domain?: string; type?: string; page?: number; pageSize?: number }) { listTopics({ q = '', domain, type, page = 1, pageSize = 24, language = 'en', userId }: { q?: string; domain?: string; type?: string; page?: number; pageSize?: number; language?: Language; userId?: string }) {
const params: unknown[] = []; const params: unknown[] = [];
let where = 'WHERE t.active_revision_id IS NOT NULL'; let where = 'WHERE t.active_revision_id IS NOT NULL';
if (q) { where += ' AND t.id IN (SELECT topic_id FROM topic_search WHERE topic_search MATCH ?)'; params.push(q.replace(/["']/g, '')); } const localized = "CASE WHEN ?='en' THEN r.payload ELSE COALESCE(tr.payload,r.payload) END";
if (domain) { where += " AND json_extract(r.payload, '$.domains') LIKE ?"; params.push(`%${domain}%`); } if (q) { where += ` AND (${localized} LIKE ? OR t.id IN (SELECT topic_id FROM topic_search WHERE topic_search MATCH ?))`; params.push(language, `%${q}%`, q.replace(/["']/g, '')); }
if (type) { where += " AND json_extract(r.payload, '$.type') = ?"; params.push(type); } if (domain) { where += ` AND json_extract(${localized}, '$.domains') LIKE ?`; params.push(language, `%${domain}%`); }
const base = `FROM topics t JOIN revisions r ON r.id = t.active_revision_id ${where}`; if (type) { where += ` AND json_extract(${localized}, '$.type') = ?`; params.push(language, type); }
const total = Number((this.db.prepare(`SELECT count(*) AS count ${base}`).get(...params) as { count: number }).count); const joins = `FROM topics t JOIN revisions r ON r.id = t.active_revision_id LEFT JOIN topic_translations tr ON tr.revision_id=r.id AND tr.language='da'`;
const rows = this.db.prepare(`SELECT t.id, t.slug, r.payload, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at ${base} ORDER BY json_extract(r.payload, '$.title') LIMIT ? OFFSET ?`).all(...params, pageSize, (page - 1) * pageSize) as Array<Record<string, string>>; const total = Number((this.db.prepare(`SELECT count(*) AS count ${joins} ${where}`).get(...params) as { count: number }).count);
return { items: rows.map(toListItem), total, page, pageSize }; const rows = this.db.prepare(`SELECT t.id, t.slug, ${localized} payload, tr.id translation_id, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at, CASE WHEN f.user_id IS NULL THEN 0 ELSE 1 END is_favorite ${joins} LEFT JOIN favorites f ON f.topic_id=t.id AND f.user_id=? ${where} ORDER BY json_extract(${localized}, '$.title') LIMIT ? OFFSET ?`).all(language, userId ?? '', ...params, language, pageSize, (page - 1) * pageSize) as Array<Record<string, string>>;
return { items: rows.map((row) => toListItem(row, language)), total, page, pageSize };
} }
getTopic(slug: string): TopicDetail | null { getTopic(slug: string, language: Language = 'en', userId?: string): TopicDetail | null {
const row = this.db.prepare(`SELECT t.id, t.slug, r.id revision_id, r.number, r.payload, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at FROM topics t JOIN revisions r ON r.id = t.active_revision_id WHERE t.slug = ?`).get(slug) as Record<string, string | number> | undefined; const row = this.db.prepare(`SELECT t.id, t.slug, r.id revision_id, r.number, CASE WHEN ?='en' THEN r.payload ELSE COALESCE(tr.payload,r.payload) END payload, tr.id translation_id, CASE WHEN f.user_id IS NULL THEN 0 ELSE 1 END is_favorite, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at FROM topics t JOIN revisions r ON r.id = t.active_revision_id LEFT JOIN topic_translations tr ON tr.revision_id=r.id AND tr.language=? LEFT JOIN favorites f ON f.topic_id=t.id AND f.user_id=? WHERE t.slug = ?`).get(language, language, userId ?? '', slug) as Record<string, string | number> | undefined;
if (!row) return null; if (!row) return null;
const item = toListItem(row as Record<string, string>); const item = toListItem(row as Record<string, string>, language);
const data = parse<TopicRevisionInput>(String(row.payload)); const data = parse<TopicRevisionInput>(String(row.payload));
const sourceRows = this.db.prepare(`SELECT s.* FROM sources s JOIN revision_sources rs ON rs.source_id=s.id WHERE rs.revision_id=? ORDER BY s.verified_at DESC`).all(row.revision_id) as Array<Record<string, string>>; const sourceRows = this.db.prepare(`SELECT s.* FROM sources s JOIN revision_sources rs ON rs.source_id=s.id WHERE rs.revision_id=? ORDER BY s.verified_at DESC`).all(row.revision_id) as Array<Record<string, string>>;
const relationRows = this.db.prepare(`SELECT rel.type, t.id, t.slug, r.payload, COALESCE(r.published_at,r.created_at) verified_at FROM relations rel JOIN topics t ON t.id=rel.target_topic_id JOIN revisions r ON r.id=t.active_revision_id WHERE rel.source_topic_id=?`).all(row.id) as Array<Record<string, string>>; const relationRows = this.db.prepare(`SELECT rel.type, t.id, t.slug, r.payload, COALESCE(r.published_at,r.created_at) verified_at FROM relations rel JOIN topics t ON t.id=rel.target_topic_id JOIN revisions r ON r.id=t.active_revision_id WHERE rel.source_topic_id=?`).all(row.id) as Array<Record<string, string>>;
return { return {
...item, aliases: data.aliases, audience: data.audience, supportedVersions: data.supportedVersions, ...item, aliases: data.aliases, audience: data.audience, supportedVersions: data.supportedVersions,
content: data.content, revisionId: String(row.revision_id), revisionNumber: Number(row.number), content: data.content, revisionId: String(row.revision_id), revisionNumber: Number(row.number),
relations: relationRows.map((relation) => ({ type: relation.type as TopicDetail['relations'][number]['type'], topic: toListItem(relation) })), relations: relationRows.map((relation) => ({ type: relation.type as TopicDetail['relations'][number]['type'], topic: toListItem(relation, 'en') })),
sources: sourceRows.map((source) => ({ id: source.id!, title: source.title!, url: source.url!, publisher: source.publisher!, type: source.source_type as TopicDetail['sources'][number]['type'], verifiedAt: source.verified_at!, claims: parse(source.claims!) })) sources: sourceRows.map((source) => ({ id: source.id!, title: source.title!, url: source.url!, publisher: source.publisher!, type: source.source_type as TopicDetail['sources'][number]['type'], verifiedAt: source.verified_at!, claims: parse(source.claims!) }))
}; };
} }
@ -116,9 +117,9 @@ export class LibraryRepository {
} }
} }
function toListItem(row: Record<string, string>): TopicListItem { function toListItem(row: Record<string, string>, language: Language): TopicListItem {
const data = parse<TopicRevisionInput>(row.payload!); const data = parse<TopicRevisionInput>(row.payload!);
return { id: row.id!, slug: row.slug!, title: data.title, summary: data.summary, type: data.type, domains: data.domains, tags: data.tags, currentVersion: data.currentVersion, verifiedAt: row.verified_at! }; return { id: row.id!, slug: row.slug!, title: data.title, summary: data.summary, type: data.type, domains: data.domains, tags: data.tags, currentVersion: data.currentVersion, verifiedAt: row.verified_at!, language: row.translation_id || language === 'en' ? language : 'en', sourceLanguage: 'en', translationFallback: language !== 'en' && !row.translation_id, isFavorite: Boolean(Number(row.is_favorite ?? 0)) };
} }
function countValues(values: string[]) { function countValues(values: string[]) {

View file

@ -39,6 +39,8 @@ export type TopicSection = z.infer<typeof topicSectionSchema>;
export type TopicType = (typeof topicTypes)[number]; export type TopicType = (typeof topicTypes)[number];
export type RelationType = (typeof relationTypes)[number]; export type RelationType = (typeof relationTypes)[number];
export type SourceType = (typeof sourceTypes)[number]; export type SourceType = (typeof sourceTypes)[number];
export const languages = ['en', 'da'] as const;
export type Language = (typeof languages)[number];
export interface TopicListItem { export interface TopicListItem {
id: string; id: string;
@ -50,6 +52,10 @@ export interface TopicListItem {
tags: string[]; tags: string[];
currentVersion: string | null; currentVersion: string | null;
verifiedAt: string; verifiedAt: string;
language: Language;
sourceLanguage: 'en';
translationFallback: boolean;
isFavorite?: boolean;
} }
export interface TopicDetail extends TopicListItem { export interface TopicDetail extends TopicListItem {

View file

@ -15,7 +15,13 @@ const migrations = [
CREATE VIRTUAL TABLE IF NOT EXISTS topic_search USING fts5(topic_id UNINDEXED, title, summary, aliases, content, tags, tokenize='porter unicode61'); CREATE VIRTUAL TABLE IF NOT EXISTS topic_search USING fts5(topic_id UNINDEXED, title, summary, aliases, content, tags, tokenize='porter unicode61');
CREATE INDEX IF NOT EXISTS revisions_topic_id ON revisions(topic_id); CREATE INDEX IF NOT EXISTS revisions_topic_id ON revisions(topic_id);
CREATE INDEX IF NOT EXISTS findings_status ON findings(status); CREATE INDEX IF NOT EXISTS findings_status ON findings(status);
CREATE INDEX IF NOT EXISTS relations_source ON relations(source_topic_id);` CREATE INDEX IF NOT EXISTS relations_source ON relations(source_topic_id);`,
`CREATE TABLE IF NOT EXISTS topic_translations (id TEXT PRIMARY KEY, revision_id TEXT NOT NULL REFERENCES revisions(id), language TEXT NOT NULL, payload TEXT NOT NULL, source_revision_id TEXT NOT NULL REFERENCES revisions(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(revision_id, language));
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS user_sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_token TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, topic_id));
CREATE INDEX IF NOT EXISTS user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS favorites_user ON favorites(user_id);`
]; ];
export function migrate(sqlite: Database.Database): void { export function migrate(sqlite: Database.Database): void {

View file

@ -98,3 +98,34 @@ export const workerJobs = sqliteTable('worker_jobs', {
completedAt: text('completed_at'), completedAt: text('completed_at'),
result: text('result', { mode: 'json' }) result: text('result', { mode: 'json' })
}); });
export const topicTranslations = sqliteTable('topic_translations', {
id: text('id').primaryKey(),
revisionId: text('revision_id').notNull().references(() => revisions.id),
language: text('language').notNull(),
payload: text('payload', { mode: 'json' }).notNull(),
sourceRevisionId: text('source_revision_id').notNull().references(() => revisions.id),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull()
}, (table) => [uniqueIndex('translation_revision_language').on(table.revisionId, table.language)]);
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: text('created_at').notNull()
});
export const userSessions = sqliteTable('user_sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
csrfToken: text('csrf_token').notNull(),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull()
});
export const favorites = sqliteTable('favorites', {
userId: text('user_id').notNull().references(() => users.id),
topicId: text('topic_id').notNull().references(() => topics.id),
createdAt: text('created_at').notNull()
}, (table) => [uniqueIndex('favorite_user_topic').on(table.userId, table.topicId)]);

View file

@ -68,6 +68,7 @@ export function seedDatabase(sqlite: Database.Database): number {
const insertSource = sqlite.prepare('INSERT OR IGNORE INTO sources(id, url, publisher, source_type, title, fetched_at, verified_at, fingerprint, claims) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); const insertSource = sqlite.prepare('INSERT OR IGNORE INTO sources(id, url, publisher, source_type, title, fetched_at, verified_at, fingerprint, claims) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
const linkSource = sqlite.prepare('INSERT OR IGNORE INTO revision_sources(revision_id, source_id) VALUES (?, ?)'); const linkSource = sqlite.prepare('INSERT OR IGNORE INTO revision_sources(revision_id, source_id) VALUES (?, ?)');
const indexSearch = sqlite.prepare('INSERT INTO topic_search(topic_id, title, summary, aliases, content, tags) VALUES (?, ?, ?, ?, ?, ?)'); const indexSearch = sqlite.prepare('INSERT INTO topic_search(topic_id, title, summary, aliases, content, tags) VALUES (?, ?, ?, ?, ?, ?)');
const insertTranslation = sqlite.prepare('INSERT OR IGNORE INTO topic_translations(id,revision_id,language,payload,source_revision_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)');
const countBefore = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count); const countBefore = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count);
sqlite.transaction(() => { sqlite.transaction(() => {
@ -80,6 +81,7 @@ export function seedDatabase(sqlite: Database.Database): number {
insertRevision.run(revisionId, topicId, 'approved', JSON.stringify(data), data.changeNote, now, now, now); insertRevision.run(revisionId, topicId, 'approved', JSON.stringify(data), data.changeNote, now, now, now);
insertSource.run(sourceId, item.url, item.publisher, 'documentation', `${item.title} official documentation`, now, now, createHash('sha256').update(item.url).digest('hex'), JSON.stringify(['Official definition and current technical guidance'])); insertSource.run(sourceId, item.url, item.publisher, 'documentation', `${item.title} official documentation`, now, now, createHash('sha256').update(item.url).digest('hex'), JSON.stringify(['Official definition and current technical guidance']));
linkSource.run(revisionId, sourceId); linkSource.run(revisionId, sourceId);
insertTranslation.run(`translation_${item.slug}_da`, revisionId, 'da', JSON.stringify(danishPayload(data, item)), revisionId, now, now);
if (!(sqlite.prepare('SELECT 1 FROM topic_search WHERE topic_id = ?').get(topicId))) { if (!(sqlite.prepare('SELECT 1 FROM topic_search WHERE topic_id = ?').get(topicId))) {
indexSearch.run(topicId, data.title, data.summary, data.aliases.join(' '), JSON.stringify(data.content), data.tags.join(' ')); indexSearch.run(topicId, data.title, data.summary, data.aliases.join(' '), JSON.stringify(data.content), data.tags.join(' '));
} }
@ -89,3 +91,24 @@ export function seedDatabase(sqlite: Database.Database): number {
const countAfter = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count); const countAfter = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count);
return countAfter - countBefore; return countAfter - countBefore;
} }
function danishPayload(source: TopicRevisionInput, topic: SeedTopic): TopicRevisionInput {
const domain = ({ 'Web platform': 'Webplatform', 'Frontend and UI': 'Frontend og UI', 'Backend and APIs': 'Backend og API’er', Data: 'Data', 'Mobile and desktop': 'Mobil og desktop', 'DevOps and cloud': 'DevOps og cloud', 'Testing and observability': 'Test og observability', 'Security and privacy': 'Sikkerhed og privacy' } as Record<string, string>)[topic.domain] ?? topic.domain;
return {
...source, domains: [domain],
summary: `${topic.title} er et centralt emne inden for ${domain.toLowerCase()}, med fokus på praktisk anvendelse, kompatibilitet og dokumenterede tekniske valg.`,
content: {
...source.content,
introduction: `${topic.title} er en vigtig del af moderne softwareudvikling. Opslaget giver et praktisk overblik over teknologien, dens trade-offs og dens officielle kilder.`,
concepts: ['Kernemodel og terminologi', 'Økosystem og interoperabilitet', 'Livscyklus og vedligeholdelse'],
useCases: [`Vurdér ${topic.title} til nye og eksisterende systemer`, 'Forstå integrationsgrænser og driftsmæssig påvirkning'],
benefits: ['Underbygget af en officiel, sporbar primærkilde', 'Præsenteret i lag til læsere med forskellig erfaring'],
limitations: ['Præcise muligheder og support varierer efter version og platform'],
avoidWhen: ['Driftsomkostninger eller kompatibilitetskrav overstiger værdien for projektet'],
compatibility: 'Se den officielle dokumentation for aktuel platform- og versionskompatibilitet.',
requirements: ['Kontrollér understøttede versioner før anvendelse', 'Test i det planlagte produktionsmiljø'],
securityNotes: ['Gennemgå officiel sikkerhedsvejledning og supportpolitik før produktionsbrug.']
},
changeNote: 'Første dansk oversættelse baseret på den engelske kilderevision'
};
}