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:
+162
-24
@@ -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
@@ -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
|
||||
// ═══════════════════════════════
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user