mirror of
https://github.com/bueckerlars/obsidian-note-mover-shortcut.git
synced 2026-07-22 05:46:00 +00:00
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:
parent
db88468b15
commit
54eaac7714
11 changed files with 549 additions and 53 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (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.<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.
|
||||
|
|
|
|||
46
src/domain/dates/property-date.test.ts
Normal file
46
src/domain/dates/property-date.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
170
src/domain/dates/property-date.ts
Normal file
170
src/domain/dates/property-date.ts
Normal 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] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
46
src/domain/templates/property-placeholder.test.ts
Normal file
46
src/domain/templates/property-placeholder.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
102
src/domain/templates/property-placeholder.ts
Normal file
102
src/domain/templates/property-placeholder.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue