diff --git a/README.md b/README.md index 706b16e..cbdbcd2 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,10 @@ Start the local read-only web app with: nlc serve --host 127.0.0.1 --port 3000 ``` -The first web UI is intentionally functional: dashboard, links, sponsored links, dead links, and runs. +The web UI is intentionally functional and read-only. It includes dashboard totals, a two-pane +newsletter browser, all links, sponsored links, dead links, and run history. The newsletter browser +opens on the most recent issue to keep the default view focused; switch to **All Issues** when you +want the full link history for the selected newsletter. ## Build and Distribution diff --git a/src/database/store.ts b/src/database/store.ts index 45777a4..8a53b62 100644 --- a/src/database/store.ts +++ b/src/database/store.ts @@ -16,6 +16,21 @@ export interface CatalogRunPayload extends CatalogPayload { errors: number; } +export interface NewsletterSummary { + id: number; + name: string; + latestIssueDate: string; + issueCount: number; + linkCount: number; + sponsorCount: number; +} + +export interface NewsletterLinkQuery { + scope?: 'latest' | 'all'; + search?: string; + category?: string; +} + export class CatalogDatabase { private readonly db: import('node:sqlite').DatabaseSync; @@ -97,6 +112,91 @@ export class CatalogDatabase { .all(); } + public newsletterSummaries(search = ''): NewsletterSummary[] { + const searchPattern = `%${search}%`; + return this.db + .prepare( + `SELECT + n.id, + n.name, + COALESCE(MAX(i.issue_date), '') AS latestIssueDate, + COUNT(DISTINCT i.id) AS issueCount, + COUNT(DISTINCT o.id) AS linkCount, + COUNT(DISTINCT s.id) AS sponsorCount + FROM newsletters n + LEFT JOIN issues i ON i.newsletter_id = n.id + LEFT JOIN link_occurrences o ON o.issue_id = i.id + LEFT JOIN sponsors s ON s.newsletter = n.name + WHERE n.name LIKE ? + GROUP BY n.id, n.name + ORDER BY n.name` + ) + .all(searchPattern) as unknown as NewsletterSummary[]; + } + + public newsletterById(id: number): NewsletterSummary | undefined { + return this.newsletterSummaries().find((newsletter) => newsletter.id === id); + } + + public categoriesForNewsletter(newsletterId: number): string[] { + return this.db + .prepare( + `SELECT DISTINCT o.category + FROM link_occurrences o + JOIN issues i ON i.id = o.issue_id + WHERE i.newsletter_id = ? AND o.category <> '' + ORDER BY o.category` + ) + .all(newsletterId) + .map((row: any) => row.category); + } + + public newsletterLinks(newsletterId: number, query: NewsletterLinkQuery = {}): any[] { + const scope = query.scope ?? 'latest'; + const filters = ['i.newsletter_id = ?']; + const params: Array = [newsletterId]; + + if (scope === 'latest') { + filters.push('i.issue_date = (SELECT MAX(issue_date) FROM issues WHERE newsletter_id = ?)'); + params.push(newsletterId); + } + if (query.search) { + filters.push('(o.title LIKE ? OR o.description LIKE ? OR l.url LIKE ?)'); + const search = `%${query.search}%`; + params.push(search, search, search); + } + if (query.category) { + filters.push('o.category = ?'); + params.push(query.category); + } + + return this.db + .prepare( + `SELECT i.issue_date AS issueDate, o.category, o.title, o.description, l.url, o.also_in AS alsoIn + FROM link_occurrences o + JOIN issues i ON i.id = o.issue_id + JOIN links l ON l.id = o.link_id + WHERE ${filters.join(' AND ')} + ORDER BY i.issue_date DESC, o.title` + ) + .all(...params); + } + + public allLinks(search = ''): any[] { + const searchPattern = `%${search}%`; + return this.db + .prepare( + `SELECT n.name AS newsletter, i.issue_date AS issueDate, o.category, o.title, l.url, o.description + FROM link_occurrences o + JOIN issues i ON i.id = o.issue_id + JOIN newsletters n ON n.id = i.newsletter_id + JOIN links l ON l.id = o.link_id + WHERE o.title LIKE ? OR o.description LIKE ? OR l.url LIKE ? OR n.name LIKE ? + ORDER BY i.issue_date DESC, n.name, o.title` + ) + .all(searchPattern, searchPattern, searchPattern, searchPattern); + } + public sponsoredLinks(): any[] { return this.db .prepare('SELECT newsletter, sponsor, description FROM sponsors ORDER BY newsletter, sponsor') diff --git a/src/web/app.ts b/src/web/app.ts index b8b4228..7b23f50 100644 --- a/src/web/app.ts +++ b/src/web/app.ts @@ -1,6 +1,6 @@ import express from 'express'; import { CatalogDatabase } from '../database/store.js'; -import { dashboard, page, table } from './views.js'; +import { dashboard, newsletterBrowser, page, table } from './views.js'; export function createWebApp(databasePath: string) { const app = express(); @@ -9,24 +9,65 @@ export function createWebApp(databasePath: string) { withDatabase(databasePath, (db) => res.send(dashboard(db.dashboardCounts()))).catch(next); }); - app.get('/links', (_req, res, next) => { + app.get('/links', (req, res, next) => { + const search = stringQuery(req.query.q); withDatabase(databasePath, (db) => res.send( page( 'Links', - `

Links

${table(db.contentLinks(), [ + `

All Links

+
+ + +
+ ${table(db.allLinks(search), [ ['newsletter', 'Newsletter'], ['issueDate', 'Issue Date'], ['category', 'Category'], ['title', 'Title'], ['url', 'URL'], ['description', 'Description'] - ])}` + ])}`, + 'links' ) ) ).catch(next); }); + app.get('/newsletters', (req, res, next) => { + withDatabase(databasePath, (db) => { + const scope = req.query.scope === 'all' ? 'all' : 'latest'; + const linkSearch = stringQuery(req.query.q); + const category = stringQuery(req.query.category); + const newsletters = db.newsletterSummaries(); + const requestedNewsletterId = Number(req.query.newsletter); + const selected = + newsletters.find((newsletter) => newsletter.id === requestedNewsletterId) ?? newsletters[0]; + + res.send( + newsletterBrowser({ + newsletters, + selected, + links: selected + ? db.newsletterLinks(selected.id, { + category, + scope, + search: linkSearch + }) + : [], + categories: selected ? db.categoriesForNewsletter(selected.id) : [], + filters: { + category, + linkSearch, + scope + } + }) + ); + }).catch(next); + }); + app.get('/sponsors', (_req, res, next) => { withDatabase(databasePath, (db) => res.send( @@ -36,7 +77,8 @@ export function createWebApp(databasePath: string) { ['newsletter', 'Newsletter'], ['sponsor', 'Sponsor'], ['description', 'Description'] - ])}` + ])}`, + 'sponsors' ) ) ).catch(next); @@ -52,7 +94,8 @@ export function createWebApp(databasePath: string) { ['status', 'Status'], ['source', 'Source'], ['date', 'Date'] - ])}` + ])}`, + 'dead-links' ) ) ).catch(next); @@ -71,7 +114,8 @@ export function createWebApp(databasePath: string) { ['sponsors', 'Sponsors'], ['dead_links', 'Dead Links'], ['errors', 'Errors'] - ])}` + ])}`, + 'runs' ) ) ).catch(next); @@ -87,6 +131,19 @@ export function createWebApp(databasePath: string) { return app; } +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function stringQuery(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + async function withDatabase( databasePath: string, callback: (database: CatalogDatabase) => void diff --git a/src/web/views.ts b/src/web/views.ts index 794d44a..43a3ca8 100644 --- a/src/web/views.ts +++ b/src/web/views.ts @@ -1,3 +1,19 @@ +import { NewsletterSummary } from '../database/store.js'; + +type ActivePage = 'dashboard' | 'newsletters' | 'links' | 'sponsors' | 'dead-links' | 'runs'; + +interface NewsletterBrowserModel { + newsletters: NewsletterSummary[]; + selected?: NewsletterSummary; + links: Record[]; + categories: string[]; + filters: { + category: string; + linkSearch: string; + scope: 'latest' | 'all'; + }; +} + function escapeHtml(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') @@ -7,7 +23,27 @@ function escapeHtml(value: unknown): string { .replaceAll("'", '''); } -export function page(title: string, body: string): string { +function escapeAttr(value: unknown): string { + return escapeHtml(value); +} + +function pageUrl(path: string, params: Record): string { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + query.set(key, String(value)); + } + }); + const serialized = query.toString(); + return serialized ? `${path}?${serialized}` : path; +} + +function navLink(href: string, label: string, pageName: ActivePage, active: ActivePage): string { + const current = pageName === active ? ' aria-current="page"' : ''; + return `${escapeHtml(label)}`; +} + +export function page(title: string, body: string, active: ActivePage = 'dashboard'): string { return ` @@ -15,25 +51,247 @@ export function page(title: string, body: string): string { ${escapeHtml(title)} - - ${body} +
+ Newsletter Link Catalog + + +
+
${body}
+ `; } @@ -51,13 +309,148 @@ export function dashboard(counts: Record): string { return page( 'Newsletter Link Catalog', `

Newsletter Link Catalog

-
+
${Object.entries(counts) .map( ([key, value]) => - `
${escapeHtml(value)}
${escapeHtml(key)}
` + `
${escapeHtml(value)}${escapeHtml(key)}
` ) .join('')} -
` +
`, + 'dashboard' ); } + +export function newsletterBrowser(model: NewsletterBrowserModel): string { + const { selected } = model; + const selectedId = selected?.id ?? ''; + return page( + 'Newsletters', + `
+ +
+ ${selected ? selectedNewsletterView(model, selectedId) : '

No newsletter selected.

'} +
+
`, + 'newsletters' + ); +} + +function newsletterListItem( + newsletter: NewsletterSummary, + filters: NewsletterBrowserModel['filters'], + isSelected: boolean +): string { + const href = pageUrl('/newsletters', { + newsletter: newsletter.id, + scope: filters.scope, + q: filters.linkSearch, + category: filters.category + }); + return ``; +} + +function selectedNewsletterView( + model: NewsletterBrowserModel, + selectedId: number | string +): string { + const { filters, selected } = model; + const latestHref = pageUrl('/newsletters', { + newsletter: selectedId, + scope: 'latest', + q: filters.linkSearch, + category: filters.category + }); + const allHref = pageUrl('/newsletters', { + newsletter: selectedId, + scope: 'all', + q: filters.linkSearch, + category: filters.category + }); + return `
+
+
+

${escapeHtml(selected?.name)}

+

Latest issue ${escapeHtml(selected?.latestIssueDate || 'not available')}

+
+
+ ${escapeHtml(selected?.issueCount)} issues + ${escapeHtml(selected?.linkCount)} links + ${escapeHtml(selected?.sponsorCount)} sponsored +
+
+ +
+ + + + + +
+
+
+ ${linksTable(model.links, filters.scope)} +
`; +} + +function linksTable(rows: Record[], scope: 'latest' | 'all'): string { + if (rows.length === 0) { + return '

No links match the current filters.

'; + } + const issueHeader = scope === 'all' ? 'Issue Date' : ''; + return ` + + ${issueHeader} + + + ${rows.map((row) => linkRow(row, scope)).join('')} + +
TitleCategoryDescriptionURLAlso In
`; +} + +function linkRow(row: Record, scope: 'latest' | 'all'): string { + const issueDate = scope === 'all' ? `${escapeHtml(row.issueDate)}` : ''; + return ` + ${issueDate} + ${escapeHtml(row.title || row.url)} + ${escapeHtml(row.category)} + ${escapeHtml(row.description)} + ${escapeHtml(row.url)} + ${escapeHtml(row.alsoIn)} + `; +} diff --git a/tests/web.test.ts b/tests/web.test.ts index 34ad192..37a34f2 100644 --- a/tests/web.test.ts +++ b/tests/web.test.ts @@ -33,12 +33,22 @@ function fixtureDatabase(): string { const db = new CatalogDatabase(path); db.saveCatalogRun({ mode: 'test', - newslettersProcessed: 1, - linksExtracted: 1, + newslettersProcessed: 2, + linksExtracted: 3, sponsorCount: 1, deadLinkCount: 1, errors: 0, rows: [ + { + 'Issue Date': '2026-05-10', + Category: 'SQLite', + 'Link URL': 'https://sqlite.example/old', + Title: 'Older SQLite Post', + Description: 'An older database post', + 'Page Title + Meta': '', + 'Source Newsletter': 'DB Weekly', + 'Also In': '' + }, { 'Issue Date': '2026-05-17', Category: 'SQLite', @@ -48,6 +58,16 @@ function fixtureDatabase(): string { 'Page Title + Meta': '', 'Source Newsletter': 'DB Weekly', 'Also In': '' + }, + { + 'Issue Date': '2026-05-16', + Category: 'JavaScript', + 'Link URL': 'https://js.example', + Title: 'JS Post', + Description: 'A JavaScript post', + 'Page Title + Meta': '', + 'Source Newsletter': 'JS Weekly', + 'Also In': '' } ], sponsors: [ @@ -67,13 +87,79 @@ function fixtureDatabase(): string { } describe('web app', () => { + it('provides newsletter summaries and latest/all issue link scopes', () => { + const path = fixtureDatabase(); + const db = new CatalogDatabase(path); + try { + const summaries = db.newsletterSummaries(); + expect( + summaries.map(({ name, latestIssueDate, issueCount, linkCount, sponsorCount }) => ({ + name, + latestIssueDate, + issueCount, + linkCount, + sponsorCount + })) + ).toEqual([ + { + name: 'DB Weekly', + latestIssueDate: '2026-05-17', + issueCount: 2, + linkCount: 2, + sponsorCount: 1 + }, + { + name: 'JS Weekly', + latestIssueDate: '2026-05-16', + issueCount: 1, + linkCount: 1, + sponsorCount: 0 + } + ]); + + const dbWeekly = summaries.find((newsletter) => newsletter.name === 'DB Weekly'); + expect( + db.newsletterLinks(dbWeekly?.id ?? 0, { scope: 'latest' }).map((link) => link.title) + ).toEqual(['SQLite Post']); + expect( + db.newsletterLinks(dbWeekly?.id ?? 0, { scope: 'all' }).map((link) => link.title) + ).toEqual(['SQLite Post', 'Older SQLite Post']); + } finally { + db.close(); + } + }); + it('renders dashboard and catalog pages from SQLite', async () => { const path = fixtureDatabase(); await expect(withServer(path, '/')).resolves.toContain('Newsletter Link Catalog'); await expect(withServer(path, '/links')).resolves.toContain('SQLite Post'); + await expect(withServer(path, '/links?q=JavaScript')).resolves.toContain('JS Post'); + await expect(withServer(path, '/links?q=JavaScript')).resolves.not.toContain('SQLite Post'); await expect(withServer(path, '/sponsors')).resolves.toContain('Acme'); await expect(withServer(path, '/dead-links')).resolves.toContain('https://dead.example'); await expect(withServer(path, '/runs')).resolves.toContain('test'); }); + + it('renders the two-pane newsletter browser with latest issue by default and all issue mode', async () => { + const path = fixtureDatabase(); + + const latest = await withServer(path, '/newsletters'); + expect(latest).toContain('class="app-shell"'); + expect(latest).toContain('Search newsletters'); + expect(latest).toContain('aria-current="true"'); + expect(latest).toContain('Latest Issue'); + expect(latest).toContain('All Issues'); + expect(latest).toContain('SQLite Post'); + expect(latest).not.toContain('Older SQLite Post'); + expect(latest).toContain('target="_blank"'); + expect(latest).toContain('rel="noopener noreferrer"'); + + const allIssues = await withServer(path, '/newsletters?scope=all'); + expect(allIssues).toContain('SQLite Post'); + expect(allIssues).toContain('Older SQLite Post'); + expect(allIssues).toContain('2026-05-10'); + expect(allIssues).toContain('name="category"'); + expect(allIssues).toContain('name="q"'); + }); });