From 54eaac77149cd7fa52d7cf048d6986a0d60d5ec0 Mon Sep 17 00:00:00 2001 From: larsbuecker Date: Sat, 13 Jun 2026 21:36:50 +0200 Subject: [PATCH] feat: add date components to destination template placeholders Allow {{property..}} syntax so folder paths can be built from date frontmatter (e.g. Archive/{{property.created.year}}) without separate year/month fields. Supports year, month, day, iso, monthName, and dayOfWeek with literal-key precedence and timezone-safe ISO parsing. Closes #98 --- CHANGELOG.md | 1 + README.md | 3 +- docs/destination-templates.md | 37 ++++ src/domain/dates/property-date.test.ts | 46 +++++ src/domain/dates/property-date.ts | 170 ++++++++++++++++++ src/domain/templates/DestinationTemplate.ts | 76 ++++---- .../templates/destination-template.test.ts | 30 ++++ .../templates/property-placeholder.test.ts | 46 +++++ src/domain/templates/property-placeholder.ts | 102 +++++++++++ src/modals/RuleEditorModal.ts | 2 +- src/settings/suggesters/FolderSuggest.ts | 89 +++++++-- 11 files changed, 549 insertions(+), 53 deletions(-) create mode 100644 src/domain/dates/property-date.test.ts create mode 100644 src/domain/dates/property-date.ts create mode 100644 src/domain/templates/property-placeholder.test.ts create mode 100644 src/domain/templates/property-placeholder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48fadb8..3421962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - **Vault re-evaluation after rule changes** ([#80](https://github.com/bueckerlars/obsidian-note-mover-shortcut/issues/80)): New command **Re-evaluate entire vault with current rules** and a matching button under **Settings → Rules**. Syncs the latest rules, clears the evaluation cache, opens a preview of planned moves, and only moves files when you confirm in the modal. +- **Date components in destination templates** ([#98](https://github.com/bueckerlars/obsidian-note-mover-shortcut/issues/98)): Property placeholders now support date components such as `{{property.created.year}}`, `{{property.created.month}}`, `{{property.created.monthName}}`, and `{{property.created.dayOfWeek}}` for building folder paths from date frontmatter without separate year/month fields. ### Fixes diff --git a/README.md b/README.md index a61ba30..d3d7d2b 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,12 @@ Match notes on almost any piece of metadata: ### Dynamic destination templates -Instead of a static folder path, use `{{tag.…}}` and `{{property.…}}` placeholders to build the destination from the note's own metadata: +Instead of a static folder path, use `{{tag.…}}` and `{{property.…}}` placeholders to build the destination from the note's own metadata. Date properties also support components such as `Archive/{{property.created.year}}`: ``` Clients/{{property.client}}/Notes Projects/{{property.year}}/{{tag.project}} +Archive/{{property.created.year}}/{{property.created.month}} ``` The placeholder resolves at move time — if the note has `client: Acme` in its frontmatter, it goes to `Clients/Acme/Notes`. If the value is missing, the note is not moved. diff --git a/docs/destination-templates.md b/docs/destination-templates.md index 569ecea..46e1e63 100644 --- a/docs/destination-templates.md +++ b/docs/destination-templates.md @@ -58,6 +58,43 @@ Looks up `` in the note's frontmatter. --- +## Date property components + +For date frontmatter properties, append a component suffix to extract parts of the date at move time: + +``` +{{property..}} +``` + +Supported components: + +| Component | Example output (`created: 2025-06-13`) | Notes | +| ----------- | -------------------------------------- | ---------------------------------- | +| `year` | `2025` | 4-digit year | +| `month` | `06` | Zero-padded month (01–12) | +| `day` | `13` | Zero-padded day (01–31) | +| `iso` | `2025-06-13` | Normalized ISO date (time ignored) | +| `monthName` | `June` | English full month name | +| `dayOfWeek` | `friday` | Lowercase English day name | + +### Examples + +| Template | Frontmatter | Resolves to | +| -------------------------------------------------------------- | --------------------- | ----------------- | +| `Archive/{{property.created.year}}` | `created: 2025-06-13` | `Archive/2025` | +| `Journal/{{property.created.year}}/{{property.created.month}}` | `created: 2025-06-13` | `Journal/2025/06` | +| `Days/{{property.created.dayOfWeek}}` | `created: 2025-06-13` | `Days/friday` | + +Type `{{property..` in the destination field to get component suggestions for date properties discovered in your vault. + +### Literal property keys take precedence + +If a frontmatter key exactly matches the full placeholder path (including the suffix), the raw property value is used instead of date extraction. For example, if you have a property literally named `created.year`, then `{{property.created.year}}` resolves to that property's value — not the year from `created`. + +To gate moves on a date property, add a trigger: `properties` → `created` (date) → `has any value`. + +--- + ## `{{tag.}}` Looks for a tag that matches ``, adding a `#` prefix if not present. diff --git a/src/domain/dates/property-date.test.ts b/src/domain/dates/property-date.test.ts new file mode 100644 index 0000000..48df4ee --- /dev/null +++ b/src/domain/dates/property-date.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { + formatDateComponent, + isDatePlaceholderComponent, + parsePropertyDateValue, +} from './property-date'; + +describe('property-date', () => { + it('recognizes date placeholder components', () => { + expect(isDatePlaceholderComponent('year')).toBe(true); + expect(isDatePlaceholderComponent('monthName')).toBe(true); + expect(isDatePlaceholderComponent('foo')).toBe(false); + }); + + it('parses ISO date strings without timezone drift', () => { + const parsed = parsePropertyDateValue('2025-06-13'); + expect(parsed).toEqual({ year: 2025, month: 6, day: 13 }); + }); + + it('parses ISO datetime strings using the date portion only', () => { + const parsed = parsePropertyDateValue('2025-06-13T15:30:00'); + expect(parsed).toEqual({ year: 2025, month: 6, day: 13 }); + }); + + it('parses numeric timestamps', () => { + const parsed = parsePropertyDateValue(Date.UTC(2025, 5, 13)); + expect(parsed).not.toBeNull(); + }); + + it('returns null for invalid values', () => { + expect(parsePropertyDateValue('')).toBeNull(); + expect(parsePropertyDateValue('not-a-date')).toBeNull(); + expect(parsePropertyDateValue('2025-13-40')).toBeNull(); + }); + + it('formats all date components', () => { + const date = { year: 2025, month: 6, day: 13 }; + + expect(formatDateComponent(date, 'year')).toBe('2025'); + expect(formatDateComponent(date, 'month')).toBe('06'); + expect(formatDateComponent(date, 'day')).toBe('13'); + expect(formatDateComponent(date, 'iso')).toBe('2025-06-13'); + expect(formatDateComponent(date, 'monthName')).toBe('June'); + expect(formatDateComponent(date, 'dayOfWeek')).toBe('friday'); + }); +}); diff --git a/src/domain/dates/property-date.ts b/src/domain/dates/property-date.ts new file mode 100644 index 0000000..fa71952 --- /dev/null +++ b/src/domain/dates/property-date.ts @@ -0,0 +1,170 @@ +import { stringifyUnknown } from '../../utils/stringify-unknown'; + +export const DATE_PLACEHOLDER_COMPONENTS = [ + 'year', + 'month', + 'day', + 'iso', + 'monthName', + 'dayOfWeek', +] as const; + +export type DatePlaceholderComponent = + (typeof DATE_PLACEHOLDER_COMPONENTS)[number]; + +const DATE_COMPONENT_SET = new Set(DATE_PLACEHOLDER_COMPONENTS); + +const ISO_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/; + +const MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const; + +const DAY_OF_WEEK_NAMES = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] as const; + +export interface ParsedPropertyDate { + year: number; + month: number; + day: number; +} + +export function isDatePlaceholderComponent( + value: string +): value is DatePlaceholderComponent { + return DATE_COMPONENT_SET.has(value); +} + +function padTwo(value: number): string { + return String(value).padStart(2, '0'); +} + +function isValidDateParts(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1 || day > 31) { + return false; + } + + const date = new Date(year, month - 1, day); + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + ); +} + +function parseIsoDatePrefix(value: string): ParsedPropertyDate | null { + const match = ISO_DATE_PREFIX.exec(value.trim()); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + if (!isValidDateParts(year, month, day)) { + return null; + } + + return { year, month, day }; +} + +function parseFromDateObject(date: Date): ParsedPropertyDate | null { + if (isNaN(date.getTime())) { + return null; + } + + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + }; +} + +function parseFromString(value: string): ParsedPropertyDate | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const isoDate = parseIsoDatePrefix(trimmed); + if (isoDate) { + return isoDate; + } + + const normalized = trimmed + .replace(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, '$3-$2-$1') + .replace(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, '$3-$2-$1'); + + const normalizedIso = parseIsoDatePrefix(normalized); + if (normalizedIso) { + return normalizedIso; + } + + return parseFromDateObject(new Date(trimmed)); +} + +export function parsePropertyDateValue( + raw: unknown +): ParsedPropertyDate | null { + if (raw === null || raw === undefined) { + return null; + } + + if (typeof raw === 'number' && Number.isFinite(raw)) { + return parseFromDateObject(new Date(raw)); + } + + if (typeof raw === 'string') { + return parseFromString(raw); + } + + if (Array.isArray(raw)) { + if (raw.length === 0) { + return null; + } + return parsePropertyDateValue(raw[0]); + } + + return parseFromString(stringifyUnknown(raw)); +} + +export function formatDateComponent( + date: ParsedPropertyDate, + component: DatePlaceholderComponent +): string { + switch (component) { + case 'year': + return String(date.year); + case 'month': + return padTwo(date.month); + case 'day': + return padTwo(date.day); + case 'iso': + return `${date.year}-${padTwo(date.month)}-${padTwo(date.day)}`; + case 'monthName': + return MONTH_NAMES[date.month - 1] ?? ''; + case 'dayOfWeek': { + const dayIndex = new Date(date.year, date.month - 1, date.day).getDay(); + return DAY_OF_WEEK_NAMES[dayIndex] ?? ''; + } + } +} diff --git a/src/domain/templates/DestinationTemplate.ts b/src/domain/templates/DestinationTemplate.ts index f0cad89..e1d5548 100644 --- a/src/domain/templates/DestinationTemplate.ts +++ b/src/domain/templates/DestinationTemplate.ts @@ -1,4 +1,8 @@ -import { stringifyUnknown } from '../../utils/stringify-unknown'; +import type { DatePlaceholderComponent } from '../dates/property-date'; +import { + parsePropertyPlaceholderKey, + resolvePropertyPlaceholder, +} from './property-placeholder'; export type TemplateSegment = | { @@ -8,8 +12,15 @@ export type TemplateSegment = | { kind: 'placeholder'; raw: string; - type: 'tag' | 'property'; + type: 'tag'; key: string; + } + | { + kind: 'placeholder'; + raw: string; + type: 'property'; + key: string; + dateComponent?: DatePlaceholderComponent; }; export interface TemplateParseError { @@ -39,6 +50,7 @@ const PLACEHOLDER_END = '}}'; * Supported placeholder formats: * - {{tag.}} * - {{property.}} + * - {{property..}} * * Everything else is treated as plain text. */ @@ -124,12 +136,23 @@ export function parseDestinationTemplate(raw: string): { }; } - segments.push({ - kind: 'placeholder', - raw: inner, - type: prefix, - key, - }); + if (prefix === 'tag') { + segments.push({ + kind: 'placeholder', + raw: inner, + type: 'tag', + key, + }); + } else { + const parsedProperty = parsePropertyPlaceholderKey(key); + segments.push({ + kind: 'placeholder', + raw: inner, + type: 'property', + key: parsedProperty.lookupKey, + dateComponent: parsedProperty.dateComponent, + }); + } index = end + PLACEHOLDER_END.length; } @@ -180,6 +203,8 @@ export function validateDestinationTemplate( * - If no matching tag exists, the placeholder becomes an empty string. * - Property placeholders: * - {{property.status}} → stringified value of properties['status']. + * - {{property.created.year}} → date component from properties['created'] when parseable. + * - Literal property keys take precedence over date components (e.g. property created.year). * - If the property is missing or empty, the placeholder becomes an empty string. */ export function renderDestinationTemplate( @@ -214,7 +239,10 @@ export function renderDestinationTemplate( if (segment.type === 'tag') { result += resolveTagPlaceholder(segment.key, context.tags); } else if (segment.type === 'property') { - result += resolvePropertyPlaceholder(segment.key, context.properties); + const propertyKey = segment.dateComponent + ? `${segment.key}.${segment.dateComponent}` + : segment.key; + result += resolvePropertyPlaceholder(propertyKey, context.properties); } } @@ -256,33 +284,3 @@ function resolveTagPlaceholder(key: string, tags: string[]): string { function stripTagHash(tag: string): string { return tag.startsWith('#') ? tag.substring(1) : tag; } - -function resolvePropertyPlaceholder( - key: string, - properties: Record -): string { - if (!properties || typeof properties !== 'object') { - return ''; - } - - if (!Object.prototype.hasOwnProperty.call(properties, key)) { - return ''; - } - - const value = properties[key]; - - if (value === null || value === undefined) { - return ''; - } - - if (typeof value === 'string') { - return value; - } - - // Basic handling for lists and other types: stringify - if (Array.isArray(value)) { - return value.join(', '); - } - - return stringifyUnknown(value); -} diff --git a/src/domain/templates/destination-template.test.ts b/src/domain/templates/destination-template.test.ts index f4a9b8b..6b8b503 100644 --- a/src/domain/templates/destination-template.test.ts +++ b/src/domain/templates/destination-template.test.ts @@ -25,4 +25,34 @@ describe('DestinationTemplate', () => { expect(out).toContain('Done'); expect(out).toContain('tasks/personal'); }); + + it('renders date property components', () => { + const out = renderDestinationTemplate('Archive/{{property.created.year}}', { + tags: [], + properties: { created: '2025-06-13' }, + }); + expect(out).toBe('Archive/2025'); + }); + + it('renders monthName and day date components', () => { + const out = renderDestinationTemplate( + '{{property.created.monthName}}/{{property.created.day}}', + { + tags: [], + properties: { created: '2025-06-13' }, + } + ); + expect(out).toBe('June/13'); + }); + + it('prefers literal property keys over date components', () => { + const out = renderDestinationTemplate('X/{{property.created.year}}', { + tags: [], + properties: { + 'created.year': 'manual', + created: '2025-06-13', + }, + }); + expect(out).toBe('X/manual'); + }); }); diff --git a/src/domain/templates/property-placeholder.test.ts b/src/domain/templates/property-placeholder.test.ts new file mode 100644 index 0000000..7db0381 --- /dev/null +++ b/src/domain/templates/property-placeholder.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePropertyPlaceholderKey, + resolvePropertyPlaceholder, +} from './property-placeholder'; + +describe('property-placeholder', () => { + it('parses date component suffixes', () => { + expect(parsePropertyPlaceholderKey('created.year')).toEqual({ + lookupKey: 'created', + dateComponent: 'year', + }); + expect(parsePropertyPlaceholderKey('status')).toEqual({ + lookupKey: 'status', + }); + expect(parsePropertyPlaceholderKey('client.name')).toEqual({ + lookupKey: 'client.name', + }); + }); + + it('resolves date components from base properties', () => { + const properties = { created: '2025-06-13' }; + expect(resolvePropertyPlaceholder('created.year', properties)).toBe('2025'); + expect(resolvePropertyPlaceholder('created.month', properties)).toBe('06'); + expect(resolvePropertyPlaceholder('created.monthName', properties)).toBe( + 'June' + ); + }); + + it('prefers literal property keys over date components', () => { + const properties = { + 'created.year': 'manual', + created: '2025-06-13', + }; + expect(resolvePropertyPlaceholder('created.year', properties)).toBe( + 'manual' + ); + }); + + it('returns empty string for missing or invalid date values', () => { + expect(resolvePropertyPlaceholder('created.year', {})).toBe(''); + expect( + resolvePropertyPlaceholder('created.year', { created: 'invalid' }) + ).toBe(''); + }); +}); diff --git a/src/domain/templates/property-placeholder.ts b/src/domain/templates/property-placeholder.ts new file mode 100644 index 0000000..8c50d42 --- /dev/null +++ b/src/domain/templates/property-placeholder.ts @@ -0,0 +1,102 @@ +import { + type DatePlaceholderComponent, + formatDateComponent, + isDatePlaceholderComponent, + parsePropertyDateValue, +} from '../dates/property-date'; +import { stringifyUnknown } from '../../utils/stringify-unknown'; + +export interface ParsedPropertyPlaceholderKey { + lookupKey: string; + dateComponent?: DatePlaceholderComponent; +} + +export function parsePropertyPlaceholderKey( + key: string +): ParsedPropertyPlaceholderKey { + const trimmed = key.trim(); + if (!trimmed) { + return { lookupKey: '' }; + } + + const lastDot = trimmed.lastIndexOf('.'); + if (lastDot <= 0 || lastDot === trimmed.length - 1) { + return { lookupKey: trimmed }; + } + + const suffix = trimmed.substring(lastDot + 1); + if (!isDatePlaceholderComponent(suffix)) { + return { lookupKey: trimmed }; + } + + return { + lookupKey: trimmed.substring(0, lastDot), + dateComponent: suffix, + }; +} + +function stringifyPropertyValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value.join(', '); + } + + return stringifyUnknown(value); +} + +function resolveLiteralPropertyValue( + key: string, + properties: Record +): string { + if (!Object.prototype.hasOwnProperty.call(properties, key)) { + return ''; + } + + return stringifyPropertyValue(properties[key]); +} + +export function resolvePropertyPlaceholder( + key: string, + properties: Record +): string { + if (!properties || typeof properties !== 'object') { + return ''; + } + + const trimmedKey = key.trim(); + if (!trimmedKey) { + return ''; + } + + if (Object.prototype.hasOwnProperty.call(properties, trimmedKey)) { + return resolveLiteralPropertyValue(trimmedKey, properties); + } + + const parsed = parsePropertyPlaceholderKey(trimmedKey); + if (!parsed.dateComponent) { + return ''; + } + + if (!Object.prototype.hasOwnProperty.call(properties, parsed.lookupKey)) { + return ''; + } + + const rawValue = properties[parsed.lookupKey]; + if (rawValue === null || rawValue === undefined) { + return ''; + } + + const parsedDate = parsePropertyDateValue(rawValue); + if (!parsedDate) { + return ''; + } + + return formatDateComponent(parsedDate, parsed.dateComponent); +} diff --git a/src/modals/RuleEditorModal.ts b/src/modals/RuleEditorModal.ts index 0814425..5650a43 100644 --- a/src/modals/RuleEditorModal.ts +++ b/src/modals/RuleEditorModal.ts @@ -160,7 +160,7 @@ export class RuleEditorModal extends BaseModal { // Move description below the input to give the input more horizontal space const descriptionText = - 'Folder or template where files matching this rule will be moved. Supports {{tag.*}} and {{property.*}} placeholders. Type {{tag. or {{property. to get template suggestions.'; + 'Folder or template where files matching this rule will be moved. Supports {{tag.*}} and {{property.*}} placeholders, including date components such as Archive/{{property.created.year}}. Type {{tag. or {{property. to get template suggestions.'; const descriptionEl = container.createDiv({ cls: 'advancedNoteMover-rule-destination-description', text: descriptionText, diff --git a/src/settings/suggesters/FolderSuggest.ts b/src/settings/suggesters/FolderSuggest.ts index 50796e6..269bed4 100644 --- a/src/settings/suggesters/FolderSuggest.ts +++ b/src/settings/suggesters/FolderSuggest.ts @@ -1,6 +1,9 @@ import { AbstractInputSuggest, App, TFolder } from 'obsidian'; import { GENERAL_CONSTANTS } from '../../config/constants'; import { MetadataExtractor } from '../../core/MetadataExtractor'; +import { DATE_PLACEHOLDER_COMPONENTS } from '../../domain/dates/property-date'; +import { inferPropertyTypeFromSamples } from '../../utils/OperatorMapping'; +import type { PropertyType } from './PropertySuggest'; type FolderOrTemplateSuggestion = | { @@ -24,13 +27,18 @@ type TemplateContext = | { type: 'property'; search: string; + } + | { + type: 'propertyDateComponent'; + propertyName: string; + search: string; }; export class FolderSuggest extends AbstractInputSuggest { private cachedFolders: TFolder[] | null = null; private metadataExtractor: MetadataExtractor; private cachedTags: string[] | null = null; - private cachedProperties: string[] | null = null; + private cachedPropertyInfo: Map | null = null; constructor( app: App, @@ -54,7 +62,7 @@ export class FolderSuggest extends AbstractInputSuggest= + GENERAL_CONSTANTS.SUGGESTION_LIMITS.FOLDER_SUGGESTIONS + ) { + break; + } + } + } + + return suggestions; + } + + private getPropertyType(propertyName: string): PropertyType | undefined { + return this.loadPropertyInfo().get(propertyName); + } + + private loadPropertyInfo(): Map { + if (this.cachedPropertyInfo !== null) { + return this.cachedPropertyInfo; + } + const files = this.app.vault.getMarkdownFiles(); - const propertyNames = new Set(); + const propertyValues = new Map(); files.forEach(file => { const cachedMetadata = this.app.metadataCache.getFileCache(file); if (cachedMetadata?.frontmatter) { - Object.keys(cachedMetadata.frontmatter).forEach(key => { + Object.entries(cachedMetadata.frontmatter).forEach(([key, value]) => { if (!key.startsWith('position') && key !== 'tags') { - propertyNames.add(key); + if (!propertyValues.has(key)) { + propertyValues.set(key, []); + } + propertyValues.get(key)!.push(value); } }); } }); - return Array.from(propertyNames).sort(); + const propertyInfo = new Map(); + propertyValues.forEach((values, key) => { + propertyInfo.set(key, inferPropertyTypeFromSamples(values)); + }); + + this.cachedPropertyInfo = propertyInfo; + return propertyInfo; } }