fix: folder renames no longer destroy bookmarks, plus data.json save race

- onRename remapped paths by exact equality only, so files inside a moved
  folder kept stale paths and the view then permanently deleted their
  bookmarks on next click. Add pure remapRenamedPath() (path-utils.ts,
  obsidian-free so it is unit-testable) and remap recentFiles + the whole
  bookmark tree, folder moves included.
- saveSettings/saveWaypointData each did loadData -> mutate one key ->
  saveData, so two concurrent saves carried a stale snapshot of the other
  key and one silently reverted the other. Both now delegate to persistAll(),
  which writes both keys from memory through a promise-chain mutex.
- data.json is read once in onload instead of twice; period sub-objects are
  deep-merged so old configs pick up new fields.
- detectPeriodType now derives from the configured nameFormat with strict
  moment parsing instead of hardcoded regexes, so custom formats work.
- Merge openPeriodNoteInLeaf into openPeriodNote(period, date, leaf?); stop
  double-appending .md to templateFile; type the caught error as unknown.
- hasNoteForDate is synchronous and O(1) over a maintained basename Set;
  delete dead getNotesForDate.
- Implement the previously dead omittedTags filter and the no-op
  updateOn: 'file-edit' mode (vault modify event).
This commit is contained in:
2026-09-07 20:03:36 -04:00
parent 3c7b6741f5
commit 6a1a044566
2 changed files with 292 additions and 184 deletions
+271 -184
View File
@@ -3,34 +3,37 @@
import { import {
Plugin, Plugin,
WorkspaceLeaf, WorkspaceLeaf,
ItemView,
Notice, Notice,
TFile, TFile,
TAbstractFile, TAbstractFile,
getAllTags,
moment, moment,
} from 'obsidian'; } from 'obsidian';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings'; import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
import { WaypointSettingTab } from 'src/settings-tab'; import { WaypointSettingTab } from 'src/settings-tab';
import { WaypointView, WAYPOINT_VIEW_TYPE } from 'src/views/waypoint-view'; import { WaypointView, WAYPOINT_VIEW_TYPE } from 'src/views/waypoint-view';
import { BookmarkItem, WaypointData } from 'src/models/bookmark'; import { BookmarkItem, WaypointData } from 'src/models/bookmark';
import { remapRenamedPath } from 'src/utils/path-utils';
const DEFAULT_DATA: WaypointData = { export type PeriodKey = 'day' | 'week' | 'month' | 'quarter' | 'year';
bookmarks: [],
recentFiles: [],
};
export default class WaypointPlugin extends Plugin { export default class WaypointPlugin extends Plugin {
public settings: WaypointSettings; public settings: WaypointSettings;
public waypointData: WaypointData; public waypointData: WaypointData;
public recentFiles: { path: string; basename: string }[] = []; public recentFiles: { path: string; basename: string }[] = [];
private recentFilesSaveTimer: ReturnType<typeof setTimeout> | null = null; private recentFilesSaveTimer: number | undefined;
/** Serializes writes to data.json so overlapping saves cannot lose updates. */
private savePromise: Promise<void> = Promise.resolve();
/** Basenames of every markdown file in the vault, for O(1) calendar lookups. */
private markdownBasenames: Set<string> = new Set();
async onload(): Promise<void> { async onload(): Promise<void> {
console.debug('Waypoint: loading plugin v' + this.manifest.version); console.debug('Waypoint: loading plugin v' + this.manifest.version);
// Load persisted data // Load persisted data — data.json is read exactly once here.
await this.loadSettings(); const saved = await this.loadData() as Record<string, unknown> | null;
await this.loadWaypointData(); this.applySettings(saved);
this.applyWaypointData(saved);
// Register the sidebar view // Register the sidebar view
this.registerView( this.registerView(
@@ -156,23 +159,31 @@ export default class WaypointPlugin extends Plugin {
); );
this.registerEvent( this.registerEvent(
this.app.vault.on('create', () => this.onVaultChange()), this.app.vault.on('create', (file: TAbstractFile) => this.onVaultCreate(file)),
); );
this.registerEvent( this.registerEvent(
this.app.vault.on('delete', () => this.onVaultChange()), this.app.vault.on('delete', (file: TAbstractFile) => this.onVaultDelete(file)),
); );
this.registerEvent( this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => this.onRename(file, oldPath)), this.app.vault.on('rename', (file, oldPath) => this.onRename(file, oldPath)),
); );
this.registerEvent(
this.app.vault.on('modify', (file: TAbstractFile) => this.onFileModify(file)),
);
// Auto-open view on first load // Auto-open view on first load
this.app.workspace.onLayoutReady(() => { this.app.workspace.onLayoutReady(() => {
this.buildMarkdownIndex();
const leaves = this.app.workspace.getLeavesOfType(WAYPOINT_VIEW_TYPE); const leaves = this.app.workspace.getLeavesOfType(WAYPOINT_VIEW_TYPE);
if (leaves.length === 0) { if (leaves.length === 0) {
const leaf = this.app.workspace.getLeftLeaf(false); const leaf = this.app.workspace.getLeftLeaf(false);
if (leaf) { if (leaf) {
leaf.setViewState({ type: WAYPOINT_VIEW_TYPE }); leaf.setViewState({ type: WAYPOINT_VIEW_TYPE });
} }
} else {
// A restored view may have rendered before the index existed.
this.broadcastRedraw();
} }
}); });
@@ -193,31 +204,33 @@ export default class WaypointPlugin extends Plugin {
this.app.workspace.detachLeavesOfType(WAYPOINT_VIEW_TYPE); this.app.workspace.detachLeavesOfType(WAYPOINT_VIEW_TYPE);
} }
// ── Settings ── // ── Settings & persistence ──
async loadSettings(): Promise<void> { /**
const saved = await this.loadData() as Record<string, unknown> | null; * Merge persisted settings over the defaults. Nested objects are merged
* individually so existing configs keep their values while picking up
* fields added in newer versions.
*/
private applySettings(saved: Record<string, unknown> | null): void {
const s = (saved?.settings || {}) as Partial<WaypointSettings>; const s = (saved?.settings || {}) as Partial<WaypointSettings>;
this.settings = Object.assign({}, DEFAULT_SETTINGS, s); this.settings = Object.assign({}, DEFAULT_SETTINGS, s);
// Deep merge nested objects that might be missing new fields
this.settings.recentFiles = Object.assign({}, DEFAULT_SETTINGS.recentFiles, s.recentFiles || {}); this.settings.recentFiles = Object.assign({}, DEFAULT_SETTINGS.recentFiles, s.recentFiles || {});
this.settings.calendar = Object.assign({}, DEFAULT_SETTINGS.calendar, s.calendar || {}); this.settings.calendar = Object.assign({}, DEFAULT_SETTINGS.calendar, s.calendar || {});
this.settings.display = Object.assign({}, DEFAULT_SETTINGS.display, s.display || {}); this.settings.display = Object.assign({}, DEFAULT_SETTINGS.display, s.display || {});
for (const key of PERIOD_SETTING_KEYS) {
this.settings[key] = Object.assign({}, DEFAULT_SETTINGS[key], s[key] || {});
}
} }
async saveSettings(): Promise<void> { private applyWaypointData(saved: Record<string, unknown> | null): void {
const all = (await this.loadData()) as Record<string, unknown> || {};
all.settings = this.settings;
await this.saveData(all);
}
async loadWaypointData(): Promise<void> {
const saved = await this.loadData() as Record<string, unknown> | null;
const d = (saved?.waypointData || {}) as Partial<WaypointData>; const d = (saved?.waypointData || {}) as Partial<WaypointData>;
this.waypointData = Object.assign({}, DEFAULT_DATA, d); this.waypointData = {
bookmarks: Array.isArray(d.bookmarks) ? d.bookmarks : [],
recentFiles: Array.isArray(d.recentFiles) ? d.recentFiles : [],
};
// Load persisted recent files // Load persisted recent files
this.recentFiles = this.waypointData.recentFiles || []; this.recentFiles = this.waypointData.recentFiles;
// Apply current limit (in case maxItems was reduced since last save) // Apply current limit (in case maxItems was reduced since last save)
if (this.recentFiles.length > this.settings.recentFiles.maxItems) { if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
@@ -226,6 +239,37 @@ export default class WaypointPlugin extends Plugin {
} }
} }
/**
* Write both top-level keys of data.json from memory.
*
* Saves never re-read from disk: `settings` and `waypointData` are the only
* keys and both are held in memory, so a read-modify-write would only give
* two concurrent saves a stale snapshot of the other's key. Writes are
* chained onto `savePromise` so they cannot interleave.
*/
private persistAll(): Promise<void> {
const write = this.savePromise.then(() => {
// Sync recentFiles into waypointData before writing
this.waypointData.recentFiles = this.recentFiles;
return this.saveData({
settings: this.settings,
waypointData: this.waypointData,
});
});
// Keep the queue usable after a failed write without leaving an
// unhandled rejection behind; callers still see `write` reject.
this.savePromise = write.catch(() => undefined);
return write;
}
async saveSettings(): Promise<void> {
await this.persistAll();
}
async saveWaypointData(): Promise<void> {
await this.persistAll();
}
/** /**
* Trim recent files to the current maxItems limit and persist. * Trim recent files to the current maxItems limit and persist.
* Called when the maxItems setting changes. * Called when the maxItems setting changes.
@@ -237,21 +281,13 @@ export default class WaypointPlugin extends Plugin {
} }
} }
async saveWaypointData(): Promise<void> {
// Sync recentFiles into waypointData before saving
this.waypointData.recentFiles = this.recentFiles;
const all = (await this.loadData()) as Record<string, unknown> || {};
all.waypointData = this.waypointData;
await this.saveData(all);
}
/** /**
* Persist recent files to disk (debounced to avoid excessive writes on rapid opens). * Persist recent files to disk (debounced to avoid excessive writes on rapid opens).
*/ */
persistRecentFiles(): void { persistRecentFiles(): void {
this.waypointData.recentFiles = this.recentFiles; this.waypointData.recentFiles = this.recentFiles;
if (this.recentFilesSaveTimer) clearTimeout(this.recentFilesSaveTimer); window.clearTimeout(this.recentFilesSaveTimer);
this.recentFilesSaveTimer = setTimeout(() => { this.recentFilesSaveTimer = window.setTimeout(() => {
this.saveWaypointData(); this.saveWaypointData();
}, 300); }, 300);
} }
@@ -259,24 +295,20 @@ export default class WaypointPlugin extends Plugin {
// ── Recent Files ── // ── Recent Files ──
private onFileOpen(file: TFile): void { private onFileOpen(file: TFile): void {
if (this.settings.recentFiles.updateOn === 'file-edit') { if (this.settings.recentFiles.updateOn !== 'file-open') return;
// We'll handle this via quick-preview in a future refinement this.addToRecentFiles(file);
return; }
}
private onFileModify(file: TAbstractFile): void {
if (this.settings.recentFiles.updateOn !== 'file-edit') return;
if (!(file instanceof TFile)) return;
// Already the most recent entry: nothing to reorder or redraw.
if (this.recentFiles.length > 0 && this.recentFiles[0].path === file.path) return;
this.addToRecentFiles(file); this.addToRecentFiles(file);
} }
addToRecentFiles(file: TFile): void { addToRecentFiles(file: TFile): void {
// Apply omitted paths filter if (this.isOmittedFromRecentFiles(file)) return;
if (this.settings.recentFiles.omittedPaths.length > 0) {
for (const pattern of this.settings.recentFiles.omittedPaths) {
try {
if (new RegExp(pattern).test(file.path)) return;
} catch {
// Invalid regex, skip
}
}
}
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path); this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
this.recentFiles.unshift({ path: file.path, basename: file.basename }); this.recentFiles.unshift({ path: file.path, basename: file.basename });
@@ -290,23 +322,137 @@ export default class WaypointPlugin extends Plugin {
this.broadcastRedraw(); this.broadcastRedraw();
} }
private onRename(file: TAbstractFile, oldPath: string): void { /**
const entry = this.recentFiles.find(f => f.path === oldPath); * Apply the omittedPaths / omittedTags filters. Each entry is treated as a
if (entry) { * regex; an invalid pattern is skipped rather than throwing.
entry.path = file.path; */
entry.basename = (file as TFile).basename || file.name.replace(/\.[^/.]+$/, ''); private isOmittedFromRecentFiles(file: TFile): boolean {
this.persistRecentFiles(); for (const pattern of this.settings.recentFiles.omittedPaths) {
this.broadcastRedraw(); try {
if (new RegExp(pattern).test(file.path)) return true;
} catch {
// Invalid regex, skip
}
} }
// Update bookmark file paths const omittedTags = this.settings.recentFiles.omittedTags;
this.updateBookmarkPath(oldPath, file.path); if (omittedTags.length === 0) return false;
const cache = this.app.metadataCache.getFileCache(file);
const tags = (cache ? getAllTags(cache) : null) || [];
if (tags.length === 0) return false;
const bareTags = tags.map(tag => tag.replace(/^#/, ''));
for (const pattern of omittedTags) {
try {
const regex = new RegExp(pattern);
if (bareTags.some(tag => regex.test(tag))) return true;
} catch {
// Invalid regex, skip
}
}
return false;
} }
private onVaultChange(): void { /**
* Obsidian fires `rename` for folders too, so a stored path may need
* remapping either because it is the renamed item or because it sits
* inside a renamed folder.
*/
private onRename(file: TAbstractFile, oldPath: string): void {
const indexChanged = this.syncIndexForRename(file, oldPath);
let dataChanged = false;
for (const entry of this.recentFiles) {
const remapped = remapRenamedPath(entry.path, oldPath, file.path);
if (remapped === null) continue;
entry.path = remapped;
entry.basename = basenameFromPath(remapped);
dataChanged = true;
}
const remapBookmarks = (items: BookmarkItem[]): void => {
for (const item of items) {
if (item.filePath) {
const remapped = remapRenamedPath(item.filePath, oldPath, file.path);
if (remapped !== null) {
item.filePath = remapped;
dataChanged = true;
}
}
if (item.children) remapBookmarks(item.children);
}
};
remapBookmarks(this.waypointData.bookmarks);
if (dataChanged) {
this.waypointData.recentFiles = this.recentFiles;
this.persistAll();
}
if (dataChanged || indexChanged) this.broadcastRedraw();
}
private onVaultCreate(file: TAbstractFile): void {
if (file instanceof TFile && file.extension === 'md') {
this.markdownBasenames.add(file.basename);
}
this.broadcastRedraw(); this.broadcastRedraw();
} }
private onVaultDelete(file: TAbstractFile): void {
if (file instanceof TFile && file.extension === 'md') {
this.removeFromMarkdownIndex(file.basename, file.path);
}
this.broadcastRedraw();
}
// ── Markdown basename index (calendar note indicators) ──
private buildMarkdownIndex(): void {
this.markdownBasenames.clear();
for (const file of this.app.vault.getMarkdownFiles()) {
this.markdownBasenames.add(file.basename);
}
}
/**
* Drop a basename from the index, unless another markdown file still
* carries it. `path` is excluded from that check because the vault may not
* have dropped the file yet when the event fires.
*/
private removeFromMarkdownIndex(basename: string, path: string): boolean {
if (!this.markdownBasenames.has(basename)) return false;
const stillExists = this.app.vault.getMarkdownFiles()
.some(f => f.basename === basename && f.path !== path);
if (stillExists) return false;
this.markdownBasenames.delete(basename);
return true;
}
/** Returns true when the index changed. Folder renames never change basenames. */
private syncIndexForRename(file: TAbstractFile, oldPath: string): boolean {
if (!(file instanceof TFile)) return false;
let changed = false;
const oldBasename = basenameFromPath(oldPath);
if (oldPath.toLowerCase().endsWith('.md') && (oldBasename !== file.basename || file.extension !== 'md')) {
changed = this.removeFromMarkdownIndex(oldBasename, oldPath);
}
if (file.extension === 'md' && !this.markdownBasenames.has(file.basename)) {
this.markdownBasenames.add(file.basename);
changed = true;
}
return changed;
}
/**
* Whether any markdown file in the vault is named exactly `dateStr`.
* Synchronous and O(1) — the calendar calls this once per day cell.
*/
hasNoteForDate(dateStr: string): boolean {
return this.markdownBasenames.has(dateStr);
}
// ── Bookmarks ── // ── Bookmarks ──
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = ''): BookmarkItem { addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = ''): BookmarkItem {
@@ -363,137 +509,72 @@ export default class WaypointPlugin extends Plugin {
} }
} }
private updateBookmarkPath(oldPath: string, newPath: string): void {
const updateRecursive = (items: BookmarkItem[]) => {
for (const item of items) {
if (item.filePath === oldPath) {
item.filePath = newPath;
}
if (item.children) updateRecursive(item.children);
}
};
updateRecursive(this.waypointData.bookmarks);
}
// ── Period note creation/opening ── // ── Period note creation/opening ──
async openPeriodNote(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment): Promise<void> { /**
// Map period to settings and date format * Open (creating if needed) the period note for `date`.
type PeriodConfig = { * Opens in `leaf` when given, otherwise in the active leaf.
settings: PeriodNoteSettings; */
label: string; async openPeriodNote(period: PeriodKey, date: moment.Moment, leaf?: WorkspaceLeaf): Promise<void> {
}; const config = PERIOD_CONFIGS[period];
const periodSettings = this.settings[config.key];
const configs: Record<string, PeriodConfig> = {
day: { settings: this.settings.daily, label: 'Daily' },
week: { settings: this.settings.weekly, label: 'Weekly' },
month: { settings: this.settings.monthly, label: 'Monthly' },
quarter: { settings: this.settings.quarterly, label: 'Quarterly' },
year: { settings: this.settings.yearly, label: 'Yearly' },
};
const config = configs[period];
if (!config) return;
const { settings: periodSettings } = config;
const filename = date.format(periodSettings.nameFormat) + '.md'; const filename = date.format(periodSettings.nameFormat) + '.md';
const fullPath = periodSettings.folder const fullPath = periodSettings.folder
? `${periodSettings.folder}/${filename}` ? `${periodSettings.folder}/${filename}`
: filename; : filename;
// Check if exists
let file = this.app.vault.getFileByPath(fullPath); let file = this.app.vault.getFileByPath(fullPath);
if (!file) { if (!file) {
// Create it from template file = await this.createPeriodNote(fullPath, periodSettings, date, config.label);
try { if (!file) return;
// Try to find template file
const templatePath = periodSettings.templateFile + '.md';
const templateFile = this.app.vault.getFileByPath(templatePath);
if (templateFile) {
const templateContent = await this.app.vault.read(templateFile);
file = await this.app.vault.create(fullPath, templateContent);
} else {
// Fallback: create with minimal frontmatter
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
file = await this.app.vault.create(fullPath, content);
}
} catch (err) {
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
return;
}
new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`); new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`);
} }
if (file) { const target = leaf || this.app.workspace.getLeaf(false);
const leaf = this.app.workspace.getLeaf(false); await target.openFile(file);
await leaf.openFile(file); }
/** Create a period note from its template, or from minimal frontmatter. */
private async createPeriodNote(
fullPath: string,
periodSettings: PeriodNoteSettings,
date: moment.Moment,
label: string,
): Promise<TFile | null> {
try {
const templateFile = this.resolveTemplateFile(periodSettings.templateFile);
const content = templateFile
? await this.app.vault.read(templateFile)
: `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
return await this.app.vault.create(fullPath, content);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create ${label.toLowerCase()} note: ${message}`);
return null;
} }
} }
// ── Open period note in a specific leaf (for middle-click) ── /** The setting may or may not already carry the .md extension. */
async openPeriodNoteInLeaf(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment, leaf: any): Promise<void> { private resolveTemplateFile(templateFile: string): TFile | null {
type PeriodConfig = { settings: PeriodNoteSettings; label: string }; if (!templateFile) return null;
const configs: Record<string, PeriodConfig> = { const path = templateFile.toLowerCase().endsWith('.md') ? templateFile : `${templateFile}.md`;
day: { settings: this.settings.daily, label: 'Daily' }, return this.app.vault.getFileByPath(path);
week: { settings: this.settings.weekly, label: 'Weekly' },
month: { settings: this.settings.monthly, label: 'Monthly' },
quarter: { settings: this.settings.quarterly, label: 'Quarterly' },
year: { settings: this.settings.yearly, label: 'Yearly' },
};
const config = configs[period];
if (!config) return;
const { settings: periodSettings } = config;
const filename = date.format(periodSettings.nameFormat) + '.md';
const fullPath = periodSettings.folder ? `${periodSettings.folder}/${filename}` : filename;
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
try {
const templatePath = periodSettings.templateFile + '.md';
const templateFile = this.app.vault.getFileByPath(templatePath);
if (templateFile) {
const templateContent = await this.app.vault.read(templateFile);
file = await this.app.vault.create(fullPath, templateContent);
} else {
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
file = await this.app.vault.create(fullPath, content);
}
} catch (err) {
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
return;
}
}
if (file) {
await leaf.openFile(file);
}
} }
// ── Period note navigation (next/prev from current file) ── // ── Period note navigation (next/prev from current file) ──
/** /**
* Detect the period type from a filename's basename. * Detect the period type from a filename's basename using the configured
* name formats, so custom formats keep working. Parsing is strict, which
* stops a loose format (e.g. YYYY) from swallowing a longer basename.
* Returns the period key and the parsed moment, or null if not a period note. * Returns the period key and the parsed moment, or null if not a period note.
*/ */
private detectPeriodType(basename: string): { period: 'day' | 'week' | 'month' | 'quarter' | 'year'; date: moment.Moment } | null { private detectPeriodType(basename: string): { period: PeriodKey; date: moment.Moment } | null {
// YYYY-MM-DD → daily for (const period of PERIOD_DETECTION_ORDER) {
if (/^\d{4}-\d{2}-\d{2}$/.test(basename)) { const format = this.settings[PERIOD_CONFIGS[period].key].nameFormat;
return { period: 'day', date: moment(basename, 'YYYY-MM-DD') }; if (!format) continue;
} const date = moment(basename, format, true);
// GGGG-WWW → weekly (e.g. 2026-W24) if (date.isValid()) return { period, date };
if (/^\d{4}-W\d{2}$/.test(basename)) {
return { period: 'week', date: moment(basename, 'GGGG-[W]WW') };
}
// YYYY-MM → monthly
if (/^\d{4}-\d{2}$/.test(basename)) {
return { period: 'month', date: moment(basename, 'YYYY-MM') };
}
// YYYY-Q# → quarterly
if (/^\d{4}-Q[1-4]$/.test(basename)) {
return { period: 'quarter', date: moment(basename, 'YYYY-[Q]Q') };
}
// YYYY → yearly
if (/^\d{4}$/.test(basename)) {
return { period: 'year', date: moment(basename, 'YYYY') };
} }
return null; return null;
} }
@@ -545,20 +626,26 @@ export default class WaypointPlugin extends Plugin {
} }
} }
} }
}
/**
* Get all markdown files that exist on a specific date. // ── Module helpers ──
* Used to show note indicators on the calendar.
*/ type PeriodSettingKey = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly';
async getNotesForDate(dateStr: string): Promise<TFile[]> {
return this.app.vault.getFiles().filter(f => const PERIOD_SETTING_KEYS: PeriodSettingKey[] = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'];
f.extension === 'md' && f.basename === dateStr,
); const PERIOD_CONFIGS: Record<PeriodKey, { key: PeriodSettingKey; label: string }> = {
} day: { key: 'daily', label: 'Daily' },
week: { key: 'weekly', label: 'Weekly' },
async hasNoteForDate(dateStr: string): Promise<boolean> { month: { key: 'monthly', label: 'Monthly' },
return this.app.vault.getFiles().some(f => quarter: { key: 'quarterly', label: 'Quarterly' },
f.extension === 'md' && f.basename === dateStr, year: { key: 'yearly', label: 'Yearly' },
); };
}
/** Checked day → year so a loose format (YYYY) cannot claim a longer basename. */
const PERIOD_DETECTION_ORDER: PeriodKey[] = ['day', 'week', 'month', 'quarter', 'year'];
function basenameFromPath(path: string): string {
const name = path.slice(path.lastIndexOf('/') + 1);
return name.replace(/\.[^/.]+$/, '');
} }
+21
View File
@@ -0,0 +1,21 @@
// ── Path helpers ──
// Pure functions only: no 'obsidian' imports, so this stays unit-testable in plain node.
/**
* Remap a stored vault path after a rename.
*
* Obsidian's `vault.on('rename')` fires for folders as well as files, so a
* stored path can be affected either because it *is* the renamed item or
* because it lives inside a renamed folder.
*
* The nested check requires a `/` boundary, so renaming `notes/foo` leaves
* `notes/foobar.md` untouched.
*
* @returns the updated path, or `null` when `path` is unaffected.
*/
export function remapRenamedPath(path: string, oldPath: string, newPath: string): string | null {
if (!path || !oldPath) return null;
if (path === oldPath) return newPath;
if (path.startsWith(oldPath + '/')) return newPath + path.slice(oldPath.length);
return null;
}