vetrenar_v-library-search/settings.ts
2026-06-27 12:56:24 +03:00

767 lines
No EOL
40 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ════════════════════════════════════════════════════════════════════════════
SETTINGS — types, defaults, icon picker, and the settings tab.
Overhaul increment 1 — information architecture:
• Three top-level bands that follow the pipeline, not the data model:
Sources → Matching → Presentation (+ General at the end)
• The PDF index moves up into "Sources" — it CREATES the card notes whose
Contents headings are later searched, so it belongs at the start.
• Panel visibility is now a rule (all / only-in / except), no longer tied to
the library folder.
• Group editor is a card with labelled fields and a collapsible "where to
search" block.
Search-target shape, per-group term source, and per-target options are handled
in later increments — DisplaySettings / BehaviorSettings / SearchTargets are
left unchanged here so main.ts keeps compiling.
═══════════════════════════════════════════════════════════════════════════ */
import { App, Modal, PluginSettingTab, Setting, setIcon, setTooltip, debounce } from 'obsidian';
import type LibrarySearchPlugin from './main';
import {
PdfOutlineSettings,
DEFAULT_PDF_OUTLINE_SETTINGS,
renderPdfOutlineSettings,
} from './pdf-outline';
import { t, setLanguage, Lang } from './i18n';
import { LUCIDE_ICONS, LucideIconEntry } from './icons';
/* ─── Settings types & defaults ─────────────────────────────────────────── */
export type HeadingOutputMode = 'heading-only' | 'with-excerpt' | 'with-section';
/** Headings target — output mode and its size limits live here, per group. */
export interface HeadingTarget {
enabled: boolean;
output: HeadingOutputMode;
excerptMaxLines: number;
sectionMaxChars: number;
}
/** Callouts target — search titles and/or bodies, optionally limited to types. */
export interface CalloutTarget {
titles: boolean;
bodies: boolean;
types: string[]; // restrict to these callout types (lowercase); empty = any
showSnippet: boolean;
snippetContextChars: number;
}
/** Full note body target. */
export interface BodyTarget {
enabled: boolean;
showSnippet: boolean;
snippetContextChars: number;
}
/** List items / tasks target. */
export interface ListTarget {
enabled: boolean;
includeTasks: boolean; // also match checkbox/task items
onlyUnchecked: boolean; // among tasks, only unchecked ones
showSnippet: boolean;
snippetContextChars: number;
}
export interface SearchTargets {
headings: HeadingTarget;
callouts: CalloutTarget;
frontmatterFields: string[];
body: BodyTarget;
filename: boolean;
lists: ListTarget;
}
/** A fresh target set with sensible defaults. Used for new groups, the
* built-in defaults, and as the migration base for legacy boolean targets. */
export function defaultTargets(): SearchTargets {
return {
headings: { enabled: true, output: 'heading-only', excerptMaxLines: 3, sectionMaxChars: 400 },
callouts: { titles: true, bodies: false, types: [], showSnippet: true, snippetContextChars: 90 },
frontmatterFields: [],
body: { enabled: false, showSnippet: true, snippetContextChars: 90 },
filename: false,
lists: { enabled: false, includeTasks: true, onlyUnchecked: false, showSnippet: true, snippetContextChars: 90 },
};
}
/** Global result-display options. Per-match formatting (heading output mode,
* snippets) now lives inside each group's SearchTargets. */
export interface DisplaySettings {
showTags: boolean;
showFilePath: boolean;
groupByType: boolean;
}
/** Как текст алиаса сопоставляется с текстом заметки.
* 'substring' — фраза как подстрока, без границ слова (максимум совпадений);
* 'whole' — фраза целиком с границами слова (точно, без словоформ);
* 'stem' — каждое слово алиаса усекается до основы, окончание свободно,
* поэтому совпадают словоформы: «рыжая собака» → «рыжей собаки» … */
export type MatchMode = 'substring' | 'stem' | 'whole';
export interface BehaviorSettings {
caseSensitive: boolean;
/** @deprecated оставлено для миграции в `matchMode`. */
wholeWord: boolean;
matchMode: MatchMode;
maxMatchesPerFile: number;
}
/** Where the inline panel may appear.
* 'all' — every note (default)
* 'only' — only inside the listed folders
* 'except' — everywhere except the listed folders */
export type PanelScope = 'all' | 'only' | 'except';
export interface PanelSettings {
enabled: boolean;
position: 'top' | 'bottom';
readingMode: boolean;
collapsedByDefault: boolean;
title: string;
scope: PanelScope;
folders: string[]; // used by 'only' / 'except'; ignored for 'all'
}
export interface GroupRule {
name: string;
property: string;
values: string[];
icon: string;
/** Per-group term source. Undefined → use the global `settings.terms`. */
terms?: TermSourceSettings;
targets: SearchTargets;
}
export interface TermSourceSettings {
source: 'aliases' | 'property';
property: string;
}
export interface LibrarySearchSettings {
language: 'en' | 'ru';
libraryFolder: string;
terms: TermSourceSettings;
groups: GroupRule[];
display: DisplaySettings;
behavior: BehaviorSettings;
panel: PanelSettings;
pdfOutline: PdfOutlineSettings;
}
export const DEFAULT_SETTINGS: LibrarySearchSettings = {
language: 'en',
libraryFolder: '',
terms: { source: 'aliases', property: '' },
groups: [
{ name: 'Books', property: 'type', values: ['book'], icon: 'book-open',
targets: { ...defaultTargets(), frontmatterFields: ['description'] } },
{ name: 'Articles', property: 'type', values: ['article'], icon: 'file-text',
targets: { ...defaultTargets(), frontmatterFields: ['abstract'] } },
],
display: {
showTags: true,
showFilePath: false,
groupByType: true,
},
behavior: {
caseSensitive: false,
wholeWord: false,
matchMode: 'substring',
maxMatchesPerFile: 0,
},
panel: {
enabled: true,
position: 'top',
readingMode: true,
collapsedByDefault: false,
title: 'Library',
scope: 'all',
folders: [],
},
pdfOutline: DEFAULT_PDF_OUTLINE_SETTINGS,
};
/* ─── Icon picker ───────────────────────────────────────────────────────── */
class IconPickerModal extends Modal {
private onSelect: (iconId: string) => void;
private current: string;
private filter = '';
constructor(app: App, currentIcon: string, onSelect: (iconId: string) => void) {
super(app);
this.current = currentIcon;
this.onSelect = onSelect;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('lsv-icon-picker');
const searchInput = contentEl.createEl('input', {
type: 'text',
cls: 'lsv-icon-picker-search',
placeholder: t('settings.pickIcon') + '…',
});
const gridContainer = contentEl.createDiv();
searchInput.addEventListener('input', () => {
this.filter = searchInput.value.trim().toLowerCase();
this.renderGrid(contentEl, gridContainer);
});
this.renderGrid(contentEl, gridContainer);
}
private renderGrid(contentEl: HTMLElement, container: HTMLElement) {
container.empty();
const filtered = this.filter
? LUCIDE_ICONS.filter(ic => ic.label.toLowerCase().includes(this.filter) || ic.category.toLowerCase().includes(this.filter))
: LUCIDE_ICONS;
if (filtered.length === 0) {
container.createDiv({ cls: 'lsv-icon-picker-empty', text: t('ui.noResults') });
return;
}
const categories = new Map<string, LucideIconEntry[]>();
for (const ic of filtered) {
let arr = categories.get(ic.category);
if (!arr) { arr = []; categories.set(ic.category, arr); }
arr.push(ic);
}
for (const [cat, icons] of categories) {
container.createDiv({ cls: 'lsv-icon-picker-category', text: cat });
const grid = container.createDiv({ cls: 'lsv-icon-picker-grid' });
for (const ic of icons) {
const cell = grid.createDiv({ cls: 'lsv-icon-picker-cell' });
if (ic.value === this.current) {
cell.addClass('lsv-icon-picker-cell--selected');
}
const iconSpan = cell.createSpan();
setIcon(iconSpan, ic.value);
cell.createDiv({ cls: 'lsv-icon-picker-cell-name', text: ic.label });
cell.addEventListener('click', () => {
this.onSelect(ic.value);
this.close();
});
}
}
}
onClose() {
this.contentEl.empty();
}
}
/* ─── Settings tab ──────────────────────────────────────────────────────── */
/** Вкладки таба настроек. Порядок = сценарий настройки:
* собрать библиотеку (start → pdf → types), затем как искать и показывать. */
type SettingsTab = 'start' | 'pdf' | 'types' | 'search' | 'view' | 'more';
export class LibrarySearchSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: LibrarySearchPlugin) { super(app, plugin); }
private activeTab: SettingsTab = 'start';
private saveDebounced = debounce(() => { void this.plugin.saveSettings(); }, 400, false);
private refreshDebounced = debounce(() => { this.plugin.refreshPanels(true); }, 400, false);
/** Cancel any pending debounced save then save synchronously — prevents toggle
* firing before a text-input debounce and writing a stale value. */
private async saveFlushed(): Promise<void> {
this.saveDebounced.cancel();
await this.plugin.saveSettings();
}
hide() {
this.saveDebounced.cancel();
this.refreshDebounced.cancel();
void this.plugin.saveSettings();
this.plugin.refreshPanels(true);
}
/** Top-level band header (custom styled, the larger of the two levels). */
private band(el: HTMLElement, title: string, desc?: string) {
el.createDiv({ text: title, cls: 'lsv-settings-section' });
if (desc) el.createDiv({ text: desc, cls: 'setting-item-description' });
}
/** Hybrid description strategy: primary settings keep a visible
* `.setDesc()` line; the dense per-target sub-settings revealed inside a
* group's "Where to search" block (output mode, char/line limits,
* callout types, snippet toggles…) instead get a small (i) icon next to
* their name, with the explanation as a hover tooltip. Those rows are
* already nested under a toggle the user deliberately turned on, so the
* reclaimed vertical space matters more there than the visible-by-default
* guarantee that primary settings need. */
private infoTip(setting: Setting, text: string) {
const icon = setting.nameEl.createSpan({ cls: 'lsv-info-icon' });
setIcon(icon, 'info');
setTooltip(icon, text);
}
/** Switch the active tab and re-render. */
private setActiveTab(tab: SettingsTab) {
this.activeTab = tab;
this.display();
}
/** Tab bar across the top; only the active tab's section renders below. */
private renderTabBar(el: HTMLElement) {
const tabs: { id: SettingsTab; label: string }[] = [
{ id: 'start', label: t('settings.tabStart') },
{ id: 'pdf', label: t('settings.tabPdf') },
{ id: 'types', label: t('settings.tabTypes') },
{ id: 'search', label: t('settings.tabSearch') },
{ id: 'view', label: t('settings.tabView') },
{ id: 'more', label: t('settings.tabMore') },
];
const bar = el.createDiv({ cls: 'lsv-settings-tabs' });
for (const tab of tabs) {
const btn = bar.createEl('button', { text: tab.label, cls: 'lsv-settings-tab' });
btn.toggleClass('is-active', this.activeTab === tab.id);
btn.onclick = () => this.setActiveTab(tab.id);
}
}
/** Inline note/callout. `action`, if given, renders a button that jumps to
* another tab — used to wire the PDF-index workflow to the search setup. */
private note(
el: HTMLElement,
variant: 'accent' | 'warning',
icon: string,
title: string,
body: string,
action?: { label: string; tab: SettingsTab },
) {
const box = el.createDiv({ cls: `lsv-note lsv-note--${variant}` });
const ic = box.createSpan({ cls: 'lsv-note-icon' });
setIcon(ic, icon);
const content = box.createDiv({ cls: 'lsv-note-content' });
if (title) content.createDiv({ text: title, cls: 'lsv-note-title' });
content.createDiv({ text: body, cls: 'lsv-note-body' });
if (action) {
const btn = content.createEl('button', { text: action.label, cls: 'lsv-note-btn' });
btn.onclick = () => this.setActiveTab(action.tab);
}
}
display() {
const { containerEl: el } = this;
const s = this.plugin.settings;
el.empty();
el.addClass('lsv-settings');
this.renderTabBar(el);
if (this.activeTab === 'start') {
/* ── Intro: how it works ───────────────────────────────────────── */
const guide = el.createDiv({ cls: 'pdfo-guide setting-item-description' });
guide.createDiv({ text: t('settings.guideTitle'), cls: 'pdfo-guide-title' });
['settings.guideStep1', 'settings.guideStep2', 'settings.guideStep3']
.forEach(k => guide.createDiv({ text: t(k), cls: 'pdfo-guide-step' }));
new Setting(el)
.setName(t('settings.libraryFolder'))
.setDesc(t('settings.libraryFolderDesc'))
.addText(text => text.setValue(s.libraryFolder).onChange(v => {
s.libraryFolder = v.trim();
this.saveDebounced();
}));
// Связка вперёд: построение библиотеки из PDF начинается с настройки
// папок и формата карточек на вкладке «PDF-индекс».
this.note(el, 'accent', 'file-stack',
t('settings.pdfCalloutTitle'), t('settings.pdfCalloutBody'),
{ label: t('settings.pdfCalloutBtn'), tab: 'pdf' });
} // end tab: start
if (this.activeTab === 'pdf') {
// Связка обратно: эти настройки создают карточки-заметки из PDF и кладут
// в них оглавление — по ним потом работает поиск.
this.note(el, 'accent', 'info', '', t('settings.pdfBackCallout'));
new Setting(el).setName(t('settings.pdfSection')).setHeading();
el.createDiv({ text: t('settings.pdfIntro'), cls: 'setting-item-description' });
renderPdfOutlineSettings(el, s.pdfOutline, () => this.saveFlushed());
} // end tab: pdf
if (this.activeTab === 'types') {
/* ── Search-term source (global default) ───────────────────────── */
let termPropSetting: Setting;
new Setting(el)
.setName(t('settings.termSource'))
.setDesc(t('settings.termSourceDesc'))
.addDropdown(d => d
.addOption('aliases', t('settings.termSourceAliases'))
.addOption('property', t('settings.termSourceProperty'))
.setValue(s.terms.source)
.onChange(async v => {
s.terms.source = v as 'aliases' | 'property';
await this.saveFlushed();
termPropSetting.settingEl.style.display = s.terms.source === 'property' ? '' : 'none';
this.plugin.refreshPanels(true);
}));
termPropSetting = new Setting(el)
.setName(t('settings.termProperty'))
.setDesc(t('settings.termPropertyDesc'))
.addText(text => text.setValue(s.terms.property).onChange(v => {
s.terms.property = v.trim();
this.saveDebounced();
this.refreshDebounced();
}));
termPropSetting.settingEl.style.display = s.terms.source === 'property' ? '' : 'none';
/* ── Result groups ─────────────────────────────────────────────── */
new Setting(el).setName(t('settings.resultGroups')).setHeading();
el.createDiv({ text: t('settings.resultGroupsDesc'), cls: 'setting-item-description' });
// "Split results by group" sits right next to the groups it controls.
new Setting(el).setName(t('settings.groupByType')).setDesc(t('settings.groupByTypeDesc'))
.addToggle(toggle => toggle.setValue(s.display.groupByType)
.onChange(async v => { s.display.groupByType = v; await this.saveFlushed(); }));
const groupsWrap = el.createDiv({ cls: 'lsv-groups-editor' });
const renderGroups = () => {
groupsWrap.empty();
s.groups.forEach((g, i) => {
if (!g.targets || typeof (g.targets as unknown as Record<string, unknown>).headings !== 'object') g.targets = defaultTargets();
if (typeof g.property !== 'string') g.property = 'type';
if (!Array.isArray(g.values)) g.values = [];
const card = groupsWrap.createDiv({ cls: 'lsv-group-card' });
/* Identity row: icon · name · delete */
const idRow = new Setting(card).setClass('lsv-group-id-row');
idRow.addButton(b => {
b.setClass('lsv-icon-picker-btn');
const iconSpan = b.buttonEl.createSpan({ cls: 'lsv-icon-picker-btn-icon' });
setIcon(iconSpan, g.icon);
b.setTooltip(t('settings.pickIcon'));
b.onClick(() => {
new IconPickerModal(this.app, g.icon, (iconId) => {
void (async () => {
g.icon = iconId;
await this.saveFlushed();
renderGroups();
})();
}).open();
});
});
idRow.addText(txt => txt.setPlaceholder(t('settings.groupNamePh')).setValue(g.name).onChange(v => {
g.name = v; this.saveDebounced();
}));
idRow.addExtraButton(b => b.setIcon('trash-2').setTooltip(t('settings.deleteGroup')).onClick(async () => {
s.groups.splice(i, 1); await this.saveFlushed(); renderGroups();
}));
/* Membership rule — each field on its own labelled row */
new Setting(card)
.setName(t('settings.groupProperty'))
.setDesc(t('settings.groupPropertyDesc'))
.addText(txt => txt.setPlaceholder(t('settings.groupPropertyPh')).setValue(g.property).onChange(v => {
g.property = v.trim() || 'type'; this.saveDebounced();
}));
new Setting(card)
.setName(t('settings.groupValues'))
.setDesc(t('settings.groupValuesDesc'))
.addText(txt => txt.setPlaceholder(t('settings.groupValuesPh')).setValue(g.values.join(', ')).onChange(v => {
g.values = v.split(',').map(x => x.trim()).filter(Boolean); this.saveDebounced();
}));
/* Collapsible per-group term-source override (default = global) */
const effSrc = (): TermSourceSettings => g.terms ?? s.terms;
const termsSummaryText = () => {
if (!g.terms) return t('settings.groupTermsDefault');
return g.terms.source === 'property'
? `${t('settings.termSourceProperty')}: ${g.terms.property || '—'}`
: t('settings.termSourceAliases');
};
const termsDetails = card.createEl('details', { cls: 'lsv-group-terms-details' });
const termsSummary = termsDetails.createEl('summary', { cls: 'lsv-group-targets-summary' });
termsSummary.createSpan({ text: t('settings.groupTermsSummary') + ': ' });
const termsVal = termsSummary.createSpan({ cls: 'lsv-group-targets-summary-val', text: termsSummaryText() });
const refreshTermsSummary = () => termsVal.setText(termsSummaryText());
const termsBody = termsDetails.createDiv({ cls: 'lsv-group-targets' });
let grpSrcSetting: Setting;
let grpPropSetting: Setting;
const updateTermsVis = () => {
const own = !!g.terms;
grpSrcSetting.settingEl.style.display = own ? '' : 'none';
grpPropSetting.settingEl.style.display = own && g.terms?.source === 'property' ? '' : 'none';
};
new Setting(termsBody)
.setName(t('settings.groupTermsOwn'))
.setDesc(t('settings.groupTermsOwnDesc'))
.addToggle(tg => tg.setValue(!!g.terms).onChange(async val => {
g.terms = val ? { source: s.terms.source, property: s.terms.property } : undefined;
refreshTermsSummary(); updateTermsVis();
await this.saveFlushed(); this.plugin.refreshPanels(true);
}));
grpSrcSetting = new Setting(termsBody)
.setName(t('settings.termSource'))
.addDropdown(d => d
.addOption('aliases', t('settings.termSourceAliases'))
.addOption('property', t('settings.termSourceProperty'))
.setValue(effSrc().source)
.onChange(async v => {
if (!g.terms) g.terms = { source: 'aliases', property: '' };
g.terms.source = v as 'aliases' | 'property';
refreshTermsSummary(); updateTermsVis();
await this.saveFlushed(); this.plugin.refreshPanels(true);
}));
grpPropSetting = new Setting(termsBody)
.setName(t('settings.termProperty'))
.setDesc(t('settings.termPropertyDesc'))
.addText(tx => tx.setValue(effSrc().property).onChange(v => {
if (!g.terms) g.terms = { source: 'property', property: '' };
g.terms.property = v.trim();
refreshTermsSummary();
this.saveDebounced(); this.refreshDebounced();
}));
updateTermsVis();
/* Collapsible "where to search" block with a live summary */
const T = g.targets;
const summaryOf = () => {
const names: string[] = [];
if (T.headings.enabled) names.push(t('settings.headings'));
if (T.callouts.titles || T.callouts.bodies) names.push(t('settings.callouts'));
if (T.frontmatterFields.length) names.push(t('settings.frontmatterFields'));
if (T.body.enabled) names.push(t('settings.noteBody'));
if (T.lists.enabled) names.push(t('settings.lists'));
if (T.filename) names.push(t('settings.filename'));
return names.join(', ') || '—';
};
const details = card.createEl('details', { cls: 'lsv-group-targets-details' });
const summary = details.createEl('summary', { cls: 'lsv-group-targets-summary' });
summary.createSpan({ text: t('settings.whereToSearch') + ': ' });
const summaryVal = summary.createSpan({ cls: 'lsv-group-targets-summary-val', text: summaryOf() });
const refreshSummary = () => summaryVal.setText(summaryOf());
const tgt = details.createDiv({ cls: 'lsv-group-targets' });
const save = async () => { refreshSummary(); await this.saveFlushed(); };
/* — Headings (fast) — */
let hOut: Setting, hExcerpt: Setting, hSection: Setting;
const hVis = () => {
const on = T.headings.enabled;
hOut.settingEl.style.display = on ? '' : 'none';
hExcerpt.settingEl.style.display = on && T.headings.output === 'with-excerpt' ? '' : 'none';
hSection.settingEl.style.display = on && T.headings.output !== 'heading-only' ? '' : 'none';
};
new Setting(tgt).setName(t('settings.headings')).setDesc(t('settings.headingsDesc'))
.addToggle(tg => tg.setValue(T.headings.enabled).onChange(async v => { T.headings.enabled = v; hVis(); await save(); }));
hOut = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.headingOutputMode'))
.addDropdown(d => d
.addOption('heading-only', t('settings.headingOnly'))
.addOption('with-excerpt', t('settings.withExcerpt'))
.addOption('with-section', t('settings.withSection'))
.setValue(T.headings.output)
.onChange(async v => { T.headings.output = v as HeadingOutputMode; hVis(); await this.saveFlushed(); }));
this.infoTip(hOut, t('settings.headingOutputModeDesc'));
hExcerpt = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.excerptMaxLines'))
.addText(tx => tx.setValue(String(T.headings.excerptMaxLines)).onChange(v => {
const n = parseInt(v, 10); if (!isNaN(n) && n >= 0) { T.headings.excerptMaxLines = n; this.saveDebounced(); }
}));
this.infoTip(hExcerpt, t('settings.excerptMaxLinesDesc'));
hSection = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.sectionMaxChars'))
.addText(tx => tx.setValue(String(T.headings.sectionMaxChars)).onChange(v => {
const n = parseInt(v, 10); if (!isNaN(n) && n >= 0) { T.headings.sectionMaxChars = n; this.saveDebounced(); }
}));
this.infoTip(hSection, t('settings.sectionMaxCharsDesc'));
hVis();
/* — Callouts — */
let cTypes: Setting, cSnip: Setting, cSnipChars: Setting;
const cVis = () => {
const any = T.callouts.titles || T.callouts.bodies;
cTypes.settingEl.style.display = any ? '' : 'none';
cSnip.settingEl.style.display = T.callouts.bodies ? '' : 'none';
cSnipChars.settingEl.style.display = T.callouts.bodies && T.callouts.showSnippet ? '' : 'none';
};
new Setting(tgt).setName(t('settings.calloutTitles')).setDesc(t('settings.calloutTitlesDesc'))
.addToggle(tg => tg.setValue(T.callouts.titles).onChange(async v => { T.callouts.titles = v; cVis(); await save(); }));
new Setting(tgt).setName(t('settings.calloutBodies')).setDesc(t('settings.calloutBodiesDesc'))
.addToggle(tg => tg.setValue(T.callouts.bodies).onChange(async v => { T.callouts.bodies = v; cVis(); await save(); }));
cTypes = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.calloutTypes'))
.addText(tx => tx.setValue(T.callouts.types.join(', ')).onChange(v => {
T.callouts.types = v.split(',').map(x => x.trim().toLowerCase()).filter(Boolean); this.saveDebounced();
}));
this.infoTip(cTypes, t('settings.calloutTypesDesc'));
cSnip = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.showBodySnippet'))
.addToggle(tg => tg.setValue(T.callouts.showSnippet).onChange(async v => { T.callouts.showSnippet = v; cVis(); await this.saveFlushed(); }));
this.infoTip(cSnip, t('settings.showBodySnippetDesc'));
cSnipChars = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.snippetContextChars'))
.addText(tx => tx.setValue(String(T.callouts.snippetContextChars)).onChange(v => {
const n = parseInt(v, 10); if (!isNaN(n) && n > 0) { T.callouts.snippetContextChars = n; this.saveDebounced(); }
}));
this.infoTip(cSnipChars, t('settings.snippetContextCharsDesc'));
cVis();
/* — Frontmatter fields — */
new Setting(tgt).setName(t('settings.frontmatterFields')).setDesc(t('settings.frontmatterFieldsDesc'))
.addText(tx => tx.setValue(T.frontmatterFields.join(', ')).onChange(v => {
T.frontmatterFields = v.split(',').map(x => x.trim()).filter(Boolean); refreshSummary(); this.saveDebounced();
}));
/* — Full note text (slow) — */
let bSnip: Setting, bSnipChars: Setting;
const bVis = () => {
bSnip.settingEl.style.display = T.body.enabled ? '' : 'none';
bSnipChars.settingEl.style.display = T.body.enabled && T.body.showSnippet ? '' : 'none';
};
new Setting(tgt).setName(t('settings.noteBody')).setDesc(t('settings.noteBodyDesc'))
.addToggle(tg => tg.setValue(T.body.enabled).onChange(async v => { T.body.enabled = v; bVis(); await save(); }));
bSnip = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.showBodySnippet'))
.addToggle(tg => tg.setValue(T.body.showSnippet).onChange(async v => { T.body.showSnippet = v; bVis(); await this.saveFlushed(); }));
this.infoTip(bSnip, t('settings.showBodySnippetDesc'));
bSnipChars = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.snippetContextChars'))
.addText(tx => tx.setValue(String(T.body.snippetContextChars)).onChange(v => {
const n = parseInt(v, 10); if (!isNaN(n) && n > 0) { T.body.snippetContextChars = n; this.saveDebounced(); }
}));
this.infoTip(bSnipChars, t('settings.snippetContextCharsDesc'));
bVis();
/* — Lists & tasks — */
let lTasks: Setting, lUnchecked: Setting;
const lVis = () => {
lTasks.settingEl.style.display = T.lists.enabled ? '' : 'none';
lUnchecked.settingEl.style.display = T.lists.enabled && T.lists.includeTasks ? '' : 'none';
};
new Setting(tgt).setName(t('settings.lists')).setDesc(t('settings.listsDesc'))
.addToggle(tg => tg.setValue(T.lists.enabled).onChange(async v => { T.lists.enabled = v; lVis(); await save(); }));
lTasks = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.includeTasks'))
.addToggle(tg => tg.setValue(T.lists.includeTasks).onChange(async v => { T.lists.includeTasks = v; lVis(); await this.saveFlushed(); }));
this.infoTip(lTasks, t('settings.includeTasksDesc'));
lUnchecked = new Setting(tgt).setClass('lsv-target-sub').setName(t('settings.onlyUnchecked'))
.addToggle(tg => tg.setValue(T.lists.onlyUnchecked).onChange(async v => { T.lists.onlyUnchecked = v; await this.saveFlushed(); }));
this.infoTip(lUnchecked, t('settings.onlyUncheckedDesc'));
lVis();
/* — Filename — */
new Setting(tgt).setName(t('settings.filename')).setDesc(t('settings.filenameDesc'))
.addToggle(tg => tg.setValue(T.filename).onChange(async v => { T.filename = v; await save(); }));
});
new Setting(groupsWrap).addButton(b => b.setButtonText(t('settings.addGroup')).onClick(async () => {
s.groups.push({
name: t('settings.newGroup'), property: 'type', values: [], icon: 'folder',
targets: defaultTargets()
});
await this.saveFlushed(); renderGroups();
}));
};
renderGroups();
// Подсказка: группы фильтруют карточки по свойству; шаблон карточки PDF
// должен это свойство проставлять, иначе карточка не попадёт в группу.
this.note(el, 'warning', 'alert-triangle', '', t('settings.groupPropertyHint'));
} // end tab: types
if (this.activeTab === 'search') {
/* ── Search behaviour (applies to all groups) ──────────────────── */
new Setting(el).setName(t('settings.searchBehavior')).setHeading();
new Setting(el).setName(t('settings.caseSensitive')).setDesc(t('settings.caseSensitiveDesc'))
.addToggle(toggle => toggle.setValue(s.behavior.caseSensitive).onChange(async v => { s.behavior.caseSensitive = v; await this.saveFlushed(); }));
new Setting(el).setName(t('settings.matchMode')).setDesc(t('settings.matchModeDesc'))
.addDropdown(d => d
.addOption('substring', t('settings.matchSubstring'))
.addOption('stem', t('settings.matchStem'))
.addOption('whole', t('settings.matchWhole'))
.setValue(s.behavior.matchMode ?? (s.behavior.wholeWord ? 'whole' : 'substring'))
.onChange(async v => {
s.behavior.matchMode = v as MatchMode;
s.behavior.wholeWord = v === 'whole'; // держим старое поле в согласии
await this.saveFlushed();
}));
new Setting(el).setName(t('settings.maxMatchesPerFile')).setDesc(t('settings.maxMatchesPerFileDesc'))
.addText(text => text.setValue(String(s.behavior.maxMatchesPerFile)).onChange(v => {
const n = parseInt(v, 10); if (!isNaN(n) && n >= 0) { s.behavior.maxMatchesPerFile = n; this.saveDebounced(); }
}));
} // end tab: search
if (this.activeTab === 'view') {
/* ── Inline note panel ─────────────────────────────────────────── */
new Setting(el).setName(t('settings.inlinePanel')).setHeading();
new Setting(el).setName(t('settings.enablePanel')).setDesc(t('settings.enablePanelDesc'))
.addToggle(toggle => toggle.setValue(s.panel.enabled).onChange(async v => { s.panel.enabled = v; await this.saveFlushed(); this.plugin.refreshPanels(true); }));
new Setting(el).setName(t('settings.panelPosition')).setDesc(t('settings.panelPositionDesc'))
.addDropdown(d => d.addOption('top', t('settings.top')).addOption('bottom', t('settings.bottom'))
.setValue(s.panel.position).onChange(async v => { s.panel.position = v as 'top' | 'bottom'; await this.saveFlushed(); this.plugin.refreshPanels(true); }));
new Setting(el).setName(t('settings.readingMode')).setDesc(t('settings.readingModeDesc'))
.addToggle(toggle => toggle.setValue(s.panel.readingMode).onChange(async v => { s.panel.readingMode = v; await this.saveFlushed(); this.plugin.refreshPanels(true); }));
new Setting(el).setName(t('settings.collapsedDefault')).setDesc(t('settings.collapsedDefaultDesc'))
.addToggle(toggle => toggle.setValue(s.panel.collapsedByDefault).onChange(async v => { s.panel.collapsedByDefault = v; await this.saveFlushed(); }));
new Setting(el).setName(t('settings.panelTitle')).setDesc(t('settings.panelTitleDesc'))
.addText(text => text.setValue(s.panel.title).onChange(v => { s.panel.title = v; this.saveDebounced(); this.refreshDebounced(); }));
// Visibility rule: all notes / only-in / except. Replaces the old
// "trigger folders default to the library folder" behaviour.
let panelFoldersSetting: Setting;
new Setting(el).setName(t('settings.panelScope')).setDesc(t('settings.panelScopeDesc'))
.addDropdown(d => d
.addOption('all', t('settings.scopeAll'))
.addOption('only', t('settings.scopeOnly'))
.addOption('except', t('settings.scopeExcept'))
.setValue(s.panel.scope)
.onChange(async v => {
s.panel.scope = v as PanelScope;
await this.saveFlushed();
panelFoldersSetting.settingEl.style.display = s.panel.scope === 'all' ? 'none' : '';
this.plugin.refreshPanels(true);
}));
panelFoldersSetting = new Setting(el).setName(t('settings.panelFolders')).setDesc(t('settings.panelFoldersDesc'))
.addText(text => text.setValue(s.panel.folders.join(', ')).onChange(v => {
s.panel.folders = v.split(',').map(x => x.trim()).filter(Boolean);
this.saveDebounced();
this.refreshDebounced();
}));
panelFoldersSetting.settingEl.style.display = s.panel.scope === 'all' ? 'none' : '';
/* ── Result display ────────────────────────────────────────────── */
new Setting(el).setName(t('settings.displayResults')).setHeading();
el.createDiv({ text: t('settings.perTargetNote'), cls: 'setting-item-description' });
new Setting(el).setName(t('settings.showTags')).setDesc(t('settings.showTagsDesc'))
.addToggle(toggle => toggle.setValue(s.display.showTags).onChange(async v => { s.display.showTags = v; await this.saveFlushed(); }));
new Setting(el).setName(t('settings.showFilePath')).setDesc(t('settings.showFilePathDesc'))
.addToggle(toggle => toggle.setValue(s.display.showFilePath).onChange(async v => { s.display.showFilePath = v; await this.saveFlushed(); }));
} // end tab: view
if (this.activeTab === 'more') {
new Setting(el)
.setName(t('settings.language'))
.setDesc(t('settings.languageDesc'))
.addDropdown(d => d
.addOption('en', 'English')
.addOption('ru', 'Русский')
.setValue(s.language)
.onChange(async v => {
s.language = v as Lang;
setLanguage(s.language);
await this.saveFlushed();
this.display();
this.plugin.refreshPanels(true);
}));
} // end tab: more
}
}