mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-16 15:13:31 -07:00
Fix Windows Anki startup and overlay regressions (#128)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed Highlight Word not bolding the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence field did not already contain bold markup.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed Lapis/Kiku word cards enriched through SubMiner missing the word-and-sentence marker, which could hide sentence context on the card front.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed known-word cache refreshes without a configured deck by using AnkiConnect's valid all-notes query instead of `is:note`.
|
||||
- Fixed Windows media generation after background launches by recreating missing FFmpeg temp output directories before clipping audio or images.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: dictionary
|
||||
|
||||
- Fixed Windows `SubMiner mpv` shortcut launches so character dictionary auto-sync can fall back to mpv's current video path when app media state is not ready yet.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed shaky Windows subtitle-bar hover/click interaction when a video attaches to an already-running background SubMiner app.
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const electron_1 = require("electron");
|
||||
const help_1 = require("./cli/help");
|
||||
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||
const early_single_instance_1 = require("./main/early-single-instance");
|
||||
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||
const app_control_client_1 = require("./shared/app-control-client");
|
||||
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||
const fatal_error_1 = require("./main/fatal-error");
|
||||
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||
const log_files_1 = require("./shared/log-files");
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||
}
|
||||
function applySanitizedEnv(sanitizedEnv) {
|
||||
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
}
|
||||
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
else {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||
dirname: __dirname,
|
||||
appPath: electron_1.app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}) ?? undefined);
|
||||
}
|
||||
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||
return [
|
||||
'SubMiner detected an installed mpv plugin at:',
|
||||
pathValue,
|
||||
'',
|
||||
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||
].join('\n');
|
||||
}
|
||||
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||
const response = await electron_1.dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
});
|
||||
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||
candidates,
|
||||
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||
});
|
||||
if (result.ok) {
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Legacy mpv plugin removed',
|
||||
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||
});
|
||||
return 'removed';
|
||||
}
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Could not remove legacy mpv plugin',
|
||||
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||
});
|
||||
return 'cancel';
|
||||
}
|
||||
function createWindowsRuntimePluginPolicy() {
|
||||
return {
|
||||
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
}),
|
||||
notifyInstalledPluginDetected: (detection) => {
|
||||
if (!detection.installed || !detection.path)
|
||||
return;
|
||||
electron_1.dialog.showMessageBoxSync({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||
});
|
||||
},
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||
};
|
||||
}
|
||||
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||
consoleError: (message, error) => console.error(message, error),
|
||||
});
|
||||
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||
reportFatalError,
|
||||
exit: (code) => electron_1.app.exit(code),
|
||||
});
|
||||
function startMainProcess() {
|
||||
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
electron_1.app.exit(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
}
|
||||
catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
electron_1.app.exit(1);
|
||||
}
|
||||
}
|
||||
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||
return false;
|
||||
}
|
||||
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
if (result.ok) {
|
||||
electron_1.app.exit(0);
|
||||
return true;
|
||||
}
|
||||
if (!result.unavailable) {
|
||||
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||
electron_1.app.exit(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function runEntryProcess() {
|
||||
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
await electron_1.app.whenReady();
|
||||
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||
: '';
|
||||
if (mpvLogPath) {
|
||||
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||
}
|
||||
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||
getEnv: (name) => process.env[name],
|
||||
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 350,
|
||||
}),
|
||||
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
showError: (title, content) => {
|
||||
electron_1.dialog.showErrorBox(title, content);
|
||||
},
|
||||
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||
electron_1.app.exit(result.ok ? 0 : 1);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||
await electron_1.app.whenReady();
|
||||
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||
electron_1.app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
startMainProcess();
|
||||
}
|
||||
void runEntryProcess().catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
//# sourceMappingURL=main-entry.js.map
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -95,7 +95,7 @@ function createIntegrationTestContext(
|
||||
knownWordsScope: string;
|
||||
knownWordsLastRefreshedAtMs: number;
|
||||
};
|
||||
privateState.knownWordsScope = 'is:note';
|
||||
privateState.knownWordsScope = 'all';
|
||||
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
||||
|
||||
return {
|
||||
@@ -324,6 +324,119 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
|
||||
}
|
||||
});
|
||||
|
||||
function processSentenceWithConfig(
|
||||
config: Partial<AnkiConnectConfig>,
|
||||
mpvSentence: string,
|
||||
noteFields: Record<string, string>,
|
||||
): string {
|
||||
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
|
||||
return (
|
||||
integration as unknown as {
|
||||
processSentence: (sentence: string, fields: Record<string, string>) => string;
|
||||
}
|
||||
).processSentence(mpvSentence, noteFields);
|
||||
}
|
||||
|
||||
function processSentenceFuriganaWithConfig(
|
||||
config: Partial<AnkiConnectConfig>,
|
||||
sentenceFurigana: string,
|
||||
noteFields: Record<string, string>,
|
||||
): string {
|
||||
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
|
||||
return (
|
||||
integration as unknown as {
|
||||
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => string;
|
||||
}
|
||||
).processSentenceFurigana(sentenceFurigana, noteFields);
|
||||
}
|
||||
|
||||
test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => {
|
||||
const processed = processSentenceWithConfig(
|
||||
{
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
behavior: {
|
||||
highlightWord: true,
|
||||
},
|
||||
},
|
||||
'先日 貴様らが潜入した キールダンジョンから―',
|
||||
{
|
||||
expression: '潜入',
|
||||
sentence: '先日 貴様らが潜入した キールダンジョンから―',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(processed, '先日 貴様らが<b>潜入</b>した キールダンジョンから―');
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
|
||||
const processed = processSentenceWithConfig(
|
||||
{
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
behavior: {
|
||||
highlightWord: true,
|
||||
},
|
||||
},
|
||||
'先日 貴様らが潜入した キールダンジョンから―',
|
||||
{
|
||||
expression: '潜入',
|
||||
sentence: '<b>潜入した</b>',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(processed, '先日 貴様らが<b>潜入した</b> キールダンジョンから―');
|
||||
});
|
||||
|
||||
test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
|
||||
const processed = processSentenceWithConfig(
|
||||
{
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
behavior: {
|
||||
highlightWord: false,
|
||||
},
|
||||
},
|
||||
'先日 貴様らが潜入した キールダンジョンから―',
|
||||
{
|
||||
expression: '潜入',
|
||||
sentence: '<b>潜入</b>',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
|
||||
});
|
||||
|
||||
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
|
||||
const processed = processSentenceFuriganaWithConfig(
|
||||
{
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
behavior: {
|
||||
highlightWord: true,
|
||||
},
|
||||
},
|
||||
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
|
||||
{
|
||||
expression: '特技',
|
||||
sentence: '不思議な特技を',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
processed,
|
||||
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
|
||||
+114
-7
@@ -70,7 +70,7 @@ interface NoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio';
|
||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
@@ -78,6 +78,66 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function stripRubyReadingText(value: string): string {
|
||||
return value
|
||||
.replace(/<rt\b[^>]*>[\s\S]*?<\/rt>/gi, '')
|
||||
.replace(/<rp\b[^>]*>[\s\S]*?<\/rp>/gi, '');
|
||||
}
|
||||
|
||||
function stripHtmlTags(value: string): string {
|
||||
return value.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
function getVisibleFuriganaText(value: string): string {
|
||||
return stripHtmlTags(stripRubyReadingText(value));
|
||||
}
|
||||
|
||||
function boldMatchingFuriganaTerms(sentenceFurigana: string, highlightedText: string): string {
|
||||
if (!sentenceFurigana || !highlightedText || /<b\b/i.test(sentenceFurigana)) {
|
||||
return sentenceFurigana;
|
||||
}
|
||||
|
||||
const spanRegex = /<span\b[^>]*>[\s\S]*?<\/span>/gi;
|
||||
const spans: Array<{ start: number; end: number; visibleStart: number; visibleEnd: number }> = [];
|
||||
let visibleSentence = '';
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = spanRegex.exec(sentenceFurigana)) !== null) {
|
||||
const visibleStart = visibleSentence.length;
|
||||
visibleSentence += getVisibleFuriganaText(match[0] || '');
|
||||
spans.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
visibleStart,
|
||||
visibleEnd: visibleSentence.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (spans.length === 0) {
|
||||
return sentenceFurigana.replace(highlightedText, `<b>${highlightedText}</b>`);
|
||||
}
|
||||
|
||||
const highlightStart = visibleSentence.indexOf(highlightedText);
|
||||
if (highlightStart === -1) {
|
||||
return sentenceFurigana;
|
||||
}
|
||||
const highlightEnd = highlightStart + highlightedText.length;
|
||||
const matchingSpans = spans.filter(
|
||||
(span) => span.visibleEnd > highlightStart && span.visibleStart < highlightEnd,
|
||||
);
|
||||
if (matchingSpans.length === 0) {
|
||||
return sentenceFurigana;
|
||||
}
|
||||
|
||||
let result = sentenceFurigana;
|
||||
for (const span of [...matchingSpans].reverse()) {
|
||||
result = `${result.slice(0, span.start)}<b>${result.slice(
|
||||
span.start,
|
||||
span.end,
|
||||
)}</b>${result.slice(span.end)}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeURIComponentSafe(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
@@ -461,6 +521,10 @@ export class AnkiIntegration {
|
||||
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
||||
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
||||
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
||||
processSentenceFurigana: (sentenceFurigana, noteFields) =>
|
||||
this.processSentenceFurigana(sentenceFurigana, noteFields),
|
||||
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
|
||||
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||
@@ -677,20 +741,25 @@ export class AnkiIntegration {
|
||||
return result;
|
||||
}
|
||||
|
||||
private getSentenceHighlightText(noteFields: Record<string, string>): string {
|
||||
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
||||
const existingSentence = noteFields[sentenceFieldName] || '';
|
||||
return (
|
||||
existingSentence.match(/<b>(.*?)<\/b>/)?.[1] ||
|
||||
getPreferredWordValueFromExtractedFields(noteFields, this.config).trim()
|
||||
);
|
||||
}
|
||||
|
||||
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
|
||||
if (this.config.behavior?.highlightWord === false) {
|
||||
return mpvSentence;
|
||||
}
|
||||
|
||||
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
||||
const existingSentence = noteFields[sentenceFieldName] || '';
|
||||
|
||||
const highlightMatch = existingSentence.match(/<b>(.*?)<\/b>/);
|
||||
if (!highlightMatch || !highlightMatch[1]) {
|
||||
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||
if (!highlightedText) {
|
||||
return mpvSentence;
|
||||
}
|
||||
|
||||
const highlightedText = highlightMatch[1];
|
||||
const index = mpvSentence.indexOf(highlightedText);
|
||||
|
||||
if (index === -1) {
|
||||
@@ -702,6 +771,20 @@ export class AnkiIntegration {
|
||||
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
||||
}
|
||||
|
||||
private processSentenceFurigana(
|
||||
sentenceFurigana: string,
|
||||
noteFields: Record<string, string>,
|
||||
): string {
|
||||
if (this.config.behavior?.highlightWord === false) {
|
||||
return sentenceFurigana;
|
||||
}
|
||||
|
||||
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||
return highlightedText
|
||||
? boldMatchingFuriganaTerms(sentenceFurigana, highlightedText)
|
||||
: sentenceFurigana;
|
||||
}
|
||||
|
||||
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
||||
if (!this.consumeSubtitleMiningContextCallback) {
|
||||
return null;
|
||||
@@ -1030,6 +1113,30 @@ export class AnkiIntegration {
|
||||
): void {
|
||||
const audioFlagNames = ['IsAudioCard'];
|
||||
|
||||
if (cardKind === 'word-and-sentence') {
|
||||
const wordAndSentenceFlag = this.resolveFieldName(
|
||||
availableFieldNames,
|
||||
'IsWordAndSentenceCard',
|
||||
);
|
||||
if (!wordAndSentenceFlag) {
|
||||
return;
|
||||
}
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
||||
updatedFields[sentenceFlag] = '';
|
||||
}
|
||||
|
||||
for (const audioFlagName of audioFlagNames) {
|
||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardKind === 'sentence') {
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag) {
|
||||
|
||||
@@ -6,6 +6,27 @@ import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
function setWordAndSentenceCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
||||
): void {
|
||||
if (cardKind !== 'word-and-sentence') return;
|
||||
|
||||
const resolveFieldName = (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
service: CardCreationService;
|
||||
updatedFields: Record<string, string>[];
|
||||
@@ -142,6 +163,72 @@ test('manual clipboard subtitle update replaces sentence audio without touching
|
||||
);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
|
||||
const { service, updatedFields } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: false,
|
||||
overwriteImage: false,
|
||||
},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.deepEqual(updatedFields[0], {
|
||||
Sentence: '字幕',
|
||||
IsWordAndSentenceCard: 'x',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||
client: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AiConfig } from '../types/integrations';
|
||||
import { MpvClient } from '../types/runtime';
|
||||
import { resolveSentenceBackText } from './ai';
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
@@ -18,7 +19,7 @@ export interface CardCreationNoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio';
|
||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||
|
||||
interface CardCreationClient {
|
||||
addNote(
|
||||
@@ -219,7 +220,8 @@ export class CardCreationService {
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
|
||||
const sentence = blocks.join(' ');
|
||||
const updatedFields: Record<string, string> = {};
|
||||
@@ -230,6 +232,13 @@ export class CardCreationService {
|
||||
if (sentenceField) {
|
||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||
this.deps.setCardTypeFields(
|
||||
updatedFields,
|
||||
Object.keys(noteInfo.fields),
|
||||
'word-and-sentence',
|
||||
);
|
||||
}
|
||||
updatePerformed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: 120_000,
|
||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
||||
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
@@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: 59_000,
|
||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
||||
scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
@@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: 1,
|
||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
|
||||
scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}',
|
||||
words: ['猫', '犬'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
@@ -276,6 +276,36 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager uses empty query when no known-word deck is configured', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('', [1]);
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '猫' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager skips malformed note info without fields', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
@@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
||||
scope: string;
|
||||
words: string[];
|
||||
};
|
||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}');
|
||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||
assert.deepEqual(persisted.words, ['猫']);
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
@@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations',
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now(),
|
||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}',
|
||||
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
|
||||
@@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
||||
}
|
||||
|
||||
const configuredDeck = trimToNonEmptyString(config.deck);
|
||||
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
|
||||
return configuredDeck ? `deck:${configuredDeck}` : 'all';
|
||||
}
|
||||
|
||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||
@@ -396,7 +396,7 @@ export class KnownWordCacheManager {
|
||||
private buildKnownWordsQuery(): string {
|
||||
const decks = this.getKnownWordDecks();
|
||||
if (decks.length === 0) {
|
||||
return 'is:note';
|
||||
return '';
|
||||
}
|
||||
|
||||
if (decks.length === 1) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface NoteFieldValueInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
|
||||
const resolvedFieldName = Object.keys(noteInfo.fields).find(
|
||||
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||
);
|
||||
return resolvedFieldName ? (noteInfo.fields[resolvedFieldName]?.value ?? '') : null;
|
||||
}
|
||||
|
||||
export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): boolean {
|
||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||
}
|
||||
|
||||
export function shouldMarkWordAndSentenceCard(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
||||
): boolean {
|
||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
||||
if (wordAndSentenceValue === null) {
|
||||
return false;
|
||||
}
|
||||
if (wordAndSentenceValue.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,27 @@ import {
|
||||
} from './note-update-workflow';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
|
||||
function setWordAndSentenceCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'word-and-sentence',
|
||||
): void {
|
||||
assert.equal(cardKind, 'word-and-sentence');
|
||||
const resolveFieldName = (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
|
||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
||||
@@ -40,6 +61,7 @@ function createWorkflowHarness() {
|
||||
getCurrentSubtitleStart: () => 12.3,
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled' as const,
|
||||
}),
|
||||
@@ -57,6 +79,7 @@ function createWorkflowHarness() {
|
||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||
false,
|
||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||
if (!preferred) return null;
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
@@ -102,6 +125,118 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
||||
assert.equal(harness.notifications.length, 1);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'tokugi' },
|
||||
Sentence: { value: '' },
|
||||
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
|
||||
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
IsWordAndSentenceCard: 'x',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'sentence expression' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: 'x' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
@@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
|
||||
let notesInfoCallCount = 0;
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'auto',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
@@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
getCurrentSubtitleStart: () => number | undefined;
|
||||
getEffectiveSentenceCardConfig: () => {
|
||||
sentenceField: string;
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
};
|
||||
@@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps {
|
||||
expression: string,
|
||||
) => Promise<boolean>;
|
||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
||||
processSentenceFurigana?: (
|
||||
sentenceFurigana: string,
|
||||
noteFields: Record<string, string>,
|
||||
) => string;
|
||||
setCardTypeFields: (
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'word-and-sentence',
|
||||
) => void;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -189,8 +200,32 @@ export class NoteUpdateWorkflow {
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||
this.deps.setCardTypeFields(
|
||||
updatedFields,
|
||||
Object.keys(noteInfo.fields),
|
||||
'word-and-sentence',
|
||||
);
|
||||
}
|
||||
updatePerformed = true;
|
||||
}
|
||||
const sentenceFuriganaField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
'SentenceFurigana',
|
||||
);
|
||||
const existingSentenceFurigana = sentenceFuriganaField
|
||||
? noteInfo.fields[sentenceFuriganaField]?.value || ''
|
||||
: '';
|
||||
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
|
||||
const processedSentenceFurigana = this.deps.processSentenceFurigana(
|
||||
existingSentenceFurigana,
|
||||
fields,
|
||||
);
|
||||
if (processedSentenceFurigana !== existingSentenceFurigana) {
|
||||
updatedFields[sentenceFuriganaField] = processedSentenceFurigana;
|
||||
updatePerformed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.media?.generateAudio) {
|
||||
try {
|
||||
|
||||
@@ -2035,6 +2035,76 @@ Aligned English subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(
|
||||
async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
addYomitanNote: async () => 777,
|
||||
createMediaGenerator: () => ({
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
}),
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
isKiku: {
|
||||
enabled: true,
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
|
||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||
assert.equal(fields.Sentence, '<b>猫</b>を見た');
|
||||
assert.equal(fields.IsWordAndSentenceCard, 'x');
|
||||
assert.equal(fields.IsSentenceCard, '');
|
||||
assert.equal(fields.IsAudioCard, '');
|
||||
},
|
||||
{
|
||||
notesInfoFields: {
|
||||
Expression: { value: '猫' },
|
||||
Sentence: { value: '' },
|
||||
Picture: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
|
||||
@@ -204,6 +204,29 @@ function getStatsWordMiningAudioFieldName(
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
|
||||
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||
}
|
||||
|
||||
function applyStatsWordAndSentenceCardFields(
|
||||
fields: Record<string, string>,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
): void {
|
||||
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
||||
|
||||
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
fields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
fields[resolved] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStatsDirectMiningAudioFieldNames(
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
@@ -1299,7 +1322,11 @@ export function createStatsApp(
|
||||
|
||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
||||
if (
|
||||
audioBuffer ||
|
||||
(syncAnimatedImageToWordAudio && generateImage) ||
|
||||
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
||||
) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
@@ -1339,6 +1366,7 @@ export function createStatsApp(
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
|
||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||
|
||||
+10
-2
@@ -2244,6 +2244,7 @@ const mediaRuntime = createMediaRuntimeService(
|
||||
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
|
||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
@@ -2561,6 +2562,10 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||
}
|
||||
|
||||
function tickWindowsOverlayPointerInteractionNow(): void {
|
||||
visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow();
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
|
||||
}
|
||||
@@ -5408,13 +5413,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
if (!mainWindow || senderWindow !== mainWindow) {
|
||||
return;
|
||||
}
|
||||
if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) {
|
||||
const previousActive =
|
||||
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive();
|
||||
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
|
||||
if (previousActive === active) {
|
||||
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
return;
|
||||
}
|
||||
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
onOverlayInteractiveHint: (interactive, senderWindow) => {
|
||||
@@ -5614,6 +5621,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
if (overlayContentMeasurementStore.report(payload)) {
|
||||
tickLinuxOverlayPointerInteractionNow();
|
||||
tickWindowsOverlayPointerInteractionNow();
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
|
||||
@@ -2386,6 +2386,36 @@ test('buildMergedDictionary rebuilds snapshots written with an older format vers
|
||||
}
|
||||
});
|
||||
|
||||
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const mpvPath =
|
||||
'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv';
|
||||
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => null,
|
||||
getCurrentVideoPath: () => mpvPath,
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
|
||||
calls.push({ mediaPath, mediaTitle });
|
||||
return {
|
||||
title: 'KonoSuba - God’s blessing on this wonderful world!!',
|
||||
season: 2,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
};
|
||||
},
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||
|
||||
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
|
||||
assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!');
|
||||
assert.equal(snapshot.candidates.length, 0);
|
||||
});
|
||||
|
||||
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
function trimToNull(input: string | null | undefined): string | null {
|
||||
const trimmed = typeof input === 'string' ? input.trim() : '';
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function isVideoFile(filePath: string): boolean {
|
||||
return hasVideoExtension(path.extname(filePath));
|
||||
}
|
||||
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
return dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
: {
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
mediaPath:
|
||||
trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
|
||||
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
|
||||
export interface CharacterDictionaryRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentVideoPath?: () => string | null | undefined;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
guessAnilistMediaInfo: (
|
||||
|
||||
@@ -308,7 +308,7 @@ test('visible overlay content-ready does not tokenize before first measurement',
|
||||
);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
||||
test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
@@ -317,6 +317,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
@@ -324,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
||||
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
|
||||
|
||||
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||
overlayManager: {
|
||||
@@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let windowsOverlayPointerInteractionActive = false;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||
@@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
|
||||
function resetVisibleOverlayInputState(): void {
|
||||
visibleOverlayInteractionActive = false;
|
||||
windowsOverlayPointerInteractionActive = false;
|
||||
linuxOverlayInputShapeActive = false;
|
||||
linuxOverlayPointerInteractionStateApplied = false;
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
@@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
|
||||
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||
tickWindowsOverlayPointerInteractionNow();
|
||||
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -571,6 +575,56 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuspendWindowsOverlayPointerInteraction(): boolean {
|
||||
return (
|
||||
deps.getModalInputExclusive() ||
|
||||
deps.getStatsOverlayVisible() ||
|
||||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows())
|
||||
);
|
||||
}
|
||||
|
||||
function updateWindowsOverlayPointerInteractionActive(active: boolean): void {
|
||||
windowsOverlayPointerInteractionActive = active;
|
||||
visibleOverlayInteractionActive = active;
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
!mainWindow ||
|
||||
mainWindow.isDestroyed() ||
|
||||
!mainWindow.isVisible()
|
||||
) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
|
||||
const windowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
|
||||
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
|
||||
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
|
||||
getInteractionActive: () => windowsOverlayPointerInteractionActive,
|
||||
setInteractionActive: updateWindowsOverlayPointerInteractionActive,
|
||||
};
|
||||
|
||||
function tickWindowsOverlayPointerInteractionNow(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
if (!windowsOverlayPointerInteractionActive && visibleOverlayInteractionActive) {
|
||||
return;
|
||||
}
|
||||
tickWindowsOverlayPointerInteraction(windowsOverlayPointerInteractionDeps);
|
||||
}
|
||||
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||
|
||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||
@@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
updateLinuxOverlayPointerInteractionActive,
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||
requestLinuxOverlayZOrderFollow,
|
||||
tickWindowsOverlayPointerInteractionNow,
|
||||
tickLinuxOverlayPointerInteractionNow,
|
||||
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||
visibleOverlayInteractionActive = active;
|
||||
windowsOverlayPointerInteractionActive = false;
|
||||
},
|
||||
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
isCursorOverWindowsOverlayInteractiveRect,
|
||||
resolveDesiredWindowsOverlayInteractive,
|
||||
tickWindowsOverlayPointerInteraction,
|
||||
type WindowsOverlayPointerInteractionDeps,
|
||||
} from './windows-overlay-pointer-interaction';
|
||||
import type { OverlayContentMeasurement } from '../../types';
|
||||
|
||||
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
|
||||
const MEASUREMENT: OverlayContentMeasurement = {
|
||||
layer: 'visible',
|
||||
measuredAtMs: 1,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 800, y: 900, width: 320, height: 80 },
|
||||
};
|
||||
|
||||
function makeDeps(overrides: Partial<WindowsOverlayPointerInteractionDeps>): {
|
||||
deps: WindowsOverlayPointerInteractionDeps;
|
||||
state: { active: boolean };
|
||||
} {
|
||||
const state = { active: false };
|
||||
const deps: WindowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
shouldSuspend: () => false,
|
||||
getInteractionActive: () => state.active,
|
||||
setInteractionActive: (active) => {
|
||||
state.active = active;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { deps, state };
|
||||
}
|
||||
|
||||
test('isCursorOverWindowsOverlayInteractiveRect hit-tests measured overlay rects', () => {
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isCursorOverWindowsOverlayInteractiveRect scales viewport px to window px', () => {
|
||||
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 1700, y: 1900 }, scaled, MEASUREMENT),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isCursorOverWindowsOverlayInteractiveRect uses separate interactive rects', () => {
|
||||
const measurement: OverlayContentMeasurement = {
|
||||
layer: 'visible',
|
||||
measuredAtMs: 1,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 300 }, BOUNDS, measurement),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 180 }, BOUNDS, measurement),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 1060 }, BOUNDS, measurement),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredWindowsOverlayInteractive: interactive over subtitle, passthrough off it', () => {
|
||||
assert.equal(resolveDesiredWindowsOverlayInteractive(makeDeps({}).deps), true);
|
||||
assert.equal(
|
||||
resolveDesiredWindowsOverlayInteractive(
|
||||
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredWindowsOverlayInteractive returns null while another surface owns input', () => {
|
||||
assert.equal(
|
||||
resolveDesiredWindowsOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredWindowsOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('tickWindowsOverlayPointerInteraction toggles only the fallback-owned state', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps, state } = makeDeps({
|
||||
setInteractionActive: (active) => {
|
||||
calls.push(active);
|
||||
state.active = active;
|
||||
},
|
||||
});
|
||||
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, [true]);
|
||||
|
||||
deps.getCursorScreenPoint = () => ({ x: 200, y: 200 });
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, [true, false]);
|
||||
});
|
||||
|
||||
test('tickWindowsOverlayPointerInteraction leaves renderer-owned state alone while suspended', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getInteractionActive: () => true,
|
||||
shouldSuspend: () => true,
|
||||
setInteractionActive: (active) => calls.push(active),
|
||||
});
|
||||
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
|
||||
|
||||
type PointerPoint = { x: number; y: number };
|
||||
type PointerRect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
type PointerInteractionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
getBounds: () => PointerRect;
|
||||
};
|
||||
|
||||
export type WindowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionWindow | null;
|
||||
getCursorScreenPoint: () => PointerPoint;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurement | null;
|
||||
getRendererInteractiveHint?: () => boolean;
|
||||
/** True when a modal/stats/separate window owns input. */
|
||||
shouldSuspend: () => boolean;
|
||||
getInteractionActive: () => boolean;
|
||||
setInteractionActive: (active: boolean) => void;
|
||||
};
|
||||
|
||||
// Match Linux fallback padding so hover survives tiny measurement/cursor gaps.
|
||||
const SUBTITLE_HIT_PADDING_PX = 6;
|
||||
|
||||
function measuredRectsForInput(
|
||||
measurement: OverlayContentMeasurement | null,
|
||||
): OverlayContentRect[] {
|
||||
if (!measurement) return [];
|
||||
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
|
||||
? measurement.interactiveRects
|
||||
: measurement.contentRect
|
||||
? [measurement.contentRect]
|
||||
: [];
|
||||
}
|
||||
|
||||
function isCursorOverRect(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
viewport: { width: number; height: number },
|
||||
rect: OverlayContentRect,
|
||||
): boolean {
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
|
||||
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
|
||||
if (!(rect.width > 0) || !(rect.height > 0)) return false;
|
||||
|
||||
const scaleX = bounds.width / viewport.width;
|
||||
const scaleY = bounds.height / viewport.height;
|
||||
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
|
||||
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
|
||||
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
|
||||
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
|
||||
|
||||
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
|
||||
}
|
||||
|
||||
export function isCursorOverWindowsOverlayInteractiveRect(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
measurement: OverlayContentMeasurement | null,
|
||||
): boolean {
|
||||
if (!measurement) return false;
|
||||
return measuredRectsForInput(measurement).some((rect) =>
|
||||
isCursorOverRect(cursor, bounds, measurement.viewport, rect),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the desired Windows overlay mouse-input state, or null when another surface
|
||||
* currently owns interaction and the fallback should not touch BrowserWindow passthrough.
|
||||
*/
|
||||
export function resolveDesiredWindowsOverlayInteractive(
|
||||
deps: WindowsOverlayPointerInteractionDeps,
|
||||
): boolean | null {
|
||||
if (!deps.getVisibleOverlayVisible()) return false;
|
||||
if (deps.shouldSuspend()) return null;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deps.getRendererInteractiveHint?.()) return true;
|
||||
return isCursorOverWindowsOverlayInteractiveRect(
|
||||
deps.getCursorScreenPoint(),
|
||||
mainWindow.getBounds(),
|
||||
deps.getSubtitleMeasurement(),
|
||||
);
|
||||
}
|
||||
|
||||
export function tickWindowsOverlayPointerInteraction(
|
||||
deps: WindowsOverlayPointerInteractionDeps,
|
||||
): void {
|
||||
const desired = resolveDesiredWindowsOverlayInteractive(deps);
|
||||
if (desired === null) return;
|
||||
if (deps.getInteractionActive() === desired) return;
|
||||
deps.setInteractionActive(desired);
|
||||
}
|
||||
+34
-12
@@ -14,23 +14,31 @@ async function withStubbedFfmpeg(
|
||||
const tempDir = path.join(root, 'media');
|
||||
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const ffmpegPath = path.join(binDir, 'ffmpeg');
|
||||
const ffmpegStubPath = path.join(binDir, 'ffmpeg-stub.cjs');
|
||||
const ffmpegPath = path.join(binDir, process.platform === 'win32' ? 'ffmpeg.cmd' : 'ffmpeg');
|
||||
fs.writeFileSync(
|
||||
ffmpegPath,
|
||||
ffmpegStubPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
|
||||
' echo " V..... libaom-av1"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
|
||||
'out=""',
|
||||
'for arg in "$@"; do out="$arg"; done',
|
||||
'printf avif > "$out"',
|
||||
"const fs = require('node:fs');",
|
||||
'const args = process.argv.slice(2);',
|
||||
"if (args[0] === '-hide_banner' && args[1] === '-encoders') {",
|
||||
" console.log(' V..... libaom-av1');",
|
||||
' process.exit(0);',
|
||||
'}',
|
||||
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');",
|
||||
'const outputPath = args.at(-1);',
|
||||
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(ffmpegPath, 0o755);
|
||||
const ffmpegStub =
|
||||
process.platform === 'win32'
|
||||
? ['@echo off', `"${process.execPath}" "${ffmpegStubPath}" %*`].join('\r\n')
|
||||
: ['#!/bin/sh', `exec "${process.execPath}" "${ffmpegStubPath}" "$@"`].join('\n');
|
||||
fs.writeFileSync(ffmpegPath, ffmpegStub, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(ffmpegPath, 0o755);
|
||||
}
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
@@ -160,3 +168,17 @@ test('generateAudio clips leading padding without adding it to trailing duration
|
||||
assert.equal(args[args.indexOf('-t') + 1], '1.7');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio recreates missing temp directory before invoking ffmpeg', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
const tempDir = (generator as unknown as { tempDir: string }).tempDir;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
await generator.generateAudio('/video.mp4', 10, 12);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
const outputPath = args.at(-1);
|
||||
assert.equal(typeof outputPath, 'string');
|
||||
assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
|
||||
});
|
||||
});
|
||||
|
||||
+18
-10
@@ -77,16 +77,23 @@ export class MediaGenerator {
|
||||
constructor(tempDir?: string) {
|
||||
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
||||
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.notifyIconDir)) {
|
||||
fs.mkdirSync(this.notifyIconDir, { recursive: true });
|
||||
}
|
||||
this.ensureDirectory(this.tempDir);
|
||||
this.ensureDirectory(this.notifyIconDir);
|
||||
// Clean up old notification icons on startup (older than 1 hour)
|
||||
this.cleanupOldNotificationIcons();
|
||||
}
|
||||
|
||||
private ensureDirectory(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private createTempOutputPath(prefix: string, extension: string): string {
|
||||
this.ensureDirectory(this.tempDir);
|
||||
return path.join(this.tempDir, `${prefix}_${Date.now()}.${extension}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up notification icons older than 1 hour.
|
||||
* Called on startup to prevent accumulation of temp files.
|
||||
@@ -121,6 +128,7 @@ export class MediaGenerator {
|
||||
* compatibility with Linux/Wayland notification daemons.
|
||||
*/
|
||||
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
|
||||
this.ensureDirectory(this.notifyIconDir);
|
||||
const filename = `icon_${noteId}_${Date.now()}.png`;
|
||||
const filePath = path.join(this.notifyIconDir, filename);
|
||||
fs.writeFileSync(filePath, iconBuffer);
|
||||
@@ -184,7 +192,7 @@ export class MediaGenerator {
|
||||
const duration = endTime - start + safePadding;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
||||
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
||||
|
||||
if (
|
||||
@@ -261,7 +269,7 @@ export class MediaGenerator {
|
||||
args.push('-y');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `screenshot_${Date.now()}.${ext}`);
|
||||
const outputPath = this.createTempOutputPath('screenshot', ext);
|
||||
args.push(outputPath);
|
||||
|
||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||
@@ -288,7 +296,7 @@ export class MediaGenerator {
|
||||
*/
|
||||
async generateNotificationIcon(videoPath: string, timestamp: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `notify_icon_${Date.now()}.png`);
|
||||
const outputPath = this.createTempOutputPath('notify_icon', 'png');
|
||||
|
||||
execFile(
|
||||
'ffmpeg',
|
||||
@@ -355,7 +363,7 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `animation_${Date.now()}.avif`);
|
||||
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||
|
||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||
if (av1Encoder === 'libaom-av1') {
|
||||
|
||||
Reference in New Issue
Block a user