mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
feat(anime): add version-aware extension updates
- Compare installed APK version codes before offering updates - Add update status labels and an Update all action
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AvailableExtension, InstalledExtensionView } from '../types/anime-browser';
|
||||
import { getExtensionUpdateState, summarizeExtensionUpdates } from './extensions-panel';
|
||||
|
||||
const installed = {
|
||||
pkg: 'pkg.example',
|
||||
name: 'Example',
|
||||
langs: ['en'],
|
||||
sourceCount: 1,
|
||||
versionCode: 12,
|
||||
error: null,
|
||||
} satisfies InstalledExtensionView;
|
||||
|
||||
const offered = {
|
||||
pkg: 'pkg.example',
|
||||
name: 'Example',
|
||||
lang: 'en',
|
||||
version: '1.2.0',
|
||||
versionCode: 12,
|
||||
nsfw: false,
|
||||
repoUrl: 'https://repo.example/index.json',
|
||||
iconUrl: 'https://repo.example/icon.png',
|
||||
sourceNames: ['Example'],
|
||||
installed: true,
|
||||
} satisfies AvailableExtension;
|
||||
|
||||
test('extension update state only enables a strictly newer repository build', () => {
|
||||
assert.equal(getExtensionUpdateState(installed, { ...offered, versionCode: 13 }), 'available');
|
||||
assert.equal(getExtensionUpdateState(installed, offered), 'current');
|
||||
assert.equal(getExtensionUpdateState(installed, { ...offered, versionCode: 11 }), 'current');
|
||||
});
|
||||
|
||||
test('extension update state does not offer unverifiable updates', () => {
|
||||
assert.equal(getExtensionUpdateState({ ...installed, versionCode: null }, offered), 'unknown');
|
||||
assert.equal(getExtensionUpdateState(installed, undefined), 'unavailable');
|
||||
});
|
||||
|
||||
test('bulk update summary distinguishes waiting, current, and unverifiable states', () => {
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'available', 'available']), {
|
||||
kind: 'available',
|
||||
count: 2,
|
||||
});
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'current']), { kind: 'current' });
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'unknown']), { kind: 'none' });
|
||||
assert.deepEqual(summarizeExtensionUpdates([]), { kind: 'none' });
|
||||
});
|
||||
+103
-12
@@ -13,6 +13,7 @@ import type {
|
||||
AvailableExtension,
|
||||
InstalledExtensionView,
|
||||
} from '../types/anime-browser';
|
||||
import { hasExtensionUpdate } from '../shared/extension-updates';
|
||||
|
||||
/**
|
||||
* The Extensions tab: what is installed, which repositories feed it, and what
|
||||
@@ -31,11 +32,17 @@ export interface ExtensionsPanelOptions {
|
||||
onSourcesChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface RowAction {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
type RowAction =
|
||||
| {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
disabled: true;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
interface RowOptions {
|
||||
name: string;
|
||||
@@ -96,8 +103,14 @@ function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
for (const action of options.actions ?? []) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.className = 'primary' in action && action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.textContent = action.label;
|
||||
if ('disabled' in action) {
|
||||
button.disabled = true;
|
||||
if (action.title) button.title = action.title;
|
||||
row.append(button);
|
||||
continue;
|
||||
}
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
void Promise.resolve(action.onClick()).finally(() => {
|
||||
@@ -110,6 +123,32 @@ function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
export type ExtensionUpdateState = 'available' | 'current' | 'unknown' | 'unavailable';
|
||||
|
||||
export type ExtensionUpdateSummary =
|
||||
| { kind: 'available'; count: number }
|
||||
| { kind: 'current' }
|
||||
| { kind: 'none' };
|
||||
|
||||
export function getExtensionUpdateState(
|
||||
installed: InstalledExtensionView,
|
||||
offered: AvailableExtension | undefined,
|
||||
): ExtensionUpdateState {
|
||||
if (!offered) return 'unavailable';
|
||||
if (installed.versionCode === null) return 'unknown';
|
||||
return hasExtensionUpdate(installed.versionCode, offered.versionCode) ? 'available' : 'current';
|
||||
}
|
||||
|
||||
export function summarizeExtensionUpdates(
|
||||
states: readonly ExtensionUpdateState[],
|
||||
): ExtensionUpdateSummary {
|
||||
const count = states.filter((state) => state === 'available').length;
|
||||
if (count > 0) return { kind: 'available', count };
|
||||
return states.length > 0 && states.every((state) => state === 'current')
|
||||
? { kind: 'current' }
|
||||
: { kind: 'none' };
|
||||
}
|
||||
|
||||
function emptyNote(text: string): HTMLParagraphElement {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'ext-empty';
|
||||
@@ -124,6 +163,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
const bridgeInfo = el<HTMLParagraphElement>('bridge-info');
|
||||
const installedList = el<HTMLDivElement>('installed-list');
|
||||
const installedCount = el<HTMLSpanElement>('installed-count');
|
||||
const updateAllButton = el<HTMLButtonElement>('update-all');
|
||||
const availableList = el<HTMLDivElement>('extensions-list');
|
||||
const availableCount = el<HTMLSpanElement>('available-count');
|
||||
const langFilter = el<HTMLDivElement>('lang-filter');
|
||||
@@ -138,6 +178,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
let iconsByPkg = new Map<string, string>();
|
||||
let repoFailures: Array<{ name: string; error: string }> = [];
|
||||
let hasRepos = false;
|
||||
let updatingAll = false;
|
||||
let pendingUpdateCount = 0;
|
||||
/** Selected language codes; empty means "All". */
|
||||
let selectedLangs = new Set<string>();
|
||||
|
||||
@@ -149,10 +191,23 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
|
||||
function renderInstalled(
|
||||
installed: InstalledExtensionView[],
|
||||
offeredPkgs: Set<string>,
|
||||
offeredByPkg: Map<string, AvailableExtension>,
|
||||
extensionsDir: string,
|
||||
): void {
|
||||
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
|
||||
const updateStates = installed.map((view) =>
|
||||
getExtensionUpdateState(view, offeredByPkg.get(view.pkg)),
|
||||
);
|
||||
const updateSummary = summarizeExtensionUpdates(updateStates);
|
||||
const updateCount = updateSummary.kind === 'available' ? updateSummary.count : 0;
|
||||
pendingUpdateCount = updateCount;
|
||||
updateAllButton.textContent =
|
||||
updateSummary.kind === 'available'
|
||||
? `Update all (${updateSummary.count})`
|
||||
: updateSummary.kind === 'current'
|
||||
? 'All up to date'
|
||||
: 'No updates';
|
||||
updateAllButton.disabled = updatingAll || updateCount === 0;
|
||||
|
||||
if (installed.length === 0) {
|
||||
installedList.replaceChildren(
|
||||
@@ -166,9 +221,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
installedList.replaceChildren(
|
||||
...installed.map((view) => {
|
||||
const actions: RowAction[] = [];
|
||||
// Only offer an update for an extension a configured repository still
|
||||
// carries; reinstalling overwrites the APK in place.
|
||||
if (offeredPkgs.has(view.pkg)) {
|
||||
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
|
||||
if (updateState === 'available') {
|
||||
actions.push({
|
||||
label: 'Update',
|
||||
onClick: async () => {
|
||||
@@ -181,6 +235,14 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (updateState === 'current') {
|
||||
actions.push({ label: 'Up to date', disabled: true });
|
||||
} else if (updateState === 'unknown') {
|
||||
actions.push({
|
||||
label: 'Version unknown',
|
||||
disabled: true,
|
||||
title: 'SubMiner could not read a version code from this APK.',
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
label: 'Remove',
|
||||
@@ -344,11 +406,13 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
repoFailures.push({ name: failure.repoUrl, error: failure.error });
|
||||
}
|
||||
|
||||
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
|
||||
const offeredByPkg = new Map(
|
||||
available.extensions.map((extension) => [extension.pkg, extension]),
|
||||
);
|
||||
// The catalogue is the only source of icons, so an installed extension can
|
||||
// only show one while a repository still carries its package.
|
||||
iconsByPkg = buildIconIndex(available.extensions);
|
||||
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
|
||||
renderInstalled(snapshot.installed, offeredByPkg, snapshot.extensionsDir);
|
||||
// Installed extensions have their own section; leaving them here too would
|
||||
// list every one of them twice.
|
||||
installable = available.extensions.filter((extension) => !extension.installed);
|
||||
@@ -371,6 +435,33 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
})();
|
||||
});
|
||||
|
||||
updateAllButton.addEventListener('click', () => {
|
||||
if (updateAllButton.disabled) return;
|
||||
updatingAll = true;
|
||||
updateAllButton.disabled = true;
|
||||
updateAllButton.textContent = 'Updating…';
|
||||
setStatus('Updating extensions…');
|
||||
void (async () => {
|
||||
try {
|
||||
const count = await api.updateAllExtensions();
|
||||
await refresh();
|
||||
await onSourcesChanged();
|
||||
setStatus(`${count} ${count === 1 ? 'extension' : 'extensions'} updated`, 'ok');
|
||||
} catch (error) {
|
||||
await refresh().catch(() => undefined);
|
||||
await onSourcesChanged().catch(() => undefined);
|
||||
setStatus(describe(error), 'error');
|
||||
} finally {
|
||||
updatingAll = false;
|
||||
updateAllButton.disabled = pendingUpdateCount === 0;
|
||||
if (updateAllButton.textContent === 'Updating…') {
|
||||
updateAllButton.textContent =
|
||||
pendingUpdateCount > 0 ? `Update all (${pendingUpdateCount})` : 'No updates';
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
repoInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -51,6 +51,7 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
versionCode: 1,
|
||||
error: null,
|
||||
}),
|
||||
'multi · 2 sources · en, ja',
|
||||
@@ -59,7 +60,14 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
||||
|
||||
test('describeInstalled falls back to the package alone when nothing loaded', () => {
|
||||
assert.equal(
|
||||
describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
|
||||
describeInstalled({
|
||||
pkg: 'broken',
|
||||
name: 'broken',
|
||||
langs: [],
|
||||
sourceCount: 0,
|
||||
versionCode: null,
|
||||
error: 'boom',
|
||||
}),
|
||||
'broken',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,9 +100,14 @@
|
||||
<span class="settings-note" id="extensions-dir"></span>
|
||||
</div>
|
||||
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<div class="ext-group-heading">
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<button class="ghost-button ext-update-all" id="update-all" type="button" disabled>
|
||||
No updates
|
||||
</button>
|
||||
</div>
|
||||
<div class="ext-list" id="installed-list" aria-label="Installed extensions"></div>
|
||||
|
||||
<h3 class="ext-group-title">Repositories</h3>
|
||||
|
||||
+19
-1
@@ -130,8 +130,26 @@
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ext-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ext-group-heading .ext-group-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ext-update-all {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* A rule between the groups, but not above the first one. */
|
||||
.ext-list + .ext-group-title {
|
||||
.ext-list + .ext-group-title,
|
||||
.ext-list + .ext-group-heading {
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user