@real1ty-obsidian-plugins/utils 2.3.0 → 2.5.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/core/evaluator/base.d.ts +22 -0
- package/dist/core/evaluator/base.d.ts.map +1 -0
- package/dist/core/evaluator/base.js +52 -0
- package/dist/core/evaluator/base.js.map +1 -0
- package/dist/core/evaluator/color.d.ts +19 -0
- package/dist/core/evaluator/color.d.ts.map +1 -0
- package/dist/core/evaluator/color.js +25 -0
- package/dist/core/evaluator/color.js.map +1 -0
- package/dist/core/evaluator/excluded.d.ts +32 -0
- package/dist/core/evaluator/excluded.d.ts.map +1 -0
- package/dist/core/evaluator/excluded.js +41 -0
- package/dist/core/evaluator/excluded.js.map +1 -0
- package/dist/core/evaluator/filter.d.ts +15 -0
- package/dist/core/evaluator/filter.d.ts.map +1 -0
- package/dist/core/evaluator/filter.js +27 -0
- package/dist/core/evaluator/filter.js.map +1 -0
- package/dist/core/evaluator/included.d.ts +36 -0
- package/dist/core/evaluator/included.d.ts.map +1 -0
- package/dist/core/evaluator/included.js +51 -0
- package/dist/core/evaluator/included.js.map +1 -0
- package/dist/core/evaluator/index.d.ts +6 -0
- package/dist/core/evaluator/index.d.ts.map +1 -0
- package/dist/core/evaluator/index.js +6 -0
- package/dist/core/evaluator/index.js.map +1 -0
- package/dist/core/expression-utils.d.ts +17 -0
- package/dist/core/expression-utils.d.ts.map +1 -0
- package/dist/core/expression-utils.js +40 -0
- package/dist/core/expression-utils.js.map +1 -0
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -1
- package/dist/core/index.js.map +1 -1
- 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 +71 -0
- package/src/core/evaluator/color.ts +37 -0
- package/src/core/evaluator/excluded.ts +63 -0
- package/src/core/evaluator/filter.ts +35 -0
- package/src/core/evaluator/included.ts +74 -0
- package/src/core/evaluator/index.ts +5 -0
- package/src/core/expression-utils.ts +53 -0
- package/src/core/generate.ts +22 -0
- package/src/core/index.ts +3 -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
- package/dist/core/evaluator-base.d.ts +0 -52
- package/dist/core/evaluator-base.d.ts.map +0 -1
- package/dist/core/evaluator-base.js +0 -84
- package/dist/core/evaluator-base.js.map +0 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { DateTime } from "luxon";
|
|
2
|
+
|
|
3
|
+
export type RecurrenceType = "daily" | "weekly" | "bi-weekly" | "monthly" | "bi-monthly" | "yearly";
|
|
4
|
+
|
|
5
|
+
export type Weekday =
|
|
6
|
+
| "sunday"
|
|
7
|
+
| "monday"
|
|
8
|
+
| "tuesday"
|
|
9
|
+
| "wednesday"
|
|
10
|
+
| "thursday"
|
|
11
|
+
| "friday"
|
|
12
|
+
| "saturday";
|
|
13
|
+
|
|
14
|
+
export const WEEKDAY_TO_NUMBER: Record<Weekday, number> = {
|
|
15
|
+
sunday: 0,
|
|
16
|
+
monday: 1,
|
|
17
|
+
tuesday: 2,
|
|
18
|
+
wednesday: 3,
|
|
19
|
+
thursday: 4,
|
|
20
|
+
friday: 5,
|
|
21
|
+
saturday: 6,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculates the next occurrence date based on recurrence type and optional weekdays
|
|
26
|
+
*/
|
|
27
|
+
export function getNextOccurrence(
|
|
28
|
+
currentDate: DateTime,
|
|
29
|
+
recurrenceType: RecurrenceType,
|
|
30
|
+
weekdays?: Weekday[]
|
|
31
|
+
): DateTime {
|
|
32
|
+
switch (recurrenceType) {
|
|
33
|
+
case "daily":
|
|
34
|
+
return currentDate.plus({ days: 1 });
|
|
35
|
+
case "weekly":
|
|
36
|
+
if (weekdays && weekdays.length > 0) {
|
|
37
|
+
return getNextWeekdayOccurrence(currentDate, weekdays);
|
|
38
|
+
}
|
|
39
|
+
return currentDate.plus({ weeks: 1 });
|
|
40
|
+
case "bi-weekly":
|
|
41
|
+
if (weekdays && weekdays.length > 0) {
|
|
42
|
+
return getNextBiWeeklyOccurrence(currentDate, weekdays);
|
|
43
|
+
}
|
|
44
|
+
return currentDate.plus({ weeks: 2 });
|
|
45
|
+
case "monthly":
|
|
46
|
+
return currentDate.plus({ months: 1 });
|
|
47
|
+
case "bi-monthly":
|
|
48
|
+
return currentDate.plus({ months: 2 });
|
|
49
|
+
case "yearly":
|
|
50
|
+
return currentDate.plus({ years: 1 });
|
|
51
|
+
default:
|
|
52
|
+
return currentDate.plus({ days: 1 });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if a given date matches any of the specified weekdays
|
|
58
|
+
*/
|
|
59
|
+
export function isDateOnWeekdays(date: DateTime, weekdays: Weekday[]): boolean {
|
|
60
|
+
const dateWeekday = date.weekday;
|
|
61
|
+
const luxonWeekdays = weekdays.map((day) => {
|
|
62
|
+
const dayNumber = WEEKDAY_TO_NUMBER[day];
|
|
63
|
+
return dayNumber === 0 ? 7 : dayNumber; // Convert Sunday from 0 to 7 for Luxon
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return luxonWeekdays.includes(dateWeekday);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Finds the next occurrence on specified weekdays
|
|
71
|
+
*/
|
|
72
|
+
export function getNextWeekdayOccurrence(currentDate: DateTime, weekdays: Weekday[]): DateTime {
|
|
73
|
+
const currentWeekday = currentDate.weekday;
|
|
74
|
+
const luxonWeekdays = weekdays.map((day) => {
|
|
75
|
+
const dayNumber = WEEKDAY_TO_NUMBER[day];
|
|
76
|
+
return dayNumber === 0 ? 7 : dayNumber; // Convert Sunday from 0 to 7 for Luxon
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Find next weekday in the current week (after today)
|
|
80
|
+
const nextWeekday = luxonWeekdays.find((day) => day > currentWeekday);
|
|
81
|
+
if (nextWeekday) {
|
|
82
|
+
return currentDate.set({ weekday: nextWeekday as 1 | 2 | 3 | 4 | 5 | 6 | 7 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No more weekdays this week, go to first weekday of next week
|
|
86
|
+
const firstWeekday = Math.min(...luxonWeekdays);
|
|
87
|
+
return currentDate.plus({ weeks: 1 }).set({ weekday: firstWeekday as 1 | 2 | 3 | 4 | 5 | 6 | 7 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Finds the next bi-weekly occurrence on specified weekdays
|
|
92
|
+
*/
|
|
93
|
+
export function getNextBiWeeklyOccurrence(currentDate: DateTime, weekdays: Weekday[]): DateTime {
|
|
94
|
+
const nextWeekly = getNextWeekdayOccurrence(currentDate, weekdays);
|
|
95
|
+
// Add one more week to make it bi-weekly
|
|
96
|
+
return nextWeekly.plus({ weeks: 1 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function* iterateOccurrencesInRange(
|
|
100
|
+
startDate: DateTime,
|
|
101
|
+
rrules: { type: RecurrenceType; weekdays?: Weekday[] },
|
|
102
|
+
rangeStart: DateTime,
|
|
103
|
+
rangeEnd: DateTime
|
|
104
|
+
): Generator<DateTime, void, unknown> {
|
|
105
|
+
// Normalize to start of day for comparison
|
|
106
|
+
const normalizedStart = startDate.startOf("day");
|
|
107
|
+
const normalizedRangeStart = rangeStart.startOf("day");
|
|
108
|
+
const normalizedRangeEnd = rangeEnd.startOf("day");
|
|
109
|
+
|
|
110
|
+
// Start from the later of startDate or rangeStart
|
|
111
|
+
let currentDate =
|
|
112
|
+
normalizedStart >= normalizedRangeStart ? normalizedStart : normalizedRangeStart;
|
|
113
|
+
|
|
114
|
+
// For weekly/bi-weekly with weekdays, we need to track which week we're in
|
|
115
|
+
if (
|
|
116
|
+
(rrules.type === "weekly" || rrules.type === "bi-weekly") &&
|
|
117
|
+
rrules.weekdays &&
|
|
118
|
+
rrules.weekdays.length > 0
|
|
119
|
+
) {
|
|
120
|
+
// Calculate week offset from start date
|
|
121
|
+
const weeksFromStart = Math.floor(currentDate.diff(normalizedStart, "weeks").weeks);
|
|
122
|
+
|
|
123
|
+
// For bi-weekly, we only want even weeks (0, 2, 4...) from the start date
|
|
124
|
+
const weekInterval = rrules.type === "bi-weekly" ? 2 : 1;
|
|
125
|
+
|
|
126
|
+
// Adjust to the correct week if we're in an off-week
|
|
127
|
+
const weekOffset = weeksFromStart % weekInterval;
|
|
128
|
+
if (weekOffset !== 0) {
|
|
129
|
+
currentDate = currentDate.plus({ weeks: weekInterval - weekOffset });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Now iterate through weeks, checking each day
|
|
133
|
+
while (currentDate <= normalizedRangeEnd) {
|
|
134
|
+
// Check all 7 days of the current week
|
|
135
|
+
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
|
136
|
+
const checkDate = currentDate.plus({ days: dayOffset });
|
|
137
|
+
|
|
138
|
+
// Only yield if within range and matches a target weekday
|
|
139
|
+
if (
|
|
140
|
+
checkDate >= normalizedRangeStart &&
|
|
141
|
+
checkDate <= normalizedRangeEnd &&
|
|
142
|
+
isDateOnWeekdays(checkDate, rrules.weekdays)
|
|
143
|
+
) {
|
|
144
|
+
yield checkDate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Move to next occurrence week (1 week for weekly, 2 weeks for bi-weekly)
|
|
149
|
+
currentDate = currentDate.plus({ weeks: weekInterval });
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// For other recurrence types (daily, monthly, yearly, or weekly without weekdays)
|
|
153
|
+
while (currentDate <= normalizedRangeEnd) {
|
|
154
|
+
if (currentDate >= normalizedRangeStart) {
|
|
155
|
+
yield currentDate;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const nextDate = getNextOccurrence(currentDate, rrules.type, rrules.weekdays);
|
|
159
|
+
|
|
160
|
+
if (nextDate <= normalizedRangeEnd) {
|
|
161
|
+
currentDate = nextDate;
|
|
162
|
+
} else {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Calculates a DateTime for a specific date with optional time
|
|
171
|
+
*/
|
|
172
|
+
export function calculateInstanceDateTime(instanceDate: DateTime, timeString?: string): DateTime {
|
|
173
|
+
if (!timeString) {
|
|
174
|
+
return instanceDate.startOf("day");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [hours, minutes] = timeString.split(":").map(Number);
|
|
178
|
+
return instanceDate.set({ hour: hours, minute: minutes, second: 0, millisecond: 0 });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function calculateRecurringInstanceDateTime(
|
|
182
|
+
nextInstanceDateTime: DateTime,
|
|
183
|
+
nodeRecuringEventDateTime: DateTime,
|
|
184
|
+
recurrenceType: RecurrenceType,
|
|
185
|
+
allDay?: boolean
|
|
186
|
+
): DateTime {
|
|
187
|
+
// Convert the original event time to the target timezone once to preserve local time
|
|
188
|
+
const originalInTargetZone = nodeRecuringEventDateTime.setZone(nextInstanceDateTime.zone);
|
|
189
|
+
|
|
190
|
+
switch (recurrenceType) {
|
|
191
|
+
case "daily":
|
|
192
|
+
case "weekly":
|
|
193
|
+
case "bi-weekly": {
|
|
194
|
+
if (allDay) {
|
|
195
|
+
return nextInstanceDateTime.startOf("day");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return nextInstanceDateTime.set({
|
|
199
|
+
hour: originalInTargetZone.hour,
|
|
200
|
+
minute: originalInTargetZone.minute,
|
|
201
|
+
second: 0,
|
|
202
|
+
millisecond: 0,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case "monthly":
|
|
207
|
+
case "bi-monthly": {
|
|
208
|
+
if (allDay) {
|
|
209
|
+
return nextInstanceDateTime.set({ day: originalInTargetZone.day }).startOf("day");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return nextInstanceDateTime.set({
|
|
213
|
+
day: originalInTargetZone.day,
|
|
214
|
+
hour: originalInTargetZone.hour,
|
|
215
|
+
minute: originalInTargetZone.minute,
|
|
216
|
+
second: 0,
|
|
217
|
+
millisecond: 0,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case "yearly": {
|
|
222
|
+
if (allDay) {
|
|
223
|
+
return nextInstanceDateTime
|
|
224
|
+
.set({
|
|
225
|
+
month: originalInTargetZone.month,
|
|
226
|
+
day: originalInTargetZone.day,
|
|
227
|
+
})
|
|
228
|
+
.startOf("day");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return nextInstanceDateTime.set({
|
|
232
|
+
month: originalInTargetZone.month,
|
|
233
|
+
day: originalInTargetZone.day,
|
|
234
|
+
hour: originalInTargetZone.hour,
|
|
235
|
+
minute: originalInTargetZone.minute,
|
|
236
|
+
second: 0,
|
|
237
|
+
millisecond: 0,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
default:
|
|
242
|
+
return nextInstanceDateTime.startOf("day");
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/date/date.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
|
|
3
|
+
export const formatDateTimeForInput = (dateString: string): string => {
|
|
4
|
+
if (!dateString) return "";
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const date = new Date(dateString);
|
|
8
|
+
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
|
|
9
|
+
const year = date.getFullYear();
|
|
10
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
11
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
12
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
13
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
14
|
+
|
|
15
|
+
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
16
|
+
} catch {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const formatDateForInput = (dateString: string): string => {
|
|
22
|
+
if (!dateString) return "";
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const date = new Date(dateString);
|
|
26
|
+
const year = date.getFullYear();
|
|
27
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
28
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
29
|
+
|
|
30
|
+
return `${year}-${month}-${day}`;
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Converts input value to ISO string, handling edge cases where
|
|
38
|
+
* browser datetime-local inputs behave differently across platforms.
|
|
39
|
+
* Returns null for invalid dates to prevent silent failures.
|
|
40
|
+
*/
|
|
41
|
+
export const inputValueToISOString = (inputValue: string): string | null => {
|
|
42
|
+
try {
|
|
43
|
+
return new Date(inputValue).toISOString();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const formatDuration = (minutes: number): string => {
|
|
50
|
+
const hours = Math.floor(minutes / 60);
|
|
51
|
+
const mins = minutes % 60;
|
|
52
|
+
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}:00`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse time string from datetime value - returns DateTime object
|
|
57
|
+
* Rejects plain HH:mm format, requires full datetime
|
|
58
|
+
*/
|
|
59
|
+
export const parseTimeString = (value: string | null): DateTime | undefined => {
|
|
60
|
+
if (value === null) return undefined;
|
|
61
|
+
|
|
62
|
+
const v = value.trim();
|
|
63
|
+
|
|
64
|
+
// Reject plain HH:mm format - require full datetime
|
|
65
|
+
if (/^\d{2}:\d{2}$/.test(v)) {
|
|
66
|
+
return undefined; // Reject plain time format
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try ISO format first (most common) - EXACT same logic as recurring events
|
|
70
|
+
let dt = DateTime.fromISO(v, { setZone: true }); // ISO: with/without seconds, Z/offset, T
|
|
71
|
+
if (!dt.isValid) dt = DateTime.fromSQL(v, { setZone: true }); // "YYYY-MM-DD HH:mm[:ss]" etc.
|
|
72
|
+
if (!dt.isValid) dt = DateTime.fromFormat(v, "yyyy-MM-dd HH:mm", { setZone: true });
|
|
73
|
+
|
|
74
|
+
return dt.isValid ? dt : undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse and validate datetime strings for event parsing
|
|
79
|
+
* Supports multiple formats including date-only and datetime formats
|
|
80
|
+
*/
|
|
81
|
+
export const parseDateTimeString = (value: string | null): DateTime | undefined => {
|
|
82
|
+
if (value === null) return undefined;
|
|
83
|
+
|
|
84
|
+
const v = value.trim();
|
|
85
|
+
if (!v) return undefined;
|
|
86
|
+
|
|
87
|
+
// Try multiple datetime formats in order of preference
|
|
88
|
+
let dt: DateTime;
|
|
89
|
+
|
|
90
|
+
// 1. Try ISO format first (most common)
|
|
91
|
+
dt = DateTime.fromISO(v, { setZone: true });
|
|
92
|
+
if (dt.isValid) return dt;
|
|
93
|
+
|
|
94
|
+
// 2. Try SQL format (YYYY-MM-DD HH:mm:ss)
|
|
95
|
+
dt = DateTime.fromSQL(v, { setZone: true });
|
|
96
|
+
if (dt.isValid) return dt;
|
|
97
|
+
|
|
98
|
+
// 3. Try common format with space (YYYY-MM-DD HH:mm)
|
|
99
|
+
dt = DateTime.fromFormat(v, "yyyy-MM-dd HH:mm", { setZone: true });
|
|
100
|
+
if (dt.isValid) return dt;
|
|
101
|
+
|
|
102
|
+
// 4. Try date-only format (YYYY-MM-DD) - treat as start of day
|
|
103
|
+
dt = DateTime.fromFormat(v, "yyyy-MM-dd", { setZone: true });
|
|
104
|
+
if (dt.isValid) return dt;
|
|
105
|
+
|
|
106
|
+
// 5. Try ISO date format (YYYY-MM-DD)
|
|
107
|
+
dt = DateTime.fromISO(v, { setZone: true });
|
|
108
|
+
if (dt.isValid) return dt;
|
|
109
|
+
|
|
110
|
+
return undefined;
|
|
111
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TFile } from "obsidian";
|
|
2
|
+
import { createFileLink } from "./file-operations";
|
|
3
|
+
import { extractFilePathFromLink } from "./link-parser";
|
|
4
|
+
|
|
5
|
+
export interface VaultAdapter {
|
|
6
|
+
getAbstractFileByPath(path: string): TFile | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractDirectoryPath(filePath: string): string {
|
|
10
|
+
const lastSlashIndex = filePath.lastIndexOf("/");
|
|
11
|
+
if (lastSlashIndex === -1) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
return filePath.substring(0, lastSlashIndex);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isRelativeChildReference(childRef: string): boolean {
|
|
18
|
+
const filePath = extractFilePathFromLink(childRef);
|
|
19
|
+
if (!filePath) {
|
|
20
|
+
return !childRef.includes("/");
|
|
21
|
+
}
|
|
22
|
+
return !filePath.includes("/");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeChildReference(
|
|
26
|
+
childRef: string,
|
|
27
|
+
vault: VaultAdapter,
|
|
28
|
+
currentFileDirectory?: string
|
|
29
|
+
): string {
|
|
30
|
+
const filePath = extractFilePathFromLink(childRef);
|
|
31
|
+
|
|
32
|
+
// Handle plain text references (not wrapped in [[]])
|
|
33
|
+
if (!filePath) {
|
|
34
|
+
// If it's not a link format, check if it should be converted to a link
|
|
35
|
+
if (!childRef.includes("/") && currentFileDirectory !== undefined) {
|
|
36
|
+
// This is a plain text reference that might need to be converted to a link
|
|
37
|
+
const potentialPath = currentFileDirectory
|
|
38
|
+
? `${currentFileDirectory}/${childRef.endsWith(".md") ? childRef : `${childRef}.md`}`
|
|
39
|
+
: childRef.endsWith(".md")
|
|
40
|
+
? childRef
|
|
41
|
+
: `${childRef}.md`;
|
|
42
|
+
const file = vault.getAbstractFileByPath(potentialPath);
|
|
43
|
+
if (file instanceof TFile) {
|
|
44
|
+
return createFileLink(file);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return childRef;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Handle relative references by making them absolute
|
|
51
|
+
if (isRelativeChildReference(childRef) && currentFileDirectory) {
|
|
52
|
+
const absolutePath = `${currentFileDirectory}/${filePath}`;
|
|
53
|
+
const file = vault.getAbstractFileByPath(absolutePath);
|
|
54
|
+
if (file instanceof TFile) {
|
|
55
|
+
return createFileLink(file);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For absolute references or when no directory context, try to find the file as-is
|
|
60
|
+
const file = vault.getAbstractFileByPath(filePath);
|
|
61
|
+
if (file instanceof TFile) {
|
|
62
|
+
return createFileLink(file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return childRef;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeChildReferences(
|
|
69
|
+
childRefs: string[],
|
|
70
|
+
vault: VaultAdapter,
|
|
71
|
+
currentFileDirectory?: string
|
|
72
|
+
): string[] {
|
|
73
|
+
return childRefs.map((childRef) => {
|
|
74
|
+
return normalizeChildReference(childRef, vault, currentFileDirectory);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { type App, Notice, TFile } from "obsidian";
|
|
4
|
+
import { generateZettelId } from "../core";
|
|
5
|
+
import { generateUniqueFilePath } from "./file";
|
|
6
|
+
import { extractFilePathFromLink } from "./link-parser";
|
|
7
|
+
|
|
8
|
+
export const fromRoot = (relativePath: string): string => {
|
|
9
|
+
return path.resolve(__dirname, `../../../${relativePath}`);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const getActiveFileOrThrow = (app: App): TFile => {
|
|
13
|
+
const activeFile: TFile | null = app.workspace.getActiveFile();
|
|
14
|
+
if (!activeFile) {
|
|
15
|
+
new Notice(`⚠️ Open a note first.`);
|
|
16
|
+
throw new Error(`Open a note first.`);
|
|
17
|
+
}
|
|
18
|
+
return activeFile;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const getTemplateContent = async (app: App, templatePath: string): Promise<string> => {
|
|
22
|
+
const templateFile = app.vault.getAbstractFileByPath(templatePath);
|
|
23
|
+
if (!templateFile) {
|
|
24
|
+
new Notice(`❌ Template not found: ${templatePath}`);
|
|
25
|
+
throw new Error(`Template not found: ${templatePath}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return await app.vault.read(templateFile as TFile);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const ensureFolderExists = async (app: App, folderPath: string): Promise<void> => {
|
|
32
|
+
if (!app.vault.getAbstractFileByPath(folderPath)) {
|
|
33
|
+
await app.vault.createFolder(folderPath).catch(() => {});
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const openFileInNewLeaf = async (app: App, file: TFile): Promise<void> => {
|
|
38
|
+
await app.workspace.getLeaf(true).openFile(file);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const getNoteFilesFromDir = async (directoryPath: string): Promise<string[]> => {
|
|
42
|
+
const files = await fs.readdir(directoryPath);
|
|
43
|
+
const directoryName = path.basename(directoryPath);
|
|
44
|
+
|
|
45
|
+
return files.filter((file) => {
|
|
46
|
+
if (!file.endsWith(".md")) return false;
|
|
47
|
+
|
|
48
|
+
const fileNameWithoutExt = path.parse(file).name;
|
|
49
|
+
if (fileNameWithoutExt === directoryName) {
|
|
50
|
+
console.log(`⏭️ Skipping directory-level file: ${file}`);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const getTargetFileFromLink = (app: App, relationshipLink: string): TFile | null => {
|
|
59
|
+
const targetFilePath = extractFilePathFromLink(relationshipLink);
|
|
60
|
+
if (!targetFilePath) {
|
|
61
|
+
console.warn(`Failed to extract file path from link: ${relationshipLink}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const targetFile = app.vault.getAbstractFileByPath(targetFilePath) as TFile;
|
|
66
|
+
if (!targetFile) {
|
|
67
|
+
console.warn(`Target file not found for link: ${relationshipLink}`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return targetFile;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const createFileLink = (file: TFile): string => {
|
|
75
|
+
const folder = file.parent?.path && file.parent.path !== "/" ? file.parent.path : "";
|
|
76
|
+
return folder ? `[[${folder}/${file.basename}|${file.basename}]]` : `[[${file.basename}]]`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const normalizeArray = (value: string | string[] | undefined): string[] => {
|
|
80
|
+
if (!value) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
return [value];
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const arraysEqual = (a: string[], b: string[]): boolean => {
|
|
93
|
+
return a.length === b.length && a.every((val, index) => val === b[index]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalizes frontmatter content by converting quoted numeric _ZettelIDs to numbers.
|
|
98
|
+
* This handles edge cases where YAML parsers treat numeric strings inconsistently.
|
|
99
|
+
*/
|
|
100
|
+
export const normalizeContent = (content: string): string => {
|
|
101
|
+
let normalized = content;
|
|
102
|
+
let hasChanges = false;
|
|
103
|
+
|
|
104
|
+
// Normalize _ZettelID: "string" → number (remove quotes)
|
|
105
|
+
const zettelIdMatch = normalized.match(/^_ZettelID:\s*"(\d+)"/m);
|
|
106
|
+
if (zettelIdMatch) {
|
|
107
|
+
const [fullMatch, numericId] = zettelIdMatch;
|
|
108
|
+
const replacement = `_ZettelID: ${numericId}`;
|
|
109
|
+
normalized = normalized.replace(fullMatch, replacement);
|
|
110
|
+
hasChanges = true;
|
|
111
|
+
console.log(` ✅ _ZettelID: "${numericId}" → ${numericId}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return hasChanges ? normalized : content;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Safely performs a file operation with error handling and file validation.
|
|
119
|
+
* Reduces boilerplate for common file operations.
|
|
120
|
+
*/
|
|
121
|
+
export const withFileOperation = async <T>(
|
|
122
|
+
app: App,
|
|
123
|
+
event: any,
|
|
124
|
+
operation: (file: TFile) => Promise<T>,
|
|
125
|
+
errorMessage: string = "Operation failed"
|
|
126
|
+
): Promise<T | null> => {
|
|
127
|
+
try {
|
|
128
|
+
const filePath = event.extendedProps.filePath;
|
|
129
|
+
const file = app.vault.getAbstractFileByPath(filePath);
|
|
130
|
+
|
|
131
|
+
if (!(file instanceof TFile)) {
|
|
132
|
+
new Notice("Could not find the file");
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return await operation(file);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error(`Error in file operation:`, error);
|
|
139
|
+
new Notice(errorMessage);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Safely performs a file operation by file path with error handling and file validation.
|
|
146
|
+
*/
|
|
147
|
+
export const withFile = async <T>(
|
|
148
|
+
app: App,
|
|
149
|
+
filePath: string,
|
|
150
|
+
operation: (file: TFile) => Promise<T>,
|
|
151
|
+
errorMessage: string = "Operation failed"
|
|
152
|
+
): Promise<T | null> => {
|
|
153
|
+
try {
|
|
154
|
+
const file = app.vault.getAbstractFileByPath(filePath);
|
|
155
|
+
|
|
156
|
+
if (!(file instanceof TFile)) {
|
|
157
|
+
new Notice("Could not find the file");
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return await operation(file);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`Error in file operation:`, error);
|
|
164
|
+
new Notice(errorMessage);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Duplicates a file with a new ZettelID, preserving the original content
|
|
171
|
+
* but updating the ZettelID in frontmatter if configured.
|
|
172
|
+
*/
|
|
173
|
+
export const duplicateFileWithNewZettelId = async (
|
|
174
|
+
app: App,
|
|
175
|
+
file: TFile,
|
|
176
|
+
zettelIdProp?: string
|
|
177
|
+
): Promise<TFile> => {
|
|
178
|
+
const content = await app.vault.read(file);
|
|
179
|
+
|
|
180
|
+
const parentPath = file.parent?.path || "";
|
|
181
|
+
const baseNameWithoutZettel = file.basename.replace(/-\d{14}$/, "");
|
|
182
|
+
const zettelId = generateZettelId();
|
|
183
|
+
const newBasename = `${baseNameWithoutZettel}-${zettelId}`;
|
|
184
|
+
const newFilePath = generateUniqueFilePath(app, parentPath, newBasename);
|
|
185
|
+
|
|
186
|
+
// Create the new file with original content
|
|
187
|
+
const newFile = await app.vault.create(newFilePath, content);
|
|
188
|
+
|
|
189
|
+
// Update the ZettelID in frontmatter if configured
|
|
190
|
+
if (zettelIdProp) {
|
|
191
|
+
await app.fileManager.processFrontMatter(newFile, (fm) => {
|
|
192
|
+
fm[zettelIdProp] = zettelId;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return newFile;
|
|
197
|
+
};
|