mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-17 03:13:30 -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;
|
knownWordsScope: string;
|
||||||
knownWordsLastRefreshedAtMs: number;
|
knownWordsLastRefreshedAtMs: number;
|
||||||
};
|
};
|
||||||
privateState.knownWordsScope = 'is:note';
|
privateState.knownWordsScope = 'all';
|
||||||
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
|
|
||||||
return {
|
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', () => {
|
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
+114
-7
@@ -70,7 +70,7 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio';
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
@@ -78,6 +78,66 @@ function trimToNonEmptyString(value: unknown): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : 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 {
|
function decodeURIComponentSafe(value: string): string {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
@@ -461,6 +521,10 @@ export class AnkiIntegration {
|
|||||||
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
||||||
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
||||||
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
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) =>
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||||
@@ -677,20 +741,25 @@ export class AnkiIntegration {
|
|||||||
return result;
|
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 {
|
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
|
||||||
if (this.config.behavior?.highlightWord === false) {
|
if (this.config.behavior?.highlightWord === false) {
|
||||||
return mpvSentence;
|
return mpvSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||||
const existingSentence = noteFields[sentenceFieldName] || '';
|
if (!highlightedText) {
|
||||||
|
|
||||||
const highlightMatch = existingSentence.match(/<b>(.*?)<\/b>/);
|
|
||||||
if (!highlightMatch || !highlightMatch[1]) {
|
|
||||||
return mpvSentence;
|
return mpvSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightedText = highlightMatch[1];
|
|
||||||
const index = mpvSentence.indexOf(highlightedText);
|
const index = mpvSentence.indexOf(highlightedText);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -702,6 +771,20 @@ export class AnkiIntegration {
|
|||||||
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
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 {
|
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
||||||
if (!this.consumeSubtitleMiningContextCallback) {
|
if (!this.consumeSubtitleMiningContextCallback) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1030,6 +1113,30 @@ export class AnkiIntegration {
|
|||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
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') {
|
if (cardKind === 'sentence') {
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
if (sentenceFlag) {
|
if (sentenceFlag) {
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import type { AnkiConnectConfig } from '../types/anki';
|
|||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
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> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
service: CardCreationService;
|
service: CardCreationService;
|
||||||
updatedFields: Record<string, string>[];
|
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 () => {
|
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||||
client: {
|
client: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AiConfig } from '../types/integrations';
|
|||||||
import { MpvClient } from '../types/runtime';
|
import { MpvClient } from '../types/runtime';
|
||||||
import { resolveSentenceBackText } from './ai';
|
import { resolveSentenceBackText } from './ai';
|
||||||
import { resolveMediaGenerationInputPath } from './media-source';
|
import { resolveMediaGenerationInputPath } from './media-source';
|
||||||
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio';
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
@@ -219,7 +220,8 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
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 sentence = blocks.join(' ');
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
@@ -230,6 +232,13 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
|
this.deps.setCardTypeFields(
|
||||||
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 120_000,
|
refreshedAtMs: 120_000,
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 59_000,
|
refreshedAtMs: 59_000,
|
||||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 1,
|
refreshedAtMs: 1,
|
||||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}',
|
||||||
words: ['猫', '犬'],
|
words: ['猫', '犬'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'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 () => {
|
test('KnownWordCacheManager skips malformed note info without fields', async () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
|||||||
scope: string;
|
scope: string;
|
||||||
words: 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, ['猫']);
|
assert.deepEqual(persisted.words, ['猫']);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||||
@@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations',
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: Date.now(),
|
refreshedAtMs: Date.now(),
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}',
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configuredDeck = trimToNonEmptyString(config.deck);
|
const configuredDeck = trimToNonEmptyString(config.deck);
|
||||||
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
|
return configuredDeck ? `deck:${configuredDeck}` : 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||||
@@ -396,7 +396,7 @@ export class KnownWordCacheManager {
|
|||||||
private buildKnownWordsQuery(): string {
|
private buildKnownWordsQuery(): string {
|
||||||
const decks = this.getKnownWordDecks();
|
const decks = this.getKnownWordDecks();
|
||||||
if (decks.length === 0) {
|
if (decks.length === 0) {
|
||||||
return 'is:note';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decks.length === 1) {
|
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';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
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() {
|
function createWorkflowHarness() {
|
||||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
||||||
@@ -40,6 +61,7 @@ function createWorkflowHarness() {
|
|||||||
getCurrentSubtitleStart: () => 12.3,
|
getCurrentSubtitleStart: () => 12.3,
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
sentenceField: 'Sentence',
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
kikuEnabled: false,
|
kikuEnabled: false,
|
||||||
kikuFieldGrouping: 'disabled' as const,
|
kikuFieldGrouping: 'disabled' as const,
|
||||||
}),
|
}),
|
||||||
@@ -57,6 +79,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
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);
|
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 () => {
|
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () => [];
|
harness.deps.client.notesInfo = async () => [];
|
||||||
@@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
|
|||||||
let notesInfoCallCount = 0;
|
let notesInfoCallCount = 0;
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
sentenceField: 'Sentence',
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
kikuEnabled: true,
|
kikuEnabled: true,
|
||||||
kikuFieldGrouping: 'auto',
|
kikuFieldGrouping: 'auto',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
getCurrentSubtitleStart: () => number | undefined;
|
getCurrentSubtitleStart: () => number | undefined;
|
||||||
getEffectiveSentenceCardConfig: () => {
|
getEffectiveSentenceCardConfig: () => {
|
||||||
sentenceField: string;
|
sentenceField: string;
|
||||||
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
};
|
};
|
||||||
@@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
expression: string,
|
expression: string,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
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: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
@@ -189,8 +200,32 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
|
this.deps.setCardTypeFields(
|
||||||
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
}
|
||||||
updatePerformed = true;
|
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) {
|
if (config.media?.generateAudio) {
|
||||||
try {
|
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 () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
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(
|
function getStatsDirectMiningAudioFieldNames(
|
||||||
ankiConfig: AnkiConnectConfig,
|
ankiConfig: AnkiConnectConfig,
|
||||||
noteInfo: StatsServerNoteInfo | null,
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
@@ -1299,7 +1322,11 @@ export function createStatsApp(
|
|||||||
|
|
||||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
let noteInfo: StatsServerNoteInfo | null = null;
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
if (
|
||||||
|
audioBuffer ||
|
||||||
|
(syncAnimatedImageToWordAudio && generateImage) ||
|
||||||
|
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
noteInfo = noteInfoResult[0] ?? null;
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
@@ -1339,6 +1366,7 @@ export function createStatsApp(
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
|
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||||
|
|||||||
+10
-2
@@ -2244,6 +2244,7 @@ const mediaRuntime = createMediaRuntimeService(
|
|||||||
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
|
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
|
||||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||||
@@ -2561,6 +2562,10 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
|||||||
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
|
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tickWindowsOverlayPointerInteractionNow(): void {
|
||||||
|
visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow();
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
|
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
|
||||||
}
|
}
|
||||||
@@ -5408,13 +5413,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
if (!mainWindow || senderWindow !== mainWindow) {
|
if (!mainWindow || senderWindow !== mainWindow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) {
|
const previousActive =
|
||||||
|
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive();
|
||||||
|
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
|
||||||
|
if (previousActive === active) {
|
||||||
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
|
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
},
|
},
|
||||||
onOverlayInteractiveHint: (interactive, senderWindow) => {
|
onOverlayInteractiveHint: (interactive, senderWindow) => {
|
||||||
@@ -5614,6 +5621,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
reportOverlayContentBounds: (payload: unknown) => {
|
reportOverlayContentBounds: (payload: unknown) => {
|
||||||
if (overlayContentMeasurementStore.report(payload)) {
|
if (overlayContentMeasurementStore.report(payload)) {
|
||||||
tickLinuxOverlayPointerInteractionNow();
|
tickLinuxOverlayPointerInteractionNow();
|
||||||
|
tickWindowsOverlayPointerInteractionNow();
|
||||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
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 () => {
|
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
|
|||||||
return input;
|
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 {
|
function isVideoFile(filePath: string): boolean {
|
||||||
return hasVideoExtension(path.extname(filePath));
|
return hasVideoExtension(path.extname(filePath));
|
||||||
}
|
}
|
||||||
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
return dictionaryTarget.length > 0
|
return dictionaryTarget.length > 0
|
||||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||||
: {
|
: {
|
||||||
mediaPath: deps.getCurrentMediaPath(),
|
mediaPath:
|
||||||
mediaTitle: deps.getCurrentMediaTitle(),
|
trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
|
||||||
|
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
|
|||||||
export interface CharacterDictionaryRuntimeDeps {
|
export interface CharacterDictionaryRuntimeDeps {
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
getCurrentMediaPath: () => string | null;
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getCurrentVideoPath?: () => string | null | undefined;
|
||||||
getCurrentMediaTitle: () => string | null;
|
getCurrentMediaTitle: () => string | null;
|
||||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||||
guessAnilistMediaInfo: (
|
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 source = readMainSource();
|
||||||
const measurementBlock = source.match(
|
const measurementBlock = source.match(
|
||||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/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.ok(measurementBlock);
|
||||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||||
|
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
|
||||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||||
@@ -324,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
|||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||||
|
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
|
||||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
|||||||
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||||
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||||
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||||
|
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
|
||||||
|
|
||||||
export interface VisibleOverlayInteractionRuntimeDeps {
|
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||||
overlayManager: {
|
overlayManager: {
|
||||||
@@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let windowsOverlayPointerInteractionActive = false;
|
||||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||||
@@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
|
|
||||||
function resetVisibleOverlayInputState(): void {
|
function resetVisibleOverlayInputState(): void {
|
||||||
visibleOverlayInteractionActive = false;
|
visibleOverlayInteractionActive = false;
|
||||||
|
windowsOverlayPointerInteractionActive = false;
|
||||||
linuxOverlayInputShapeActive = false;
|
linuxOverlayInputShapeActive = false;
|
||||||
linuxOverlayPointerInteractionStateApplied = false;
|
linuxOverlayPointerInteractionStateApplied = false;
|
||||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||||
@@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
|
|
||||||
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||||
maybePollWindowsVisibleOverlayForegroundProcess();
|
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||||
|
tickWindowsOverlayPointerInteractionNow();
|
||||||
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
}, 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();
|
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||||
|
|
||||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||||
@@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
updateLinuxOverlayPointerInteractionActive,
|
updateLinuxOverlayPointerInteractionActive,
|
||||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||||
requestLinuxOverlayZOrderFollow,
|
requestLinuxOverlayZOrderFollow,
|
||||||
|
tickWindowsOverlayPointerInteractionNow,
|
||||||
tickLinuxOverlayPointerInteractionNow,
|
tickLinuxOverlayPointerInteractionNow,
|
||||||
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
setVisibleOverlayInteractionActive: (active: boolean) => {
|
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||||
visibleOverlayInteractionActive = active;
|
visibleOverlayInteractionActive = active;
|
||||||
|
windowsOverlayPointerInteractionActive = false;
|
||||||
},
|
},
|
||||||
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||||
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
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 tempDir = path.join(root, 'media');
|
||||||
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
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(
|
fs.writeFileSync(
|
||||||
ffmpegPath,
|
ffmpegStubPath,
|
||||||
[
|
[
|
||||||
'#!/bin/sh',
|
"const fs = require('node:fs');",
|
||||||
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
|
'const args = process.argv.slice(2);',
|
||||||
' echo " V..... libaom-av1"',
|
"if (args[0] === '-hide_banner' && args[1] === '-encoders') {",
|
||||||
' exit 0',
|
" console.log(' V..... libaom-av1');",
|
||||||
'fi',
|
' process.exit(0);',
|
||||||
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
|
'}',
|
||||||
'out=""',
|
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');",
|
||||||
'for arg in "$@"; do out="$arg"; done',
|
'const outputPath = args.at(-1);',
|
||||||
'printf avif > "$out"',
|
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8',
|
'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 originalPath = process.env.PATH;
|
||||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
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');
|
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) {
|
constructor(tempDir?: string) {
|
||||||
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
||||||
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
||||||
if (!fs.existsSync(this.tempDir)) {
|
this.ensureDirectory(this.tempDir);
|
||||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
this.ensureDirectory(this.notifyIconDir);
|
||||||
}
|
|
||||||
if (!fs.existsSync(this.notifyIconDir)) {
|
|
||||||
fs.mkdirSync(this.notifyIconDir, { recursive: true });
|
|
||||||
}
|
|
||||||
// Clean up old notification icons on startup (older than 1 hour)
|
// Clean up old notification icons on startup (older than 1 hour)
|
||||||
this.cleanupOldNotificationIcons();
|
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.
|
* Clean up notification icons older than 1 hour.
|
||||||
* Called on startup to prevent accumulation of temp files.
|
* Called on startup to prevent accumulation of temp files.
|
||||||
@@ -121,6 +128,7 @@ export class MediaGenerator {
|
|||||||
* compatibility with Linux/Wayland notification daemons.
|
* compatibility with Linux/Wayland notification daemons.
|
||||||
*/
|
*/
|
||||||
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
|
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
|
||||||
|
this.ensureDirectory(this.notifyIconDir);
|
||||||
const filename = `icon_${noteId}_${Date.now()}.png`;
|
const filename = `icon_${noteId}_${Date.now()}.png`;
|
||||||
const filePath = path.join(this.notifyIconDir, filename);
|
const filePath = path.join(this.notifyIconDir, filename);
|
||||||
fs.writeFileSync(filePath, iconBuffer);
|
fs.writeFileSync(filePath, iconBuffer);
|
||||||
@@ -184,7 +192,7 @@ export class MediaGenerator {
|
|||||||
const duration = endTime - start + safePadding;
|
const duration = endTime - start + safePadding;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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];
|
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -261,7 +269,7 @@ export class MediaGenerator {
|
|||||||
args.push('-y');
|
args.push('-y');
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `screenshot_${Date.now()}.${ext}`);
|
const outputPath = this.createTempOutputPath('screenshot', ext);
|
||||||
args.push(outputPath);
|
args.push(outputPath);
|
||||||
|
|
||||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||||
@@ -288,7 +296,7 @@ export class MediaGenerator {
|
|||||||
*/
|
*/
|
||||||
async generateNotificationIcon(videoPath: string, timestamp: number): Promise<Buffer> {
|
async generateNotificationIcon(videoPath: string, timestamp: number): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `notify_icon_${Date.now()}.png`);
|
const outputPath = this.createTempOutputPath('notify_icon', 'png');
|
||||||
|
|
||||||
execFile(
|
execFile(
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
@@ -355,7 +363,7 @@ export class MediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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];
|
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||||
if (av1Encoder === 'libaom-av1') {
|
if (av1Encoder === 'libaom-av1') {
|
||||||
|
|||||||
Reference in New Issue
Block a user