mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -07:00
feat(overlay): add in-app changelog modal (#187)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ChangelogSnapshot,
|
||||
CompiledSessionBinding,
|
||||
ControllerConfigUpdate,
|
||||
PlaylistBrowserMutationResult,
|
||||
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
|
||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
entries: [],
|
||||
})),
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
getChangelogSnapshot: options.getChangelogSnapshot,
|
||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => {
|
||||
const refresh =
|
||||
typeof payload === 'object' && payload !== null && 'refresh' in payload
|
||||
? (payload as { refresh?: unknown }).refresh === true
|
||||
: false;
|
||||
if (!deps.getChangelogSnapshot) {
|
||||
throw new Error('Changelog service is unavailable.');
|
||||
}
|
||||
return await deps.getChangelogSnapshot({ refresh });
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
||||
return await deps.getPlaylistBrowserSnapshot();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseChangelog, resolveChangelogGroupKey } from './changelog-parse';
|
||||
|
||||
const SAMPLE = `# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Changed
|
||||
- Subsync: picks both tracks now.
|
||||
|
||||
### Fixed
|
||||
- Overlay: shows the plain line immediately.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Patched \`undici\`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
### Added
|
||||
- **Word Card Type:**
|
||||
- Adds a setting.
|
||||
- Flags clear each other.
|
||||
|
||||
## v0.18.0 (2026-07-01)
|
||||
|
||||
### Fixed
|
||||
- Something older.
|
||||
`;
|
||||
|
||||
test('changelog parser reads versions, dates, and sections in file order', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => `${entry.version}@${entry.date}`),
|
||||
['0.19.2@2026-08-04', '0.19.1@2026-08-01', '0.18.0@2026-07-01'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
entries[0]?.sections.map((section) => section.heading),
|
||||
['Changed', 'Fixed', 'Internal'],
|
||||
);
|
||||
assert.deepEqual(entries[0]?.sections[1]?.items, [
|
||||
{ text: 'Overlay: shows the plain line immediately.', children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser flags sections inside the details block as internal', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
const sections = entries[0]?.sections ?? [];
|
||||
|
||||
assert.deepEqual(
|
||||
sections.map((section) => section.internal),
|
||||
[false, false, true],
|
||||
);
|
||||
assert.deepEqual(sections[2]?.items, [{ text: 'Patched `undici`.', children: [] }]);
|
||||
});
|
||||
|
||||
test('changelog parser groups entries by major.minor', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.groupKey),
|
||||
['0.19', '0.19', '0.18'],
|
||||
);
|
||||
assert.equal(resolveChangelogGroupKey('1.2.3'), '1.2');
|
||||
});
|
||||
|
||||
test('changelog parser keeps bullets that precede any section heading', () => {
|
||||
const entries = parseChangelog('## v0.1.0 (2025-01-01)\n\n- Initial release.\n');
|
||||
|
||||
assert.deepEqual(entries[0]?.sections, [
|
||||
{
|
||||
heading: 'Changes',
|
||||
items: [{ text: 'Initial release.', children: [] }],
|
||||
internal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser drops empty sections and tolerates missing dates', () => {
|
||||
const entries = parseChangelog('## v0.2.0\n\n### Added\n\n### Fixed\n- One fix.\n');
|
||||
|
||||
assert.equal(entries[0]?.date, '');
|
||||
assert.deepEqual(
|
||||
entries[0]?.sections.map((section) => section.heading),
|
||||
['Fixed'],
|
||||
);
|
||||
});
|
||||
|
||||
test('changelog parser keeps indented sub-bullets nested under their lead bullet', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
const added = entries[1]?.sections.find((section) => section.heading === 'Added');
|
||||
|
||||
assert.deepEqual(added?.items, [
|
||||
{
|
||||
text: '**Word Card Type:**',
|
||||
children: [
|
||||
{ text: 'Adds a setting.', children: [] },
|
||||
{ text: 'Flags clear each other.', children: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser nests three bullet levels and rejoins wrapped lines', () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
'## v0.9.0 (2025-05-05)',
|
||||
'',
|
||||
'### Added',
|
||||
'- Top level',
|
||||
' - Second level',
|
||||
' - Third level',
|
||||
' continued on the next line',
|
||||
' - Back to second level',
|
||||
'- Another top level',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.deepEqual(entries[0]?.sections[0]?.items, [
|
||||
{
|
||||
text: 'Top level',
|
||||
children: [
|
||||
{
|
||||
text: 'Second level',
|
||||
children: [{ text: 'Third level continued on the next line', children: [] }],
|
||||
},
|
||||
{ text: 'Back to second level', children: [] },
|
||||
],
|
||||
},
|
||||
{ text: 'Another top level', children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser reads prerelease and build metadata version headings', () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
'## v0.16.0 (2026-06-01)',
|
||||
'',
|
||||
'### Added',
|
||||
'- New in 0.16.',
|
||||
'',
|
||||
'## v0.15.0-rc.1+build.2 (2026-05-29)',
|
||||
'',
|
||||
'### Added',
|
||||
'- Release candidate note.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// The prerelease heading has to become its own entry. Asserting the exact
|
||||
// version list is what catches the failure mode: a heading the regex misses
|
||||
// is not skipped, its notes silently fold into the release above it.
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.version),
|
||||
['0.16.0', '0.15.0-rc.1+build.2'],
|
||||
);
|
||||
assert.equal(entries[1]?.date, '2026-05-29');
|
||||
assert.equal(entries[1]?.groupKey, '0.15');
|
||||
assert.equal(entries[0]?.sections.length, 1);
|
||||
// The prerelease body has to land on its own entry, not fold into 0.16.0.
|
||||
assert.deepEqual(entries[1]?.sections, [
|
||||
{
|
||||
heading: 'Added',
|
||||
items: [{ text: 'Release candidate note.', children: [] }],
|
||||
internal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser handles the repo CHANGELOG.md', () => {
|
||||
const markdown = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8');
|
||||
const entries = parseChangelog(markdown);
|
||||
|
||||
assert.ok(entries.length > 3);
|
||||
for (const entry of entries) {
|
||||
assert.match(entry.version, /^\d+\.\d+\.\d+/);
|
||||
assert.ok(entry.sections.length > 0, `expected sections for v${entry.version}`);
|
||||
for (const section of entry.sections) {
|
||||
for (const item of section.items) {
|
||||
assert.ok(item.text.length > 0, `empty bullet in v${entry.version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Older entries group notes under a bold lead bullet; nesting must survive.
|
||||
const breaking = entries
|
||||
.find((entry) => entry.version === '0.15.0')
|
||||
?.sections.find((section) => section.heading === 'Breaking Changes');
|
||||
assert.deepEqual(
|
||||
breaking?.items.map((item) => `${item.text}:${item.children.length}`),
|
||||
['**Subsync:**:2', '**N+1 Highlighting:**:2'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog';
|
||||
|
||||
// Prerelease and build metadata are matched separately: a single `[-+]`-led
|
||||
// group cannot span `-rc.1+build.2`, and an unmatched heading silently folds
|
||||
// that release's notes into the previous entry.
|
||||
const VERSION_HEADING =
|
||||
/^##\s+v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\s*(?:\(([^)]*)\))?\s*$/;
|
||||
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
|
||||
const BULLET = /^(\s*)[-*]\s+(.*)$/;
|
||||
|
||||
/**
|
||||
* Entries are grouped by `major.minor` so the whole current minor line renders
|
||||
* expanded, matching how docs-site/changelog.md splits current vs previous.
|
||||
*/
|
||||
export function resolveChangelogGroupKey(version: string): string {
|
||||
const match = version.match(/^(\d+)\.(\d+)/);
|
||||
if (!match) return version;
|
||||
return `${match[1]}.${match[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the repo CHANGELOG.md into version entries. Bullets keep their inline
|
||||
* markdown and their nesting: older entries group related notes under a bold
|
||||
* lead bullet with indented children, and flattening them loses that structure.
|
||||
*/
|
||||
export function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let entry: ChangelogEntry | null = null;
|
||||
let section: ChangelogSection | null = null;
|
||||
let internal = false;
|
||||
// Open bullets from outermost to innermost, used to place the next bullet.
|
||||
let openItems: Array<{ indent: number; item: ChangelogItem }> = [];
|
||||
|
||||
function startSection(heading: string): void {
|
||||
section = { heading, items: [], internal };
|
||||
openItems = [];
|
||||
entry?.sections.push(section);
|
||||
}
|
||||
|
||||
function addBullet(indent: number, text: string): void {
|
||||
if (!section) {
|
||||
// Bullets before any "###" heading (older entries) land in a generic group.
|
||||
startSection('Changes');
|
||||
}
|
||||
const item: ChangelogItem = { text, children: [] };
|
||||
|
||||
while (openItems.length > 0 && (openItems[openItems.length - 1]?.indent ?? 0) >= indent) {
|
||||
openItems.pop();
|
||||
}
|
||||
const parent = openItems[openItems.length - 1];
|
||||
if (parent) {
|
||||
parent.item.children.push(item);
|
||||
} else {
|
||||
section?.items.push(item);
|
||||
}
|
||||
openItems.push({ indent, item });
|
||||
}
|
||||
|
||||
function appendContinuation(text: string): void {
|
||||
const current = openItems[openItems.length - 1];
|
||||
if (!current) return;
|
||||
current.item.text = `${current.item.text} ${text}`;
|
||||
}
|
||||
|
||||
for (const rawLine of markdown.split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
|
||||
const versionMatch = trimmed.match(VERSION_HEADING);
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1] ?? '';
|
||||
entry = {
|
||||
version,
|
||||
date: versionMatch[2]?.trim() ?? '',
|
||||
groupKey: resolveChangelogGroupKey(version),
|
||||
sections: [],
|
||||
};
|
||||
entries.push(entry);
|
||||
section = null;
|
||||
internal = false;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry) continue;
|
||||
|
||||
if (trimmed.startsWith('<details')) {
|
||||
internal = true;
|
||||
section = null;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('</details')) {
|
||||
internal = false;
|
||||
section = null;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('<summary')) continue;
|
||||
|
||||
const sectionMatch = trimmed.match(SECTION_HEADING);
|
||||
if (sectionMatch) {
|
||||
startSection(sectionMatch[1] ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(BULLET);
|
||||
if (bulletMatch) {
|
||||
addBullet((bulletMatch[1] ?? '').length, bulletMatch[2] ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
// An indented non-bullet line continues the bullet above it, including
|
||||
// across a blank line: that is CommonMark's continuation paragraph, and
|
||||
// dropping the open bullets here would silently discard the text.
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (/^\s/.test(line)) {
|
||||
appendContinuation(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map((item) => ({
|
||||
...item,
|
||||
sections: item.sections.filter((entrySection) => entrySection.items.length > 0),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Loose semver ordering shared by the updater and the changelog UI.
|
||||
* Returns >0 when `a` is newer, <0 when older, 0 when equal.
|
||||
*/
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
// Build metadata ("+build.2") is not part of precedence per semver, and
|
||||
// leaving it attached makes it leak into the prerelease comparison.
|
||||
const normalized = value.replace(/^v/i, '').split('+', 1)[0] ?? '';
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user