src/services/changelog.ts
Rama main · réplica de lectura del repositorio original.
import type { RouteLocationRaw } from 'vue-router'
import { compare, tryParse } from '@std/semver'
import { invokeCapgoApi } from '~/services/capgoApi'
export type ChangelogProduct = 'console' | 'cli' | 'updater' | 'plugin' | 'capacitor' | 'dependency'
export type ChangelogSectionType = 'added' | 'changed' | 'fixed' | 'security' | 'removed' | 'deprecated' | 'other'
export type DigestImpact = 'high' | 'medium' | 'low'
export interface ChangelogSection {
type: ChangelogSectionType
items: string[]
}
export interface ChangelogRelease {
version: string
tag: string
date: string
url: string
sections: ChangelogSection[]
}
export interface ChangelogReleasesResponse {
product: ChangelogProduct
repo: string
latest: string | null
truncated: boolean
releases: ChangelogRelease[]
}
export interface ChangelogPlugin {
name: string
repo: string
description: string
version: string
date: string
}
export interface DigestHighlight {
title: string
detail: string
type: ChangelogSectionType
version: string
impact: DigestImpact
relevant: boolean
}
export interface ChangelogDigest {
product: ChangelogProduct
repo: string
fromVersion: string | null
toVersion: string | null
releaseCount: number
counts: Record<ChangelogSectionType, number>
headline: string
highlights: DigestHighlight[]
actionRequired: string[]
aiGenerated: boolean
truncated: boolean
releases: ChangelogRelease[]
}
export interface DigestContext {
platforms?: string[]
features?: string[]
}
export const UPDATER_PACKAGE = '@capgo/capacitor-updater'
export const CLI_PACKAGE = '@capgo/cli'
export const SECTION_ORDER: ChangelogSectionType[] = ['security', 'removed', 'added', 'changed', 'fixed', 'deprecated', 'other']
export const CHANGELOG_PRODUCTS: ChangelogProduct[] = ['console', 'cli', 'updater', 'plugin', 'capacitor', 'dependency']
const RELEASES_CACHE_MS = 5 * 60 * 1000
const releasesCache = new Map<string, { at: number, promise: Promise<ChangelogReleasesResponse | null> }>()
const PLUGINS_CACHE_MS = 30 * 60 * 1000
let pluginsCache: { at: number, promise: Promise<ChangelogPlugin[]> } | null = null
const digestCache = new Map<string, { at: number, ttl: number, promise: Promise<ChangelogDigest | null> }>()
const dependencyCache = new Map<string, { at: number, promise: Promise<string | null> }>()
const dependencyQueue: Array<() => void> = []
let dependencyRequests = 0
export const CAPACITOR_CORE_PACKAGES = new Set(['@capacitor/core', '@capacitor/android', '@capacitor/ios', '@capacitor/cli'])
export function githubRepoUrl(repo: string) {
return `https://github.com/${repo.includes('/') ? repo : `Cap-go/${repo}`}`
}
export function packageChangelog(name: string, plugins: ChangelogPlugin[] = []): { product: ChangelogProduct, repo?: string } {
if (name === UPDATER_PACKAGE)
return { product: 'updater' }
if (name === CLI_PACKAGE)
return { product: 'cli' }
if (CAPACITOR_CORE_PACKAGES.has(name))
return { product: 'capacitor' }
const repo = pluginRepoFromPackage(name, plugins)
return repo ? { product: 'plugin', repo } : { product: 'dependency', repo: name }
}
export function isChangelogProduct(value: unknown): value is ChangelogProduct {
return typeof value === 'string' && (CHANGELOG_PRODUCTS as string[]).includes(value)
}
export function cleanVersion(version: string | null | undefined): string | null {
if (!version)
return null
const cleaned = version.trim().replace(/^[v^~=]+/, '')
return tryParse(cleaned) ? cleaned : null
}
/** True when `latest` is a strictly newer semver than `current`. */
export function isNewerVersion(latest: string | null | undefined, current: string | null | undefined): boolean {
const left = tryParse(cleanVersion(latest) ?? '')
const right = tryParse(cleanVersion(current) ?? '')
if (!left || !right)
return false
return compare(left, right) > 0
}
export function releasesSince(releases: ChangelogRelease[], since: string | null | undefined) {
const from = tryParse(cleanVersion(since) ?? '')
if (!from)
return releases
return releases.filter((release) => {
const version = tryParse(release.version)
return !!version && compare(version, from) > 0
})
}
/** Plugin repo for an npm package name, e.g. @capgo/capacitor-nfc → capacitor-nfc. */
export function pluginRepoFromPackage(name: string, plugins: ChangelogPlugin[] = []) {
return plugins.find(plugin => plugin.name === name)?.repo ?? null
}
export function changelogLocation(product: ChangelogProduct, options: { since?: string | null, repo?: string | null, to?: string | null } = {}): RouteLocationRaw {
const query: Record<string, string> = { product }
if (options.repo)
query.repo = options.repo
const since = cleanVersion(options.since)
if (since)
query.since = since
// Upper bound of the upgrade, e.g. the latest updater for the app's Capacitor major (not the overall latest).
const to = cleanVersion(options.to)
if (to)
query.to = to
return { path: '/changelog', query }
}
/** Route every detected native dependency to its product's release notes. */
export function changelogLocationForPackage(name: string, version: string | null | undefined, plugins: ChangelogPlugin[]): RouteLocationRaw {
const target = packageChangelog(name, plugins)
return changelogLocation(target.product, { since: version, repo: target.repo })
}
export function fetchReleases(product: ChangelogProduct, repo?: string | null): Promise<ChangelogReleasesResponse | null> {
const key = `${product}:${repo ?? ''}`
const hit = releasesCache.get(key)
if (hit && Date.now() - hit.at < RELEASES_CACHE_MS)
return hit.promise
const params = new URLSearchParams({ product })
if (repo)
params.set('repo', repo)
const promise = invokeCapgoApi<ChangelogReleasesResponse>(`private/changelog?${params.toString()}`, { method: 'GET' })
.then(({ data, error }) => {
if (error || !data) {
releasesCache.delete(key)
return null
}
return data
})
releasesCache.set(key, { at: Date.now(), promise })
return promise
}
export async function fetchLatestVersion(product: ChangelogProduct, repo?: string | null) {
if (product === 'cli')
return fetchDependencyVersion(CLI_PACKAGE)
if (product === 'updater')
return fetchDependencyVersion(UPDATER_PACKAGE)
if (product === 'dependency' && repo)
return fetchDependencyVersion(repo)
if (product === 'capacitor')
return fetchDependencyVersion('@capacitor/core')
return (await fetchReleases(product, repo))?.latest ?? null
}
/** Limit registry lookups for a bundle with many native packages; release notes load only on navigation. */
function fetchDependencyVersion(name: string): Promise<string | null> {
const cached = dependencyCache.get(name)
if (cached && Date.now() - cached.at < PLUGINS_CACHE_MS)
return cached.promise
const promise = (async () => {
if (dependencyRequests >= 4)
await new Promise<void>(resolve => dependencyQueue.push(resolve))
dependencyRequests++
try {
const params = new URLSearchParams({ package: name })
const { data, error } = await invokeCapgoApi<{ version: string }>(`private/changelog/dependency?${params}`, { method: 'GET', retries: 0 })
if (error || !data) {
dependencyCache.delete(name)
return null
}
return cleanVersion(data.version)
}
finally {
dependencyRequests--
dependencyQueue.shift()?.()
}
})()
if (dependencyCache.size >= 200)
dependencyCache.delete(dependencyCache.keys().next().value ?? '')
dependencyCache.set(name, { at: Date.now(), promise })
return promise
}
export function fetchPlugins(): Promise<ChangelogPlugin[]> {
if (pluginsCache && Date.now() - pluginsCache.at < PLUGINS_CACHE_MS)
return pluginsCache.promise
const promise = invokeCapgoApi<{ plugins: ChangelogPlugin[] }>('private/changelog/plugins', { method: 'GET' })
.then(({ data, error }) => {
if (error || !data) {
pluginsCache = null
return []
}
return data.plugins
})
pluginsCache = { at: Date.now(), promise }
return promise
}
export function fetchDigest(input: {
product: ChangelogProduct
repo?: string | null
fromVersion: string
toVersion?: string | null
locale?: string
context?: DigestContext
}): Promise<ChangelogDigest | null> {
const normalized = {
product: input.product,
repo: input.repo ?? null,
fromVersion: cleanVersion(input.fromVersion) ?? input.fromVersion,
toVersion: cleanVersion(input.toVersion),
locale: (input.locale ?? 'en').toLowerCase(),
context: {
platforms: [...new Set((input.context?.platforms ?? []).map(value => value.toLowerCase()))].sort(),
features: [...new Set((input.context?.features ?? []).map(value => value.toLowerCase()))].sort(),
},
}
const key = JSON.stringify(normalized)
const hit = digestCache.get(key)
if (hit && Date.now() - hit.at < hit.ttl)
return hit.promise
const promise = invokeCapgoApi<ChangelogDigest>('private/changelog/digest', {
method: 'POST',
body: normalized,
// Workers AI can take a few seconds; never retry a paid call.
retries: 0,
}).then(({ data, error }) => {
if (error || !data) {
digestCache.delete(key)
}
else if (!data.aiGenerated) {
const cached = digestCache.get(key)
if (cached)
cached.ttl = 60_000
}
return error ? null : data
})
if (digestCache.size >= 100)
digestCache.delete(digestCache.keys().next().value ?? '')
digestCache.set(key, { at: Date.now(), ttl: RELEASES_CACHE_MS, promise })
return promise
}
/** Case-insensitive match of every search word against a release's version and items. */
export function filterReleases(
releases: ChangelogRelease[],
query: string,
types: Set<ChangelogSectionType>,
): ChangelogRelease[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
// Without filters every release stays, even one with no parsed notes (it shows its version and GitHub link).
if (!words.length && types.size === 0)
return releases
return releases.flatMap((release) => {
const versionHit = words.length > 0 && words.every(word => release.version.includes(word))
const sections = release.sections
.filter(section => types.size === 0 || types.has(section.type))
.map(section => ({
...section,
items: versionHit || !words.length
? section.items
: section.items.filter((item) => {
const lower = item.toLowerCase()
return words.every(word => lower.includes(word))
}),
}))
.filter(section => section.items.length)
return sections.length ? [{ ...release, sections }] : []
})
}
export interface HighlightPart {
text: string
match: boolean
}
/** Split text into parts so the template can wrap search hits in <mark> without v-html. */
export function highlightParts(text: string, query: string): HighlightPart[] {
const words = query.split(/\s+/).filter(Boolean).map(word => word.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
if (!words.length)
return [{ text, match: false }]
const pattern = new RegExp(`(${words.join('|')})`, 'gi')
return text.split(pattern).filter(Boolean).map(part => ({ text: part, match: words.some(word => new RegExp(`^${word}$`, 'i').test(part)) }))
}
/** Render the small markdown subset release notes use (**bold**, `code`, #123) as safe parts. */
export type InlinePart = { kind: 'text' | 'bold' | 'code', text: string } | { kind: 'issue', text: string, number: string }
export function inlineMarkdownParts(text: string): InlinePart[] {
const parts: InlinePart[] = []
const pattern = /\*\*(.+?)\*\*|`([^`]+)`|\(#(\d+)\)|#(\d+)\b/g
let last = 0
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0
if (index > last)
parts.push({ kind: 'text', text: text.slice(last, index) })
if (match[1])
parts.push({ kind: 'bold', text: match[1].replace(/`/g, '') })
else if (match[2])
parts.push({ kind: 'code', text: match[2] })
else
parts.push({ kind: 'issue', text: `#${match[3] ?? match[4]}`, number: match[3] ?? match[4] })
last = index + match[0].length
}
if (last < text.length)
parts.push({ kind: 'text', text: text.slice(last) })
return parts
}
const LAST_SEEN_CONSOLE_KEY = 'capgo_changelog_console_seen'
export function getLastSeenConsoleVersion() {
try {
return localStorage.getItem(LAST_SEEN_CONSOLE_KEY)
}
catch {
return null
}
}
export function setLastSeenConsoleVersion(version: string) {
try {
localStorage.setItem(LAST_SEEN_CONSOLE_KEY, version)
}
catch {
// Private mode: the modal may show again next session, which is harmless.
}
}