@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real1ty-obsidian-plugins/utils",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Shared utilities for Obsidian plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,10 +20,8 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"dist
|
|
24
|
-
"
|
|
25
|
-
"dist/**/*.js.map",
|
|
26
|
-
"dist/**/*.d.ts.map",
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
27
25
|
"README.md",
|
|
28
26
|
"LICENSE"
|
|
29
27
|
],
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a function that ensures an async operation runs only once,
|
|
3
|
+
* returning the same promise for concurrent calls.
|
|
4
|
+
*
|
|
5
|
+
* Useful for initialization patterns where you want to ensure
|
|
6
|
+
* expensive async operations (like indexing, API calls, etc.)
|
|
7
|
+
* only happen once even if called multiple times.
|
|
8
|
+
*
|
|
9
|
+
* @param fn The async function to memoize
|
|
10
|
+
* @returns A function that returns the same promise on subsequent calls
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const initializeOnce = onceAsync(async () => {
|
|
15
|
+
* await heavyInitialization();
|
|
16
|
+
* console.log("Initialized!");
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // All these calls will share the same promise
|
|
20
|
+
* await initializeOnce();
|
|
21
|
+
* await initializeOnce(); // Won't run again
|
|
22
|
+
* await initializeOnce(); // Won't run again
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function onceAsync<T>(fn: () => Promise<T>): () => Promise<T> {
|
|
26
|
+
let promise: Promise<T> | null = null;
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
if (!promise) {
|
|
30
|
+
try {
|
|
31
|
+
promise = fn();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// Convert synchronous errors to rejected promises
|
|
34
|
+
promise = Promise.reject(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return promise;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a function that ensures an async operation runs only once per key,
|
|
43
|
+
* useful for caching expensive operations with different parameters.
|
|
44
|
+
*
|
|
45
|
+
* @param fn The async function to memoize
|
|
46
|
+
* @returns A function that memoizes results by key
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const fetchUserOnce = onceAsyncKeyed(async (userId: string) => {
|
|
51
|
+
* return await api.getUser(userId);
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* // Each unique userId will only be fetched once
|
|
55
|
+
* await fetchUserOnce("user1");
|
|
56
|
+
* await fetchUserOnce("user1"); // Returns cached promise
|
|
57
|
+
* await fetchUserOnce("user2"); // New fetch for different key
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function onceAsyncKeyed<TArgs extends readonly unknown[], TReturn>(
|
|
61
|
+
fn: (...args: TArgs) => Promise<TReturn>
|
|
62
|
+
): (...args: TArgs) => Promise<TReturn> {
|
|
63
|
+
const cache = new Map<string, Promise<TReturn>>();
|
|
64
|
+
|
|
65
|
+
return (...args: TArgs) => {
|
|
66
|
+
const key = JSON.stringify(args);
|
|
67
|
+
|
|
68
|
+
if (!cache.has(key)) {
|
|
69
|
+
cache.set(key, fn(...args));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return cache.get(key)!;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Creates a resettable version of onceAsync that can be cleared and re-run.
|
|
78
|
+
*
|
|
79
|
+
* @param fn The async function to memoize
|
|
80
|
+
* @returns Object with execute and reset methods
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* const { execute: initialize, reset } = onceAsyncResettable(async () => {
|
|
85
|
+
* await heavyInitialization();
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* await initialize(); // Runs
|
|
89
|
+
* await initialize(); // Cached
|
|
90
|
+
*
|
|
91
|
+
* reset(); // Clear cache
|
|
92
|
+
* await initialize(); // Runs again
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function onceAsyncResettable<T>(fn: () => Promise<T>): {
|
|
96
|
+
execute: () => Promise<T>;
|
|
97
|
+
reset: () => void;
|
|
98
|
+
} {
|
|
99
|
+
let promise: Promise<T> | null = null;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
execute: () => {
|
|
103
|
+
if (!promise) {
|
|
104
|
+
try {
|
|
105
|
+
promise = fn();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// Convert synchronous errors to rejected promises
|
|
108
|
+
promise = Promise.reject(error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return promise;
|
|
112
|
+
},
|
|
113
|
+
reset: () => {
|
|
114
|
+
promise = null;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Notice } from "obsidian";
|
|
2
|
+
|
|
3
|
+
export interface BatchOperationOptions {
|
|
4
|
+
closeAfter?: boolean;
|
|
5
|
+
callOnComplete?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BatchOperationResult {
|
|
9
|
+
successCount: number;
|
|
10
|
+
errorCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runBatchOperation<T>(
|
|
14
|
+
items: T[],
|
|
15
|
+
operationLabel: string,
|
|
16
|
+
handler: (item: T) => Promise<void>,
|
|
17
|
+
showResult: boolean = true
|
|
18
|
+
): Promise<BatchOperationResult> {
|
|
19
|
+
let successCount = 0;
|
|
20
|
+
let errorCount = 0;
|
|
21
|
+
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
try {
|
|
24
|
+
await handler(item);
|
|
25
|
+
successCount++;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`${operationLabel}: error processing item:`, error);
|
|
28
|
+
errorCount++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (showResult) {
|
|
33
|
+
showBatchOperationResult(operationLabel, successCount, errorCount);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { successCount, errorCount };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function showBatchOperationResult(
|
|
40
|
+
operation: string,
|
|
41
|
+
successCount: number,
|
|
42
|
+
errorCount: number
|
|
43
|
+
): void {
|
|
44
|
+
if (errorCount === 0) {
|
|
45
|
+
new Notice(
|
|
46
|
+
`${operation}: ${successCount} item${successCount === 1 ? "" : "s"} processed successfully`
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
new Notice(
|
|
50
|
+
`${operation}: ${successCount} succeeded, ${errorCount} failed. Check console for details.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { BehaviorSubject, Subscription } from "rxjs";
|
|
2
|
+
|
|
3
|
+
export interface BaseRule {
|
|
4
|
+
id: string;
|
|
5
|
+
expression: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generic base class for evaluating JavaScript expressions against frontmatter objects.
|
|
11
|
+
* Provides reactive compilation of rules via RxJS subscription and safe evaluation.
|
|
12
|
+
*/
|
|
13
|
+
export abstract class BaseEvaluator<TRule extends BaseRule, TSettings> {
|
|
14
|
+
protected compiledRules: Array<
|
|
15
|
+
TRule & { fn: (frontmatter: Record<string, unknown>) => boolean }
|
|
16
|
+
> = [];
|
|
17
|
+
private settingsSubscription: Subscription | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(settingsStore: BehaviorSubject<TSettings>) {
|
|
20
|
+
const initialRules = this.extractRules(settingsStore.value);
|
|
21
|
+
this.compileRules(initialRules);
|
|
22
|
+
|
|
23
|
+
this.settingsSubscription = settingsStore.subscribe((settings) => {
|
|
24
|
+
const newRules = this.extractRules(settings);
|
|
25
|
+
this.compileRules(newRules);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract rules from settings object. Must be implemented by subclasses.
|
|
31
|
+
*/
|
|
32
|
+
protected abstract extractRules(settings: TSettings): TRule[];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile rules into executable functions with error handling.
|
|
36
|
+
*/
|
|
37
|
+
private compileRules(rules: TRule[]): void {
|
|
38
|
+
this.compiledRules = [];
|
|
39
|
+
|
|
40
|
+
for (const rule of rules) {
|
|
41
|
+
if (!rule.enabled || !rule.expression.trim()) continue;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const cleanExpression = rule.expression.trim();
|
|
45
|
+
|
|
46
|
+
// Create a function that takes 'fm' (frontmatter) as parameter
|
|
47
|
+
// and evaluates the expression in that context
|
|
48
|
+
const fn = new Function("fm", `return (${cleanExpression});`) as (
|
|
49
|
+
frontmatter: Record<string, unknown>
|
|
50
|
+
) => boolean;
|
|
51
|
+
|
|
52
|
+
// Test the function with a dummy object to catch syntax errors early
|
|
53
|
+
fn({});
|
|
54
|
+
|
|
55
|
+
this.compiledRules.push({
|
|
56
|
+
...rule,
|
|
57
|
+
expression: cleanExpression,
|
|
58
|
+
fn,
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(`Invalid rule expression "${rule.expression}":`, error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Evaluate a single rule against frontmatter. Returns the result or undefined if error.
|
|
68
|
+
*/
|
|
69
|
+
protected evaluateRule(
|
|
70
|
+
rule: TRule & { fn: (frontmatter: Record<string, unknown>) => boolean },
|
|
71
|
+
frontmatter: Record<string, unknown>
|
|
72
|
+
): boolean | undefined {
|
|
73
|
+
try {
|
|
74
|
+
return rule.fn(frontmatter);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn(`Error evaluating rule "${rule.expression}":`, error);
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert evaluation result to boolean - only explicit true is considered truthy.
|
|
83
|
+
*/
|
|
84
|
+
protected isTruthy(result: boolean | undefined): boolean {
|
|
85
|
+
return result === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clean up subscriptions and compiled rules.
|
|
90
|
+
*/
|
|
91
|
+
destroy(): void {
|
|
92
|
+
if (this.settingsSubscription) {
|
|
93
|
+
this.settingsSubscription.unsubscribe();
|
|
94
|
+
this.settingsSubscription = null;
|
|
95
|
+
}
|
|
96
|
+
this.compiledRules = [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the number of active (compiled) rules.
|
|
101
|
+
*/
|
|
102
|
+
getActiveRuleCount(): number {
|
|
103
|
+
return this.compiledRules.length;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get information about all rules including their validity.
|
|
108
|
+
*/
|
|
109
|
+
getRuleInfo(): Array<{ expression: string; isValid: boolean; enabled: boolean }> {
|
|
110
|
+
const validExpressions = new Set(this.compiledRules.map((r) => r.expression));
|
|
111
|
+
|
|
112
|
+
return this.compiledRules.map((rule) => ({
|
|
113
|
+
expression: rule.expression,
|
|
114
|
+
isValid: validExpressions.has(rule.expression),
|
|
115
|
+
enabled: rule.enabled,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface TimestampData {
|
|
2
|
+
startDate: string;
|
|
3
|
+
zettelId: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const generateZettelId = (): number => {
|
|
7
|
+
const currentTimestamp = new Date();
|
|
8
|
+
const padWithZero = (number: number) => String(number).padStart(2, "0");
|
|
9
|
+
return Number(
|
|
10
|
+
`${currentTimestamp.getFullYear()}${padWithZero(currentTimestamp.getMonth() + 1)}${padWithZero(currentTimestamp.getDate())}${padWithZero(currentTimestamp.getHours())}${padWithZero(currentTimestamp.getMinutes())}${padWithZero(currentTimestamp.getSeconds())}`
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const generateTimestamps = (): TimestampData => {
|
|
15
|
+
const padWithZero = (number: number): string => String(number).padStart(2, "0");
|
|
16
|
+
const currentDate = new Date();
|
|
17
|
+
|
|
18
|
+
const formattedStartDate: string = `${currentDate.getFullYear()}-${padWithZero(currentDate.getMonth() + 1)}-${padWithZero(currentDate.getDate())}`;
|
|
19
|
+
const uniqueZettelId: number = generateZettelId();
|
|
20
|
+
|
|
21
|
+
return { startDate: formattedStartDate, zettelId: uniqueZettelId };
|
|
22
|
+
};
|
|
@@ -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
|
+
};
|