feat: add date systems menu to calendar days

Right-clicking a calendar day now lists every configured date-prefixed note system. Journal notes are singletons; {title} formats support multiple notes per date, such as meetings.\n\nAdds default Journal and Meeting systems, configurable settings UI, safe filename/title handling, one shared dated-note creation path, and a compiled menu smoke test fixture in the test vault.
This commit is contained in:
2026-09-07 21:07:18 -04:00
parent b617c275eb
commit 26a1a481ac
10 changed files with 731 additions and 76 deletions
+162 -24
View File
@@ -10,14 +10,38 @@ import {
getAllTags,
moment,
} from 'obsidian';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
import { WaypointSettings, DEFAULT_SETTINGS, DateSystemSettings, DEFAULT_DATE_SYSTEM } from 'src/settings';
import { WaypointSettingTab } from 'src/settings-tab';
import { WaypointView, WAYPOINT_VIEW_TYPE } from 'src/views/waypoint-view';
import { BookmarkItem, WaypointData } from 'src/models/bookmark';
import { remapRenamedPath } from 'src/utils/path-utils';
import {
splitNameFormat,
isInFolder,
matchesSystemName,
titleFromBasename,
sanitizeTitle,
formatHasDateToken,
} from 'src/utils/date-systems';
export type PeriodKey = 'day' | 'week' | 'month' | 'quarter' | 'year';
/** One existing note of a date system, with the label the menu should show. */
export interface DateSystemNote {
file: TFile;
/** Free-text title for many-per-date systems, else the system name. */
label: string;
}
/** A day-scoped system and the notes it already holds for one date. */
export interface DateSystemNotes {
system: DateSystemSettings;
/** Sorted by basename. Empty when the date has no note in this system. */
notes: DateSystemNote[];
/** True when nameFormat carries {title}, i.e. many notes per date. */
multiple: boolean;
}
export default class WaypointPlugin extends Plugin {
public settings: WaypointSettings;
public waypointData: WaypointData;
@@ -221,6 +245,13 @@ export default class WaypointPlugin extends Plugin {
for (const key of PERIOD_SETTING_KEYS) {
this.settings[key] = Object.assign({}, DEFAULT_SETTINGS[key], s[key] || {});
}
// Cloned, not Object.assign'd in: the defaults array would otherwise be
// aliased into the live settings and the settings UI would edit
// DEFAULT_SETTINGS itself. Saved entries merge over
// DEFAULT_DATE_SYSTEM so older configs pick up fields added since.
this.settings.dateSystems = Array.isArray(s.dateSystems)
? s.dateSystems.map(sys => Object.assign({}, DEFAULT_DATE_SYSTEM, sys))
: DEFAULT_SETTINGS.dateSystems.map(sys => Object.assign({}, sys));
}
private applyWaypointData(saved: Record<string, unknown> | null): void {
@@ -510,44 +541,142 @@ export default class WaypointPlugin extends Plugin {
}
}
// ── Period note creation/opening ──
// ── Date systems: discovery, creation, opening ──
/** Periodic notes are date systems with a fixed one-note-per-period format. */
private periodAsDateSystem(period: PeriodKey): DateSystemSettings {
const config = PERIOD_CONFIGS[period];
const periodSettings = this.settings[config.key];
return {
id: config.key,
name: config.label,
folder: periodSettings.folder,
nameFormat: periodSettings.nameFormat,
templateFile: periodSettings.templateFile,
typeProperty: periodSettings.typeProperty,
icon: 'calendar',
};
}
/** All day-scoped systems: the daily periodic note first, then settings.dateSystems. */
dateSystems(): DateSystemSettings[] {
return [this.periodAsDateSystem('day'), ...this.settings.dateSystems];
}
/**
* Bucket the notes every day-scoped system already holds for `date`.
*
* One vault scan serves all systems at once. That is affordable because
* this runs once per right-click, not once per calendar cell, and it keeps
* arbitrary user-defined name formats out of the incremental index.
*/
findDateSystemNotes(date: moment.Moment): DateSystemNotes[] {
const buckets = this.dateSystems().map(system => {
const parts = splitNameFormat(system.nameFormat);
return {
result: { system, notes: [] as DateSystemNote[], multiple: parts.hasTitle },
// Each half is formatted on its own: the raw format may still
// hold {title}, whose letters are live moment tokens.
before: formatDatePart(date, parts.before),
after: formatDatePart(date, parts.after),
hasTitle: parts.hasTitle,
// Empty/date-less formats have no date-specific filename. They stay
// editable in Settings but must not contribute a menu action.
skip: !formatHasDateToken(system.nameFormat),
};
});
for (const file of this.app.vault.getMarkdownFiles()) {
for (const bucket of buckets) {
if (bucket.skip) continue;
const system = bucket.result.system;
if (!isInFolder(file.path, system.folder)) continue;
if (!matchesSystemName(file.basename, bucket.before, bucket.after, bucket.hasTitle)) continue;
bucket.result.notes.push({
file,
label: bucket.hasTitle
? titleFromBasename(file.basename, bucket.before, bucket.after)
: system.name,
});
}
}
for (const bucket of buckets) {
bucket.result.notes.sort((a, b) => a.file.basename.localeCompare(b.file.basename));
}
return buckets
.filter(bucket => !bucket.skip)
.map(bucket => bucket.result);
}
/**
* Open (creating if needed) the note `system` holds for `date`.
*
* `opts.title` fills the `{title}` token of a many-per-date system and is
* ignored by systems without one. Opens in `opts.leaf` when given,
* otherwise in the active leaf.
*/
async openDateSystemNote(
system: DateSystemSettings,
date: moment.Moment,
opts?: { title?: string; leaf?: WorkspaceLeaf },
): Promise<void> {
if (!formatHasDateToken(system.nameFormat)) {
new Notice(`Waypoint: ${system.name || 'This'} name format needs a date placeholder.`);
return;
}
const parts = splitNameFormat(system.nameFormat);
const before = formatDatePart(date, parts.before);
const after = formatDatePart(date, parts.after);
let basename: string;
if (parts.hasTitle) {
const title = sanitizeTitle(opts?.title || '');
// Public method, so a caller can legitimately hand us nothing;
// creating "2026-09-07 - .md" would be worse than refusing.
if (!title) {
new Notice(`Waypoint: a ${system.name.toLowerCase()} note needs a title.`);
return;
}
basename = before + title + after;
} else {
basename = before + after;
}
const fullPath = system.folder ? `${system.folder}/${basename}.md` : `${basename}.md`;
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
file = await this.createDatedNote(fullPath, system, date);
if (!file) return;
}
const target = opts?.leaf || this.app.workspace.getLeaf(false);
await target.openFile(file);
}
/**
* Open (creating if needed) the period note for `date`.
* Opens in `leaf` when given, otherwise in the active leaf.
*/
async openPeriodNote(period: PeriodKey, date: moment.Moment, leaf?: WorkspaceLeaf): Promise<void> {
const config = PERIOD_CONFIGS[period];
const periodSettings = this.settings[config.key];
const filename = date.format(periodSettings.nameFormat) + '.md';
const fullPath = periodSettings.folder
? `${periodSettings.folder}/${filename}`
: filename;
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
file = await this.createPeriodNote(fullPath, periodSettings, date, config.label);
if (!file) return;
}
const target = leaf || this.app.workspace.getLeaf(false);
await target.openFile(file);
await this.openDateSystemNote(this.periodAsDateSystem(period), date, { leaf });
}
/**
* Create a period note from its template, or from minimal frontmatter.
* Create a date system's note from its template, or from minimal frontmatter.
*
* Every failure path names the offending path and the reason: the usual
* cause is a configured folder that does not exist yet, which `vault.create`
* refuses outright rather than creating.
*/
private async createPeriodNote(
private async createDatedNote(
fullPath: string,
periodSettings: PeriodNoteSettings,
system: DateSystemSettings,
date: moment.Moment,
label: string,
): Promise<TFile | null> {
const noun = label.toLowerCase();
const noun = system.name.toLowerCase();
const slash = fullPath.lastIndexOf('/');
const folder = slash < 0 ? '' : fullPath.slice(0, slash);
@@ -563,7 +692,7 @@ export default class WaypointPlugin extends Plugin {
return null;
}
const configuredTemplate = periodSettings.templateFile;
const configuredTemplate = system.templateFile;
const templateFile = this.resolveTemplateFile(configuredTemplate);
let content: string;
@@ -579,7 +708,7 @@ export default class WaypointPlugin extends Plugin {
return null;
}
} else {
content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
content = `---\ntype: ${system.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
}
let file: TFile;
@@ -733,3 +862,12 @@ const DIAGNOSTIC_NOTICE_MS = 10000;
function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Format one half of a split name format. Moment falls back to its default
* ISO output when handed an empty format string, so an empty half must never
* reach it.
*/
function formatDatePart(date: moment.Moment, part: string): string {
return part ? date.format(part) : '';
}
+139 -2
View File
@@ -1,12 +1,13 @@
import { Setting, PluginSettingTab, App, setIcon } from 'obsidian';
import type WaypointPlugin from 'src/main';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
import { WaypointSettings, DEFAULT_SETTINGS, DEFAULT_DATE_SYSTEM, PeriodNoteSettings } from 'src/settings';
import { formatHasDateToken } from 'src/utils/date-systems';
export class WaypointSettingTab extends PluginSettingTab {
private plugin: WaypointPlugin;
private settings: WaypointSettings;
private onSettingsChange: () => void;
private activeTab: 'calendar' | 'periodic' | 'recent' | 'display' | 'about' = 'calendar';
private activeTab: 'calendar' | 'periodic' | 'systems' | 'recent' | 'display' | 'about' = 'calendar';
constructor(app: App, plugin: WaypointPlugin, settings: WaypointSettings, onSettingsChange: () => void) {
super(app, plugin);
@@ -24,6 +25,7 @@ export class WaypointSettingTab extends PluginSettingTab {
const tabs = [
{ key: 'calendar' as const, label: 'Calendar' },
{ key: 'periodic' as const, label: 'Periodic Notes' },
{ key: 'systems' as const, label: 'Date systems' },
{ key: 'recent' as const, label: 'Recent Files' },
{ key: 'display' as const, label: 'Display' },
{ key: 'about' as const, label: 'About' },
@@ -49,6 +51,9 @@ export class WaypointSettingTab extends PluginSettingTab {
case 'periodic':
this.renderPeriodicTab(tabContent);
break;
case 'systems':
this.renderSystemsTab(tabContent);
break;
case 'recent':
this.renderRecentTab(tabContent);
break;
@@ -157,6 +162,138 @@ export class WaypointSettingTab extends PluginSettingTab {
});
}
// ═══════════════════════════════
// Date systems tab
// ═══════════════════════════════
private renderSystemsTab(container: HTMLElement): void {
const intro = new DocumentFragment();
intro.createDiv({ text: 'A date system is a folder of notes whose filenames start with a date. Each one appears in the calendar\'s right-click menu for that day.' });
intro.createDiv({ text: 'The daily note is configured under Periodic Notes and always comes first in that menu.' });
intro.createDiv({ text: 'Name format is a moment.js format. Literal words need bracket escaping, e.g. YYYY-MM-DD - [Journal].' });
intro.createDiv({ text: 'Include {title} for systems that hold many notes per date, such as meetings: the text before the token finds the existing notes, and the token marks where a typed title goes. Without it, a date has exactly one note.' });
new Setting(container)
.setHeading()
.setName('Date systems')
.setDesc(intro);
this.settings.dateSystems.forEach((system, i, arr) => {
new Setting(container)
.setHeading()
.setName(system.name || 'Untitled system')
.addExtraButton((btn) => {
btn
.setIcon('arrow-up')
.setTooltip('Move up')
.setDisabled(i === 0)
.onClick(async () => {
if (i === 0) return;
const above = arr[i - 1];
arr[i - 1] = arr[i];
arr[i] = above;
await this.saveAndRefresh();
this.display();
});
})
.addExtraButton((btn) => {
btn
.setIcon('arrow-down')
.setTooltip('Move down')
.setDisabled(i === arr.length - 1)
.onClick(async () => {
if (i === arr.length - 1) return;
const below = arr[i + 1];
arr[i + 1] = arr[i];
arr[i] = below;
await this.saveAndRefresh();
this.display();
});
})
.addExtraButton((btn) => {
btn
.setIcon('trash')
.setTooltip('Delete this date system')
.onClick(async () => {
arr.splice(i, 1);
await this.saveAndRefresh();
this.display();
});
});
this.addSystemTextSetting(container,
'Name', 'Label shown in the calendar right-click menu.',
system, 'name', 'Journal',
);
this.addSystemTextSetting(container,
'Folder', 'Folder these notes live in.',
system, 'folder', 'periodic/journal',
);
const formatSetting = this.addSystemTextSetting(container,
'Name format', 'Filename format (moment.js format). Include {title} for many notes per date.',
system, 'nameFormat', 'YYYY-MM-DD - {title}',
);
// A blank row is still being filled in, so only warn once something was typed.
if (system.nameFormat && !formatHasDateToken(system.nameFormat)) {
formatSetting.descEl.createDiv({
cls: 'waypoint-settings-warning',
text: 'This name format has no date placeholder, so it will never match or create dated notes.',
});
}
this.addSystemTextSetting(container,
'Template file', 'Path to the template file. The .md extension is optional.',
system, 'templateFile', 'resources/template/journal',
);
this.addSystemTextSetting(container,
'Type property', `Fallback value for the 'type' frontmatter property, used when no template is found.`,
system, 'typeProperty', 'journal-note',
);
this.addSystemTextSetting(container,
'Icon', 'Lucide icon name for the menu item. Browse names at lucide.dev.',
system, 'icon', 'book-open',
);
});
new Setting(container)
.addButton((btn) =>
btn
.setButtonText('Add date system')
.setCta()
.onClick(async () => {
this.settings.dateSystems.push(Object.assign(
{ id: `ds-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` },
DEFAULT_DATE_SYSTEM,
));
await this.saveAndRefresh();
this.display();
}),
);
}
/** Returns the Setting so callers can append validation notices to its description. */
private addSystemTextSetting<K extends string>(
container: HTMLElement,
name: string,
desc: string,
obj: Record<K, string>,
key: K,
placeholder: string,
): Setting {
return new Setting(container)
.setName(name)
.setDesc(desc)
.addText((text) => {
text.setPlaceholder(placeholder);
text.setValue(obj[key]);
text.onChange((value) => {
obj[key] = value;
this.saveAndRefresh();
});
});
}
// ═══════════════════════════════
// Recent Files tab
// ═══════════════════════════════
+54
View File
@@ -7,6 +7,38 @@ export interface PeriodNoteSettings {
typeProperty: string;
}
/**
* A folder of notes whose filenames begin with a date — the vault's "systems":
* daily notes, a journal, meeting notes.
*
* `nameFormat` is a moment format string, so literal text needs bracket
* escaping (`YYYY-MM-DD - [Journal]`). When it contains `{title}` the system
* holds many notes per date and the token marks where the free-text title
* goes; without it a date maps to exactly one filename.
*/
export interface DateSystemSettings {
/** Stable across edits and reordering, so the settings UI can key rows. */
id: string;
/** Shown in the calendar's right-click menu. */
name: string;
folder: string;
nameFormat: string;
templateFile: string;
typeProperty: string;
/** Lucide icon name for the menu item. */
icon: string;
}
/** Field defaults for a newly added system, and the merge base for saved ones. */
export const DEFAULT_DATE_SYSTEM: Omit<DateSystemSettings, 'id'> = {
name: '',
folder: '',
nameFormat: 'YYYY-MM-DD - {title}',
templateFile: '',
typeProperty: '',
icon: 'file',
};
export interface CalendarSettings {
firstDayOfWeek: number; // 0=Sunday, 1=Monday
showNoteIndicators: boolean;
@@ -36,6 +68,8 @@ export interface WaypointSettings {
monthly: PeriodNoteSettings;
quarterly: PeriodNoteSettings;
yearly: PeriodNoteSettings;
/** Day-scoped systems beyond the daily note, in menu order. */
dateSystems: DateSystemSettings[];
recentFiles: RecentFilesSettings;
display: DisplaySettings;
}
@@ -75,6 +109,26 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
nameFormat: 'YYYY',
typeProperty: 'yearly-note',
},
dateSystems: [
{
id: 'journal',
name: 'Journal',
folder: 'periodic/journal',
nameFormat: 'YYYY-MM-DD - [Journal]',
templateFile: 'resources/template/journal',
typeProperty: 'journal-note',
icon: 'book-open',
},
{
id: 'meetings',
name: 'Meeting',
folder: 'periodic/meetings',
nameFormat: 'YYYY-MM-DD - {title}',
templateFile: 'resources/template/meeting',
typeProperty: 'meeting-note',
icon: 'users',
},
],
recentFiles: {
maxItems: 50,
updateOn: 'file-open',
+103
View File
@@ -0,0 +1,103 @@
// ── Date system filename helpers ──
//
// A "date system" is a folder of notes whose filenames begin with a date:
//
// periodic/daily/2026-09-07.md one per date
// periodic/journal/2026-09-07 - Journal.md one per date
// periodic/meetings/2026-09-07 - Meeting w Mark.md many per date
//
// The system's `nameFormat` is a moment format string. When it contains
// `{title}` the system holds many notes per date: the part before the token is
// the date prefix used to find them, and the token marks where a free-text
// title goes when creating one.
//
// This module imports nothing from 'obsidian' so it stays unit-testable in
// plain node. Callers do the moment formatting and pass the results in.
export const TITLE_TOKEN = '{title}';
export interface NameFormatParts {
/** Moment format for the text before the title. */
before: string;
/** Moment format for the text after the title. Empty for most systems. */
after: string;
/** True when the format carries a title token, i.e. many notes per date. */
hasTitle: boolean;
}
/**
* Split a name format around its title token.
*
* The split has to happen before moment sees the string: `t`, `i`, `l` and `e`
* are all live moment tokens, so formatting `{title}` directly would mangle it.
*/
export function splitNameFormat(nameFormat: string): NameFormatParts {
const idx = nameFormat.indexOf(TITLE_TOKEN);
if (idx < 0) return { before: nameFormat, after: '', hasTitle: false };
return {
before: nameFormat.slice(0, idx),
after: nameFormat.slice(idx + TITLE_TOKEN.length),
hasTitle: true,
};
}
/** Whether `path` sits in `folder` or any subfolder. An empty folder is the vault root. */
export function isInFolder(path: string, folder: string): boolean {
if (!folder) return true;
const prefix = folder.endsWith('/') ? folder : `${folder}/`;
return path.startsWith(prefix);
}
/**
* Whether `basename` is a note of a system whose formatted name parts are
* `before` and `after`.
*
* One-per-date systems must match the whole basename. Many-per-date systems
* match on the date prefix, since the middle is a free-text title.
*/
export function matchesSystemName(
basename: string,
before: string,
after: string,
hasTitle: boolean,
): boolean {
if (!hasTitle) return basename === before + after;
// A format of just `{title}` formats to an empty prefix, which would claim
// every file in the folder for every date. Treat it as matching nothing;
// the settings tab flags the format as missing a date instead.
if (!before) return false;
if (basename.length < before.length + after.length) return false;
return basename.startsWith(before) && basename.endsWith(after);
}
/**
* The free-text part of a many-per-date basename, for use as a menu label.
* Falls back to the whole basename when the affixes do not line up.
*/
export function titleFromBasename(basename: string, before: string, after: string): string {
const start = basename.startsWith(before) ? before.length : 0;
const end = after && basename.endsWith(after)
? basename.length - after.length
: basename.length;
if (end <= start) return basename;
return basename.slice(start, end).trim() || basename;
}
/**
* Characters Obsidian rejects in filenames, plus the link-syntax characters
* (`#^[]|`) it refuses to index cleanly. Collapsed to a dash so a typed title
* like "Meeting w/ Mark" still produces a creatable filename.
*/
const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|#^[\]]/g;
export function sanitizeTitle(title: string): string {
return title.replace(ILLEGAL_FILENAME_CHARS, '-').replace(/\s+/g, ' ').trim();
}
/** Whether a name format resolves to a usable date prefix at all. */
export function formatHasDateToken(nameFormat: string): boolean {
const { before } = splitNameFormat(nameFormat);
// Strip bracket-escaped literals, then look for any moment date token.
const unescaped = before.replace(/\[[^\]]*\]/g, '');
return /[YMDQGWEwdgeo]/.test(unescaped);
}
+98 -9
View File
@@ -15,6 +15,7 @@ import {
type PaneType,
} from 'obsidian';
import type WaypointPlugin from 'src/main';
import type { DateSystemNotes } from 'src/main';
import { getMonthGrid } from 'src/utils/date-utils';
import { BookmarkItem } from 'src/models/bookmark';
@@ -224,6 +225,11 @@ export class WaypointView extends ItemView {
this.plugin.openPeriodNote('day', day.date, this.app.workspace.getLeaf('tab'));
}
});
cell.addEventListener('contextmenu', (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.showDayContextMenu(event, day.date);
});
}
}
@@ -236,6 +242,79 @@ export class WaypointView extends ItemView {
this.redraw();
}
/**
* Right-click menu for a day cell: every date system's existing notes for
* that date, plus the actions that would create the ones it is missing.
*/
private showDayContextMenu(event: MouseEvent, date: moment.Moment): void {
const menu = new Menu();
menu.addItem((i) => i.setTitle(date.format('dddd, MMMM D, YYYY')).setIsLabel(true));
for (const bucket of this.plugin.findDateSystemNotes(date)) {
// Many-per-date systems can always take another note; one-per-date
// systems only offer creation while their single note is missing.
const canCreate = bucket.multiple || bucket.notes.length === 0;
if (bucket.notes.length + (canCreate ? 1 : 0) === 0) continue;
menu.addSeparator();
for (const note of bucket.notes) {
menu.addItem((i) =>
i
.setTitle(note.label)
.setIcon(bucket.system.icon)
.onClick((evt) => this.focusFile(note.file, Keymap.isModEvent(evt))),
);
}
if (!canCreate) continue;
const noun = bucket.system.name.toLowerCase();
menu.addItem((i) =>
i
// The ellipsis promises the prompt that a free-text title needs.
.setTitle(bucket.multiple ? `New ${noun} note…` : `New ${noun} note`)
.setIcon('plus')
.onClick((evt) => this.createDateSystemNote(bucket, date, Keymap.isModEvent(evt))),
);
}
menu.showAtPosition({ x: event.clientX, y: event.clientY });
}
/**
* Creates a date system's note, asking for a title first when the system
* holds many notes per date. A held modifier opens the result in a new pane.
*/
private createDateSystemNote(bucket: DateSystemNotes, date: moment.Moment, newLeaf: PaneType | boolean): void {
if (!bucket.multiple) {
void this.plugin.openDateSystemNote(bucket.system, date, {
leaf: newLeaf ? this.app.workspace.getLeaf(newLeaf) : undefined,
});
return;
}
const name = bucket.system.name;
new PromptModal(
this.app,
{
title: `New ${name.toLowerCase()} note`,
placeholder: `${name} with…`,
cta: 'Create',
},
(title) => {
// `getLeaf` materialises the pane immediately, so the target pane is
// resolved only after the title is confirmed — cancelling the prompt
// must not leave an empty tab behind.
void this.plugin.openDateSystemNote(bucket.system, date, {
title,
leaf: newLeaf ? this.app.workspace.getLeaf(newLeaf) : undefined,
});
},
).open();
}
// ════════════════════════════════════════
// Recent Files panel
// ════════════════════════════════════════
@@ -1062,7 +1141,7 @@ export class WaypointView extends ItemView {
}
private promptRename(item: BookmarkItem): void {
new RenameModal(this.app, item.label, (newLabel) => {
new PromptModal(this.app, { title: 'Rename bookmark', initialValue: item.label }, (newLabel) => {
if (newLabel && newLabel.trim()) {
this.plugin.updateBookmark(item.id, { label: newLabel.trim() });
}
@@ -1077,24 +1156,34 @@ export class WaypointView extends ItemView {
}
}
// ── Rename modal ──
// ── Text prompt modal ──
class RenameModal extends Modal {
private currentValue: string;
interface PromptModalOptions {
title: string;
placeholder?: string;
initialValue?: string;
/** Submit button label. Defaults to 'Save'. */
cta?: string;
}
/** Single-line text prompt, shared by bookmark renaming and note creation. */
class PromptModal extends Modal {
private options: PromptModalOptions;
private onSubmit: (value: string) => void;
constructor(app: App, currentValue: string, onSubmit: (value: string) => void) {
constructor(app: App, options: PromptModalOptions, onSubmit: (value: string) => void) {
super(app);
this.currentValue = currentValue;
this.options = options;
this.onSubmit = onSubmit;
}
onOpen(): void {
this.titleEl.setText('Rename bookmark');
this.titleEl.setText(this.options.title);
const input = this.contentEl.createEl('input', {
type: 'text',
value: this.currentValue,
value: this.options.initialValue ?? '',
placeholder: this.options.placeholder ?? '',
});
input.style.width = '100%';
input.style.marginBottom = '12px';
@@ -1107,7 +1196,7 @@ class RenameModal extends Modal {
cancelBtn.style.marginRight = '8px';
cancelBtn.addEventListener('click', () => this.close());
const saveBtn = btnContainer.createEl('button', { text: 'Save', cls: 'mod-cta' });
const saveBtn = btnContainer.createEl('button', { text: this.options.cta ?? 'Save', cls: 'mod-cta' });
saveBtn.addEventListener('click', () => {
this.onSubmit(input.value);
this.close();