feat(content): add source-based multilingual revisions
This commit is contained in:
parent
00700b593a
commit
eaf237a502
5 changed files with 83 additions and 16 deletions
|
|
@ -1,35 +1,36 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
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;
|
||||
|
||||
export class LibraryRepository {
|
||||
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[] = [];
|
||||
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, '')); }
|
||||
if (domain) { where += " AND json_extract(r.payload, '$.domains') LIKE ?"; params.push(`%${domain}%`); }
|
||||
if (type) { where += " AND json_extract(r.payload, '$.type') = ?"; params.push(type); }
|
||||
const base = `FROM topics t JOIN revisions r ON r.id = t.active_revision_id ${where}`;
|
||||
const total = Number((this.db.prepare(`SELECT count(*) AS count ${base}`).get(...params) as { count: number }).count);
|
||||
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>>;
|
||||
return { items: rows.map(toListItem), total, page, pageSize };
|
||||
const localized = "CASE WHEN ?='en' THEN r.payload ELSE COALESCE(tr.payload,r.payload) END";
|
||||
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 (domain) { where += ` AND json_extract(${localized}, '$.domains') LIKE ?`; params.push(language, `%${domain}%`); }
|
||||
if (type) { where += ` AND json_extract(${localized}, '$.type') = ?`; params.push(language, type); }
|
||||
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 total = Number((this.db.prepare(`SELECT count(*) AS count ${joins} ${where}`).get(...params) as { count: number }).count);
|
||||
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 {
|
||||
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;
|
||||
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, 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;
|
||||
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 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>>;
|
||||
return {
|
||||
...item, aliases: data.aliases, audience: data.audience, supportedVersions: data.supportedVersions,
|
||||
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!) }))
|
||||
};
|
||||
}
|
||||
|
|
@ -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!);
|
||||
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[]) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export type TopicSection = z.infer<typeof topicSectionSchema>;
|
|||
export type TopicType = (typeof topicTypes)[number];
|
||||
export type RelationType = (typeof relationTypes)[number];
|
||||
export type SourceType = (typeof sourceTypes)[number];
|
||||
export const languages = ['en', 'da'] as const;
|
||||
export type Language = (typeof languages)[number];
|
||||
|
||||
export interface TopicListItem {
|
||||
id: string;
|
||||
|
|
@ -50,6 +52,10 @@ export interface TopicListItem {
|
|||
tags: string[];
|
||||
currentVersion: string | null;
|
||||
verifiedAt: string;
|
||||
language: Language;
|
||||
sourceLanguage: 'en';
|
||||
translationFallback: boolean;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export interface TopicDetail extends TopicListItem {
|
||||
|
|
|
|||
|
|
@ -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 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 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 {
|
||||
|
|
|
|||
|
|
@ -98,3 +98,34 @@ export const workerJobs = sqliteTable('worker_jobs', {
|
|||
completedAt: text('completed_at'),
|
||||
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)]);
|
||||
|
|
|
|||
|
|
@ -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 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 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);
|
||||
|
||||
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);
|
||||
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);
|
||||
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))) {
|
||||
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);
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue