@real1ty-obsidian-plugins/utils 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/components/frontmatter-propagation-modal.d.ts +17 -0
  2. package/dist/components/frontmatter-propagation-modal.d.ts.map +1 -0
  3. package/dist/components/frontmatter-propagation-modal.js +85 -0
  4. package/dist/components/frontmatter-propagation-modal.js.map +1 -0
  5. package/dist/components/index.d.ts +1 -0
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/components/index.js +1 -0
  8. package/dist/components/index.js.map +1 -1
  9. package/dist/core/index.d.ts +1 -0
  10. package/dist/core/index.d.ts.map +1 -1
  11. package/dist/core/index.js +1 -0
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/core/indexer.d.ts +118 -0
  14. package/dist/core/indexer.d.ts.map +1 -0
  15. package/dist/core/indexer.js +205 -0
  16. package/dist/core/indexer.js.map +1 -0
  17. package/dist/file/frontmatter-diff.d.ts +38 -0
  18. package/dist/file/frontmatter-diff.d.ts.map +1 -0
  19. package/dist/file/frontmatter-diff.js +162 -0
  20. package/dist/file/frontmatter-diff.js.map +1 -0
  21. package/dist/file/frontmatter-propagation.d.ts +12 -0
  22. package/dist/file/frontmatter-propagation.d.ts.map +1 -0
  23. package/dist/file/frontmatter-propagation.js +42 -0
  24. package/dist/file/frontmatter-propagation.js.map +1 -0
  25. package/dist/file/index.d.ts +2 -0
  26. package/dist/file/index.d.ts.map +1 -1
  27. package/dist/file/index.js +2 -0
  28. package/dist/file/index.js.map +1 -1
  29. package/dist/file/templater.d.ts +11 -1
  30. package/dist/file/templater.d.ts.map +1 -1
  31. package/dist/file/templater.js +32 -1
  32. package/dist/file/templater.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/frontmatter-propagation-modal.ts +115 -0
  35. package/src/components/index.ts +1 -0
  36. package/src/core/index.ts +1 -0
  37. package/src/core/indexer.ts +353 -0
  38. package/src/file/frontmatter-diff.ts +198 -0
  39. package/src/file/frontmatter-propagation.ts +59 -0
  40. package/src/file/index.ts +2 -0
  41. package/src/file/templater.ts +57 -1
@@ -0,0 +1,353 @@
1
+ import { type App, type MetadataCache, type TAbstractFile, TFile, type Vault } from "obsidian";
2
+ import {
3
+ type BehaviorSubject,
4
+ from,
5
+ fromEventPattern,
6
+ lastValueFrom,
7
+ merge,
8
+ type Observable,
9
+ of,
10
+ BehaviorSubject as RxBehaviorSubject,
11
+ Subject,
12
+ type Subscription,
13
+ } from "rxjs";
14
+ import { debounceTime, filter, groupBy, map, mergeMap, switchMap, toArray } from "rxjs/operators";
15
+ import { isFileInConfiguredDirectory } from "../file/file";
16
+ import { compareFrontmatter, type FrontmatterDiff } from "../file/frontmatter-diff";
17
+
18
+ /**
19
+ * Generic frontmatter object type for indexer
20
+ */
21
+ export type IndexerFrontmatter = Record<string, unknown>;
22
+
23
+ /**
24
+ * Configuration for the generic indexer
25
+ */
26
+ export interface IndexerConfig {
27
+ /**
28
+ * Directory to scan for files (e.g., "Calendar", "Notes")
29
+ * If empty string or undefined, scans entire vault
30
+ */
31
+ directory?: string;
32
+
33
+ /**
34
+ * Properties to exclude when comparing frontmatter diffs
35
+ */
36
+ excludedDiffProps?: Set<string>;
37
+
38
+ /**
39
+ * Concurrency limit for file scanning operations
40
+ */
41
+ scanConcurrency?: number;
42
+
43
+ /**
44
+ * Debounce time in milliseconds for file change events
45
+ */
46
+ debounceMs?: number;
47
+ }
48
+
49
+ /**
50
+ * Raw file source with frontmatter and metadata
51
+ */
52
+ export interface FileSource {
53
+ filePath: string;
54
+ mtime: number;
55
+ frontmatter: IndexerFrontmatter;
56
+ folder: string;
57
+ }
58
+
59
+ /**
60
+ * Types of indexer events
61
+ */
62
+ export type IndexerEventType = "file-changed" | "file-deleted";
63
+
64
+ /**
65
+ * Generic indexer event
66
+ */
67
+ export interface IndexerEvent {
68
+ type: IndexerEventType;
69
+ filePath: string;
70
+ oldPath?: string;
71
+ source?: FileSource;
72
+ oldFrontmatter?: IndexerFrontmatter;
73
+ frontmatterDiff?: FrontmatterDiff;
74
+ }
75
+
76
+ type VaultEvent = "create" | "modify" | "delete" | "rename";
77
+
78
+ type FileIntent =
79
+ | { kind: "changed"; file: TFile; path: string; oldPath?: string }
80
+ | { kind: "deleted"; path: string };
81
+
82
+ /**
83
+ * Generic indexer that listens to Obsidian vault events and emits
84
+ * RxJS observables with frontmatter diffs and metadata.
85
+ *
86
+ * This indexer is framework-agnostic and can be used by any plugin
87
+ * that needs to track file changes with frontmatter.
88
+ */
89
+ export class Indexer {
90
+ private config: Required<IndexerConfig>;
91
+ private fileSub: Subscription | null = null;
92
+ private configSubscription: Subscription | null = null;
93
+ private vault: Vault;
94
+ private metadataCache: MetadataCache;
95
+ private scanEventsSubject = new Subject<IndexerEvent>();
96
+ private indexingCompleteSubject = new RxBehaviorSubject<boolean>(false);
97
+ private frontmatterCache: Map<string, IndexerFrontmatter> = new Map();
98
+
99
+ public readonly events$: Observable<IndexerEvent>;
100
+ public readonly indexingComplete$: Observable<boolean>;
101
+
102
+ constructor(_app: App, configStore: BehaviorSubject<IndexerConfig>) {
103
+ this.vault = _app.vault;
104
+ this.metadataCache = _app.metadataCache;
105
+
106
+ // Set defaults
107
+ this.config = {
108
+ directory: configStore.value.directory || "",
109
+ excludedDiffProps: configStore.value.excludedDiffProps || new Set(),
110
+ scanConcurrency: configStore.value.scanConcurrency || 10,
111
+ debounceMs: configStore.value.debounceMs || 100,
112
+ };
113
+
114
+ // Subscribe to config changes
115
+ this.configSubscription = configStore.subscribe((newConfig) => {
116
+ const directoryChanged = this.config.directory !== (newConfig.directory || "");
117
+
118
+ this.config = {
119
+ directory: newConfig.directory || "",
120
+ excludedDiffProps: newConfig.excludedDiffProps || new Set(),
121
+ scanConcurrency: newConfig.scanConcurrency || 10,
122
+ debounceMs: newConfig.debounceMs || 100,
123
+ };
124
+
125
+ // Rescan if directory changed
126
+ if (directoryChanged) {
127
+ this.indexingCompleteSubject.next(false);
128
+ void this.scanAllFiles();
129
+ }
130
+ });
131
+
132
+ this.events$ = this.scanEventsSubject.asObservable();
133
+ this.indexingComplete$ = this.indexingCompleteSubject.asObservable();
134
+ }
135
+
136
+ /**
137
+ * Start the indexer and perform initial scan
138
+ */
139
+ async start(): Promise<void> {
140
+ this.indexingCompleteSubject.next(false);
141
+
142
+ const fileSystemEvents$ = this.buildFileSystemEvents$();
143
+
144
+ this.fileSub = fileSystemEvents$.subscribe((event) => {
145
+ this.scanEventsSubject.next(event);
146
+ });
147
+
148
+ await this.scanAllFiles();
149
+ }
150
+
151
+ /**
152
+ * Stop the indexer and clean up subscriptions
153
+ */
154
+ stop(): void {
155
+ this.fileSub?.unsubscribe();
156
+ this.fileSub = null;
157
+ this.configSubscription?.unsubscribe();
158
+ this.configSubscription = null;
159
+ this.indexingCompleteSubject.complete();
160
+ }
161
+
162
+ /**
163
+ * Clear cache and rescan all files
164
+ */
165
+ resync(): void {
166
+ this.frontmatterCache.clear();
167
+ this.indexingCompleteSubject.next(false);
168
+ void this.scanAllFiles();
169
+ }
170
+
171
+ /**
172
+ * Scan all markdown files in the configured directory
173
+ */
174
+ private async scanAllFiles(): Promise<void> {
175
+ const allFiles = this.vault.getMarkdownFiles();
176
+ const relevantFiles = allFiles.filter((file) => this.isRelevantFile(file));
177
+
178
+ const events$ = from(relevantFiles).pipe(
179
+ mergeMap(async (file) => {
180
+ try {
181
+ return await this.buildEvent(file);
182
+ } catch (error) {
183
+ console.error(`Error processing file ${file.path}:`, error);
184
+ return null;
185
+ }
186
+ }, this.config.scanConcurrency),
187
+ filter((event): event is IndexerEvent => event !== null),
188
+ toArray()
189
+ );
190
+
191
+ try {
192
+ const allEvents = await lastValueFrom(events$);
193
+
194
+ for (const event of allEvents) {
195
+ this.scanEventsSubject.next(event);
196
+ }
197
+
198
+ this.indexingCompleteSubject.next(true);
199
+ } catch (error) {
200
+ console.error("❌ Error during file scanning:", error);
201
+ this.indexingCompleteSubject.next(true);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Create an observable from a vault event
207
+ */
208
+ private fromVaultEvent(eventName: VaultEvent): Observable<TAbstractFile> {
209
+ if (eventName === "create") {
210
+ return fromEventPattern<TAbstractFile>(
211
+ (handler) => this.vault.on("create", handler),
212
+ (handler) => this.vault.off("create", handler)
213
+ );
214
+ }
215
+
216
+ if (eventName === "modify") {
217
+ return fromEventPattern<TAbstractFile>(
218
+ (handler) => this.vault.on("modify", handler),
219
+ (handler) => this.vault.off("modify", handler)
220
+ );
221
+ }
222
+
223
+ if (eventName === "delete") {
224
+ return fromEventPattern<TAbstractFile>(
225
+ (handler) => this.vault.on("delete", handler),
226
+ (handler) => this.vault.off("delete", handler)
227
+ );
228
+ }
229
+
230
+ // eventName === "rename"
231
+ return fromEventPattern<[TAbstractFile, string]>(
232
+ (handler) => this.vault.on("rename", handler),
233
+ (handler) => this.vault.off("rename", handler)
234
+ ).pipe(map(([file]) => file));
235
+ }
236
+
237
+ /**
238
+ * Type guard to check if file is a markdown file
239
+ */
240
+ private static isMarkdownFile(f: TAbstractFile): f is TFile {
241
+ return f instanceof TFile && f.extension === "md";
242
+ }
243
+
244
+ /**
245
+ * Filter to only relevant markdown files in configured directory
246
+ */
247
+ private toRelevantFiles<T extends TAbstractFile>() {
248
+ return (source: Observable<T>) =>
249
+ source.pipe(
250
+ filter((f: TAbstractFile): f is TFile => Indexer.isMarkdownFile(f)),
251
+ filter((f) => this.isRelevantFile(f))
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Debounce events by file path
257
+ */
258
+ private debounceByPath<T>(ms: number, key: (x: T) => string) {
259
+ return (source: Observable<T>) =>
260
+ source.pipe(
261
+ groupBy(key),
262
+ mergeMap((g$) => g$.pipe(debounceTime(ms)))
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Build the file system events observable stream
268
+ */
269
+ private buildFileSystemEvents$(): Observable<IndexerEvent> {
270
+ const created$ = this.fromVaultEvent("create").pipe(this.toRelevantFiles());
271
+ const modified$ = this.fromVaultEvent("modify").pipe(this.toRelevantFiles());
272
+ const deleted$ = this.fromVaultEvent("delete").pipe(this.toRelevantFiles());
273
+
274
+ const renamed$ = fromEventPattern<[TAbstractFile, string]>(
275
+ (handler) => this.vault.on("rename", handler),
276
+ (handler) => this.vault.off("rename", handler)
277
+ );
278
+
279
+ const changedIntents$ = merge(created$, modified$).pipe(
280
+ this.debounceByPath(this.config.debounceMs, (f) => f.path),
281
+ map((file): FileIntent => ({ kind: "changed", file, path: file.path }))
282
+ );
283
+
284
+ const deletedIntents$ = deleted$.pipe(
285
+ map((file): FileIntent => ({ kind: "deleted", path: file.path }))
286
+ );
287
+
288
+ const renamedIntents$ = renamed$.pipe(
289
+ map(([f, oldPath]) => [f, oldPath] as const),
290
+ filter(([f]) => Indexer.isMarkdownFile(f) && this.isRelevantFile(f)),
291
+ mergeMap(([f, oldPath]) => [
292
+ { kind: "deleted", path: oldPath } as FileIntent,
293
+ { kind: "changed", file: f, path: f.path, oldPath } as FileIntent,
294
+ ])
295
+ );
296
+
297
+ const intents$ = merge(changedIntents$, deletedIntents$, renamedIntents$);
298
+
299
+ return intents$.pipe(
300
+ switchMap((intent) => {
301
+ if (intent.kind === "deleted") {
302
+ this.frontmatterCache.delete(intent.path);
303
+ return of<IndexerEvent>({ type: "file-deleted", filePath: intent.path });
304
+ }
305
+
306
+ return from(this.buildEvent(intent.file, intent.oldPath)).pipe(
307
+ filter((e): e is IndexerEvent => e !== null)
308
+ );
309
+ })
310
+ );
311
+ }
312
+
313
+ /**
314
+ * Build an indexer event from a file
315
+ */
316
+ private async buildEvent(file: TFile, oldPath?: string): Promise<IndexerEvent | null> {
317
+ const cache = this.metadataCache.getFileCache(file);
318
+ if (!cache || !cache.frontmatter) return null;
319
+
320
+ const { frontmatter } = cache;
321
+ const oldFrontmatter = this.frontmatterCache.get(file.path);
322
+
323
+ const source: FileSource = {
324
+ filePath: file.path,
325
+ mtime: file.stat.mtime,
326
+ frontmatter,
327
+ folder: file.parent?.path || "",
328
+ };
329
+
330
+ const event: IndexerEvent = {
331
+ type: "file-changed",
332
+ filePath: file.path,
333
+ oldPath,
334
+ source,
335
+ oldFrontmatter,
336
+ frontmatterDiff: oldFrontmatter
337
+ ? compareFrontmatter(oldFrontmatter, frontmatter, this.config.excludedDiffProps)
338
+ : undefined,
339
+ };
340
+
341
+ // Update cache
342
+ this.frontmatterCache.set(file.path, { ...frontmatter });
343
+
344
+ return event;
345
+ }
346
+
347
+ /**
348
+ * Check if file is in the configured directory
349
+ */
350
+ private isRelevantFile(file: TFile): boolean {
351
+ return isFileInConfiguredDirectory(file.path, this.config.directory);
352
+ }
353
+ }
@@ -0,0 +1,198 @@
1
+ export type Frontmatter = Record<string, unknown>;
2
+
3
+ export interface FrontmatterChange {
4
+ key: string;
5
+ oldValue: unknown;
6
+ newValue: unknown;
7
+ changeType: "added" | "modified" | "deleted";
8
+ }
9
+
10
+ export interface FrontmatterDiff {
11
+ hasChanges: boolean;
12
+ changes: FrontmatterChange[];
13
+ added: FrontmatterChange[];
14
+ modified: FrontmatterChange[];
15
+ deleted: FrontmatterChange[];
16
+ }
17
+
18
+ /**
19
+ * Compares two frontmatter objects and returns a detailed diff.
20
+ * Excludes specified properties from comparison (e.g., Prisma-managed properties).
21
+ *
22
+ * @param oldFrontmatter - The original frontmatter
23
+ * @param newFrontmatter - The updated frontmatter
24
+ * @param excludeProps - Set of property keys to exclude from comparison
25
+ * @returns Detailed diff with categorized changes
26
+ */
27
+ export function compareFrontmatter(
28
+ oldFrontmatter: Frontmatter,
29
+ newFrontmatter: Frontmatter,
30
+ excludeProps: Set<string> = new Set()
31
+ ): FrontmatterDiff {
32
+ const changes: FrontmatterChange[] = [];
33
+ const added: FrontmatterChange[] = [];
34
+ const modified: FrontmatterChange[] = [];
35
+ const deleted: FrontmatterChange[] = [];
36
+
37
+ const allKeys = new Set([...Object.keys(oldFrontmatter), ...Object.keys(newFrontmatter)]);
38
+
39
+ for (const key of allKeys) {
40
+ if (excludeProps.has(key)) {
41
+ continue;
42
+ }
43
+
44
+ const oldValue = oldFrontmatter[key];
45
+ const newValue = newFrontmatter[key];
46
+
47
+ if (!(key in oldFrontmatter) && key in newFrontmatter) {
48
+ const change: FrontmatterChange = {
49
+ key,
50
+ oldValue: undefined,
51
+ newValue,
52
+ changeType: "added",
53
+ };
54
+
55
+ changes.push(change);
56
+ added.push(change);
57
+ } else if (key in oldFrontmatter && !(key in newFrontmatter)) {
58
+ const change: FrontmatterChange = {
59
+ key,
60
+ oldValue,
61
+ newValue: undefined,
62
+ changeType: "deleted",
63
+ };
64
+
65
+ changes.push(change);
66
+ deleted.push(change);
67
+ } else if (!deepEqual(oldValue, newValue)) {
68
+ const change: FrontmatterChange = {
69
+ key,
70
+ oldValue,
71
+ newValue,
72
+ changeType: "modified",
73
+ };
74
+
75
+ changes.push(change);
76
+ modified.push(change);
77
+ }
78
+ }
79
+
80
+ return {
81
+ hasChanges: changes.length > 0,
82
+ changes,
83
+ added,
84
+ modified,
85
+ deleted,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Deep equality check for frontmatter values.
91
+ * Handles primitives, arrays, and objects.
92
+ */
93
+ function deepEqual(a: unknown, b: unknown): boolean {
94
+ if (a === b) return true;
95
+
96
+ if (a === null || b === null || a === undefined || b === undefined) {
97
+ return a === b;
98
+ }
99
+
100
+ if (typeof a !== typeof b) return false;
101
+
102
+ if (Array.isArray(a) && Array.isArray(b)) {
103
+ if (a.length !== b.length) return false;
104
+ return a.every((val, idx) => deepEqual(val, b[idx]));
105
+ }
106
+
107
+ if (typeof a === "object" && typeof b === "object") {
108
+ const keysA = Object.keys(a as Record<string, unknown>);
109
+ const keysB = Object.keys(b as Record<string, unknown>);
110
+
111
+ if (keysA.length !== keysB.length) return false;
112
+
113
+ return keysA.every((key) =>
114
+ deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
115
+ );
116
+ }
117
+
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Merges multiple frontmatter diffs into a single accumulated diff.
123
+ * Later diffs override earlier ones for the same key.
124
+ *
125
+ * @param diffs - Array of diffs to merge (in chronological order)
126
+ * @returns A single merged diff containing all accumulated changes
127
+ */
128
+ export function mergeFrontmatterDiffs(diffs: FrontmatterDiff[]): FrontmatterDiff {
129
+ if (diffs.length === 0) {
130
+ return {
131
+ hasChanges: false,
132
+ changes: [],
133
+ added: [],
134
+ modified: [],
135
+ deleted: [],
136
+ };
137
+ }
138
+
139
+ if (diffs.length === 1) {
140
+ return diffs[0];
141
+ }
142
+
143
+ const changesByKey = new Map<string, FrontmatterChange>();
144
+
145
+ for (const diff of diffs) {
146
+ for (const change of diff.changes) {
147
+ const existing = changesByKey.get(change.key);
148
+
149
+ if (!existing) {
150
+ changesByKey.set(change.key, { ...change });
151
+ } else {
152
+ existing.newValue = change.newValue;
153
+ existing.changeType = change.changeType;
154
+
155
+ if (existing.oldValue === change.newValue) {
156
+ changesByKey.delete(change.key);
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ const allChanges = Array.from(changesByKey.values());
163
+ const added = allChanges.filter((c) => c.changeType === "added");
164
+ const modified = allChanges.filter((c) => c.changeType === "modified");
165
+ const deleted = allChanges.filter((c) => c.changeType === "deleted");
166
+
167
+ return {
168
+ hasChanges: allChanges.length > 0,
169
+ changes: allChanges,
170
+ added,
171
+ modified,
172
+ deleted,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Formats a frontmatter change for display in a modal.
178
+ * Returns a human-readable string describing the change.
179
+ */
180
+ export function formatChangeForDisplay(change: FrontmatterChange): string {
181
+ const formatValue = (value: unknown): string => {
182
+ if (value === undefined) return "(not set)";
183
+ if (value === null) return "null";
184
+ if (typeof value === "string") return `"${value}"`;
185
+ if (typeof value === "object") return JSON.stringify(value);
186
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
187
+ return JSON.stringify(value);
188
+ };
189
+
190
+ switch (change.changeType) {
191
+ case "added":
192
+ return `+ ${change.key}: ${formatValue(change.newValue)}`;
193
+ case "deleted":
194
+ return `- ${change.key}: ${formatValue(change.oldValue)}`;
195
+ case "modified":
196
+ return `~ ${change.key}: ${formatValue(change.oldValue)} → ${formatValue(change.newValue)}`;
197
+ }
198
+ }
@@ -0,0 +1,59 @@
1
+ import type { App } from "obsidian";
2
+ import { TFile } from "obsidian";
3
+ import type { Frontmatter, FrontmatterDiff } from "./frontmatter-diff";
4
+
5
+ export interface NexusPropertiesSettings {
6
+ excludedPropagatedProps?: string;
7
+ parentProp: string;
8
+ childrenProp: string;
9
+ relatedProp: string;
10
+ zettelIdProp: string;
11
+ }
12
+
13
+ export function parseExcludedProps(settings: NexusPropertiesSettings): Set<string> {
14
+ const excludedPropsStr = settings.excludedPropagatedProps || "";
15
+ const userExcluded = excludedPropsStr
16
+ .split(",")
17
+ .map((prop) => prop.trim())
18
+ .filter((prop) => prop.length > 0);
19
+
20
+ const alwaysExcluded = [
21
+ settings.parentProp,
22
+ settings.childrenProp,
23
+ settings.relatedProp,
24
+ settings.zettelIdProp,
25
+ ];
26
+
27
+ return new Set([...alwaysExcluded, ...userExcluded]);
28
+ }
29
+
30
+ export async function applyFrontmatterChanges(
31
+ app: App,
32
+ targetPath: string,
33
+ sourceFrontmatter: Frontmatter,
34
+ diff: FrontmatterDiff
35
+ ): Promise<void> {
36
+ try {
37
+ const file = app.vault.getAbstractFileByPath(targetPath);
38
+ if (!(file instanceof TFile)) {
39
+ console.warn(`Target file not found: ${targetPath}`);
40
+ return;
41
+ }
42
+
43
+ await app.fileManager.processFrontMatter(file, (fm) => {
44
+ for (const change of diff.added) {
45
+ fm[change.key] = sourceFrontmatter[change.key];
46
+ }
47
+
48
+ for (const change of diff.modified) {
49
+ fm[change.key] = sourceFrontmatter[change.key];
50
+ }
51
+
52
+ for (const change of diff.deleted) {
53
+ delete fm[change.key];
54
+ }
55
+ });
56
+ } catch (error) {
57
+ console.error(`Error applying frontmatter changes to ${targetPath}:`, error);
58
+ }
59
+ }
package/src/file/index.ts CHANGED
@@ -3,6 +3,8 @@ export * from "./file";
3
3
  export * from "./file-operations";
4
4
  export * from "./file-utils";
5
5
  export * from "./frontmatter";
6
+ export * from "./frontmatter-diff";
7
+ export * from "./frontmatter-propagation";
6
8
  export * from "./link-parser";
7
9
  export * from "./property-utils";
8
10
  export * from "./templater";
@@ -1,4 +1,4 @@
1
- import { type App, Notice, normalizePath, type TFile } from "obsidian";
1
+ import { type App, Notice, normalizePath, TFile } from "obsidian";
2
2
 
3
3
  const TEMPLATER_ID = "templater-obsidian";
4
4
 
@@ -13,6 +13,16 @@ interface TemplaterLike {
13
13
  create_new_note_from_template: CreateFn;
14
14
  }
15
15
 
16
+ export interface FileCreationOptions {
17
+ title: string;
18
+ targetDirectory: string;
19
+ filename?: string;
20
+ content?: string;
21
+ frontmatter?: Record<string, unknown>;
22
+ templatePath?: string;
23
+ useTemplater?: boolean;
24
+ }
25
+
16
26
  async function waitForTemplater(app: App, timeoutMs = 8000): Promise<TemplaterLike | null> {
17
27
  await new Promise<void>((resolve) => app.workspace.onLayoutReady(resolve));
18
28
 
@@ -73,3 +83,49 @@ export async function createFromTemplate(
73
83
  return null;
74
84
  }
75
85
  }
86
+
87
+ export async function createFileWithTemplate(
88
+ app: App,
89
+ options: FileCreationOptions
90
+ ): Promise<TFile> {
91
+ const { title, targetDirectory, filename, content, frontmatter, templatePath, useTemplater } =
92
+ options;
93
+
94
+ const finalFilename = filename || title;
95
+ const baseName = finalFilename.replace(/\.md$/, "");
96
+ const filePath = normalizePath(`${targetDirectory}/${baseName}.md`);
97
+
98
+ const existingFile = app.vault.getAbstractFileByPath(filePath);
99
+ if (existingFile instanceof TFile) {
100
+ return existingFile;
101
+ }
102
+
103
+ if (useTemplater && templatePath && templatePath.trim() !== "" && isTemplaterAvailable(app)) {
104
+ const templateFile = await createFromTemplate(
105
+ app,
106
+ templatePath,
107
+ targetDirectory,
108
+ finalFilename
109
+ );
110
+
111
+ if (templateFile) {
112
+ if (frontmatter && Object.keys(frontmatter).length > 0) {
113
+ await app.fileManager.processFrontMatter(templateFile, (fm) => {
114
+ Object.assign(fm, frontmatter);
115
+ });
116
+ }
117
+ return templateFile;
118
+ }
119
+ }
120
+
121
+ const fileContent = content || "";
122
+ const file = await app.vault.create(filePath, fileContent);
123
+
124
+ if (frontmatter && Object.keys(frontmatter).length > 0) {
125
+ await app.fileManager.processFrontMatter(file, (fm) => {
126
+ Object.assign(fm, frontmatter);
127
+ });
128
+ }
129
+
130
+ return file;
131
+ }