@real1ty-obsidian-plugins/utils 2.11.0 → 2.14.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 (75) hide show
  1. package/dist/components/index.d.ts +2 -0
  2. package/dist/components/index.d.ts.map +1 -0
  3. package/dist/components/index.js +2 -0
  4. package/dist/components/index.js.map +1 -0
  5. package/dist/components/input-managers/base.d.ts +30 -0
  6. package/dist/components/input-managers/base.d.ts.map +1 -0
  7. package/dist/components/input-managers/base.js +115 -0
  8. package/dist/components/input-managers/base.js.map +1 -0
  9. package/dist/components/input-managers/expression.d.ts +12 -0
  10. package/dist/components/input-managers/expression.d.ts.map +1 -0
  11. package/dist/components/input-managers/expression.js +56 -0
  12. package/dist/components/input-managers/expression.js.map +1 -0
  13. package/dist/components/input-managers/index.d.ts +4 -0
  14. package/dist/components/input-managers/index.d.ts.map +1 -0
  15. package/dist/components/input-managers/index.js +4 -0
  16. package/dist/components/input-managers/index.js.map +1 -0
  17. package/dist/components/input-managers/search.d.ts +6 -0
  18. package/dist/components/input-managers/search.d.ts.map +1 -0
  19. package/dist/components/input-managers/search.js +16 -0
  20. package/dist/components/input-managers/search.js.map +1 -0
  21. package/dist/core/evaluator/base.d.ts.map +1 -1
  22. package/dist/core/evaluator/base.js +12 -3
  23. package/dist/core/evaluator/base.js.map +1 -1
  24. package/dist/core/index.d.ts +1 -0
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/core/index.js +1 -0
  27. package/dist/core/index.js.map +1 -1
  28. package/dist/core/property-renderer.d.ts +9 -0
  29. package/dist/core/property-renderer.d.ts.map +1 -0
  30. package/dist/core/property-renderer.js +42 -0
  31. package/dist/core/property-renderer.js.map +1 -0
  32. package/dist/file/file-utils.d.ts +28 -0
  33. package/dist/file/file-utils.d.ts.map +1 -0
  34. package/dist/file/file-utils.js +55 -0
  35. package/dist/file/file-utils.js.map +1 -0
  36. package/dist/file/file.d.ts +51 -1
  37. package/dist/file/file.d.ts.map +1 -1
  38. package/dist/file/file.js +69 -10
  39. package/dist/file/file.js.map +1 -1
  40. package/dist/file/index.d.ts +1 -0
  41. package/dist/file/index.d.ts.map +1 -1
  42. package/dist/file/index.js +1 -0
  43. package/dist/file/index.js.map +1 -1
  44. package/dist/file/link-parser.d.ts +28 -0
  45. package/dist/file/link-parser.d.ts.map +1 -1
  46. package/dist/file/link-parser.js +59 -0
  47. package/dist/file/link-parser.js.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +2 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/string/filename-utils.d.ts +46 -0
  53. package/dist/string/filename-utils.d.ts.map +1 -0
  54. package/dist/string/filename-utils.js +65 -0
  55. package/dist/string/filename-utils.js.map +1 -0
  56. package/dist/string/index.d.ts +1 -0
  57. package/dist/string/index.d.ts.map +1 -1
  58. package/dist/string/index.js +1 -0
  59. package/dist/string/index.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/components/index.ts +1 -0
  62. package/src/components/input-managers/base.ts +150 -0
  63. package/src/components/input-managers/expression.ts +92 -0
  64. package/src/components/input-managers/index.ts +3 -0
  65. package/src/components/input-managers/search.ts +25 -0
  66. package/src/core/evaluator/base.ts +15 -3
  67. package/src/core/index.ts +1 -0
  68. package/src/core/property-renderer.ts +62 -0
  69. package/src/file/file-utils.ts +67 -0
  70. package/src/file/file.ts +90 -8
  71. package/src/file/index.ts +1 -0
  72. package/src/file/link-parser.ts +71 -0
  73. package/src/index.ts +2 -0
  74. package/src/string/filename-utils.ts +77 -0
  75. package/src/string/index.ts +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filename-utils.js","sourceRoot":"","sources":["../../src/string/filename-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,8BAA8B,EAAE,MAAM,cAAc,CAAC;AAE9D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,SAAiB,EAAU,EAAE;IACnE,OAAO,SAAS,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;AACnD,CAAC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CACnC,QAAgB,EAC6B,EAAE;IAC/C,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IACxD,IAAI,CAAC,SAAS,EAAE,CAAC;QAChB,OAAO,IAAI,CAAC;IACb,CAAC;IAED,MAAM,OAAO,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC7B,MAAM,SAAS,GAAG,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,MAAM,MAAM,GAAG,QAAQ,CAAC,SAAS,CAAC,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9D,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;AAC5B,CAAC,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,CAC9C,eAAuB,EACvB,QAAgB,EACA,EAAE;IAClB,MAAM,aAAa,GAAG,oBAAoB,CAAC,eAAe,CAAC,CAAC;IAC5D,IAAI,CAAC,aAAa,EAAE,CAAC;QACpB,OAAO,IAAI,CAAC;IACb,CAAC;IAED,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,aAAa,CAAC;IAE1C,yDAAyD;IACzD,MAAM,aAAa,GAAG,QAAQ,CAAC,OAAO,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC;IAC5D,MAAM,iBAAiB,GAAG,8BAA8B,CAAC,aAAa,CAAC,CAAC;IAExE,OAAO,GAAG,iBAAiB,IAAI,OAAO,GAAG,MAAM,EAAE,CAAC;AACnD,CAAC,CAAC","sourcesContent":["import { sanitizeFilenamePreserveSpaces } from \"../file/file\";\n\n/**\n * Normalizes a directory path for consistent comparison.\n *\n * - Trims whitespace\n * - Removes leading and trailing slashes\n * - Converts empty/whitespace-only strings to empty string\n *\n * Examples:\n * - \"tasks/\" → \"tasks\"\n * - \"/tasks\" → \"tasks\"\n * - \"/tasks/\" → \"tasks\"\n * - \" tasks \" → \"tasks\"\n * - \"\" → \"\"\n * - \" \" → \"\"\n * - \"tasks/homework\" → \"tasks/homework\"\n */\nexport const normalizeDirectoryPath = (directory: string): string => {\n\treturn directory.trim().replace(/^\\/+|\\/+$/g, \"\");\n};\n\n/**\n * Extracts the date and suffix (everything after the date) from a physical instance filename.\n * Physical instance format: \"[title] [date]-[ZETTELID]\"\n *\n * @param basename - The filename without extension\n * @returns Object with dateStr and suffix, or null if no date found\n *\n * @example\n * extractDateAndSuffix(\"My Event 2025-01-15-ABC123\") // { dateStr: \"2025-01-15\", suffix: \"-ABC123\" }\n * extractDateAndSuffix(\"Invalid filename\") // null\n */\nexport const extractDateAndSuffix = (\n\tbasename: string\n): { dateStr: string; suffix: string } | null => {\n\tconst dateMatch = basename.match(/(\\d{4}-\\d{2}-\\d{2})/);\n\tif (!dateMatch) {\n\t\treturn null;\n\t}\n\n\tconst dateStr = dateMatch[1];\n\tconst dateIndex = basename.indexOf(dateStr);\n\tconst suffix = basename.substring(dateIndex + dateStr.length);\n\n\treturn { dateStr, suffix };\n};\n\n/**\n * Rebuilds a physical instance filename with a new title while preserving the date and zettel ID.\n * Physical instance format: \"[title] [date]-[ZETTELID]\"\n *\n * @param currentBasename - Current filename without extension\n * @param newTitle - New title (with or without zettel ID - will be stripped)\n * @returns New filename, or null if current filename format is invalid\n *\n * @example\n * rebuildPhysicalInstanceFilename(\"Old Title 2025-01-15-ABC123\", \"New Title-XYZ789\")\n * // Returns: \"New Title 2025-01-15-ABC123\"\n */\nexport const rebuildPhysicalInstanceFilename = (\n\tcurrentBasename: string,\n\tnewTitle: string\n): string | null => {\n\tconst dateAndSuffix = extractDateAndSuffix(currentBasename);\n\tif (!dateAndSuffix) {\n\t\treturn null;\n\t}\n\n\tconst { dateStr, suffix } = dateAndSuffix;\n\n\t// Remove any zettel ID from the new title (just in case)\n\tconst newTitleClean = newTitle.replace(/-[A-Z0-9]{6}$/, \"\");\n\tconst newTitleSanitized = sanitizeFilenamePreserveSpaces(newTitleClean);\n\n\treturn `${newTitleSanitized} ${dateStr}${suffix}`;\n};\n"]}
@@ -1,2 +1,3 @@
1
+ export * from "./filename-utils";
1
2
  export * from "./string";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/string/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/string/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,UAAU,CAAC"}
@@ -1,2 +1,3 @@
1
+ export * from "./filename-utils";
1
2
  export * from "./string";
2
3
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/string/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC","sourcesContent":["export * from \"./string\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/string/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,UAAU,CAAC","sourcesContent":["export * from \"./filename-utils\";\nexport * from \"./string\";\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real1ty-obsidian-plugins/utils",
3
- "version": "2.11.0",
3
+ "version": "2.14.0",
4
4
  "description": "Shared utilities for Obsidian plugins",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1 @@
1
+ export * from "./input-managers";
@@ -0,0 +1,150 @@
1
+ export type InputManagerFilterChangeCallback = () => void;
2
+
3
+ const DEFAULT_DEBOUNCE_MS = 150;
4
+
5
+ export abstract class InputManager {
6
+ protected containerEl: HTMLElement;
7
+ protected inputEl: HTMLInputElement | null = null;
8
+ protected debounceTimer: number | null = null;
9
+ protected currentValue = "";
10
+ protected persistentlyVisible = false;
11
+ protected onHide?: () => void;
12
+ protected hiddenClass: string;
13
+ protected debounceMs: number;
14
+ protected cssClass: string;
15
+
16
+ constructor(
17
+ protected parentEl: HTMLElement,
18
+ protected placeholder: string,
19
+ protected cssPrefix: string,
20
+ protected onFilterChange: InputManagerFilterChangeCallback,
21
+ initiallyVisible: boolean,
22
+ onHide?: () => void,
23
+ debounceMs: number = DEFAULT_DEBOUNCE_MS
24
+ ) {
25
+ this.hiddenClass = `${cssPrefix}-hidden`;
26
+ this.debounceMs = debounceMs;
27
+ this.cssClass = `${cssPrefix}-input`;
28
+
29
+ const classes = initiallyVisible
30
+ ? `${cssPrefix}-container`
31
+ : `${cssPrefix}-container ${this.hiddenClass}`;
32
+
33
+ this.containerEl = this.parentEl.createEl("div", {
34
+ cls: classes,
35
+ });
36
+
37
+ this.onHide = onHide;
38
+
39
+ this.render();
40
+ }
41
+
42
+ private render(): void {
43
+ this.inputEl = this.containerEl.createEl("input", {
44
+ type: "text",
45
+ cls: this.cssClass,
46
+ placeholder: this.placeholder,
47
+ });
48
+
49
+ this.inputEl.addEventListener("input", () => {
50
+ this.handleInputChange();
51
+ });
52
+
53
+ this.inputEl.addEventListener("keydown", (evt) => {
54
+ if (evt.key === "Escape") {
55
+ // Only allow hiding if not persistently visible
56
+ if (!this.persistentlyVisible) {
57
+ this.hide();
58
+ } else {
59
+ // Just blur the input if persistently visible
60
+ this.inputEl?.blur();
61
+ }
62
+ } else if (evt.key === "Enter") {
63
+ this.applyFilterImmediately();
64
+ }
65
+ });
66
+ }
67
+
68
+ private handleInputChange(): void {
69
+ if (this.debounceTimer !== null) {
70
+ window.clearTimeout(this.debounceTimer);
71
+ }
72
+
73
+ this.debounceTimer = window.setTimeout(() => {
74
+ this.applyFilterImmediately();
75
+ }, this.debounceMs);
76
+ }
77
+
78
+ protected applyFilterImmediately(): void {
79
+ const newValue = this.inputEl?.value.trim() ?? "";
80
+
81
+ if (newValue !== this.currentValue) {
82
+ this.updateFilterValue(newValue);
83
+ }
84
+ }
85
+
86
+ protected updateFilterValue(value: string): void {
87
+ this.currentValue = value;
88
+
89
+ this.onFilterChange();
90
+ }
91
+
92
+ getCurrentValue(): string {
93
+ return this.currentValue;
94
+ }
95
+
96
+ show(): void {
97
+ this.containerEl.removeClass(this.hiddenClass);
98
+
99
+ this.inputEl?.focus();
100
+ }
101
+
102
+ hide(): void {
103
+ // Don't allow hiding if persistently visible
104
+ if (this.persistentlyVisible) {
105
+ return;
106
+ }
107
+
108
+ this.containerEl.addClass(this.hiddenClass);
109
+
110
+ if (this.inputEl) {
111
+ this.inputEl.value = "";
112
+ }
113
+
114
+ this.updateFilterValue("");
115
+
116
+ this.onHide?.();
117
+ }
118
+
119
+ focus(): void {
120
+ this.inputEl?.focus();
121
+ }
122
+
123
+ isVisible(): boolean {
124
+ return !this.containerEl.hasClass(this.hiddenClass);
125
+ }
126
+
127
+ setPersistentlyVisible(value: boolean): void {
128
+ this.persistentlyVisible = value;
129
+
130
+ if (value) {
131
+ this.show();
132
+ } else {
133
+ this.hide();
134
+ }
135
+ }
136
+
137
+ destroy(): void {
138
+ if (this.debounceTimer !== null) {
139
+ window.clearTimeout(this.debounceTimer);
140
+
141
+ this.debounceTimer = null;
142
+ }
143
+
144
+ this.containerEl.remove();
145
+
146
+ this.inputEl = null;
147
+ }
148
+
149
+ abstract shouldInclude(data: unknown): boolean;
150
+ }
@@ -0,0 +1,92 @@
1
+ import { buildPropertyMapping, sanitizeExpression } from "../../core/expression-utils";
2
+
3
+ import { InputManager } from "./base";
4
+
5
+ export class ExpressionFilterInputManager extends InputManager {
6
+ private compiledFunc: ((...args: unknown[]) => boolean) | null = null;
7
+
8
+ private propertyMapping = new Map<string, string>();
9
+
10
+ private lastWarnedExpression: string | null = null;
11
+
12
+ constructor(
13
+ parentEl: HTMLElement,
14
+ cssPrefix: string,
15
+ onFilterChange: () => void,
16
+ initiallyVisible: boolean = false,
17
+ placeholder: string = "Status === 'Done'",
18
+ onHide?: () => void,
19
+ debounceMs?: number
20
+ ) {
21
+ super(parentEl, placeholder, cssPrefix, onFilterChange, initiallyVisible, onHide, debounceMs);
22
+ this.cssClass = `${cssPrefix}-expression-input`;
23
+ if (this.inputEl) {
24
+ this.inputEl.className = this.cssClass;
25
+ }
26
+ }
27
+
28
+ protected updateFilterValue(filterValue: string): void {
29
+ super.updateFilterValue(filterValue);
30
+
31
+ this.compiledFunc = null;
32
+
33
+ this.propertyMapping.clear();
34
+
35
+ this.lastWarnedExpression = null;
36
+ }
37
+
38
+ shouldInclude(event: { meta?: Record<string, unknown> }): boolean {
39
+ if (!this.currentValue) return true;
40
+
41
+ const frontmatter = event.meta || {};
42
+
43
+ try {
44
+ const currentKeys = new Set(Object.keys(frontmatter));
45
+
46
+ const existingKeys = new Set(this.propertyMapping.keys());
47
+
48
+ const newKeys = [...currentKeys].filter((key) => !existingKeys.has(key));
49
+
50
+ if (newKeys.length > 0) {
51
+ const allKeys = new Set([...existingKeys, ...currentKeys]);
52
+
53
+ this.propertyMapping = buildPropertyMapping(Array.from(allKeys));
54
+
55
+ this.compiledFunc = null;
56
+ }
57
+
58
+ if (!this.compiledFunc) {
59
+ const sanitized = sanitizeExpression(this.currentValue, this.propertyMapping);
60
+
61
+ const params = Array.from(this.propertyMapping.values());
62
+
63
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval -- Dynamic function creation for expression evaluation with sanitized input
64
+ this.compiledFunc = new Function(...params, `"use strict"; return ${sanitized};`) as (
65
+ ...args: unknown[]
66
+ ) => boolean;
67
+ }
68
+
69
+ const values = Array.from(this.propertyMapping.keys()).map(
70
+ (key) => frontmatter[key] ?? undefined
71
+ );
72
+
73
+ const result = this.compiledFunc(...values);
74
+
75
+ return result;
76
+ } catch (error) {
77
+ if (error instanceof ReferenceError) {
78
+ const hasInequality = this.currentValue.includes("!==") || this.currentValue.includes("!=");
79
+
80
+ return hasInequality;
81
+ }
82
+
83
+ if (this.lastWarnedExpression !== this.currentValue) {
84
+ console.warn("Invalid filter expression:", this.currentValue, error);
85
+
86
+ this.lastWarnedExpression = this.currentValue;
87
+ }
88
+
89
+ return false;
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./base";
2
+ export * from "./expression";
3
+ export * from "./search";
@@ -0,0 +1,25 @@
1
+ import { InputManager } from "./base";
2
+
3
+ export class SearchFilterInputManager extends InputManager {
4
+ constructor(
5
+ parentEl: HTMLElement,
6
+ cssPrefix: string,
7
+ onFilterChange: () => void,
8
+ initiallyVisible: boolean = false,
9
+ placeholder: string = "Search ...",
10
+ onHide?: () => void,
11
+ debounceMs?: number
12
+ ) {
13
+ super(parentEl, placeholder, cssPrefix, onFilterChange, initiallyVisible, onHide, debounceMs);
14
+ this.cssClass = `${cssPrefix}-search-input`;
15
+ if (this.inputEl) {
16
+ this.inputEl.className = this.cssClass;
17
+ }
18
+ }
19
+
20
+ shouldInclude(value: string): boolean {
21
+ if (!this.currentValue) return true;
22
+
23
+ return value.toLowerCase().includes(this.currentValue.toLowerCase());
24
+ }
25
+ }
@@ -40,8 +40,17 @@ export abstract class BaseEvaluator<TRule extends BaseRule, TSettings> {
40
40
  }
41
41
 
42
42
  try {
43
- if (this.propertyMapping.size === 0) {
44
- this.propertyMapping = buildPropertyMapping(Object.keys(frontmatter));
43
+ // Progressively build property mapping as we encounter new properties
44
+ const currentKeys = new Set(Object.keys(frontmatter));
45
+ const existingKeys = new Set(this.propertyMapping.keys());
46
+ const newKeys = [...currentKeys].filter((key) => !existingKeys.has(key));
47
+
48
+ // If new properties are found, rebuild the mapping and invalidate compiled functions
49
+ if (newKeys.length > 0) {
50
+ const allKeys = new Set([...existingKeys, ...currentKeys]);
51
+ this.propertyMapping = buildPropertyMapping(Array.from(allKeys));
52
+ // Clear compiled functions since property mapping changed
53
+ this.compiledFunctions.clear();
45
54
  }
46
55
 
47
56
  let compiledFunc = this.compiledFunctions.get(rule.id);
@@ -55,7 +64,10 @@ export abstract class BaseEvaluator<TRule extends BaseRule, TSettings> {
55
64
  this.compiledFunctions.set(rule.id, compiledFunc);
56
65
  }
57
66
 
58
- const values = Array.from(this.propertyMapping.keys()).map((key) => frontmatter[key]);
67
+ // Use undefined for missing properties instead of letting them be undefined implicitly
68
+ const values = Array.from(this.propertyMapping.keys()).map(
69
+ (key) => frontmatter[key] ?? undefined
70
+ );
59
71
  const result = compiledFunc(...values);
60
72
 
61
73
  return result === true;
package/src/core/index.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./evaluator";
4
4
  export * from "./expression-utils";
5
5
  export * from "./frontmatter-value";
6
6
  export * from "./generate";
7
+ export * from "./property-renderer";
7
8
  export * from "./validation";
@@ -0,0 +1,62 @@
1
+ import { getObsidianLinkAlias, getObsidianLinkPath, isObsidianLink } from "../file/link-parser";
2
+
3
+ export interface PropertyRendererConfig {
4
+ createLink: (text: string, path: string, isObsidianLink: boolean) => HTMLElement;
5
+ createText: (text: string) => HTMLElement | Text;
6
+ createSeparator?: () => HTMLElement | Text;
7
+ }
8
+
9
+ export function renderPropertyValue(
10
+ container: HTMLElement,
11
+ value: any,
12
+ config: PropertyRendererConfig
13
+ ): void {
14
+ // Handle arrays - render each item separately
15
+ if (Array.isArray(value)) {
16
+ const hasClickableLinks = value.some(isObsidianLink);
17
+
18
+ if (hasClickableLinks) {
19
+ for (let index = 0; index < value.length; index++) {
20
+ if (index > 0 && config.createSeparator) {
21
+ container.appendChild(config.createSeparator());
22
+ }
23
+ renderSingleValue(container, value[index], config);
24
+ }
25
+ } else {
26
+ // Plain array - just join with commas
27
+ const textNode = config.createText(value.join(", "));
28
+ container.appendChild(textNode);
29
+ }
30
+ return;
31
+ }
32
+
33
+ renderSingleValue(container, value, config);
34
+ }
35
+
36
+ function renderSingleValue(
37
+ container: HTMLElement,
38
+ value: any,
39
+ config: PropertyRendererConfig
40
+ ): void {
41
+ const stringValue = String(value).trim();
42
+
43
+ if (isObsidianLink(stringValue)) {
44
+ const displayText = getObsidianLinkAlias(stringValue);
45
+ const linkPath = getObsidianLinkPath(stringValue);
46
+ const link = config.createLink(displayText, linkPath, true);
47
+ container.appendChild(link);
48
+ return;
49
+ }
50
+
51
+ // Regular text
52
+ const textNode = config.createText(stringValue);
53
+ container.appendChild(textNode);
54
+ }
55
+
56
+ export function createTextNode(text: string): Text {
57
+ return document.createTextNode(text);
58
+ }
59
+
60
+ export function createDefaultSeparator(): Text {
61
+ return document.createTextNode(", ");
62
+ }
@@ -0,0 +1,67 @@
1
+ import type { App } from "obsidian";
2
+ import { TFile } from "obsidian";
3
+
4
+ /**
5
+ * Gets a TFile by path or throws an error if not found.
6
+ * Useful when you need to ensure a file exists before proceeding.
7
+ */
8
+ export const getTFileOrThrow = (app: App, path: string): TFile => {
9
+ const f = app.vault.getAbstractFileByPath(path);
10
+ if (!(f instanceof TFile)) throw new Error(`File not found: ${path}`);
11
+ return f;
12
+ };
13
+
14
+ /**
15
+ * Executes an operation on a file's frontmatter.
16
+ * Wrapper around Obsidian's processFrontMatter for more concise usage.
17
+ */
18
+ export const withFrontmatter = async (
19
+ app: App,
20
+ file: TFile,
21
+ update: (fm: Record<string, unknown>) => void
22
+ ) => app.fileManager.processFrontMatter(file, update);
23
+
24
+ /**
25
+ * Creates a backup copy of a file's frontmatter.
26
+ * Useful for undo/redo operations or temporary modifications.
27
+ */
28
+ export const backupFrontmatter = async (app: App, file: TFile) => {
29
+ let copy: Record<string, unknown> = {};
30
+ await withFrontmatter(app, file, (fm) => {
31
+ copy = { ...fm };
32
+ });
33
+ return copy;
34
+ };
35
+
36
+ /**
37
+ * Restores a file's frontmatter from a backup.
38
+ * Clears existing frontmatter and replaces with the backup.
39
+ */
40
+ export const restoreFrontmatter = async (
41
+ app: App,
42
+ file: TFile,
43
+ original: Record<string, unknown>
44
+ ) =>
45
+ withFrontmatter(app, file, (fm) => {
46
+ for (const k of Object.keys(fm)) {
47
+ delete fm[k];
48
+ }
49
+ Object.assign(fm, original);
50
+ });
51
+
52
+ /**
53
+ * Extracts the content that appears after the frontmatter section.
54
+ * Returns the entire content if no frontmatter is found.
55
+ */
56
+ export const extractContentAfterFrontmatter = (fullContent: string): string => {
57
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
58
+ const match = fullContent.match(frontmatterRegex);
59
+
60
+ if (match) {
61
+ // Return content after frontmatter
62
+ return fullContent.substring(match.index! + match[0].length);
63
+ }
64
+
65
+ // If no frontmatter found, return the entire content
66
+ return fullContent;
67
+ };
package/src/file/file.ts CHANGED
@@ -548,16 +548,98 @@ export function findRootNodesInFolder(app: App, folderPath: string): string[] {
548
548
  }
549
549
 
550
550
  // ============================================================================
551
- // Legacy Utility Functions (kept for backwards compatibility)
551
+ // Filename Sanitization
552
552
  // ============================================================================
553
553
 
554
- export const sanitizeForFilename = (input: string): string => {
555
- return input
556
- .replace(/[<>:"/\\|?*]/g, "") // Remove invalid filename characters
557
- .replace(/\s+/g, "-") // Replace spaces with hyphens
558
- .replace(/-+/g, "-") // Replace multiple hyphens with single
559
- .replace(/^-|-$/g, "") // Remove leading/trailing hyphens
560
- .toLowerCase();
554
+ export interface SanitizeFilenameOptions {
555
+ /**
556
+ * Style of sanitization to apply.
557
+ * - "kebab": Convert to lowercase, replace spaces with hyphens (default, backwards compatible)
558
+ * - "preserve": Preserve spaces and case, only remove invalid characters
559
+ */
560
+ style?: "kebab" | "preserve";
561
+ }
562
+
563
+ /**
564
+ * Sanitizes a string for use as a filename.
565
+ * Defaults to kebab-case style for backwards compatibility.
566
+ *
567
+ * @param input - String to sanitize
568
+ * @param options - Sanitization options
569
+ * @returns Sanitized filename string
570
+ *
571
+ * @example
572
+ * // Default kebab-case style (backwards compatible)
573
+ * sanitizeForFilename("My File Name") // "my-file-name"
574
+ *
575
+ * // Preserve spaces and case
576
+ * sanitizeForFilename("My File Name", { style: "preserve" }) // "My File Name"
577
+ */
578
+ export const sanitizeForFilename = (
579
+ input: string,
580
+ options: SanitizeFilenameOptions = {}
581
+ ): string => {
582
+ const { style = "kebab" } = options;
583
+
584
+ if (style === "preserve") {
585
+ return sanitizeFilenamePreserveSpaces(input);
586
+ }
587
+
588
+ // Default: kebab-case style (legacy behavior)
589
+ return sanitizeFilenameKebabCase(input);
590
+ };
591
+
592
+ /**
593
+ * Sanitizes filename using kebab-case style.
594
+ * - Removes invalid characters
595
+ * - Converts to lowercase
596
+ * - Replaces spaces with hyphens
597
+ *
598
+ * Best for: CLI tools, URLs, slugs, technical files
599
+ *
600
+ * @example
601
+ * sanitizeFilenameKebabCase("My File Name") // "my-file-name"
602
+ * sanitizeFilenameKebabCase("Travel Around The World") // "travel-around-the-world"
603
+ */
604
+ export const sanitizeFilenameKebabCase = (input: string): string => {
605
+ return (
606
+ input
607
+ // Remove invalid filename characters
608
+ .replace(/[<>:"/\\|?*]/g, "")
609
+ // Replace spaces with hyphens
610
+ .replace(/\s+/g, "-")
611
+ // Replace multiple hyphens with single
612
+ .replace(/-+/g, "-")
613
+ // Remove leading/trailing hyphens
614
+ .replace(/^-|-$/g, "")
615
+ // Convert to lowercase
616
+ .toLowerCase()
617
+ );
618
+ };
619
+
620
+ /**
621
+ * Sanitizes filename while preserving spaces and case.
622
+ * - Removes invalid characters only
623
+ * - Preserves spaces and original casing
624
+ * - Removes trailing dots (Windows compatibility)
625
+ *
626
+ * Best for: Note titles, human-readable filenames, Obsidian notes
627
+ *
628
+ * @example
629
+ * sanitizeFilenamePreserveSpaces("My File Name") // "My File Name"
630
+ * sanitizeFilenamePreserveSpaces("Travel Around The World") // "Travel Around The World"
631
+ * sanitizeFilenamePreserveSpaces("File<Invalid>Chars") // "FileInvalidChars"
632
+ */
633
+ export const sanitizeFilenamePreserveSpaces = (input: string): string => {
634
+ return (
635
+ input
636
+ // Remove invalid filename characters (cross-platform compatibility)
637
+ .replace(/[<>:"/\\|?*]/g, "")
638
+ // Remove trailing dots (invalid on Windows)
639
+ .replace(/\.+$/g, "")
640
+ // Remove leading/trailing whitespace
641
+ .trim()
642
+ );
561
643
  };
562
644
 
563
645
  export const getFilenameFromPath = (filePath: string): string => {
package/src/file/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./child-reference";
2
2
  export * from "./file";
3
3
  export * from "./file-operations";
4
+ export * from "./file-utils";
4
5
  export * from "./frontmatter";
5
6
  export * from "./link-parser";
6
7
  export * from "./property-utils";
@@ -89,3 +89,74 @@ export function formatWikiLink(filePath: string): string {
89
89
 
90
90
  return `[[${trimmed}|${displayName}]]`;
91
91
  }
92
+
93
+ /**
94
+ * Represents a parsed Obsidian link with its components
95
+ */
96
+ export interface ObsidianLink {
97
+ raw: string;
98
+ path: string;
99
+ alias: string;
100
+ }
101
+
102
+ /**
103
+ * Checks if a value is an Obsidian internal link in the format [[...]]
104
+ */
105
+ export function isObsidianLink(value: unknown): boolean {
106
+ if (typeof value !== "string") return false;
107
+ const trimmed = value.trim();
108
+ return /^\[\[.+\]\]$/.test(trimmed);
109
+ }
110
+
111
+ /**
112
+ * Parses an Obsidian internal link and extracts its components
113
+ *
114
+ * Supports both formats:
115
+ * - Simple: [[Page Name]]
116
+ * - With alias: [[Path/To/Page|Display Name]]
117
+ */
118
+ export function parseObsidianLink(linkString: string): ObsidianLink | null {
119
+ if (!isObsidianLink(linkString)) return null;
120
+
121
+ const trimmed = linkString.trim();
122
+ const linkContent = trimmed.match(/^\[\[(.+?)\]\]$/)?.[1];
123
+
124
+ if (!linkContent) return null;
125
+
126
+ // Handle pipe syntax: [[path|display]]
127
+ if (linkContent.includes("|")) {
128
+ const parts = linkContent.split("|");
129
+ const path = parts[0].trim();
130
+ const alias = parts.slice(1).join("|").trim(); // Handle multiple pipes
131
+
132
+ return {
133
+ raw: trimmed,
134
+ path,
135
+ alias,
136
+ };
137
+ }
138
+
139
+ // Simple format: [[path]]
140
+ const path = linkContent.trim();
141
+ return {
142
+ raw: trimmed,
143
+ path,
144
+ alias: path,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Gets the display alias from an Obsidian link
150
+ */
151
+ export function getObsidianLinkAlias(linkString: string): string {
152
+ const parsed = parseObsidianLink(linkString);
153
+ return parsed?.alias ?? linkString;
154
+ }
155
+
156
+ /**
157
+ * Gets the file path from an Obsidian link
158
+ */
159
+ export function getObsidianLinkPath(linkString: string): string {
160
+ const parsed = parseObsidianLink(linkString);
161
+ return parsed?.path ?? linkString;
162
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // Async utilities
4
4
  export * from "./async";
5
+ // Components
6
+ export * from "./components";
5
7
  // Core utilities
6
8
  export * from "./core";
7
9