@svgrid/grid 2.1.21 → 2.1.23
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/README.md +9 -2
- package/dist/DockNodeView.svelte +306 -0
- package/dist/DockNodeView.svelte.d.ts +7 -0
- package/dist/DockPopoutHost.svelte +17 -0
- package/dist/DockPopoutHost.svelte.d.ts +14 -0
- package/dist/GridMenus.svelte +83 -3
- package/dist/SvAutoComplete.svelte +5 -3
- package/dist/SvCarousel.svelte +20 -42
- package/dist/SvColorInput.svelte +4 -3
- package/dist/SvColorInput.svelte.d.ts +1 -1
- package/dist/SvComboBox.svelte +7 -3
- package/dist/SvCommand.svelte +28 -85
- package/dist/SvCountryInput.svelte +1 -1
- package/dist/SvDateRangeInput.svelte +1 -1
- package/dist/SvDateTimePicker.svelte +9 -3
- package/dist/SvDateTimePicker.svelte.d.ts +3 -0
- package/dist/SvDockLayout.svelte +244 -0
- package/dist/SvDockLayout.svelte.d.ts +77 -0
- package/dist/SvDockManager.svelte +922 -0
- package/dist/SvDockManager.svelte.d.ts +106 -0
- package/dist/SvDrawer.svelte +69 -47
- package/dist/SvDrawer.svelte.d.ts +4 -0
- package/dist/SvDropDownList.svelte +85 -13
- package/dist/SvDropDownList.svelte.d.ts +3 -0
- package/dist/SvField.svelte +21 -4
- package/dist/SvField.svelte.d.ts +1 -0
- package/dist/SvForm.svelte +201 -91
- package/dist/SvForm.svelte.d.ts +15 -22
- package/dist/SvGrid.controller.svelte.d.ts +32 -1
- package/dist/SvGrid.controller.svelte.js +156 -27
- package/dist/SvGrid.css +193 -5
- package/dist/SvGrid.svelte +357 -12
- package/dist/SvGrid.types.d.ts +452 -1
- package/dist/SvGridDropdown.svelte +8 -1
- package/dist/SvGridSelect.svelte +1 -0
- package/dist/SvListBox.svelte +36 -14
- package/dist/SvMaskedInput.svelte +4 -3
- package/dist/SvMaskedInput.svelte.d.ts +1 -1
- package/dist/SvMenu.svelte +1 -1
- package/dist/SvMenuList.svelte +23 -71
- package/dist/SvMenuList.svelte.d.ts +2 -13
- package/dist/SvModal.svelte +12 -29
- package/dist/SvMultiSelect.svelte +1 -0
- package/dist/SvNumberInput.svelte +4 -3
- package/dist/SvNumberInput.svelte.d.ts +1 -1
- package/dist/SvPagination.svelte +18 -18
- package/dist/SvPasswordInput.svelte +3 -2
- package/dist/SvPasswordInput.svelte.d.ts +1 -1
- package/dist/SvPhoneInput.svelte +3 -2
- package/dist/SvPhoneInput.svelte.d.ts +1 -1
- package/dist/SvPopover.svelte +1 -1
- package/dist/SvStepper.svelte +6 -9
- package/dist/SvTagsInput.svelte +3 -2
- package/dist/SvTagsInput.svelte.d.ts +1 -1
- package/dist/SvTooltip.svelte +12 -23
- package/dist/SvTour.svelte +32 -4
- package/dist/SvTreeSelect.svelte +1 -0
- package/dist/build-api.js +76 -54
- package/dist/cdn/svgrid.js +10907 -6444
- package/dist/cdn/svgrid.svelte-external.js +9622 -5475
- package/dist/columns.js +6 -0
- package/dist/conditional-formatting.d.ts +67 -3
- package/dist/conditional-formatting.js +124 -17
- package/dist/createCarousel.svelte.d.ts +72 -0
- package/dist/createCarousel.svelte.js +80 -0
- package/dist/createCommand.svelte.d.ts +62 -0
- package/dist/createCommand.svelte.js +109 -0
- package/dist/createForm.svelte.d.ts +62 -0
- package/dist/createForm.svelte.js +374 -0
- package/dist/createMenu.svelte.d.ts +88 -0
- package/dist/createMenu.svelte.js +153 -0
- package/dist/createOverlay.svelte.d.ts +38 -0
- package/dist/createOverlay.svelte.js +68 -0
- package/dist/createPagination.d.ts +74 -0
- package/dist/createPagination.js +71 -0
- package/dist/createPopoverSelect.svelte.js +6 -12
- package/dist/createStepper.d.ts +44 -0
- package/dist/createStepper.js +26 -0
- package/dist/createTooltip.svelte.d.ts +32 -0
- package/dist/createTooltip.svelte.js +61 -0
- package/dist/datetime/timezone.d.ts +36 -0
- package/dist/datetime/timezone.js +110 -0
- package/dist/dock-context.d.ts +52 -0
- package/dist/dock-context.js +1 -0
- package/dist/dock-manager-model.d.ts +136 -0
- package/dist/dock-manager-model.js +514 -0
- package/dist/dock-model.d.ts +105 -0
- package/dist/dock-model.js +288 -0
- package/dist/dock-popout.d.ts +30 -0
- package/dist/dock-popout.js +81 -0
- package/dist/editor-contract.d.ts +2 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +35 -2
- package/dist/filtering/excel-filters.d.ts +18 -1
- package/dist/filtering/excel-filters.js +56 -0
- package/dist/form-field.d.ts +73 -0
- package/dist/form-field.js +15 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +40 -4
- package/dist/list-nav.d.ts +18 -0
- package/dist/list-nav.js +30 -0
- package/dist/list-option.d.ts +2 -0
- package/dist/menu-item.d.ts +20 -0
- package/dist/menu-item.js +1 -0
- package/dist/menus.d.ts +5 -0
- package/dist/menus.js +68 -16
- package/dist/panel-resize.d.ts +25 -0
- package/dist/panel-resize.js +40 -0
- package/dist/popover.d.ts +29 -3
- package/dist/popover.js +22 -6
- package/dist/recurrence.d.ts +33 -7
- package/dist/recurrence.js +151 -13
- package/dist/row-drag.d.ts +8 -0
- package/dist/row-drag.js +11 -0
- package/dist/scheduler-ical.d.ts +30 -0
- package/dist/scheduler-ical.js +180 -0
- package/dist/scheduler-model.d.ts +269 -0
- package/dist/scheduler-model.js +602 -0
- package/dist/scheduler-view.svelte.d.ts +22 -0
- package/dist/scheduler-view.svelte.js +13 -0
- package/dist/spreadsheet.js +20 -12
- package/dist/ui-app.types.d.ts +3 -0
- package/package.json +5 -1
- package/src/DockNodeView.svelte +306 -0
- package/src/DockPopoutHost.svelte +17 -0
- package/src/GridMenus.svelte +83 -3
- package/src/SvAutoComplete.svelte +5 -3
- package/src/SvCarousel.svelte +20 -42
- package/src/SvCarousel.test.ts +68 -0
- package/src/SvColorInput.svelte +4 -3
- package/src/SvComboBox.svelte +7 -3
- package/src/SvCommand.svelte +28 -85
- package/src/SvCommand.test.ts +78 -0
- package/src/SvCountryInput.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvDateTimePicker.svelte +9 -3
- package/src/SvDockLayout.svelte +244 -0
- package/src/SvDockManager.svelte +922 -0
- package/src/SvDrawer.svelte +69 -47
- package/src/SvDropDownList.svelte +85 -13
- package/src/SvField.svelte +21 -4
- package/src/SvForm.svelte +201 -91
- package/src/SvForm.test.ts +299 -0
- package/src/SvGrid.controller.svelte.ts +152 -28
- package/src/SvGrid.css +193 -5
- package/src/SvGrid.svelte +357 -12
- package/src/SvGrid.types.ts +463 -1
- package/src/SvGridDropdown.svelte +8 -1
- package/src/SvGridSelect.svelte +1 -0
- package/src/SvListBox.svelte +36 -14
- package/src/SvMaskedInput.svelte +4 -3
- package/src/SvMenu.svelte +1 -1
- package/src/SvMenu.test.ts +24 -0
- package/src/SvMenuList.svelte +23 -71
- package/src/SvModal.svelte +12 -29
- package/src/SvModal.test.ts +82 -0
- package/src/SvMultiSelect.svelte +1 -0
- package/src/SvNumberInput.svelte +4 -3
- package/src/SvPagination.svelte +18 -18
- package/src/SvPasswordInput.svelte +3 -2
- package/src/SvPhoneInput.svelte +3 -2
- package/src/SvPopover.svelte +1 -1
- package/src/SvStepper.svelte +6 -9
- package/src/SvTagsInput.svelte +3 -2
- package/src/SvTooltip.svelte +12 -23
- package/src/SvTooltip.test.ts +73 -0
- package/src/SvTour.svelte +32 -4
- package/src/SvTreeSelect.svelte +1 -0
- package/src/build-api.ts +74 -51
- package/src/columns.ts +5 -0
- package/src/conditional-formatting.test.ts +104 -0
- package/src/conditional-formatting.ts +176 -19
- package/src/createCarousel.svelte.ts +104 -0
- package/src/createCommand.svelte.ts +125 -0
- package/src/createForm.svelte.ts +333 -0
- package/src/createMenu.svelte.ts +202 -0
- package/src/createOverlay.svelte.ts +101 -0
- package/src/createPagination.test.ts +65 -0
- package/src/createPagination.ts +84 -0
- package/src/createPopoverSelect.svelte.ts +5 -9
- package/src/createStepper.test.ts +62 -0
- package/src/createStepper.ts +61 -0
- package/src/createTooltip.svelte.ts +86 -0
- package/src/datetime/timezone.test.ts +69 -0
- package/src/datetime/timezone.ts +134 -0
- package/src/dock-context.ts +47 -0
- package/src/dock-manager-model.test.ts +275 -0
- package/src/dock-manager-model.ts +593 -0
- package/src/dock-model.test.ts +163 -0
- package/src/dock-model.ts +368 -0
- package/src/dock-popout.ts +94 -0
- package/src/editor-contract.ts +2 -0
- package/src/filter-operators.test.ts +59 -0
- package/src/filter-operators.ts +35 -2
- package/src/filtering/excel-filters.test.ts +86 -1
- package/src/filtering/excel-filters.ts +65 -0
- package/src/form-field.ts +87 -0
- package/src/index.ts +158 -4
- package/src/list-nav.test.ts +49 -0
- package/src/list-nav.ts +29 -0
- package/src/list-option.ts +2 -0
- package/src/menu-item.ts +21 -0
- package/src/menus.test.ts +48 -0
- package/src/menus.ts +73 -16
- package/src/panel-resize.ts +52 -0
- package/src/popover.bounds.test.ts +93 -0
- package/src/popover.ts +51 -7
- package/src/recurrence.test.ts +88 -1
- package/src/recurrence.ts +166 -15
- package/src/row-drag.ts +10 -0
- package/src/scheduler-ical.test.ts +68 -0
- package/src/scheduler-ical.ts +180 -0
- package/src/scheduler-model.test.ts +546 -0
- package/src/scheduler-model.ts +853 -0
- package/src/scheduler-view.svelte.ts +33 -0
- package/src/spreadsheet.ts +20 -12
- package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
- package/src/ui-app.types.ts +3 -0
- package/src/virtual.test.ts +6 -4
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel-resize - the drag math behind a dropdown panel's bottom resize grip
|
|
3
|
+
* (the "····" handle). Rune-free and DOM-only so every popover select can reuse
|
|
4
|
+
* it: on the grip's `pointerdown` it tracks the pointer and reports a clamped
|
|
5
|
+
* height as it moves, releasing its listeners on `pointerup`.
|
|
6
|
+
*
|
|
7
|
+
* The grip is only meaningful when the panel opens DOWNWARD (it grows toward the
|
|
8
|
+
* bottom of the screen); components hide it on an upward flip, where there is no
|
|
9
|
+
* room below to grow into.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Begin a vertical resize from a grip `pointerdown`. Returns nothing; the drag
|
|
13
|
+
* ends itself on `pointerup`. Safe to call from an `onpointerdown` handler.
|
|
14
|
+
*/
|
|
15
|
+
export function startPanelResize(event, opts) {
|
|
16
|
+
// Ignore secondary buttons; only a primary drag resizes.
|
|
17
|
+
if (event.button !== 0)
|
|
18
|
+
return;
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
event.stopPropagation();
|
|
21
|
+
const startY = event.clientY;
|
|
22
|
+
const min = opts.min ?? 80;
|
|
23
|
+
const clamp = (h) => Math.max(min, Math.min(opts.max(), Math.round(h)));
|
|
24
|
+
const onMove = (e) => {
|
|
25
|
+
opts.onHeight(clamp(opts.startHeight + (e.clientY - startY)));
|
|
26
|
+
};
|
|
27
|
+
const onUp = () => {
|
|
28
|
+
window.removeEventListener('pointermove', onMove);
|
|
29
|
+
window.removeEventListener('pointerup', onUp);
|
|
30
|
+
window.removeEventListener('pointercancel', onUp);
|
|
31
|
+
document.body.style.userSelect = '';
|
|
32
|
+
document.body.style.cursor = '';
|
|
33
|
+
};
|
|
34
|
+
// Suppress text selection + keep the resize cursor for the whole drag.
|
|
35
|
+
document.body.style.userSelect = 'none';
|
|
36
|
+
document.body.style.cursor = 'ns-resize';
|
|
37
|
+
window.addEventListener('pointermove', onMove);
|
|
38
|
+
window.addEventListener('pointerup', onUp);
|
|
39
|
+
window.addEventListener('pointercancel', onUp);
|
|
40
|
+
}
|
package/dist/popover.d.ts
CHANGED
|
@@ -42,6 +42,25 @@ export type AnchoredRect = {
|
|
|
42
42
|
left: number;
|
|
43
43
|
width: number;
|
|
44
44
|
openUpward: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Comfortable panel height: the estimate, capped to the room on the chosen
|
|
47
|
+
* side so the panel never leaves the viewport. Apply as the panel's
|
|
48
|
+
* `max-height` and long content scrolls inside instead of overflowing.
|
|
49
|
+
*/
|
|
50
|
+
maxHeight: number;
|
|
51
|
+
/**
|
|
52
|
+
* Absolute height the panel may occupy on the chosen side (room minus the
|
|
53
|
+
* viewport margin). The hard ceiling a user resize must clamp to.
|
|
54
|
+
*/
|
|
55
|
+
availHeight: number;
|
|
56
|
+
/**
|
|
57
|
+
* For an upward-flipped panel: distance from the viewport bottom to where the
|
|
58
|
+
* panel's bottom edge sits (just above the trigger). Bottom-anchoring an
|
|
59
|
+
* upward panel with `style:bottom` lets it grow to its natural content height
|
|
60
|
+
* instead of being positioned from a (possibly wrong) height estimate.
|
|
61
|
+
* Undefined until `anchoredRect` computes it.
|
|
62
|
+
*/
|
|
63
|
+
bottom?: number;
|
|
45
64
|
};
|
|
46
65
|
export type AnchorOptions = {
|
|
47
66
|
/** Estimated panel height, used to decide whether to flip upward. */
|
|
@@ -52,11 +71,18 @@ export type AnchorOptions = {
|
|
|
52
71
|
minWidth?: number;
|
|
53
72
|
/** Keep the panel within the viewport horizontally. Default true. */
|
|
54
73
|
clampHorizontal?: boolean;
|
|
74
|
+
/** Viewport edge kept clear top/bottom so a panel never touches it. Default 8. */
|
|
75
|
+
viewportMargin?: number;
|
|
76
|
+
/** Floor for `maxHeight`/`availHeight` so a cramped panel stays usable. Default 96. */
|
|
77
|
+
minHeight?: number;
|
|
55
78
|
};
|
|
56
79
|
/**
|
|
57
80
|
* Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
|
|
58
|
-
* trigger when there isn't room below and there's more room above.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
81
|
+
* trigger when there isn't room below and there's more room above. Generalized
|
|
82
|
+
* with min-width + horizontal clamping for wider panels (date/time popovers),
|
|
83
|
+
* and with vertical bounds detection: `maxHeight` caps the panel to the room on
|
|
84
|
+
* the chosen side (minus a viewport margin) so a long list near a screen edge
|
|
85
|
+
* scrolls internally rather than overflowing, and an upward flip is clamped so
|
|
86
|
+
* its top never leaves the viewport.
|
|
61
87
|
*/
|
|
62
88
|
export declare function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect;
|
package/dist/popover.js
CHANGED
|
@@ -67,15 +67,22 @@ export function popIn(node, param = {}) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
|
|
70
|
-
* trigger when there isn't room below and there's more room above.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* trigger when there isn't room below and there's more room above. Generalized
|
|
71
|
+
* with min-width + horizontal clamping for wider panels (date/time popovers),
|
|
72
|
+
* and with vertical bounds detection: `maxHeight` caps the panel to the room on
|
|
73
|
+
* the chosen side (minus a viewport margin) so a long list near a screen edge
|
|
74
|
+
* scrolls internally rather than overflowing, and an upward flip is clamped so
|
|
75
|
+
* its top never leaves the viewport.
|
|
73
76
|
*/
|
|
74
77
|
export function anchoredRect(triggerRect, opts) {
|
|
75
78
|
const gap = opts.gap ?? 2;
|
|
76
|
-
const
|
|
77
|
-
const
|
|
79
|
+
const margin = opts.viewportMargin ?? 8;
|
|
80
|
+
const minH = opts.minHeight ?? 96;
|
|
81
|
+
const spaceBelow = window.innerHeight - triggerRect.bottom - gap - margin;
|
|
82
|
+
const spaceAbove = triggerRect.top - gap - margin;
|
|
78
83
|
const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow;
|
|
84
|
+
const availHeight = Math.max(minH, Math.floor(openUpward ? spaceAbove : spaceBelow));
|
|
85
|
+
const maxHeight = Math.min(opts.estimatedHeight, availHeight);
|
|
79
86
|
const width = Math.max(triggerRect.width, opts.minWidth ?? 0);
|
|
80
87
|
let left = triggerRect.left;
|
|
81
88
|
if (opts.clampHorizontal !== false) {
|
|
@@ -83,9 +90,18 @@ export function anchoredRect(triggerRect, opts) {
|
|
|
83
90
|
left = Math.max(4, Math.min(left, maxLeft));
|
|
84
91
|
}
|
|
85
92
|
return {
|
|
86
|
-
|
|
93
|
+
// Upward: pin by the comfortable height and clamp to the top margin so the
|
|
94
|
+
// panel can never start off-screen. Downward: sit just below the trigger.
|
|
95
|
+
top: openUpward
|
|
96
|
+
? Math.max(margin, triggerRect.top - gap - maxHeight)
|
|
97
|
+
: triggerRect.bottom + gap,
|
|
87
98
|
left,
|
|
88
99
|
width,
|
|
89
100
|
openUpward,
|
|
101
|
+
maxHeight,
|
|
102
|
+
availHeight,
|
|
103
|
+
// Bottom-anchor value for upward panels: viewport bottom -> panel bottom
|
|
104
|
+
// edge (which sits `gap` above the trigger top).
|
|
105
|
+
bottom: Math.round(window.innerHeight - triggerRect.top + gap),
|
|
90
106
|
};
|
|
91
107
|
}
|
package/dist/recurrence.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* recurrence - a small, pure repeat-pattern engine for SvCalendar (and anywhere
|
|
3
3
|
* you need "does this date match a recurring rule"). Supports daily / weekly /
|
|
4
|
-
* monthly / yearly frequencies with an interval, weekday lists, a day-of-month
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* monthly / yearly frequencies with an interval, weekday lists, a day-of-month
|
|
5
|
+
* (incl. negative = counted from the end), a positional weekday-of-month
|
|
6
|
+
* (`weekOfMonth`, e.g. the 2nd Tuesday / last Friday), an anchor (`from`) and two
|
|
7
|
+
* end conditions - an inclusive `until` date or a `count` of occurrences.
|
|
8
|
+
* Framework-free + pure so it is unit-tested directly - no RRULE dependency.
|
|
7
9
|
*
|
|
8
10
|
* ```ts
|
|
9
11
|
* const standup = { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } // every weekday
|
|
10
|
-
* const payday = { freq: 'monthly', day: 1 }
|
|
12
|
+
* const payday = { freq: 'monthly', day: -1 } // the last day
|
|
11
13
|
* const sprint = { freq: 'weekly', weekdays: [1], interval: 2, from: '2026-01-05' } // every other Monday
|
|
12
|
-
*
|
|
14
|
+
* const board = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 } // 1st Tuesday
|
|
15
|
+
* const review = { freq: 'weekly', weekdays: [5], count: 8 } // 8 Fridays then stop
|
|
16
|
+
* matchesRecurrence(someDate, [standup, payday, sprint, board, review])
|
|
13
17
|
* ```
|
|
14
18
|
*/
|
|
15
19
|
import { type DateLike } from './datetime/date-core';
|
|
@@ -18,19 +22,41 @@ export type RecurrenceRule = {
|
|
|
18
22
|
freq: RecurrenceFreq;
|
|
19
23
|
/** Repeat every N units of `freq`. Default 1. Uses `from` as the phase anchor. */
|
|
20
24
|
interval?: number;
|
|
21
|
-
/** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday).
|
|
25
|
+
/** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday). Also the
|
|
26
|
+
* weekday for a positional monthly / yearly rule (with `weekOfMonth`). */
|
|
22
27
|
weekdays?: ReadonlyArray<number>;
|
|
23
|
-
/** monthly / yearly: day of the month
|
|
28
|
+
/** monthly / yearly: day of the month. 1..31, or negative to count from the
|
|
29
|
+
* end (-1 = the last day, -2 = the second to last). */
|
|
24
30
|
day?: number;
|
|
31
|
+
/** monthly / yearly: the ordinal week the `weekdays` land on within the month -
|
|
32
|
+
* 1..4 (first..fourth) or -1 (last). E.g. `{ weekdays:[2], weekOfMonth:-1 }`
|
|
33
|
+
* = the last Tuesday. Takes precedence over `day` when both are set. */
|
|
34
|
+
weekOfMonth?: number;
|
|
25
35
|
/** yearly: the month (0 = January .. 11 = December). */
|
|
26
36
|
month?: number;
|
|
27
37
|
/** First occurrence + the phase for `interval`. */
|
|
28
38
|
from?: DateLike;
|
|
29
39
|
/** Last day the pattern applies (inclusive). */
|
|
30
40
|
until?: DateLike | null;
|
|
41
|
+
/** End after this many occurrences (counted from `from`). Requires `from`. */
|
|
42
|
+
count?: number;
|
|
31
43
|
};
|
|
32
44
|
/** Whether `date` matches any of the given recurrence rule(s). */
|
|
33
45
|
export declare function matchesRecurrence(date: Date, rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined): boolean;
|
|
34
46
|
/** Every matching date within [start, end] (inclusive), in order - for building
|
|
35
47
|
* event lists / agendas. Bounded to avoid runaway loops. */
|
|
36
48
|
export declare function expandRecurrence(rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined, start: Date, end: Date): Date[];
|
|
49
|
+
/** Optional English strings a caller can override for i18n. */
|
|
50
|
+
export type RecurrenceLabels = {
|
|
51
|
+
weekdays?: ReadonlyArray<string>;
|
|
52
|
+
months?: ReadonlyArray<string>;
|
|
53
|
+
ordinals?: Record<number, string>;
|
|
54
|
+
never?: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* A short, human-readable summary of a recurrence rule (or list) - e.g.
|
|
58
|
+
* "Weekly on weekdays", "Every 2 weeks on Fri, 8 times", "Monthly on the last
|
|
59
|
+
* Friday", "Yearly on the fourth Thursday of Nov". Returns the `never` label
|
|
60
|
+
* (default "") for an empty / falsy rule, so it drops cleanly into a table cell.
|
|
61
|
+
*/
|
|
62
|
+
export declare function describeRecurrence(rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined, labels?: RecurrenceLabels): string;
|
package/dist/recurrence.js
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* recurrence - a small, pure repeat-pattern engine for SvCalendar (and anywhere
|
|
3
3
|
* you need "does this date match a recurring rule"). Supports daily / weekly /
|
|
4
|
-
* monthly / yearly frequencies with an interval, weekday lists, a day-of-month
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* monthly / yearly frequencies with an interval, weekday lists, a day-of-month
|
|
5
|
+
* (incl. negative = counted from the end), a positional weekday-of-month
|
|
6
|
+
* (`weekOfMonth`, e.g. the 2nd Tuesday / last Friday), an anchor (`from`) and two
|
|
7
|
+
* end conditions - an inclusive `until` date or a `count` of occurrences.
|
|
8
|
+
* Framework-free + pure so it is unit-tested directly - no RRULE dependency.
|
|
7
9
|
*
|
|
8
10
|
* ```ts
|
|
9
11
|
* const standup = { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } // every weekday
|
|
10
|
-
* const payday = { freq: 'monthly', day: 1 }
|
|
12
|
+
* const payday = { freq: 'monthly', day: -1 } // the last day
|
|
11
13
|
* const sprint = { freq: 'weekly', weekdays: [1], interval: 2, from: '2026-01-05' } // every other Monday
|
|
12
|
-
*
|
|
14
|
+
* const board = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 } // 1st Tuesday
|
|
15
|
+
* const review = { freq: 'weekly', weekdays: [5], count: 8 } // 8 Fridays then stop
|
|
16
|
+
* matchesRecurrence(someDate, [standup, payday, sprint, board, review])
|
|
13
17
|
* ```
|
|
14
18
|
*/
|
|
15
19
|
import { startOfDay, startOfWeek, toDate } from './datetime/date-core';
|
|
16
20
|
const MS_DAY = 86400000;
|
|
17
21
|
/** Whole-day index, rounded so DST transitions don't shift it. */
|
|
18
22
|
const dayIndex = (d) => Math.round(startOfDay(d).getTime() / MS_DAY);
|
|
19
|
-
|
|
23
|
+
/** Days in the month that `d` falls in. */
|
|
24
|
+
const daysInMonth = (d) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
|
25
|
+
/** Whether `d` is the `nth` occurrence of its own weekday within its month -
|
|
26
|
+
* nth 1..4 (first..fourth) or -1 (last). */
|
|
27
|
+
function isNthWeekdayOfMonth(d, nth) {
|
|
28
|
+
if (nth === -1)
|
|
29
|
+
return d.getDate() + 7 > daysInMonth(d); // no same weekday later this month
|
|
30
|
+
return Math.floor((d.getDate() - 1) / 7) + 1 === nth;
|
|
31
|
+
}
|
|
32
|
+
function matchesRule(date, rule) {
|
|
33
|
+
// Fold a `count` end-condition into an equivalent `until` first (pure), so the
|
|
34
|
+
// rest of the matcher stays a stateless per-date test.
|
|
35
|
+
const r = rule.count && rule.count >= 1 && rule.from != null ? resolveCount(rule) : rule;
|
|
20
36
|
const d = startOfDay(date);
|
|
21
37
|
const fromD = r.from != null ? startOfDay(toDate(r.from) ?? d) : null;
|
|
22
38
|
const untilD = r.until != null ? startOfDay(toDate(r.until) ?? d) : null;
|
|
@@ -45,26 +61,63 @@ function matchesRule(date, r) {
|
|
|
45
61
|
return true;
|
|
46
62
|
}
|
|
47
63
|
case 'monthly': {
|
|
48
|
-
|
|
49
|
-
if (dom != null && d.getDate() !== dom)
|
|
64
|
+
if (!matchesMonthDay(d, r))
|
|
50
65
|
return false;
|
|
51
66
|
if (!fromD)
|
|
52
|
-
return
|
|
67
|
+
return true;
|
|
53
68
|
const months = (d.getFullYear() - fromD.getFullYear()) * 12 + (d.getMonth() - fromD.getMonth());
|
|
54
69
|
return months % interval === 0;
|
|
55
70
|
}
|
|
56
71
|
case 'yearly': {
|
|
57
72
|
if (r.month != null && d.getMonth() !== r.month)
|
|
58
73
|
return false;
|
|
59
|
-
|
|
60
|
-
if (dom != null && d.getDate() !== dom)
|
|
74
|
+
if (!matchesMonthDay(d, r))
|
|
61
75
|
return false;
|
|
62
76
|
if (!fromD)
|
|
63
|
-
return r.month != null ||
|
|
77
|
+
return r.month != null || r.day != null || r.weekOfMonth != null;
|
|
64
78
|
return (d.getFullYear() - fromD.getFullYear()) % interval === 0;
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
81
|
}
|
|
82
|
+
// The day-within-the-month test shared by monthly + yearly: a positional
|
|
83
|
+
// weekday (`weekOfMonth` + `weekdays`), else a day-of-month (`day`, negative =
|
|
84
|
+
// from the end), else - for a `from`-anchored rule - the anchor's day-of-month.
|
|
85
|
+
function matchesMonthDay(d, r) {
|
|
86
|
+
if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
|
|
87
|
+
return r.weekdays.includes(d.getDay()) && isNthWeekdayOfMonth(d, r.weekOfMonth);
|
|
88
|
+
}
|
|
89
|
+
const fromDom = r.from != null ? (toDate(r.from) ?? d).getDate() : null;
|
|
90
|
+
const dom = r.day ?? fromDom;
|
|
91
|
+
if (dom == null)
|
|
92
|
+
return false;
|
|
93
|
+
const target = dom < 0 ? daysInMonth(d) + dom + 1 : dom;
|
|
94
|
+
return d.getDate() === target;
|
|
95
|
+
}
|
|
96
|
+
/** Fold a `count` rule into an equivalent `until`-bounded rule by walking
|
|
97
|
+
* forward from `from` to the Nth occurrence. Pure; count is then stripped. */
|
|
98
|
+
function resolveCount(rule) {
|
|
99
|
+
const from = toDate(rule.from);
|
|
100
|
+
if (!from)
|
|
101
|
+
return { ...rule, count: undefined };
|
|
102
|
+
const base = { ...rule, count: undefined };
|
|
103
|
+
let cur = startOfDay(from);
|
|
104
|
+
let n = 0;
|
|
105
|
+
let lastMatch = cur;
|
|
106
|
+
let guard = 0;
|
|
107
|
+
while (guard++ < 4000 * 12) {
|
|
108
|
+
if (matchesRule(cur, base)) {
|
|
109
|
+
n++;
|
|
110
|
+
lastMatch = cur;
|
|
111
|
+
if (n >= rule.count)
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
|
|
115
|
+
}
|
|
116
|
+
const untilFromCount = lastMatch;
|
|
117
|
+
const existingUntil = rule.until != null ? startOfDay(toDate(rule.until) ?? untilFromCount) : null;
|
|
118
|
+
const until = existingUntil && existingUntil < untilFromCount ? existingUntil : untilFromCount;
|
|
119
|
+
return { ...base, until };
|
|
120
|
+
}
|
|
68
121
|
/** Whether `date` matches any of the given recurrence rule(s). */
|
|
69
122
|
export function matchesRecurrence(date, rules) {
|
|
70
123
|
if (!rules)
|
|
@@ -77,14 +130,99 @@ export function matchesRecurrence(date, rules) {
|
|
|
77
130
|
export function expandRecurrence(rules, start, end) {
|
|
78
131
|
if (!rules)
|
|
79
132
|
return [];
|
|
133
|
+
// Pre-resolve any `count` end-condition into a plain `until` once (rather than
|
|
134
|
+
// re-walking to the Nth occurrence for every day in the window).
|
|
135
|
+
const list = (Array.isArray(rules) ? rules : [rules]).map((r) => r.count && r.count >= 1 && r.from != null ? resolveCount(r) : r);
|
|
80
136
|
const out = [];
|
|
81
137
|
const last = startOfDay(end);
|
|
82
138
|
let cur = startOfDay(start);
|
|
83
139
|
let guard = 0;
|
|
84
140
|
while (cur <= last && guard++ < 4000) {
|
|
85
|
-
if (matchesRecurrence(cur,
|
|
141
|
+
if (matchesRecurrence(cur, list))
|
|
86
142
|
out.push(cur);
|
|
87
143
|
cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
|
|
88
144
|
}
|
|
89
145
|
return out;
|
|
90
146
|
}
|
|
147
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
148
|
+
const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
149
|
+
const ORD = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', [-1]: 'last' };
|
|
150
|
+
// Where in the month a monthly / yearly rule lands, as a phrase.
|
|
151
|
+
function whereInMonth(r, wd, ord) {
|
|
152
|
+
if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
|
|
153
|
+
return `the ${ord[r.weekOfMonth] ?? r.weekOfMonth} ${wd[r.weekdays[0]]}`;
|
|
154
|
+
}
|
|
155
|
+
if (r.day === -1)
|
|
156
|
+
return 'the last day';
|
|
157
|
+
if (r.day != null && r.day < 0)
|
|
158
|
+
return `the ${-r.day} day(s) from the end`;
|
|
159
|
+
if (r.day != null)
|
|
160
|
+
return `day ${r.day}`;
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
function describeRule(r, L) {
|
|
164
|
+
const n = Math.max(1, Math.floor(r.interval ?? 1));
|
|
165
|
+
let s = '';
|
|
166
|
+
switch (r.freq) {
|
|
167
|
+
case 'daily':
|
|
168
|
+
s = n === 1 ? 'Daily' : `Every ${n} days`;
|
|
169
|
+
break;
|
|
170
|
+
case 'weekly': {
|
|
171
|
+
s = n === 1 ? 'Weekly' : `Every ${n} weeks`;
|
|
172
|
+
const wds = r.weekdays && r.weekdays.length ? [...r.weekdays].sort((a, b) => a - b) : [];
|
|
173
|
+
if (wds.length) {
|
|
174
|
+
const isWeekdays = wds.length === 5 && [1, 2, 3, 4, 5].every((d) => wds.includes(d));
|
|
175
|
+
const isWeekend = wds.length === 2 && wds.includes(0) && wds.includes(6);
|
|
176
|
+
s += isWeekdays ? ' on weekdays' : isWeekend ? ' on weekends' : ` on ${wds.map((d) => L.weekdays[d]).join(', ')}`;
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'monthly': {
|
|
181
|
+
s = n === 1 ? 'Monthly' : `Every ${n} months`;
|
|
182
|
+
const where = whereInMonth(r, L.weekdays, L.ordinals);
|
|
183
|
+
if (where)
|
|
184
|
+
s += ` on ${where}`;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'yearly': {
|
|
188
|
+
s = n === 1 ? 'Yearly' : `Every ${n} years`;
|
|
189
|
+
const where = whereInMonth(r, L.weekdays, L.ordinals);
|
|
190
|
+
if (r.month != null && where)
|
|
191
|
+
s += ` on ${where} of ${L.months[r.month]}`;
|
|
192
|
+
else if (r.month != null)
|
|
193
|
+
s += ` in ${L.months[r.month]}`;
|
|
194
|
+
else if (where)
|
|
195
|
+
s += ` on ${where}`;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// End condition.
|
|
200
|
+
if (r.count && r.count >= 1)
|
|
201
|
+
s += `, ${r.count} time${r.count === 1 ? '' : 's'}`;
|
|
202
|
+
else if (r.until) {
|
|
203
|
+
const u = toDate(r.until);
|
|
204
|
+
if (u)
|
|
205
|
+
s += ` until ${L.months[u.getMonth()]} ${u.getDate()}, ${u.getFullYear()}`;
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A short, human-readable summary of a recurrence rule (or list) - e.g.
|
|
211
|
+
* "Weekly on weekdays", "Every 2 weeks on Fri, 8 times", "Monthly on the last
|
|
212
|
+
* Friday", "Yearly on the fourth Thursday of Nov". Returns the `never` label
|
|
213
|
+
* (default "") for an empty / falsy rule, so it drops cleanly into a table cell.
|
|
214
|
+
*/
|
|
215
|
+
export function describeRecurrence(rules, labels) {
|
|
216
|
+
const L = {
|
|
217
|
+
weekdays: labels?.weekdays ?? WD,
|
|
218
|
+
months: labels?.months ?? MO,
|
|
219
|
+
ordinals: labels?.ordinals ?? ORD,
|
|
220
|
+
never: labels?.never ?? '',
|
|
221
|
+
};
|
|
222
|
+
if (!rules)
|
|
223
|
+
return L.never;
|
|
224
|
+
const list = (Array.isArray(rules) ? rules : [rules]).filter((r) => r && r.freq);
|
|
225
|
+
if (!list.length)
|
|
226
|
+
return L.never;
|
|
227
|
+
return list.map((r) => describeRule(r, L)).join('; ');
|
|
228
|
+
}
|
package/dist/row-drag.d.ts
CHANGED
|
@@ -14,6 +14,14 @@ export declare function createRowDrag<TFeatures, TData>(ctx: any): {
|
|
|
14
14
|
onRowsContainerDrop: (e: DragEvent) => void;
|
|
15
15
|
onRowDragEnd: () => void;
|
|
16
16
|
rowDragGridId: number;
|
|
17
|
+
/**
|
|
18
|
+
* Clear any managed drag this grid currently owns. Call on unmount so an
|
|
19
|
+
* interrupted drag (route change / `{#if}` toggle mid-drag) doesn't leave a
|
|
20
|
+
* stale module-level `bus` that fires `removeFromSource` against a destroyed
|
|
21
|
+
* grid's data on the next grid instance (#68). Guarded by `gridId` so it
|
|
22
|
+
* never disturbs a concurrent drag owned by another grid.
|
|
23
|
+
*/
|
|
24
|
+
destroyRowDrag(): void;
|
|
17
25
|
};
|
|
18
26
|
/** Options for the {@link rowDropZone} action. */
|
|
19
27
|
export type RowDropZoneOptions<TData = unknown> = {
|
package/dist/row-drag.js
CHANGED
|
@@ -162,6 +162,17 @@ export function createRowDrag(ctx) {
|
|
|
162
162
|
onRowsContainerDrop,
|
|
163
163
|
onRowDragEnd,
|
|
164
164
|
rowDragGridId: gridId,
|
|
165
|
+
/**
|
|
166
|
+
* Clear any managed drag this grid currently owns. Call on unmount so an
|
|
167
|
+
* interrupted drag (route change / `{#if}` toggle mid-drag) doesn't leave a
|
|
168
|
+
* stale module-level `bus` that fires `removeFromSource` against a destroyed
|
|
169
|
+
* grid's data on the next grid instance (#68). Guarded by `gridId` so it
|
|
170
|
+
* never disturbs a concurrent drag owned by another grid.
|
|
171
|
+
*/
|
|
172
|
+
destroyRowDrag() {
|
|
173
|
+
if (bus?.gridId === gridId)
|
|
174
|
+
bus = null;
|
|
175
|
+
},
|
|
165
176
|
};
|
|
166
177
|
}
|
|
167
178
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-ical - pure, dependency-free iCalendar (RFC 5545) import / export for
|
|
3
|
+
* the scheduler. Works on a small {@link ICalEvent} shape so it stays data-model
|
|
4
|
+
* agnostic: map your rows to/from it. Handles timed + all-day events, RRULE
|
|
5
|
+
* round-trip (via the shared {@link RecurrenceRule}), STATUS, DESCRIPTION and
|
|
6
|
+
* LOCATION. No `Date.now()` in the core - `toICS` accepts a stamp.
|
|
7
|
+
*/
|
|
8
|
+
import type { RecurrenceRule } from './recurrence';
|
|
9
|
+
export type ICalEvent = {
|
|
10
|
+
uid?: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
location?: string;
|
|
14
|
+
start: Date;
|
|
15
|
+
end: Date;
|
|
16
|
+
allDay?: boolean;
|
|
17
|
+
status?: 'confirmed' | 'tentative' | 'cancelled';
|
|
18
|
+
rrule?: RecurrenceRule | null;
|
|
19
|
+
};
|
|
20
|
+
/** A {@link RecurrenceRule} -> an iCal `RRULE` value (without the `RRULE:` name). */
|
|
21
|
+
export declare function ruleToRRule(rule: RecurrenceRule): string;
|
|
22
|
+
/** An iCal `RRULE` value -> a {@link RecurrenceRule}. */
|
|
23
|
+
export declare function rruleToRule(value: string): RecurrenceRule | null;
|
|
24
|
+
/** Serialize events to an iCalendar string. `stamp` sets DTSTAMP (default epoch). */
|
|
25
|
+
export declare function toICS(events: ReadonlyArray<ICalEvent>, opts?: {
|
|
26
|
+
prodId?: string;
|
|
27
|
+
stamp?: Date;
|
|
28
|
+
}): string;
|
|
29
|
+
/** Parse an iCalendar string into events (unfolds lines, reads VEVENT blocks). */
|
|
30
|
+
export declare function fromICS(text: string): ICalEvent[];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const WD = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
|
2
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
3
|
+
const utcStamp = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
|
4
|
+
const dateOnly = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
|
|
5
|
+
const escText = (s) => s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
|
6
|
+
const unescText = (s) => s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
|
|
7
|
+
/** Fold a content line at 74 octets (RFC 5545 line folding). */
|
|
8
|
+
function fold(line) {
|
|
9
|
+
if (line.length <= 74)
|
|
10
|
+
return line;
|
|
11
|
+
let out = line.slice(0, 74);
|
|
12
|
+
let rest = line.slice(74);
|
|
13
|
+
while (rest.length > 73) {
|
|
14
|
+
out += '\r\n ' + rest.slice(0, 73);
|
|
15
|
+
rest = rest.slice(73);
|
|
16
|
+
}
|
|
17
|
+
return out + '\r\n ' + rest;
|
|
18
|
+
}
|
|
19
|
+
// --- RRULE serialize / parse ------------------------------------------------
|
|
20
|
+
/** A {@link RecurrenceRule} -> an iCal `RRULE` value (without the `RRULE:` name). */
|
|
21
|
+
export function ruleToRRule(rule) {
|
|
22
|
+
const parts = [`FREQ=${rule.freq.toUpperCase()}`];
|
|
23
|
+
if (rule.interval && rule.interval > 1)
|
|
24
|
+
parts.push(`INTERVAL=${rule.interval}`);
|
|
25
|
+
if (rule.weekdays?.length) {
|
|
26
|
+
if (rule.weekOfMonth != null && rule.weekdays.length === 1) {
|
|
27
|
+
parts.push(`BYDAY=${rule.weekOfMonth}${WD[rule.weekdays[0]]}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
parts.push(`BYDAY=${rule.weekdays.map((w) => WD[w]).join(',')}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (rule.day != null && rule.weekOfMonth == null)
|
|
34
|
+
parts.push(`BYMONTHDAY=${rule.day}`);
|
|
35
|
+
if (rule.month != null)
|
|
36
|
+
parts.push(`BYMONTH=${rule.month + 1}`);
|
|
37
|
+
if (rule.count != null)
|
|
38
|
+
parts.push(`COUNT=${rule.count}`);
|
|
39
|
+
if (rule.until != null)
|
|
40
|
+
parts.push(`UNTIL=${utcStamp(new Date(rule.until))}`);
|
|
41
|
+
return parts.join(';');
|
|
42
|
+
}
|
|
43
|
+
/** An iCal `RRULE` value -> a {@link RecurrenceRule}. */
|
|
44
|
+
export function rruleToRule(value) {
|
|
45
|
+
const map = {};
|
|
46
|
+
for (const kv of value.split(';')) {
|
|
47
|
+
const [k, v] = kv.split('=');
|
|
48
|
+
if (k && v)
|
|
49
|
+
map[k.toUpperCase()] = v;
|
|
50
|
+
}
|
|
51
|
+
const freq = (map.FREQ ?? '').toLowerCase();
|
|
52
|
+
if (!['daily', 'weekly', 'monthly', 'yearly'].includes(freq))
|
|
53
|
+
return null;
|
|
54
|
+
const rule = { freq: freq };
|
|
55
|
+
if (map.INTERVAL)
|
|
56
|
+
rule.interval = Number(map.INTERVAL);
|
|
57
|
+
if (map.BYDAY) {
|
|
58
|
+
const parts = map.BYDAY.split(',');
|
|
59
|
+
const m = parts[0].match(/^(-?\d+)?([A-Z]{2})$/);
|
|
60
|
+
if (parts.length === 1 && m && m[1]) {
|
|
61
|
+
rule.weekOfMonth = Number(m[1]);
|
|
62
|
+
rule.weekdays = [WD.indexOf(m[2])];
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
rule.weekdays = parts.map((p) => WD.indexOf(p.replace(/^-?\d+/, ''))).filter((n) => n >= 0);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (map.BYMONTHDAY)
|
|
69
|
+
rule.day = Number(map.BYMONTHDAY);
|
|
70
|
+
if (map.BYMONTH)
|
|
71
|
+
rule.month = Number(map.BYMONTH) - 1;
|
|
72
|
+
if (map.COUNT)
|
|
73
|
+
rule.count = Number(map.COUNT);
|
|
74
|
+
if (map.UNTIL)
|
|
75
|
+
rule.until = parseICalDate(map.UNTIL).date.toISOString();
|
|
76
|
+
return rule;
|
|
77
|
+
}
|
|
78
|
+
// --- export -----------------------------------------------------------------
|
|
79
|
+
/** Serialize events to an iCalendar string. `stamp` sets DTSTAMP (default epoch). */
|
|
80
|
+
export function toICS(events, opts = {}) {
|
|
81
|
+
const stamp = utcStamp(opts.stamp ?? new Date(0));
|
|
82
|
+
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${opts.prodId ?? '-//SvGrid//Scheduler//EN'}`];
|
|
83
|
+
events.forEach((e, i) => {
|
|
84
|
+
lines.push('BEGIN:VEVENT');
|
|
85
|
+
lines.push(`UID:${e.uid ?? `svgrid-${i}-${e.start.getTime()}`}`);
|
|
86
|
+
lines.push(`DTSTAMP:${stamp}`);
|
|
87
|
+
if (e.allDay) {
|
|
88
|
+
lines.push(`DTSTART;VALUE=DATE:${dateOnly(e.start)}`);
|
|
89
|
+
lines.push(`DTEND;VALUE=DATE:${dateOnly(e.end)}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
lines.push(`DTSTART:${utcStamp(e.start)}`);
|
|
93
|
+
lines.push(`DTEND:${utcStamp(e.end)}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push(`SUMMARY:${escText(e.title)}`);
|
|
96
|
+
if (e.description)
|
|
97
|
+
lines.push(`DESCRIPTION:${escText(e.description)}`);
|
|
98
|
+
if (e.location)
|
|
99
|
+
lines.push(`LOCATION:${escText(e.location)}`);
|
|
100
|
+
if (e.status)
|
|
101
|
+
lines.push(`STATUS:${e.status.toUpperCase()}`);
|
|
102
|
+
if (e.rrule)
|
|
103
|
+
lines.push(`RRULE:${ruleToRRule(e.rrule)}`);
|
|
104
|
+
lines.push('END:VEVENT');
|
|
105
|
+
});
|
|
106
|
+
lines.push('END:VCALENDAR');
|
|
107
|
+
return lines.map(fold).join('\r\n');
|
|
108
|
+
}
|
|
109
|
+
// --- import -----------------------------------------------------------------
|
|
110
|
+
function parseICalDate(value) {
|
|
111
|
+
// 20260706 (date) | 20260706T093000Z (utc) | 20260706T093000 (local/floating)
|
|
112
|
+
const m = value.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/);
|
|
113
|
+
if (!m)
|
|
114
|
+
return { date: new Date(NaN), allDay: false };
|
|
115
|
+
const [, y, mo, d, hh, mm, ss, z] = m;
|
|
116
|
+
if (hh == null)
|
|
117
|
+
return { date: new Date(Number(y), Number(mo) - 1, Number(d)), allDay: true };
|
|
118
|
+
if (z)
|
|
119
|
+
return { date: new Date(Date.UTC(+y, +mo - 1, +d, +hh, +mm, +ss)), allDay: false };
|
|
120
|
+
return { date: new Date(+y, +mo - 1, +d, +hh, +mm, +ss), allDay: false };
|
|
121
|
+
}
|
|
122
|
+
/** Parse an iCalendar string into events (unfolds lines, reads VEVENT blocks). */
|
|
123
|
+
export function fromICS(text) {
|
|
124
|
+
// Unfold: a line starting with space/tab continues the previous one.
|
|
125
|
+
const raw = text.replace(/\r\n/g, '\n').split('\n');
|
|
126
|
+
const unfolded = [];
|
|
127
|
+
for (const line of raw) {
|
|
128
|
+
if ((line.startsWith(' ') || line.startsWith('\t')) && unfolded.length) {
|
|
129
|
+
unfolded[unfolded.length - 1] += line.slice(1);
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
unfolded.push(line);
|
|
133
|
+
}
|
|
134
|
+
const out = [];
|
|
135
|
+
let cur = {};
|
|
136
|
+
let inEvent = false;
|
|
137
|
+
for (const line of unfolded) {
|
|
138
|
+
if (line === 'BEGIN:VEVENT') {
|
|
139
|
+
inEvent = true;
|
|
140
|
+
cur = {};
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (line === 'END:VEVENT') {
|
|
144
|
+
if (cur.title && cur.start && cur.end)
|
|
145
|
+
out.push({ title: cur.title, start: cur.start, end: cur.end, allDay: cur.allDay, uid: cur.uid, description: cur.description, location: cur.location, status: cur.status, rrule: cur.rrule ?? null });
|
|
146
|
+
inEvent = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!inEvent)
|
|
150
|
+
continue;
|
|
151
|
+
const ci = line.indexOf(':');
|
|
152
|
+
if (ci < 0)
|
|
153
|
+
continue;
|
|
154
|
+
const nameAndParams = line.slice(0, ci);
|
|
155
|
+
const value = line.slice(ci + 1);
|
|
156
|
+
const name = nameAndParams.split(';')[0].toUpperCase();
|
|
157
|
+
if (name === 'UID')
|
|
158
|
+
cur.uid = value;
|
|
159
|
+
else if (name === 'SUMMARY')
|
|
160
|
+
cur.title = unescText(value);
|
|
161
|
+
else if (name === 'DESCRIPTION')
|
|
162
|
+
cur.description = unescText(value);
|
|
163
|
+
else if (name === 'LOCATION')
|
|
164
|
+
cur.location = unescText(value);
|
|
165
|
+
else if (name === 'STATUS')
|
|
166
|
+
cur.status = value.toLowerCase();
|
|
167
|
+
else if (name === 'DTSTART') {
|
|
168
|
+
const { date, allDay } = parseICalDate(value);
|
|
169
|
+
cur.start = date;
|
|
170
|
+
cur.allDay = allDay;
|
|
171
|
+
}
|
|
172
|
+
else if (name === 'DTEND') {
|
|
173
|
+
cur.end = parseICalDate(value).date;
|
|
174
|
+
}
|
|
175
|
+
else if (name === 'RRULE') {
|
|
176
|
+
cur.rrule = rruleToRule(value) ?? undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|