mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(subtitles): add regex subtitle filters with built-in presets (#318)
- Add `subtitleFilter` config with four presets: parentheses, brackets, speaker prefix and music symbols. Custom regex rules run after the presets. - Filter primary text where it enters the app (live `sub-text` and parsed cues), so the overlay, sidebar, mining and stats all get the same filtered text. - Changes hot-reload: the current line is read again and the active track is parsed again, using the filter config as the prefetch parse key. - Add a custom rule editor in Settings under Behavior → Subtitle Filters that checks patterns as you type. - Update the config example, the configuration docs and the architecture domains doc, and add a changelog fragment.
This commit is contained in:
1 parent
848a6c4021
commit
fa3051f9cb
28 files changed
+740
-11
No files matched your search
@@ -17,6 +17,10 @@ import {
|
||||
renderKeyboardInput,
|
||||
renderMpvKeybindingsInput,
|
||||
} from './settings-keybinding-controls';
|
||||
import {
|
||||
configureSubtitleFilterControls,
|
||||
renderSubtitleFilterRulesInput,
|
||||
} from './settings-subtitle-filter-controls';
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssScopeForPath,
|
||||
@@ -29,6 +33,7 @@ export { renderNoteFieldModelPicker };
|
||||
export function configureSettingsControls(options: { requestRender: () => void }): void {
|
||||
configureAnkiControls(options);
|
||||
configureKeybindingControls(options);
|
||||
configureSubtitleFilterControls(options);
|
||||
}
|
||||
|
||||
export function initializeSettingsControls(
|
||||
@@ -179,6 +184,10 @@ export function renderControl(
|
||||
return renderMpvKeybindingsInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'subtitle-filter-rules') {
|
||||
return renderSubtitleFilterRulesInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'boolean') {
|
||||
const label = createElement('label', 'switch-control');
|
||||
const input = createElement('input') as HTMLInputElement;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getSubtitleFilterPatternError } from '../shared/subtitle-text-filter';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
import type { SubtitleFilterRule } from '../types/subtitle';
|
||||
import type { SettingsControlContext } from './settings-control-context';
|
||||
import { createElement } from './settings-control-dom';
|
||||
|
||||
let requestRender = (): void => undefined;
|
||||
|
||||
export function configureSubtitleFilterControls(options: { requestRender: () => void }): void {
|
||||
requestRender = options.requestRender;
|
||||
}
|
||||
|
||||
function toRules(value: unknown): SubtitleFilterRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(rule): rule is Partial<SubtitleFilterRule> => rule !== null && typeof rule === 'object',
|
||||
)
|
||||
.map((rule) => ({
|
||||
name: rule.name ?? '',
|
||||
pattern: rule.pattern ?? '',
|
||||
replacement: rule.replacement ?? '',
|
||||
enabled: rule.enabled ?? true,
|
||||
}));
|
||||
}
|
||||
|
||||
function renderTextInput(
|
||||
value: string,
|
||||
placeholder: string,
|
||||
className: string,
|
||||
onInput: (value: string) => void,
|
||||
): HTMLInputElement {
|
||||
const input = createElement('input', className) as HTMLInputElement;
|
||||
input.type = 'text';
|
||||
input.spellcheck = false;
|
||||
input.value = value;
|
||||
input.placeholder = placeholder;
|
||||
input.addEventListener('input', () => onInput(input.value));
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Editor for `subtitleFilter.customRules`: one card per rule, validated as you type. */
|
||||
export function renderSubtitleFilterRulesInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const rules = toRules(context.valueForField(field));
|
||||
const patternErrors = new Map<number, string>();
|
||||
const container = createElement('div', 'stacked-control');
|
||||
|
||||
// An invalid pattern blocks saving; empty patterns are inert placeholders.
|
||||
const syncFieldError = (): void => {
|
||||
const invalid = [...patternErrors.keys()].sort((a, b) => a - b);
|
||||
context.setFieldError(
|
||||
field.configPath,
|
||||
invalid.length > 0 ? `Invalid pattern in rule ${invalid[0]! + 1}` : null,
|
||||
);
|
||||
};
|
||||
const commit = (): void => {
|
||||
syncFieldError();
|
||||
context.updateDraft(
|
||||
field.configPath,
|
||||
rules.map((rule) => ({ ...rule })),
|
||||
);
|
||||
};
|
||||
|
||||
rules.forEach((rule, index) => {
|
||||
const card = createElement('div', 'deck-field-row');
|
||||
const header = createElement('div', 'deck-field-row-header');
|
||||
|
||||
const toggle = createElement('label', 'switch-control');
|
||||
toggle.title = 'Enabled';
|
||||
const checkbox = createElement('input') as HTMLInputElement;
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = rule.enabled;
|
||||
checkbox.addEventListener('change', () => {
|
||||
rule.enabled = checkbox.checked;
|
||||
commit();
|
||||
});
|
||||
toggle.append(checkbox, createElement('span', 'switch-track'));
|
||||
|
||||
const name = renderTextInput(
|
||||
rule.name,
|
||||
'Rule name',
|
||||
'config-input deck-field-row-name',
|
||||
(v) => {
|
||||
rule.name = v;
|
||||
commit();
|
||||
},
|
||||
);
|
||||
|
||||
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Remove';
|
||||
removeButton.addEventListener('click', () => {
|
||||
rules.splice(index, 1);
|
||||
commit();
|
||||
requestRender();
|
||||
});
|
||||
header.append(toggle, name, removeButton);
|
||||
|
||||
const errorHint = createElement('div', 'control-hint error');
|
||||
const showPatternError = (input: HTMLInputElement): void => {
|
||||
const error = input.value ? getSubtitleFilterPatternError(input.value) : null;
|
||||
input.classList.toggle('invalid', error !== null);
|
||||
errorHint.textContent = error ?? '';
|
||||
errorHint.hidden = error === null;
|
||||
if (error === null) patternErrors.delete(index);
|
||||
else patternErrors.set(index, error);
|
||||
};
|
||||
const pattern = renderTextInput(
|
||||
rule.pattern,
|
||||
'Regex pattern, e.g. [((][^))]*[))]',
|
||||
'config-input mono-input',
|
||||
(v) => {
|
||||
rule.pattern = v;
|
||||
showPatternError(pattern);
|
||||
commit();
|
||||
},
|
||||
);
|
||||
showPatternError(pattern);
|
||||
|
||||
const replacement = renderTextInput(
|
||||
rule.replacement,
|
||||
'Replacement (leave empty to remove the match)',
|
||||
'config-input mono-input',
|
||||
(v) => {
|
||||
rule.replacement = v;
|
||||
commit();
|
||||
},
|
||||
);
|
||||
|
||||
card.append(header, pattern, errorHint, replacement);
|
||||
container.append(card);
|
||||
});
|
||||
|
||||
syncFieldError();
|
||||
|
||||
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Add Rule';
|
||||
addButton.addEventListener('click', () => {
|
||||
rules.push({ name: '', pattern: '', replacement: '', enabled: true });
|
||||
context.updateDraft(
|
||||
field.configPath,
|
||||
rules.map((rule) => ({ ...rule })),
|
||||
);
|
||||
requestRender();
|
||||
});
|
||||
container.append(addButton);
|
||||
|
||||
return container;
|
||||
}
|
||||
Reference in new issue
Block a user