-
-
Notifications
You must be signed in to change notification settings - Fork 177
Expand file tree
/
Copy pathQuickAddEngine.ts
More file actions
317 lines (274 loc) · 10 KB
/
QuickAddEngine.ts
File metadata and controls
317 lines (274 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
import type { App } from "obsidian";
import { TFile, TFolder } from "obsidian";
import { MARKDOWN_FILE_EXTENSION_REGEX } from "../constants";
import { log } from "../logger/logManager";
import { withTemplaterFileCreationSuppressed } from "../utilityObsidian";
import { coerceYamlValue } from "../utils/yamlValues";
import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
/**
* Configuration for structured variable validation
*/
const VALIDATION_LIMITS = {
/** Maximum nesting depth for objects and arrays to prevent stack overflow */
MAX_NESTING_DEPTH: 10,
} as const;
/**
* Result of validating a structured variable
*/
interface ValidationResult {
isValid: boolean;
warnings: string[];
errors: string[];
}
export abstract class QuickAddEngine {
public app: App;
/**
* File extensions that support YAML front matter post-processing.
* Currently only Markdown files are supported (Canvas files use JSON, not YAML).
*/
private static readonly YAML_FRONTMATTER_EXTENSIONS = ['md'];
protected constructor(app: App) {
this.app = app;
}
public abstract run(): void;
/**
* Validates structured variables to ensure they can be safely processed.
* Checks for:
* - Circular references
* - Maximum nesting depth
* - Invalid types (functions, symbols, etc.)
*
* @param templatePropertyVars - Map of variables to validate
* @returns ValidationResult with any warnings or errors found
*/
protected validateStructuredVariables(templatePropertyVars: Map<string, unknown>): ValidationResult {
const warnings: string[] = [];
const errors: string[] = [];
for (const [key, value] of templatePropertyVars) {
const issues = this.validateValue(key, value, new Set(), 0);
warnings.push(...issues.warnings);
errors.push(...issues.errors);
}
return {
isValid: errors.length === 0,
warnings,
errors,
};
}
/**
* Recursively validates a value for circular references, depth limits, and invalid types.
*
* @param key - The key/path being validated (for error messages)
* @param value - The value to validate
* @param seen - Set of objects already seen (for circular reference detection)
* @param depth - Current nesting depth
* @returns Object containing arrays of warnings and errors
*/
private validateValue(
key: string,
value: unknown,
seen: Set<unknown>,
depth: number
): { warnings: string[]; errors: string[] } {
const warnings: string[] = [];
const errors: string[] = [];
// Check for invalid types
if (typeof value === "function") {
errors.push(`Variable "${key}" contains a function, which cannot be serialized to YAML`);
return { warnings, errors };
}
if (typeof value === "symbol") {
errors.push(`Variable "${key}" contains a symbol, which cannot be serialized to YAML`);
return { warnings, errors };
}
if (typeof value === "bigint") {
warnings.push(`Variable "${key}" contains a BigInt, which will be converted to a string`);
return { warnings, errors };
}
// Handle null, undefined, primitives
if (value === null || value === undefined) {
return { warnings, errors };
}
if (typeof value !== "object") {
return { warnings, errors };
}
// Check for circular references
if (seen.has(value)) {
errors.push(`Variable "${key}" contains a circular reference`);
return { warnings, errors };
}
// Check nesting depth
if (depth >= VALIDATION_LIMITS.MAX_NESTING_DEPTH) {
errors.push(
`Variable "${key}" exceeds maximum nesting depth of ${VALIDATION_LIMITS.MAX_NESTING_DEPTH}`
);
return { warnings, errors };
}
// Add to seen set for circular reference detection
seen.add(value);
try {
// Recursively validate arrays
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const childResult = this.validateValue(
`${key}[${i}]`,
value[i],
seen,
depth + 1
);
warnings.push(...childResult.warnings);
errors.push(...childResult.errors);
}
}
// Recursively validate objects
else {
for (const [childKey, childValue] of Object.entries(value)) {
const childResult = this.validateValue(
`${key}.${childKey}`,
childValue,
seen,
depth + 1
);
warnings.push(...childResult.warnings);
errors.push(...childResult.errors);
}
}
} finally {
// Remove from seen set after processing
seen.delete(value);
}
return { warnings, errors };
}
protected async createFolder(folder: string): Promise<void> {
const folderExists = await this.app.vault.adapter.exists(folder);
if (!folderExists) {
await this.app.vault.createFolder(folder);
}
}
protected normalizeMarkdownFilePath(
folderPath: string,
fileName: string
): string {
const actualFolderPath: string = folderPath ? `${folderPath}/` : "";
const formattedFileName: string = fileName.replace(
MARKDOWN_FILE_EXTENSION_REGEX,
""
);
return `${actualFolderPath}${formattedFileName}.md`;
}
protected async fileExists(filePath: string): Promise<boolean> {
return await this.app.vault.adapter.exists(filePath);
}
protected getFileByPath(filePath: string): TFile {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file) {
log.logError(`${filePath} not found`);
throw new Error(`${filePath} not found`);
}
if (file instanceof TFolder) {
log.logError(`${filePath} found but it's a folder`);
throw new Error(`${filePath} found but it's a folder`);
}
if (!(file instanceof TFile))
throw new Error(`${filePath} is not a file`);
return file;
}
protected async createFileWithInput(
filePath: string,
fileContent: string,
opts: { suppressTemplaterOnCreate?: boolean } = {},
): Promise<TFile> {
const dirMatch = filePath.match(/(.*)[/\\]/);
let dirName = "";
if (dirMatch) dirName = dirMatch[1];
const dir = this.app.vault.getAbstractFileByPath(dirName);
if (!dir || !(dir instanceof TFolder)) {
await this.createFolder(dirName);
}
const createFile = () => this.app.vault.create(filePath, fileContent);
const shouldSuppress =
opts.suppressTemplaterOnCreate &&
filePath.toLowerCase().endsWith(".md");
return shouldSuppress
? await withTemplaterFileCreationSuppressed(this.app, filePath, createFile)
: await createFile();
}
/**
* Determines if a file's front matter should be post-processed for template property types.
* Only processes files with supported extensions (Markdown) when template variables are present.
*
* @param file - The file to check
* @param templateVars - The map of template variables to be processed
* @returns true if the file should be post-processed, false otherwise
*/
protected shouldPostProcessFrontMatter(file: TFile, templateVars: Map<string, unknown>): boolean {
return QuickAddEngine.YAML_FRONTMATTER_EXTENSIONS.includes(file.extension) &&
templateVars.size > 0;
}
/**
* Post-processes the front matter of a newly created file to properly format
* template property variables (arrays, objects, etc.) using Obsidian's YAML processor.
*
* This method handles special internal conventions:
* - @date:ISO strings are automatically converted to Date objects for proper YAML formatting
* (see coerceYamlValue in utils/yamlValues.ts for implementation details)
*/
protected async postProcessFrontMatter(file: TFile, templatePropertyVars: Map<string, unknown>): Promise<void> {
// Validate structured variables before processing
const validation = this.validateStructuredVariables(templatePropertyVars);
// Log any validation warnings
if (validation.warnings.length > 0) {
for (const warning of validation.warnings) {
log.logWarning(`Structured variable validation warning: ${warning}`);
}
}
// If validation found errors, log them and skip post-processing
if (!validation.isValid) {
const errorSummary = validation.errors.join("; ");
log.logError(
`Cannot post-process front matter for file ${file.path} due to validation errors: ${errorSummary}. ` +
`The file was created successfully, but some structured variables may not be properly formatted. ` +
`Please check the variable values and ensure they don't contain circular references, ` +
`exceed nesting depth of ${VALIDATION_LIMITS.MAX_NESTING_DEPTH}, or contain unsupported types (functions, symbols).`
);
return;
}
try {
log.logMessage(`Post-processing front matter for ${file.path} with ${templatePropertyVars.size} structured variables`);
log.logMessage(`Variable types: ${Array.from(templatePropertyVars.entries())
.map(([k, v]) => `${k}:${typeof v}`).join(', ')}`);
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
for (const [key, value] of templatePropertyVars) {
const pathSegments = key.includes(TemplatePropertyCollector.PATH_SEPARATOR)
? key.split(TemplatePropertyCollector.PATH_SEPARATOR)
: [key];
const coerced = coerceYamlValue(value);
this.assignFrontmatterValue(frontmatter, pathSegments, coerced);
}
});
log.logMessage(`Successfully post-processed front matter for ${file.path}`);
} catch (err) {
// Improved error message with actionable information
log.logError(
`Failed to post-process front matter for file ${file.path}: ${err}. ` +
`The file was created successfully, but structured variables may not be properly formatted. ` +
`This usually happens when variable values contain unexpected types or when Obsidian's YAML processor encounters an issue. ` +
`Check the console for more details about which variables caused the problem.`
);
// Don't throw - the file was still created successfully
}
}
private assignFrontmatterValue(frontmatter: Record<string, unknown>, path: string[], value: unknown): void {
if (path.length === 0) return;
let target = frontmatter;
for (let i = 0; i < path.length - 1; i++) {
const segment = path[i];
const existing = target[segment];
if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
target[segment] = {};
}
target = target[segment] as Record<string, unknown>;
}
target[path[path.length - 1]] = value;
}
}