feat: add date components to destination template placeholders

Allow {{property.<key>.<component>}} 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
This commit is contained in:
larsbuecker 2026-06-13 21:36:50 +02:00
parent db88468b15
commit 54eaac7714
11 changed files with 549 additions and 53 deletions

View file

@ -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

View file

@ -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.

View file

@ -58,6 +58,43 @@ Looks up `<key>` 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.<key>.<component>}}
```
Supported components:
| Component | Example output (`created: 2025-06-13`) | Notes |
| ----------- | -------------------------------------- | ---------------------------------- |
| `year` | `2025` | 4-digit year |
| `month` | `06` | Zero-padded month (0112) |
| `day` | `13` | Zero-padded day (0131) |
| `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.<dateProperty>.` 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.<key>}}`
Looks for a tag that matches `<key>`, adding a `#` prefix if not present.

View file

@ -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');
});
});

View file

@ -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<string>(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] ?? '';
}
}
}

View file

@ -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.<tagValue>}}
* - {{property.<propertyKey>}}
* - {{property.<propertyKey>.<dateComponent>}}
*
* 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, unknown>
): 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);
}

View file

@ -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');
});
});

View file

@ -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('');
});
});

View file

@ -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, unknown>
): string {
if (!Object.prototype.hasOwnProperty.call(properties, key)) {
return '';
}
return stringifyPropertyValue(properties[key]);
}
export function resolvePropertyPlaceholder(
key: string,
properties: Record<string, unknown>
): 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);
}

View file

@ -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,

View file

@ -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<FolderOrTemplateSuggestion> {
private cachedFolders: TFolder[] | null = null;
private metadataExtractor: MetadataExtractor;
private cachedTags: string[] | null = null;
private cachedProperties: string[] | null = null;
private cachedPropertyInfo: Map<string, PropertyType> | null = null;
constructor(
app: App,
@ -54,7 +62,7 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOrTemplateSuggesti
public invalidateCache(): void {
this.cachedFolders = null;
this.cachedTags = null;
this.cachedProperties = null;
this.cachedPropertyInfo = null;
}
protected getSuggestions(
@ -84,6 +92,12 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOrTemplateSuggesti
if (templateContext.type === 'property') {
return this.getPropertyTemplateSuggestions(templateContext.search);
}
if (templateContext.type === 'propertyDateComponent') {
return this.getDateComponentTemplateSuggestions(
templateContext.propertyName,
templateContext.search
);
}
}
// Default: folder suggestions (existing behaviour)
@ -186,6 +200,18 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOrTemplateSuggesti
rest = rest.substring(1);
}
const search = this.cleanTemplateSearch(rest);
const dotIndex = search.indexOf('.');
if (dotIndex !== -1) {
const propertyName = search.substring(0, dotIndex);
const componentSearch = search.substring(dotIndex + 1).toLowerCase();
if (this.getPropertyType(propertyName) === 'date') {
return {
type: 'propertyDateComponent',
propertyName,
search: componentSearch,
};
}
}
return { type: 'property', search: search.toLowerCase() };
}
@ -244,14 +270,11 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOrTemplateSuggesti
private getPropertyTemplateSuggestions(
search: string
): FolderOrTemplateSuggestion[] {
if (this.cachedProperties === null) {
this.cachedProperties = this.loadProperties();
}
const propertyInfo = this.loadPropertyInfo();
const lowerSearch = search.toLowerCase();
const suggestions: FolderOrTemplateSuggestion[] = [];
for (const name of this.cachedProperties) {
for (const name of Array.from(propertyInfo.keys()).sort()) {
if (!lowerSearch || name.toLowerCase().includes(lowerSearch)) {
suggestions.push({
kind: 'propertyTemplate',
@ -270,21 +293,63 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOrTemplateSuggesti
return suggestions;
}
private loadProperties(): string[] {
private getDateComponentTemplateSuggestions(
propertyName: string,
search: string
): FolderOrTemplateSuggestion[] {
const suggestions: FolderOrTemplateSuggestion[] = [];
for (const component of DATE_PLACEHOLDER_COMPONENTS) {
if (!search || component.toLowerCase().startsWith(search)) {
suggestions.push({
kind: 'propertyTemplate',
value: `{{property.${propertyName}.${component}}}`,
});
if (
suggestions.length >=
GENERAL_CONSTANTS.SUGGESTION_LIMITS.FOLDER_SUGGESTIONS
) {
break;
}
}
}
return suggestions;
}
private getPropertyType(propertyName: string): PropertyType | undefined {
return this.loadPropertyInfo().get(propertyName);
}
private loadPropertyInfo(): Map<string, PropertyType> {
if (this.cachedPropertyInfo !== null) {
return this.cachedPropertyInfo;
}
const files = this.app.vault.getMarkdownFiles();
const propertyNames = new Set<string>();
const propertyValues = new Map<string, unknown[]>();
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<string, PropertyType>();
propertyValues.forEach((values, key) => {
propertyInfo.set(key, inferPropertyTypeFromSamples(values));
});
this.cachedPropertyInfo = propertyInfo;
return propertyInfo;
}
}