@real1ty-obsidian-plugins/utils 2.3.0 → 2.4.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/package.json +3 -5
- package/src/async/async.ts +117 -0
- package/src/async/batch-operations.ts +53 -0
- package/src/async/index.ts +2 -0
- package/src/core/evaluator-base.ts +118 -0
- package/src/core/generate.ts +22 -0
- package/src/core/index.ts +2 -0
- package/src/date/date-recurrence.ts +244 -0
- package/src/date/date.ts +111 -0
- package/src/date/index.ts +2 -0
- package/src/file/child-reference.ts +76 -0
- package/src/file/file-operations.ts +197 -0
- package/src/file/file.ts +570 -0
- package/src/file/frontmatter.ts +80 -0
- package/src/file/index.ts +6 -0
- package/src/file/link-parser.ts +18 -0
- package/src/file/templater.ts +75 -0
- package/src/index.ts +14 -0
- package/src/settings/index.ts +2 -0
- package/src/settings/settings-store.ts +88 -0
- package/src/settings/settings-ui-builder.ts +507 -0
- package/src/string/index.ts +1 -0
- package/src/string/string.ts +26 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/mocks/obsidian.ts +331 -0
- package/src/testing/mocks/utils.ts +113 -0
- package/src/testing/setup.ts +19 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type App, Notice, normalizePath, type TFile } from "obsidian";
|
|
2
|
+
|
|
3
|
+
const TEMPLATER_ID = "templater-obsidian";
|
|
4
|
+
|
|
5
|
+
type CreateFn = (
|
|
6
|
+
templateFile: TFile,
|
|
7
|
+
folder?: string,
|
|
8
|
+
filename?: string,
|
|
9
|
+
openNewNote?: boolean
|
|
10
|
+
) => Promise<TFile | undefined>;
|
|
11
|
+
|
|
12
|
+
interface TemplaterLike {
|
|
13
|
+
create_new_note_from_template: CreateFn;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitForTemplater(app: App, timeoutMs = 8000): Promise<TemplaterLike | null> {
|
|
17
|
+
await new Promise<void>((resolve) => app.workspace.onLayoutReady(resolve));
|
|
18
|
+
|
|
19
|
+
const started = Date.now();
|
|
20
|
+
while (Date.now() - started < timeoutMs) {
|
|
21
|
+
const plug: any = (app as any).plugins?.getPlugin?.(TEMPLATER_ID);
|
|
22
|
+
const api = plug?.templater ?? null;
|
|
23
|
+
|
|
24
|
+
const createFn: CreateFn | undefined = api?.create_new_note_from_template?.bind(api);
|
|
25
|
+
if (typeof createFn === "function") {
|
|
26
|
+
return { create_new_note_from_template: createFn };
|
|
27
|
+
}
|
|
28
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isTemplaterAvailable(app: App): boolean {
|
|
34
|
+
const instance = (app as any).plugins?.getPlugin?.(TEMPLATER_ID);
|
|
35
|
+
return !!instance;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createFromTemplate(
|
|
39
|
+
app: App,
|
|
40
|
+
templatePath: string,
|
|
41
|
+
targetFolder?: string,
|
|
42
|
+
filename?: string,
|
|
43
|
+
openNewNote = false
|
|
44
|
+
): Promise<TFile | null> {
|
|
45
|
+
const templater = await waitForTemplater(app);
|
|
46
|
+
if (!templater) {
|
|
47
|
+
console.warn("Templater isn't ready yet (or not installed/enabled).");
|
|
48
|
+
new Notice(
|
|
49
|
+
"Templater plugin is not available or enabled. Please ensure it is installed and enabled."
|
|
50
|
+
);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const templateFile = app.vault.getFileByPath(normalizePath(templatePath));
|
|
55
|
+
if (!templateFile) {
|
|
56
|
+
console.error(`Template not found: ${templatePath}`);
|
|
57
|
+
new Notice(`Template file not found: ${templatePath}. Please ensure the template file exists.`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const newFile = await templater.create_new_note_from_template(
|
|
63
|
+
templateFile,
|
|
64
|
+
targetFolder,
|
|
65
|
+
filename,
|
|
66
|
+
openNewNote
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return newFile ?? null;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error("Error creating file from template:", error);
|
|
72
|
+
new Notice("Error creating file from template. Please ensure the template file is valid.");
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Settings
|
|
2
|
+
|
|
3
|
+
// Async utilities
|
|
4
|
+
export * from "./async";
|
|
5
|
+
// Core utilities
|
|
6
|
+
export * from "./core";
|
|
7
|
+
|
|
8
|
+
// Date operations
|
|
9
|
+
export * from "./date";
|
|
10
|
+
// File operations
|
|
11
|
+
export * from "./file";
|
|
12
|
+
export * from "./settings";
|
|
13
|
+
// String utilities
|
|
14
|
+
export * from "./string";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Plugin } from "obsidian";
|
|
2
|
+
import { BehaviorSubject } from "rxjs";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export class SettingsStore<TSchema extends z.ZodTypeAny> {
|
|
6
|
+
private plugin: Plugin;
|
|
7
|
+
private schema: TSchema;
|
|
8
|
+
public readonly settings$: BehaviorSubject<z.infer<TSchema>>;
|
|
9
|
+
|
|
10
|
+
constructor(plugin: Plugin, schema: TSchema) {
|
|
11
|
+
this.plugin = plugin;
|
|
12
|
+
this.schema = schema;
|
|
13
|
+
this.settings$ = new BehaviorSubject<z.infer<TSchema>>(schema.parse({}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get currentSettings(): z.infer<TSchema> {
|
|
17
|
+
return this.settings$.value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get validationSchema(): TSchema {
|
|
21
|
+
return this.schema;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async loadSettings(): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
const data = await this.plugin.loadData();
|
|
27
|
+
const sanitized = this.schema.parse(data ?? {});
|
|
28
|
+
this.settings$.next(sanitized);
|
|
29
|
+
|
|
30
|
+
// Save back if data was sanitized/normalized
|
|
31
|
+
if (JSON.stringify(sanitized) !== JSON.stringify(data ?? {})) {
|
|
32
|
+
await this.saveSettings();
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error("Failed to load settings, using defaults:", error);
|
|
36
|
+
this.settings$.next(this.schema.parse({}));
|
|
37
|
+
await this.saveSettings();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async saveSettings(): Promise<void> {
|
|
42
|
+
await this.plugin.saveData(this.currentSettings);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async updateSettings(updater: (settings: z.infer<TSchema>) => z.infer<TSchema>): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
const newSettings = updater(this.currentSettings);
|
|
48
|
+
const validated = this.schema.parse(newSettings);
|
|
49
|
+
|
|
50
|
+
this.settings$.next(validated);
|
|
51
|
+
await this.saveSettings();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error("Failed to update settings:", error);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async resetSettings(): Promise<void> {
|
|
59
|
+
this.settings$.next(this.schema.parse({}));
|
|
60
|
+
await this.saveSettings();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async updateProperty<K extends keyof z.infer<TSchema>>(
|
|
64
|
+
key: K,
|
|
65
|
+
value: z.infer<TSchema>[K]
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
await this.updateSettings((settings) => {
|
|
68
|
+
const updated = Object.assign({}, settings);
|
|
69
|
+
(updated as any)[key] = value;
|
|
70
|
+
return updated;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async updateProperties(updates: Partial<z.infer<TSchema>>): Promise<void> {
|
|
75
|
+
await this.updateSettings((settings) => {
|
|
76
|
+
return Object.assign({}, settings, updates);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getDefaults(): z.infer<TSchema> {
|
|
81
|
+
return this.schema.parse({});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
hasCustomizations(): boolean {
|
|
85
|
+
const defaults = this.getDefaults();
|
|
86
|
+
return JSON.stringify(this.currentSettings) !== JSON.stringify(defaults);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { Notice, Setting } from "obsidian";
|
|
2
|
+
import type { ZodArray, ZodNumber, ZodObject, ZodRawShape, z } from "zod";
|
|
3
|
+
import type { SettingsStore } from "./settings-store";
|
|
4
|
+
|
|
5
|
+
interface BaseSettingConfig {
|
|
6
|
+
key: string;
|
|
7
|
+
name: string;
|
|
8
|
+
desc: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TextSettingConfig extends BaseSettingConfig {
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
commitOnChange?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SliderSettingConfig extends BaseSettingConfig {
|
|
17
|
+
min?: number;
|
|
18
|
+
max?: number;
|
|
19
|
+
step?: number;
|
|
20
|
+
commitOnChange?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface DropdownSettingConfig extends BaseSettingConfig {
|
|
24
|
+
options: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ArraySettingConfig<T = string> extends BaseSettingConfig {
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
arrayDelimiter?: string;
|
|
30
|
+
multiline?: boolean;
|
|
31
|
+
commitOnChange?: boolean;
|
|
32
|
+
itemType?: "string" | "number";
|
|
33
|
+
parser?: (input: string) => T;
|
|
34
|
+
validator?: (item: T) => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ArrayManagerConfig extends BaseSettingConfig {
|
|
38
|
+
placeholder?: string;
|
|
39
|
+
addButtonText?: string;
|
|
40
|
+
removeButtonText?: string;
|
|
41
|
+
emptyArrayFallback?: unknown;
|
|
42
|
+
preventEmpty?: boolean;
|
|
43
|
+
itemDescriptionFn?: (item: unknown) => string;
|
|
44
|
+
onBeforeAdd?: (newItem: unknown, currentItems: unknown[]) => unknown[] | Promise<unknown[]>;
|
|
45
|
+
onBeforeRemove?: (
|
|
46
|
+
itemToRemove: unknown,
|
|
47
|
+
currentItems: unknown[]
|
|
48
|
+
) => unknown[] | Promise<unknown[]>;
|
|
49
|
+
quickActions?: Array<{
|
|
50
|
+
name: string;
|
|
51
|
+
desc: string;
|
|
52
|
+
buttonText: string;
|
|
53
|
+
condition?: (currentItems: unknown[]) => boolean;
|
|
54
|
+
action: (currentItems: unknown[]) => unknown[] | Promise<unknown[]>;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SettingsUIBuilder<TSchema extends ZodObject<ZodRawShape>> {
|
|
59
|
+
constructor(private settingsStore: SettingsStore<TSchema>) {}
|
|
60
|
+
|
|
61
|
+
private get settings(): z.infer<TSchema> {
|
|
62
|
+
return this.settingsStore.currentSettings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private get schema(): TSchema {
|
|
66
|
+
return this.settingsStore.validationSchema;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async updateSetting(key: keyof z.infer<TSchema>, value: unknown): Promise<void> {
|
|
70
|
+
const newSettings = {
|
|
71
|
+
...this.settings,
|
|
72
|
+
[key]: value,
|
|
73
|
+
} as z.infer<TSchema>;
|
|
74
|
+
|
|
75
|
+
const result = this.schema.safeParse(newSettings);
|
|
76
|
+
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
const errors = result.error.issues
|
|
79
|
+
.map((e) => `${String(e.path.join("."))}${e.path.length > 0 ? ": " : ""}${e.message}`)
|
|
80
|
+
.join(", ");
|
|
81
|
+
new Notice(`Validation failed: ${errors}`, 5000);
|
|
82
|
+
throw new Error(`Validation failed: ${errors}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await this.settingsStore.updateSettings(() => newSettings);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private inferSliderBounds(key: string): { min?: number; max?: number; step?: number } {
|
|
89
|
+
try {
|
|
90
|
+
const fieldSchema = (this.schema.shape as any)[key];
|
|
91
|
+
if (!fieldSchema) return {};
|
|
92
|
+
|
|
93
|
+
let innerSchema = fieldSchema;
|
|
94
|
+
while ((innerSchema as any)._def?.innerType) {
|
|
95
|
+
innerSchema = (innerSchema as any)._def.innerType;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ((innerSchema as any)._def?.typeName === "ZodNumber") {
|
|
99
|
+
const checks = ((innerSchema as ZodNumber)._def as any).checks || [];
|
|
100
|
+
let min: number | undefined;
|
|
101
|
+
let max: number | undefined;
|
|
102
|
+
|
|
103
|
+
for (const check of checks) {
|
|
104
|
+
if ((check as any).kind === "min") {
|
|
105
|
+
min = (check as any).value;
|
|
106
|
+
}
|
|
107
|
+
if ((check as any).kind === "max") {
|
|
108
|
+
max = (check as any).value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { min, max };
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn(`Failed to infer slider bounds for key ${key}:`, error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private inferArrayItemType(key: string): "string" | "number" | undefined {
|
|
122
|
+
try {
|
|
123
|
+
const fieldSchema = (this.schema.shape as any)[key];
|
|
124
|
+
if (!fieldSchema) return undefined;
|
|
125
|
+
|
|
126
|
+
let innerSchema = fieldSchema;
|
|
127
|
+
while ((innerSchema as any)._def?.innerType) {
|
|
128
|
+
innerSchema = (innerSchema as any)._def.innerType;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if ((innerSchema as any)._def?.typeName === "ZodArray") {
|
|
132
|
+
const elementType = ((innerSchema as ZodArray<any>)._def as any).type;
|
|
133
|
+
if ((elementType as any)._def?.typeName === "ZodNumber") {
|
|
134
|
+
return "number";
|
|
135
|
+
}
|
|
136
|
+
if ((elementType as any)._def?.typeName === "ZodString") {
|
|
137
|
+
return "string";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.warn(`Failed to infer array item type for key ${key}:`, error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
addToggle(containerEl: HTMLElement, config: BaseSettingConfig): void {
|
|
148
|
+
const { key, name, desc } = config;
|
|
149
|
+
const value = this.settings[key as keyof z.infer<TSchema>];
|
|
150
|
+
|
|
151
|
+
new Setting(containerEl)
|
|
152
|
+
.setName(name)
|
|
153
|
+
.setDesc(desc)
|
|
154
|
+
.addToggle((toggle) =>
|
|
155
|
+
toggle.setValue(Boolean(value)).onChange(async (newValue) => {
|
|
156
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newValue);
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
addSlider(containerEl: HTMLElement, config: SliderSettingConfig): void {
|
|
162
|
+
const { key, name, desc, step = 1, commitOnChange = false } = config;
|
|
163
|
+
const value = this.settings[key as keyof z.infer<TSchema>];
|
|
164
|
+
|
|
165
|
+
const inferredBounds = this.inferSliderBounds(key);
|
|
166
|
+
const min = config.min ?? inferredBounds.min ?? 0;
|
|
167
|
+
const max = config.max ?? inferredBounds.max ?? 100;
|
|
168
|
+
|
|
169
|
+
new Setting(containerEl)
|
|
170
|
+
.setName(name)
|
|
171
|
+
.setDesc(desc)
|
|
172
|
+
.addSlider((slider) => {
|
|
173
|
+
slider.setLimits(min, max, step).setValue(Number(value)).setDynamicTooltip();
|
|
174
|
+
|
|
175
|
+
if (commitOnChange) {
|
|
176
|
+
// Reactive: commit on every change
|
|
177
|
+
slider.onChange(async (newValue) => {
|
|
178
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newValue);
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
// Commit only when user finishes dragging
|
|
182
|
+
const commit = async (newValue: number) => {
|
|
183
|
+
try {
|
|
184
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newValue);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
new Notice(`Invalid input: ${error}`, 5000);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Update tooltip during drag for visual feedback
|
|
191
|
+
slider.onChange((newValue) => {
|
|
192
|
+
slider.sliderEl.setAttribute("aria-valuenow", String(newValue));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Commit on mouse up
|
|
196
|
+
slider.sliderEl.addEventListener("mouseup", () => {
|
|
197
|
+
void commit(Number(slider.sliderEl.value));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Commit on keyboard navigation
|
|
201
|
+
slider.sliderEl.addEventListener("keyup", (e: KeyboardEvent) => {
|
|
202
|
+
if (
|
|
203
|
+
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)
|
|
204
|
+
) {
|
|
205
|
+
void commit(Number(slider.sliderEl.value));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return slider;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
addText(containerEl: HTMLElement, config: TextSettingConfig): void {
|
|
215
|
+
const { key, name, desc, placeholder = "", commitOnChange = false } = config;
|
|
216
|
+
const value = this.settings[key as keyof z.infer<TSchema>];
|
|
217
|
+
|
|
218
|
+
new Setting(containerEl)
|
|
219
|
+
.setName(name)
|
|
220
|
+
.setDesc(desc)
|
|
221
|
+
.addText((text) => {
|
|
222
|
+
text.setPlaceholder(placeholder);
|
|
223
|
+
text.setValue(String(value ?? ""));
|
|
224
|
+
|
|
225
|
+
if (commitOnChange) {
|
|
226
|
+
// Reactive: commit on every change
|
|
227
|
+
text.onChange(async (newValue) => {
|
|
228
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newValue);
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
// Commit only on blur or Ctrl/Cmd+Enter
|
|
232
|
+
const commit = async (inputValue: string) => {
|
|
233
|
+
try {
|
|
234
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, inputValue);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
new Notice(`Invalid input: ${error}`, 5000);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
text.inputEl.addEventListener("blur", () => void commit(text.inputEl.value));
|
|
241
|
+
text.inputEl.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
242
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
void commit(text.inputEl.value);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
addDropdown(containerEl: HTMLElement, config: DropdownSettingConfig): void {
|
|
252
|
+
const { key, name, desc, options } = config;
|
|
253
|
+
const value = this.settings[key as keyof z.infer<TSchema>];
|
|
254
|
+
|
|
255
|
+
new Setting(containerEl)
|
|
256
|
+
.setName(name)
|
|
257
|
+
.setDesc(desc)
|
|
258
|
+
.addDropdown((dropdown) =>
|
|
259
|
+
dropdown
|
|
260
|
+
.addOptions(options)
|
|
261
|
+
.setValue(String(value))
|
|
262
|
+
.onChange(async (newValue) => {
|
|
263
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newValue);
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
addTextArray<T = string>(containerEl: HTMLElement, config: ArraySettingConfig<T>): void {
|
|
269
|
+
const {
|
|
270
|
+
key,
|
|
271
|
+
name,
|
|
272
|
+
desc,
|
|
273
|
+
placeholder = "",
|
|
274
|
+
arrayDelimiter = ", ",
|
|
275
|
+
multiline = false,
|
|
276
|
+
commitOnChange = false,
|
|
277
|
+
} = config;
|
|
278
|
+
const value = this.settings[key as keyof z.infer<TSchema>] as T[];
|
|
279
|
+
|
|
280
|
+
const inferredItemType = config.itemType ?? this.inferArrayItemType(key) ?? "string";
|
|
281
|
+
const parser =
|
|
282
|
+
config.parser ??
|
|
283
|
+
((input: string) => {
|
|
284
|
+
if (inferredItemType === "number") {
|
|
285
|
+
const num = Number(input);
|
|
286
|
+
if (Number.isNaN(num)) {
|
|
287
|
+
throw new Error(`Invalid number: ${input}`);
|
|
288
|
+
}
|
|
289
|
+
return num as T;
|
|
290
|
+
}
|
|
291
|
+
return input as T;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const validator = config.validator ?? ((_item: T) => true);
|
|
295
|
+
|
|
296
|
+
const setting = new Setting(containerEl).setName(name).setDesc(desc);
|
|
297
|
+
|
|
298
|
+
if (multiline) {
|
|
299
|
+
setting.addTextArea((text) => {
|
|
300
|
+
text.setPlaceholder(placeholder);
|
|
301
|
+
text.setValue(Array.isArray(value) ? value.join("\n") : "");
|
|
302
|
+
|
|
303
|
+
const commit = async (inputValue: string) => {
|
|
304
|
+
const lines = inputValue
|
|
305
|
+
.split("\n")
|
|
306
|
+
.map((s) => s.trim())
|
|
307
|
+
.filter((s) => s.length > 0);
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const items = lines.map(parser).filter(validator);
|
|
311
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, items);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
new Notice(`Invalid input: ${error}`, 5000);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
if (commitOnChange) {
|
|
318
|
+
// Reactive: commit on every change
|
|
319
|
+
text.onChange(async (inputValue) => {
|
|
320
|
+
await commit(inputValue);
|
|
321
|
+
});
|
|
322
|
+
} else {
|
|
323
|
+
// Commit only on blur or Ctrl/Cmd+Enter
|
|
324
|
+
text.inputEl.addEventListener("blur", () => void commit(text.inputEl.value));
|
|
325
|
+
text.inputEl.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
326
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
void commit(text.inputEl.value);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
text.inputEl.rows = 5;
|
|
334
|
+
text.inputEl.classList.add("settings-ui-builder-textarea");
|
|
335
|
+
});
|
|
336
|
+
} else {
|
|
337
|
+
setting.addText((text) => {
|
|
338
|
+
text.setPlaceholder(placeholder);
|
|
339
|
+
text.setValue(Array.isArray(value) ? value.join(arrayDelimiter) : "");
|
|
340
|
+
|
|
341
|
+
const commit = async (inputValue: string) => {
|
|
342
|
+
const tokens = inputValue
|
|
343
|
+
.split(",")
|
|
344
|
+
.map((s) => s.trim())
|
|
345
|
+
.filter((s) => s.length > 0);
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const items = tokens.map(parser).filter(validator);
|
|
349
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, items);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
new Notice(`Invalid input: ${error}`, 5000);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
if (commitOnChange) {
|
|
356
|
+
// Reactive: commit on every change
|
|
357
|
+
text.onChange(async (inputValue) => {
|
|
358
|
+
await commit(inputValue);
|
|
359
|
+
});
|
|
360
|
+
} else {
|
|
361
|
+
// Commit only on blur or Ctrl/Cmd+Enter
|
|
362
|
+
text.inputEl.addEventListener("blur", () => void commit(text.inputEl.value));
|
|
363
|
+
text.inputEl.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
364
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
365
|
+
e.preventDefault();
|
|
366
|
+
void commit(text.inputEl.value);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Advanced array manager with add/remove buttons for each item
|
|
376
|
+
* Similar to the directory settings pattern
|
|
377
|
+
*/
|
|
378
|
+
addArrayManager(containerEl: HTMLElement, config: ArrayManagerConfig): void {
|
|
379
|
+
const {
|
|
380
|
+
key,
|
|
381
|
+
name,
|
|
382
|
+
desc,
|
|
383
|
+
placeholder = "",
|
|
384
|
+
addButtonText = "Add",
|
|
385
|
+
removeButtonText = "Remove",
|
|
386
|
+
emptyArrayFallback = [],
|
|
387
|
+
preventEmpty = false,
|
|
388
|
+
itemDescriptionFn,
|
|
389
|
+
onBeforeAdd,
|
|
390
|
+
onBeforeRemove,
|
|
391
|
+
quickActions = [],
|
|
392
|
+
} = config;
|
|
393
|
+
|
|
394
|
+
// Section heading
|
|
395
|
+
new Setting(containerEl).setName(name).setHeading();
|
|
396
|
+
|
|
397
|
+
// Description
|
|
398
|
+
if (desc) {
|
|
399
|
+
const descEl = containerEl.createDiv("setting-item-description");
|
|
400
|
+
descEl.setText(desc);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Container for list items
|
|
404
|
+
const listContainer = containerEl.createDiv("settings-array-manager-list");
|
|
405
|
+
|
|
406
|
+
const render = () => {
|
|
407
|
+
listContainer.empty();
|
|
408
|
+
|
|
409
|
+
const currentItems = (this.settings[key as keyof z.infer<TSchema>] as unknown[]) ?? [];
|
|
410
|
+
|
|
411
|
+
for (const item of currentItems) {
|
|
412
|
+
const itemSetting = new Setting(listContainer).setName(String(item)).addButton((button) =>
|
|
413
|
+
button
|
|
414
|
+
.setButtonText(removeButtonText)
|
|
415
|
+
.setWarning()
|
|
416
|
+
.onClick(async () => {
|
|
417
|
+
let newItems = currentItems.filter((i) => i !== item);
|
|
418
|
+
|
|
419
|
+
// Apply custom logic before removal
|
|
420
|
+
if (onBeforeRemove) {
|
|
421
|
+
newItems = await onBeforeRemove(item, currentItems);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Prevent empty array if configured
|
|
425
|
+
if (preventEmpty && newItems.length === 0) {
|
|
426
|
+
newItems = Array.isArray(emptyArrayFallback)
|
|
427
|
+
? emptyArrayFallback
|
|
428
|
+
: [emptyArrayFallback];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newItems);
|
|
432
|
+
render();
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// Add custom description for each item if provided
|
|
437
|
+
if (itemDescriptionFn) {
|
|
438
|
+
itemSetting.setDesc(itemDescriptionFn(item));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
render();
|
|
444
|
+
|
|
445
|
+
// Add new item section
|
|
446
|
+
const inputId = `settings-array-manager-input-${key}`;
|
|
447
|
+
new Setting(containerEl)
|
|
448
|
+
.setName(`Add ${name.toLowerCase()}`)
|
|
449
|
+
.setDesc(`Enter a new value`)
|
|
450
|
+
.addText((text) => {
|
|
451
|
+
text.setPlaceholder(placeholder);
|
|
452
|
+
text.inputEl.id = inputId;
|
|
453
|
+
})
|
|
454
|
+
.addButton((button) =>
|
|
455
|
+
button
|
|
456
|
+
.setButtonText(addButtonText)
|
|
457
|
+
.setCta()
|
|
458
|
+
.onClick(async () => {
|
|
459
|
+
const input = containerEl.querySelector(`#${inputId}`) as HTMLInputElement;
|
|
460
|
+
const newItem = input.value.trim();
|
|
461
|
+
|
|
462
|
+
if (!newItem) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const currentItems = (this.settings[key as keyof z.infer<TSchema>] as unknown[]) ?? [];
|
|
467
|
+
let newItems = [...currentItems];
|
|
468
|
+
|
|
469
|
+
// Apply custom logic before adding
|
|
470
|
+
if (onBeforeAdd) {
|
|
471
|
+
newItems = await onBeforeAdd(newItem, currentItems);
|
|
472
|
+
} else {
|
|
473
|
+
// Default behavior: add if not exists
|
|
474
|
+
if (!newItems.includes(newItem)) {
|
|
475
|
+
newItems.push(newItem);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newItems);
|
|
480
|
+
input.value = "";
|
|
481
|
+
render();
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// Quick actions
|
|
486
|
+
for (const quickAction of quickActions) {
|
|
487
|
+
const currentItems = (this.settings[key as keyof z.infer<TSchema>] as unknown[]) ?? [];
|
|
488
|
+
|
|
489
|
+
// Check condition if provided
|
|
490
|
+
if (quickAction.condition && !quickAction.condition(currentItems)) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
new Setting(containerEl)
|
|
495
|
+
.setName(quickAction.name)
|
|
496
|
+
.setDesc(quickAction.desc)
|
|
497
|
+
.addButton((button) =>
|
|
498
|
+
button.setButtonText(quickAction.buttonText).onClick(async () => {
|
|
499
|
+
const currentItems = (this.settings[key as keyof z.infer<TSchema>] as unknown[]) ?? [];
|
|
500
|
+
const newItems = await quickAction.action(currentItems);
|
|
501
|
+
await this.updateSetting(key as keyof z.infer<TSchema>, newItems);
|
|
502
|
+
render();
|
|
503
|
+
})
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./string";
|