-
Notifications
You must be signed in to change notification settings - Fork 222
feat: add in-app changelog notifications #1450
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { createRPCController } from '../../shared/ipc/rpc'; | ||
| import { changelogService } from '../services/ChangelogService'; | ||
|
|
||
| export const changelogController = createRPCController({ | ||
| getLatestEntry: async (args?: { version?: string }) => | ||
| changelogService.getLatestEntry(args?.version), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,349 @@ | ||
| import { | ||
| compareChangelogVersions, | ||
| EMDASH_CHANGELOG_API_URL, | ||
| EMDASH_CHANGELOG_URL, | ||
| normalizeChangelogVersion, | ||
| type ChangelogEntry, | ||
| } from '@shared/changelog'; | ||
| import { log } from '../lib/logger'; | ||
|
|
||
| type ChangelogCandidate = { | ||
| version?: string | null; | ||
| title?: string | null; | ||
| summary?: string | null; | ||
| content?: string | null; | ||
| contentHtml?: string | null; | ||
| markdown?: string | null; | ||
| body?: string | null; | ||
| html?: string | null; | ||
| publishedAt?: string | null; | ||
| published_at?: string | null; | ||
| date?: string | null; | ||
| url?: string | null; | ||
| href?: string | null; | ||
| }; | ||
|
|
||
| function firstString(...values: Array<unknown>): string | undefined { | ||
| for (const value of values) { | ||
| if (typeof value !== 'string') continue; | ||
| const trimmed = value.trim(); | ||
| if (trimmed) return trimmed; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function decodeHtmlEntities(input: string): string { | ||
| return input | ||
| .replace(/ /gi, ' ') | ||
| .replace(/&/gi, '&') | ||
| .replace(/</gi, '<') | ||
| .replace(/>/gi, '>') | ||
| .replace(/"/gi, '"') | ||
| .replace(/'/gi, "'") | ||
| .replace(/'/gi, "'"); | ||
| } | ||
|
|
||
| function stripTags(input: string): string { | ||
| return decodeHtmlEntities(input.replace(/<[^>]+>/g, ' ')) | ||
| .replace(/\s+/g, ' ') | ||
| .trim(); | ||
| } | ||
|
|
||
| function htmlToMarkdown(html: string): string { | ||
| const withoutScripts = html | ||
| .replace(/<script[\s\S]*?<\/script>/gi, '') | ||
| .replace(/<style[\s\S]*?<\/style>/gi, '') | ||
| .replace(/<!--[\s\S]*?-->/g, ''); | ||
|
|
||
| const withLinks = withoutScripts.replace( | ||
| /<a\b[^>]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, | ||
| (_match, _quote, href: string, text: string) => { | ||
| const label = stripTags(text); | ||
| return label ? `[${label}](${href.trim()})` : ''; | ||
| } | ||
| ); | ||
|
|
||
| const withFormatting = withLinks | ||
| .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, text: string) => { | ||
| const content = stripTags(text); | ||
| return content ? `**${content}**` : ''; | ||
| }) | ||
| .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, text: string) => { | ||
| const content = stripTags(text); | ||
| return content ? `*${content}*` : ''; | ||
| }) | ||
| .replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_match, text: string) => { | ||
| const content = stripTags(text); | ||
| return content ? `\`${content}\`` : ''; | ||
| }); | ||
|
|
||
| const withHeadings = withFormatting | ||
| .replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n') | ||
| .replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n') | ||
| .replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n') | ||
| .replace(/<h4\b[^>]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n') | ||
| .replace(/<h5\b[^>]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n') | ||
| .replace(/<h6\b[^>]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n'); | ||
|
|
||
| const withLists = withHeadings | ||
| .replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, '\n- $1') | ||
| .replace(/<\/(ul|ol)>/gi, '\n') | ||
| .replace(/<(ul|ol)\b[^>]*>/gi, '\n'); | ||
|
|
||
| const withParagraphs = withLists | ||
| .replace(/<br\s*\/?>/gi, '\n') | ||
| .replace(/<\/p>/gi, '\n\n') | ||
| .replace(/<p\b[^>]*>/gi, '') | ||
| .replace(/<\/div>/gi, '\n') | ||
| .replace(/<div\b[^>]*>/gi, '\n'); | ||
|
|
||
| return decodeHtmlEntities(withParagraphs.replace(/<[^>]+>/g, ' ')) | ||
| .replace(/[ \t]+\n/g, '\n') | ||
| .replace(/\n{3,}/g, '\n\n') | ||
| .replace(/ {2,}/g, ' ') | ||
| .trim(); | ||
| } | ||
|
|
||
| function extractSummaryFromContent(content: string): string { | ||
| return ( | ||
| content | ||
| .split(/\n{2,}/) | ||
| .map((block) => block.trim()) | ||
| .find((block) => block && !block.startsWith('#') && !block.startsWith('- ')) ?? '' | ||
| ); | ||
| } | ||
|
|
||
| function removeDuplicateTitle(content: string, title: string): string { | ||
| const normalizedTitle = title.trim().toLowerCase(); | ||
| const lines = content.split('\n'); | ||
|
|
||
| while (lines.length > 0) { | ||
| const line = lines[0].trim(); | ||
| if (!line) { | ||
| lines.shift(); | ||
| continue; | ||
| } | ||
|
|
||
| const normalizedLine = line | ||
| .replace(/^#+\s*/, '') | ||
| .trim() | ||
| .toLowerCase(); | ||
| if (normalizedLine === normalizedTitle) { | ||
| lines.shift(); | ||
| continue; | ||
| } | ||
|
|
||
| break; | ||
| } | ||
|
|
||
| return lines.join('\n').trim(); | ||
| } | ||
|
|
||
| function normalizeEntry( | ||
| candidate: ChangelogCandidate, | ||
| requestedVersion?: string | ||
| ): ChangelogEntry | null { | ||
| const version = normalizeChangelogVersion( | ||
| firstString(candidate.version, requestedVersion, extractVersion(candidate.title)) | ||
| ); | ||
| if (!version) return null; | ||
|
|
||
| const title = firstString(candidate.title) ?? `What's new in Emdash v${version}`; | ||
| const contentSource = | ||
| firstString(candidate.content, candidate.markdown, candidate.body) ?? | ||
| (firstString(candidate.contentHtml, candidate.html) | ||
| ? htmlToMarkdown(firstString(candidate.contentHtml, candidate.html)!) | ||
| : ''); | ||
|
|
||
| const dedupedContent = removeDuplicateTitle(contentSource, title); | ||
| const summary = | ||
| firstString(candidate.summary) ?? | ||
| extractSummaryFromContent(dedupedContent) ?? | ||
| `See what changed in Emdash v${version}.`; | ||
|
|
||
| const content = dedupedContent || summary || `See what changed in Emdash v${version}.`; | ||
|
|
||
| return { | ||
| version, | ||
| title, | ||
| summary, | ||
| content, | ||
| publishedAt: firstString(candidate.publishedAt, candidate.published_at, candidate.date), | ||
| url: firstString(candidate.url, candidate.href), | ||
| }; | ||
| } | ||
|
|
||
| function extractVersion(input: string | null | undefined): string | undefined { | ||
| if (typeof input !== 'string') return undefined; | ||
| const match = input.match(/\bv?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/); | ||
| return normalizeChangelogVersion(match?.[1] ?? null) ?? undefined; | ||
| } | ||
|
|
||
| function pickBestCandidate( | ||
| candidates: ChangelogEntry[], | ||
| requestedVersion?: string | ||
| ): ChangelogEntry | null { | ||
| if (candidates.length === 0) return null; | ||
|
|
||
| const normalizedRequested = normalizeChangelogVersion(requestedVersion); | ||
| if (normalizedRequested) { | ||
| const exact = candidates.find((candidate) => candidate.version === normalizedRequested); | ||
| if (exact) return exact; | ||
| } | ||
|
|
||
| return candidates | ||
| .slice() | ||
| .sort((left, right) => compareChangelogVersions(right.version, left.version))[0]; | ||
| } | ||
|
|
||
| function extractCandidatesFromPayload(payload: unknown): ChangelogCandidate[] { | ||
| if (!payload || typeof payload !== 'object') return []; | ||
|
|
||
| if (Array.isArray(payload)) { | ||
| return payload.filter((item): item is ChangelogCandidate => !!item && typeof item === 'object'); | ||
| } | ||
|
|
||
| const record = payload as Record<string, unknown>; | ||
| const directCandidate = normalizeEntry(record as ChangelogCandidate); | ||
| if (directCandidate) return [record as ChangelogCandidate]; | ||
|
|
||
| const collections = ['entry', 'release', 'item', 'entries', 'items', 'releases', 'data']; | ||
| for (const key of collections) { | ||
| const value = record[key]; | ||
| if (Array.isArray(value)) { | ||
| return value.filter((item): item is ChangelogCandidate => !!item && typeof item === 'object'); | ||
| } | ||
| if (value && typeof value === 'object') { | ||
| return [value as ChangelogCandidate]; | ||
| } | ||
| } | ||
|
|
||
| return []; | ||
| } | ||
|
|
||
| async function fetchJson(url: string): Promise<unknown | null> { | ||
| const response = await fetch(url, { | ||
| headers: { | ||
| Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', | ||
| 'Cache-Control': 'no-cache', | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) return null; | ||
|
|
||
| const contentType = response.headers.get('content-type') ?? ''; | ||
| if (!contentType.includes('json')) return null; | ||
|
|
||
| return response.json(); | ||
| } | ||
|
Comment on lines
+224
to
+238
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No timeout on network requests Both Consider wrapping each fetch with an async function fetchJson(url: string): Promise<unknown | null> {
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'Cache-Control': 'no-cache',
},
});
// ...
}And similarly for the HTML fallback fetch in |
||
|
|
||
| function extractTime(block: string): string | undefined { | ||
| const datetime = block.match(/<time\b[^>]*datetime=(["'])(.*?)\1/i)?.[2]; | ||
| if (datetime?.trim()) return datetime.trim(); | ||
|
|
||
| const timeContent = block.match(/<time\b[^>]*>([\s\S]*?)<\/time>/i)?.[1]; | ||
| const normalized = stripTags(timeContent ?? ''); | ||
| return normalized || undefined; | ||
| } | ||
|
|
||
| function extractTitle(block: string): string | undefined { | ||
| const heading = block.match(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/i)?.[1]; | ||
| const title = stripTags(heading ?? ''); | ||
| return title || undefined; | ||
| } | ||
|
|
||
| function extractSummary(block: string): string | undefined { | ||
| const paragraph = block.match(/<p\b[^>]*>([\s\S]*?)<\/p>/i)?.[1]; | ||
| const summary = stripTags(paragraph ?? ''); | ||
| return summary || undefined; | ||
| } | ||
|
|
||
| export function parseChangelogHtml(html: string, requestedVersion?: string): ChangelogEntry | null { | ||
| const blocks = html.match(/<(article|section)\b[\s\S]*?<\/\1>/gi) ?? []; | ||
| const candidates: ChangelogEntry[] = []; | ||
|
|
||
| for (const block of blocks) { | ||
| const versionFromBlock = normalizeChangelogVersion( | ||
| block.match(/data-version=(["'])(.*?)\1/i)?.[2] ?? | ||
| extractVersion(block) ?? | ||
| requestedVersion ?? | ||
| null | ||
| ); | ||
| if (!versionFromBlock) continue; | ||
|
|
||
| const candidate = normalizeEntry( | ||
| { | ||
| version: versionFromBlock, | ||
| title: extractTitle(block), | ||
| summary: extractSummary(block), | ||
| contentHtml: block, | ||
| publishedAt: extractTime(block), | ||
| }, | ||
| requestedVersion | ||
| ); | ||
|
|
||
| if (candidate) { | ||
| candidates.push(candidate); | ||
| } | ||
| } | ||
|
|
||
| if (candidates.length > 0) { | ||
| return pickBestCandidate(candidates, requestedVersion); | ||
| } | ||
|
|
||
| const fallback = normalizeEntry( | ||
| { | ||
| version: normalizeChangelogVersion(requestedVersion) ?? undefined, | ||
| title: extractTitle(html), | ||
| summary: extractSummary(html), | ||
| contentHtml: html, | ||
| publishedAt: extractTime(html), | ||
| }, | ||
| requestedVersion | ||
| ); | ||
|
|
||
| return fallback; | ||
| } | ||
|
|
||
| class ChangelogService { | ||
| async getLatestEntry(requestedVersion?: string): Promise<ChangelogEntry | null> { | ||
| const version = normalizeChangelogVersion(requestedVersion); | ||
| const apiUrls = [ | ||
| version | ||
| ? `${EMDASH_CHANGELOG_API_URL}?version=${encodeURIComponent(version)}` | ||
| : `${EMDASH_CHANGELOG_API_URL}?latest=1`, | ||
| version | ||
| ? `${EMDASH_CHANGELOG_URL}.json?version=${encodeURIComponent(version)}` | ||
| : `${EMDASH_CHANGELOG_URL}.json`, | ||
| ]; | ||
|
|
||
| for (const url of apiUrls) { | ||
| try { | ||
| const payload = await fetchJson(url); | ||
| if (!payload) continue; | ||
|
|
||
| const entries = extractCandidatesFromPayload(payload) | ||
| .map((candidate) => normalizeEntry(candidate, version ?? undefined)) | ||
| .filter((candidate): candidate is ChangelogEntry => candidate !== null); | ||
| const match = pickBestCandidate(entries, version ?? undefined); | ||
| if (match) return match; | ||
| } catch (error) { | ||
| log.debug('Changelog JSON fetch failed', { url, error }); | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const response = await fetch(EMDASH_CHANGELOG_URL, { | ||
| headers: { Accept: 'text/html,application/xhtml+xml', 'Cache-Control': 'no-cache' }, | ||
| }); | ||
| if (!response.ok) return null; | ||
| const html = await response.text(); | ||
| return parseChangelogHtml(html, version ?? undefined); | ||
| } catch (error) { | ||
| log.error('Failed to fetch changelog HTML', error); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const changelogService = new ChangelogService(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Direct-entry check short-circuits collection extraction
normalizeEntryat line 207 succeeds for any object that carries a validversionfield — even if that same object also contains a collection key (e.g.releases,items, ordata). Because the early return on line 208 fires before thecollectionsloop is reached, a response shaped like:{ "version": "1.0.0", "releases": [{ … }, { … }] }would be returned as a single, content-less candidate instead of the full list of releases.
Consider moving the
directCandidatecheck to the end as a last-resort fallback, after the collections loop has already had a chance to find a richer payload: