@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.
- package/dist/components/frontmatter-propagation-modal.d.ts +17 -0
- package/dist/components/frontmatter-propagation-modal.d.ts.map +1 -0
- package/dist/components/frontmatter-propagation-modal.js +85 -0
- package/dist/components/frontmatter-propagation-modal.js.map +1 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/indexer.d.ts +118 -0
- package/dist/core/indexer.d.ts.map +1 -0
- package/dist/core/indexer.js +205 -0
- package/dist/core/indexer.js.map +1 -0
- package/dist/file/frontmatter-diff.d.ts +38 -0
- package/dist/file/frontmatter-diff.d.ts.map +1 -0
- package/dist/file/frontmatter-diff.js +162 -0
- package/dist/file/frontmatter-diff.js.map +1 -0
- package/dist/file/frontmatter-propagation.d.ts +12 -0
- package/dist/file/frontmatter-propagation.d.ts.map +1 -0
- package/dist/file/frontmatter-propagation.js +42 -0
- package/dist/file/frontmatter-propagation.js.map +1 -0
- package/dist/file/index.d.ts +2 -0
- package/dist/file/index.d.ts.map +1 -1
- package/dist/file/index.js +2 -0
- package/dist/file/index.js.map +1 -1
- package/dist/file/templater.d.ts +11 -1
- package/dist/file/templater.d.ts.map +1 -1
- package/dist/file/templater.js +32 -1
- package/dist/file/templater.js.map +1 -1
- package/package.json +1 -1
- package/src/components/frontmatter-propagation-modal.ts +115 -0
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +1 -0
- package/src/core/indexer.ts +353 -0
- package/src/file/frontmatter-diff.ts +198 -0
- package/src/file/frontmatter-propagation.ts +59 -0
- package/src/file/index.ts +2 -0
- 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";
|
package/src/file/templater.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type App, Notice, normalizePath,
|
|
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
|
+
}
|