Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a741d6802 |
@@ -0,0 +1,39 @@
|
|||||||
|
name: Verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- name: Select declared npm version
|
||||||
|
run: npm install --global npm@11.17.0
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Audit runtime dependencies
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
- name: Check, test, and build
|
||||||
|
run: npm run check
|
||||||
|
- name: Install browser engines
|
||||||
|
run: npx playwright install --with-deps chromium firefox webkit
|
||||||
|
- name: Browser tests
|
||||||
|
run: npm run test:browser
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-09-02
|
||||||
|
|
||||||
|
- Added moved, cancelled, duration-changing and `RANGE=THISANDFUTURE`
|
||||||
|
recurrence overrides.
|
||||||
|
- Added VTODO, VJOURNAL, VFREEBUSY and VALARM inspection, agenda/month views,
|
||||||
|
bounded event/task authoring and free-slot planning.
|
||||||
|
- Added lossless jCal and focused JSCalendar Event/Task interchange with
|
||||||
|
explicit loss evidence.
|
||||||
|
|
||||||
## 0.1.0 - 2026-09-01
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Calendar Tools workbench.
|
- Added the initial local-first Calendar Tools workbench.
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ Calendar Tools is a standalone local-first application in the [add·ideas Toolbo
|
|||||||
## Version 0.1 scope
|
## Version 0.1 scope
|
||||||
|
|
||||||
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions
|
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions
|
||||||
- Bounded RRULE/RDATE/EXDATE occurrence preview and overlap/duplicate diagnostics
|
- VTODO tasks, VJOURNAL entries, VFREEBUSY intervals, and nested VALARM
|
||||||
|
inventory with focused diagnostics
|
||||||
|
- Bounded RRULE/RDATE/EXDATE occurrence preview, including moved or cancelled
|
||||||
|
RECURRENCE-ID instances, changed durations, and RANGE=THISANDFUTURE, plus
|
||||||
|
overlap/duplicate diagnostics
|
||||||
- Merge and deduplication by UID plus RECURRENCE-ID, preferring SEQUENCE and then DTSTAMP
|
- Merge and deduplication by UID plus RECURRENCE-ID, preferring SEQUENCE and then DTSTAMP
|
||||||
- Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
|
- Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
|
||||||
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification
|
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification
|
||||||
|
- Agenda and month occurrence views, bounded event/task authoring, and a local
|
||||||
|
working-hours/free-slot planner over event and VFREEBUSY intervals
|
||||||
|
- Lossless iCalendar component-tree import/export through RFC 7265 jCal, plus a
|
||||||
|
focused RFC 8984 JSCalendar Event/Task adapter with explicit loss warnings
|
||||||
- Relocatable offline PWA, local-only processing, and deterministic release packaging
|
- Relocatable offline PWA, local-only processing, and deterministic release packaging
|
||||||
|
|
||||||
Input is limited to 4 MiB, 100,000 physical lines, 10,000 components, and 5,000 events. Recurrence is limited to 500 occurrences per series, a 3,650-day horizon, and 5,000 displayed occurrences. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
Input is limited to 4 MiB, 100,000 physical lines, 10,000 components, and 5,000 events. Recurrence is limited to 500 occurrences per series, a 3,650-day horizon, and 5,000 displayed occurrences. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||||
@@ -27,7 +35,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
`npm run release:artifact` creates a deterministic `release/calendar-tools-0.1.0.zip` and checksum sidecar.
|
`npm run release:artifact` creates a deterministic `release/calendar-tools-0.2.0.zip` and checksum sidecar.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# Corresponding source
|
||||||
|
|
||||||
The corresponding source for Calendar Tools 0.1.0 is available at:
|
The corresponding source for Calendar Tools 0.2.0 is available at:
|
||||||
|
|
||||||
https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.1.0
|
https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.2.0
|
||||||
|
|
||||||
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
Calendar Tools 0.1.0 directly depends on these runtime packages:
|
Calendar Tools 0.2.0 directly depends on these runtime packages:
|
||||||
|
|
||||||
| Package | Pinned version | Declared licence |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||||
| `ical.js` | 2.2.1 | MPL-2.0 |
|
| `ical.js` | 2.2.1 | MPL-2.0 |
|
||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|||||||
Generated
+22
-23
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-tools",
|
"name": "calendar-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "calendar-tools",
|
"name": "calendar-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"ical.js": "2.2.1",
|
"ical.js": "2.2.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -43,24 +43,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"node": ">=20"
|
"license": "Apache-2.0"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"node": ">=22"
|
"license": "GPL-3.0-or-later"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -68,17 +68,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"node_modules/@add-ideas/toolbox-testkit": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-tools",
|
"name": "calendar-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"ical.js": "2.2.1",
|
"ical.js": "2.2.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
{
|
||||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
name: "chromium",
|
||||||
|
testIgnore: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
testIgnore: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
testIgnore: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mobile-chromium",
|
||||||
|
testMatch: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Pixel 5"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-09-02
|
||||||
|
|
||||||
|
- Added moved, cancelled, duration-changing and `RANGE=THISANDFUTURE`
|
||||||
|
recurrence overrides.
|
||||||
|
- Added VTODO, VJOURNAL, VFREEBUSY and VALARM inspection, agenda/month views,
|
||||||
|
bounded event/task authoring and free-slot planning.
|
||||||
|
- Added lossless jCal and focused JSCalendar Event/Task interchange with
|
||||||
|
explicit loss evidence.
|
||||||
|
|
||||||
## 0.1.0 - 2026-09-01
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Calendar Tools workbench.
|
- Added the initial local-first Calendar Tools workbench.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
|||||||
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-helpers@0.1.0
|
@add-ideas/toolbox-helpers@0.2.0
|
||||||
Declared licence: GPL-3.0-or-later
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
|||||||
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-shell-react@0.2.3
|
@add-ideas/toolbox-shell-react@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
|
|||||||
+10
-2
@@ -7,10 +7,18 @@ Calendar Tools is a standalone local-first application in the [add·ideas Toolbo
|
|||||||
## Version 0.1 scope
|
## Version 0.1 scope
|
||||||
|
|
||||||
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions
|
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions
|
||||||
- Bounded RRULE/RDATE/EXDATE occurrence preview and overlap/duplicate diagnostics
|
- VTODO tasks, VJOURNAL entries, VFREEBUSY intervals, and nested VALARM
|
||||||
|
inventory with focused diagnostics
|
||||||
|
- Bounded RRULE/RDATE/EXDATE occurrence preview, including moved or cancelled
|
||||||
|
RECURRENCE-ID instances, changed durations, and RANGE=THISANDFUTURE, plus
|
||||||
|
overlap/duplicate diagnostics
|
||||||
- Merge and deduplication by UID plus RECURRENCE-ID, preferring SEQUENCE and then DTSTAMP
|
- Merge and deduplication by UID plus RECURRENCE-ID, preferring SEQUENCE and then DTSTAMP
|
||||||
- Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
|
- Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
|
||||||
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification
|
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification
|
||||||
|
- Agenda and month occurrence views, bounded event/task authoring, and a local
|
||||||
|
working-hours/free-slot planner over event and VFREEBUSY intervals
|
||||||
|
- Lossless iCalendar component-tree import/export through RFC 7265 jCal, plus a
|
||||||
|
focused RFC 8984 JSCalendar Event/Task adapter with explicit loss warnings
|
||||||
- Relocatable offline PWA, local-only processing, and deterministic release packaging
|
- Relocatable offline PWA, local-only processing, and deterministic release packaging
|
||||||
|
|
||||||
Input is limited to 4 MiB, 100,000 physical lines, 10,000 components, and 5,000 events. Recurrence is limited to 500 occurrences per series, a 3,650-day horizon, and 5,000 displayed occurrences. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
Input is limited to 4 MiB, 100,000 physical lines, 10,000 components, and 5,000 events. Recurrence is limited to 500 occurrences per series, a 3,650-day horizon, and 5,000 displayed occurrences. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||||
@@ -27,7 +35,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
`npm run release:artifact` creates a deterministic `release/calendar-tools-0.1.0.zip` and checksum sidecar.
|
`npm run release:artifact` creates a deterministic `release/calendar-tools-0.2.0.zip` and checksum sidecar.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# Corresponding source
|
||||||
|
|
||||||
The corresponding source for Calendar Tools 0.1.0 is available at:
|
The corresponding source for Calendar Tools 0.2.0 is available at:
|
||||||
|
|
||||||
https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.1.0
|
https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.2.0
|
||||||
|
|
||||||
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
Calendar Tools 0.1.0 directly depends on these runtime packages:
|
Calendar Tools 0.2.0 directly depends on these runtime packages:
|
||||||
|
|
||||||
| Package | Pinned version | Declared licence |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||||
| `ical.js` | 2.2.1 | MPL-2.0 |
|
| `ical.js` | 2.2.1 | MPL-2.0 |
|
||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "calendar-tools-shell-";
|
const CACHE_PREFIX = "calendar-tools-shell-";
|
||||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.calendar-tools",
|
"id": "de.add-ideas.calendar-tools",
|
||||||
"name": "Calendar Tools",
|
"name": "Calendar Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -21,6 +21,17 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": false
|
"topLevelContext": false
|
||||||
},
|
},
|
||||||
|
"io": {
|
||||||
|
"accepts": [
|
||||||
|
{ "mediaType": "text/calendar", "extensions": [".ics", ".ifb"] },
|
||||||
|
{ "mediaType": "application/jscalendar+json", "extensions": [".json"] }
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
{ "mediaType": "text/calendar", "extensions": [".ics"] },
|
||||||
|
{ "mediaType": "application/jscalendar+json", "extensions": [".json"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": { "required": [], "optional": [] },
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import ICAL from "ical.js";
|
||||||
|
import type { CalendarDocument } from "./model";
|
||||||
|
|
||||||
|
const MAX_JSON_CHARS = 4 * 1024 * 1024;
|
||||||
|
const MAX_ITEMS = 5_000;
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
function object(value: unknown, label: string): JsonObject {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||||
|
throw new TypeError(`${label} must be an object.`);
|
||||||
|
return value as JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value.slice(0, 1_000_000) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarText(component: InstanceType<typeof ICAL.Component>): string {
|
||||||
|
let output = component
|
||||||
|
.toString()
|
||||||
|
.replaceAll("\r\n", "\n")
|
||||||
|
.replaceAll("\r", "\n")
|
||||||
|
.replaceAll("\n", "\r\n");
|
||||||
|
if (!output.endsWith("\r\n")) output += "\r\n";
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDuration(milliseconds: number): string {
|
||||||
|
const seconds = Math.max(0, Math.round(milliseconds / 1_000));
|
||||||
|
return `PT${seconds}S`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JSCalendarExport {
|
||||||
|
data: JsonObject;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportJCal(document: CalendarDocument): unknown[] {
|
||||||
|
return structuredClone(document.component.toJSON()) as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importJCal(input: unknown): string {
|
||||||
|
if (!Array.isArray(input) || input[0] !== "vcalendar")
|
||||||
|
throw new TypeError("jCal input must be a vcalendar component array.");
|
||||||
|
const serialized = JSON.stringify(input);
|
||||||
|
if (serialized.length > MAX_JSON_CHARS)
|
||||||
|
throw new RangeError("jCal input exceeds the 4 MiB JSON limit.");
|
||||||
|
const component = new ICAL.Component(input as unknown[]);
|
||||||
|
if (component.getAllSubcomponents().length > MAX_ITEMS)
|
||||||
|
throw new RangeError("jCal input exceeds the 5,000-item limit.");
|
||||||
|
return calendarText(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportJSCalendar(document: CalendarDocument): JSCalendarExport {
|
||||||
|
const entries: JsonObject = Object.create(null) as JsonObject;
|
||||||
|
for (const event of document.events.filter((item) => !item.recurrenceId)) {
|
||||||
|
entries[event.uid] = {
|
||||||
|
"@type": "Event",
|
||||||
|
uid: event.uid,
|
||||||
|
title: event.summary,
|
||||||
|
description: event.description,
|
||||||
|
start: event.startText,
|
||||||
|
timeZone: event.timezone === "floating" ? null : event.timezone,
|
||||||
|
duration:
|
||||||
|
event.startMs !== null && event.endMs !== null
|
||||||
|
? isoDuration(event.endMs - event.startMs)
|
||||||
|
: undefined,
|
||||||
|
status: event.status ? event.status.toLowerCase() : "confirmed",
|
||||||
|
locations: event.location
|
||||||
|
? { primary: { "@type": "Location", name: event.location } }
|
||||||
|
: undefined,
|
||||||
|
recurrenceRules: event.recurrenceRules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const task of document.tasks.filter((item) => !item.recurrenceId)) {
|
||||||
|
const key = entries[task.uid] ? `task-${task.uid}` : task.uid;
|
||||||
|
entries[key] = {
|
||||||
|
"@type": "Task",
|
||||||
|
uid: task.uid,
|
||||||
|
title: task.summary,
|
||||||
|
description: task.description,
|
||||||
|
start: task.startText || undefined,
|
||||||
|
due: task.dueText || undefined,
|
||||||
|
status: task.status ? task.status.toLowerCase() : "needs-action",
|
||||||
|
priority: task.priority || undefined,
|
||||||
|
progress: task.percentComplete / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (document.events.some((item) => item.recurrenceId))
|
||||||
|
warnings.push(
|
||||||
|
"RECURRENCE-ID exception components are not represented in this focused JSCalendar export.",
|
||||||
|
);
|
||||||
|
if (document.journals.length || document.freeBusy.length)
|
||||||
|
warnings.push(
|
||||||
|
"VJOURNAL and VFREEBUSY components remain available through jCal/ICS but are not mapped to JSCalendar Event/Task entries.",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
"@type": "Group",
|
||||||
|
uid: document.properties.name || "local-calendar",
|
||||||
|
name: document.properties.name || "Calendar export",
|
||||||
|
entries,
|
||||||
|
prodId: document.properties.prodid || undefined,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addText(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
name: string,
|
||||||
|
value: unknown,
|
||||||
|
) {
|
||||||
|
const content = text(value).trim();
|
||||||
|
if (content) component.addPropertyWithValue(name, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTime(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
name: string,
|
||||||
|
value: unknown,
|
||||||
|
timezone: string,
|
||||||
|
): InstanceType<typeof ICAL.Time> | undefined {
|
||||||
|
const source = text(value).trim();
|
||||||
|
if (!source) return undefined;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?Z?)?$/u.test(source))
|
||||||
|
throw new TypeError(`${name} must be an ISO local date or date-time.`);
|
||||||
|
const time = source.includes("T")
|
||||||
|
? ICAL.Time.fromDateTimeString(
|
||||||
|
source.replace(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})Z?$/u, "$1:00"),
|
||||||
|
)
|
||||||
|
: ICAL.Time.fromDateString(source);
|
||||||
|
const property = new ICAL.Property(name);
|
||||||
|
if (timezone && timezone !== "UTC" && !source.endsWith("Z"))
|
||||||
|
property.setParameter("tzid", timezone);
|
||||||
|
property.setValue(time);
|
||||||
|
component.addProperty(property);
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(value: unknown): InstanceType<typeof ICAL.Duration> {
|
||||||
|
const source = text(value, "PT1H");
|
||||||
|
if (!/^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/u.test(source))
|
||||||
|
throw new TypeError("Event duration must be a positive ISO 8601 duration.");
|
||||||
|
const parsed = ICAL.Duration.fromString(source);
|
||||||
|
if (parsed.toSeconds() < 0)
|
||||||
|
throw new TypeError("Event duration cannot be negative.");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedStatus(type: "Event" | "Task", value: unknown): string {
|
||||||
|
const supplied = text(value).toUpperCase();
|
||||||
|
const allowed =
|
||||||
|
type === "Event"
|
||||||
|
? ["TENTATIVE", "CONFIRMED", "CANCELLED"]
|
||||||
|
: ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"];
|
||||||
|
return allowed.includes(supplied) ? supplied : allowed[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentFromJSCalendar(
|
||||||
|
source: JsonObject,
|
||||||
|
fallbackUid: string,
|
||||||
|
): InstanceType<typeof ICAL.Component> {
|
||||||
|
const type = source["@type"];
|
||||||
|
if (type !== "Event" && type !== "Task")
|
||||||
|
throw new TypeError("JSCalendar entries must be Event or Task objects.");
|
||||||
|
const component = new ICAL.Component(type === "Event" ? "vevent" : "vtodo");
|
||||||
|
const uid = text(source.uid, fallbackUid).trim() || fallbackUid;
|
||||||
|
component.addPropertyWithValue("uid", uid.slice(0, 1_024));
|
||||||
|
addText(component, "summary", source.title);
|
||||||
|
addText(component, "description", source.description);
|
||||||
|
component.addPropertyWithValue(
|
||||||
|
"status",
|
||||||
|
normalizedStatus(type, source.status),
|
||||||
|
);
|
||||||
|
const timezone = text(source.timeZone);
|
||||||
|
const start = addTime(component, "dtstart", source.start, timezone);
|
||||||
|
if (type === "Event") {
|
||||||
|
if (!start) throw new TypeError(`Event ${uid} has no start.`);
|
||||||
|
const end = start.clone();
|
||||||
|
end.addDuration(duration(source.duration));
|
||||||
|
const property = new ICAL.Property("dtend");
|
||||||
|
if (timezone && timezone !== "UTC" && !text(source.start).endsWith("Z"))
|
||||||
|
property.setParameter("tzid", timezone);
|
||||||
|
property.setValue(end);
|
||||||
|
component.addProperty(property);
|
||||||
|
const locations = source.locations;
|
||||||
|
if (
|
||||||
|
locations &&
|
||||||
|
typeof locations === "object" &&
|
||||||
|
!Array.isArray(locations)
|
||||||
|
) {
|
||||||
|
const first = Object.values(locations as JsonObject).find(
|
||||||
|
(item) => item && typeof item === "object" && !Array.isArray(item),
|
||||||
|
);
|
||||||
|
if (first) addText(component, "location", (first as JsonObject).name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addTime(component, "due", source.due, timezone);
|
||||||
|
const progress = Number(source.progress ?? 0);
|
||||||
|
if (Number.isFinite(progress))
|
||||||
|
component.addPropertyWithValue(
|
||||||
|
"percent-complete",
|
||||||
|
Math.max(0, Math.min(100, Math.round(progress * 100))),
|
||||||
|
);
|
||||||
|
const priority = Number(source.priority ?? 0);
|
||||||
|
if (Number.isInteger(priority) && priority >= 0 && priority <= 9)
|
||||||
|
component.addPropertyWithValue("priority", priority);
|
||||||
|
}
|
||||||
|
component.addPropertyWithValue(
|
||||||
|
"dtstamp",
|
||||||
|
ICAL.Time.fromJSDate(new Date(0), true),
|
||||||
|
);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importJSCalendar(input: unknown): string {
|
||||||
|
if (JSON.stringify(input).length > MAX_JSON_CHARS)
|
||||||
|
throw new RangeError("JSCalendar input exceeds the 4 MiB JSON limit.");
|
||||||
|
const root = object(input, "JSCalendar input");
|
||||||
|
const values: Array<[string, JsonObject]> = [];
|
||||||
|
if (root["@type"] === "Group") {
|
||||||
|
const entries = object(root.entries, "JSCalendar Group entries");
|
||||||
|
for (const [key, value] of Object.entries(entries))
|
||||||
|
values.push([key, object(value, `JSCalendar entry ${key}`)]);
|
||||||
|
} else values.push([text(root.uid, "item-1"), root]);
|
||||||
|
if (!values.length || values.length > MAX_ITEMS)
|
||||||
|
throw new RangeError("JSCalendar input must contain 1–5,000 entries.");
|
||||||
|
const calendar = new ICAL.Component("vcalendar");
|
||||||
|
calendar.addPropertyWithValue("version", "2.0");
|
||||||
|
calendar.addPropertyWithValue(
|
||||||
|
"prodid",
|
||||||
|
"-//add·ideas//Calendar Tools JSCalendar import//EN",
|
||||||
|
);
|
||||||
|
for (const [key, value] of values)
|
||||||
|
calendar.addSubcomponent(componentFromJSCalendar(value, key));
|
||||||
|
return calendarText(calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendJSCalendarItem(
|
||||||
|
document: CalendarDocument,
|
||||||
|
input: unknown,
|
||||||
|
): string {
|
||||||
|
const imported = new ICAL.Component(ICAL.parse(importJSCalendar(input)));
|
||||||
|
const additions = imported.getAllSubcomponents();
|
||||||
|
if (
|
||||||
|
document.component.getAllSubcomponents().length + additions.length >
|
||||||
|
MAX_ITEMS
|
||||||
|
)
|
||||||
|
throw new RangeError("Calendar would exceed the 5,000-item limit.");
|
||||||
|
const clone = new ICAL.Component(
|
||||||
|
structuredClone(document.component.toJSON()) as unknown[],
|
||||||
|
);
|
||||||
|
for (const item of additions)
|
||||||
|
clone.addSubcomponent(
|
||||||
|
new ICAL.Component(structuredClone(item.toJSON()) as unknown[]),
|
||||||
|
);
|
||||||
|
return calendarText(clone);
|
||||||
|
}
|
||||||
+393
-43
@@ -29,6 +29,66 @@ export interface CalendarEvent {
|
|||||||
component: InstanceType<typeof ICAL.Component>;
|
component: InstanceType<typeof ICAL.Component>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CalendarAlarm {
|
||||||
|
ownerType: "event" | "task";
|
||||||
|
ownerUid: string;
|
||||||
|
action: string;
|
||||||
|
trigger: string;
|
||||||
|
related: "START" | "END";
|
||||||
|
repeat: number;
|
||||||
|
duration: string;
|
||||||
|
description: string;
|
||||||
|
attendees: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarTask {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
recurrenceId: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
percentComplete: number;
|
||||||
|
startText: string;
|
||||||
|
dueText: string;
|
||||||
|
completedText: string;
|
||||||
|
recurring: boolean;
|
||||||
|
alarms: CalendarAlarm[];
|
||||||
|
sequence: number;
|
||||||
|
stamp: string;
|
||||||
|
component: InstanceType<typeof ICAL.Component>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarJournal {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
dateText: string;
|
||||||
|
component: InstanceType<typeof ICAL.Component>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeBusyInterval {
|
||||||
|
startText: string;
|
||||||
|
endText: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
type: string;
|
||||||
|
sourceUid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarFreeBusy {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
organizer: string;
|
||||||
|
startText: string;
|
||||||
|
endText: string;
|
||||||
|
intervals: FreeBusyInterval[];
|
||||||
|
component: InstanceType<typeof ICAL.Component>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimezoneRecord {
|
export interface TimezoneRecord {
|
||||||
tzid: string;
|
tzid: string;
|
||||||
observances: number;
|
observances: number;
|
||||||
@@ -40,6 +100,10 @@ export interface TimezoneRecord {
|
|||||||
export interface CalendarDocument {
|
export interface CalendarDocument {
|
||||||
component: InstanceType<typeof ICAL.Component>;
|
component: InstanceType<typeof ICAL.Component>;
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
|
tasks: CalendarTask[];
|
||||||
|
journals: CalendarJournal[];
|
||||||
|
freeBusy: CalendarFreeBusy[];
|
||||||
|
alarms: CalendarAlarm[];
|
||||||
timezones: TimezoneRecord[];
|
timezones: TimezoneRecord[];
|
||||||
diagnostics: CalendarDiagnostic[];
|
diagnostics: CalendarDiagnostic[];
|
||||||
properties: { prodid: string; version: string; method: string; name: string };
|
properties: { prodid: string; version: string; method: string; name: string };
|
||||||
@@ -67,6 +131,146 @@ const MAX_LINES = 100_000;
|
|||||||
const MAX_COMPONENTS = 10_000;
|
const MAX_COMPONENTS = 10_000;
|
||||||
const MAX_EVENTS = 5_000;
|
const MAX_EVENTS = 5_000;
|
||||||
|
|
||||||
|
function propertyTimeText(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
|
const value = component.getFirstPropertyValue(name);
|
||||||
|
return value instanceof ICAL.Time ? calendarTimeText(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function alarmsFromComponent(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
ownerType: CalendarAlarm["ownerType"],
|
||||||
|
ownerUid: string,
|
||||||
|
): CalendarAlarm[] {
|
||||||
|
return component.getAllSubcomponents("valarm").map((alarm) => {
|
||||||
|
const trigger = alarm.getFirstProperty("trigger");
|
||||||
|
const related = trigger?.getParameter("related");
|
||||||
|
return {
|
||||||
|
ownerType,
|
||||||
|
ownerUid,
|
||||||
|
action: textValue(alarm, "action") || "(missing)",
|
||||||
|
trigger: trigger?.getFirstValue()?.toString() ?? "(missing)",
|
||||||
|
related:
|
||||||
|
typeof related === "string" && related.toUpperCase() === "END"
|
||||||
|
? "END"
|
||||||
|
: "START",
|
||||||
|
repeat: Number(textValue(alarm, "repeat") || 0),
|
||||||
|
duration: textValue(alarm, "duration"),
|
||||||
|
description: textValue(alarm, "description"),
|
||||||
|
attendees: alarm.getAllProperties("attendee").length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskFromComponent(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
index: number,
|
||||||
|
diagnostics: CalendarDiagnostic[],
|
||||||
|
): CalendarTask {
|
||||||
|
const uid = textValue(component, "uid") || `missing-task-uid-${index + 1}`;
|
||||||
|
if (!textValue(component, "uid"))
|
||||||
|
diagnostics.push({
|
||||||
|
severity: "warning",
|
||||||
|
uid,
|
||||||
|
message: "VTODO has no UID; an inspection-only identifier was assigned.",
|
||||||
|
});
|
||||||
|
const priority = Number(textValue(component, "priority") || 0);
|
||||||
|
const percentComplete = Number(textValue(component, "percent-complete") || 0);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(percentComplete) ||
|
||||||
|
percentComplete < 0 ||
|
||||||
|
percentComplete > 100
|
||||||
|
)
|
||||||
|
diagnostics.push({
|
||||||
|
severity: "warning",
|
||||||
|
uid,
|
||||||
|
message: "VTODO PERCENT-COMPLETE must be an integer from 0 to 100.",
|
||||||
|
});
|
||||||
|
const alarms = alarmsFromComponent(component, "task", uid);
|
||||||
|
return {
|
||||||
|
id: `${uid}:${textValue(component, "recurrence-id") || "master"}:${index}`,
|
||||||
|
uid,
|
||||||
|
recurrenceId: textValue(component, "recurrence-id"),
|
||||||
|
summary: textValue(component, "summary") || "(untitled task)",
|
||||||
|
description: textValue(component, "description"),
|
||||||
|
status: textValue(component, "status"),
|
||||||
|
priority: Number.isFinite(priority) ? priority : 0,
|
||||||
|
percentComplete: Number.isFinite(percentComplete) ? percentComplete : 0,
|
||||||
|
startText: propertyTimeText(component, "dtstart"),
|
||||||
|
dueText: propertyTimeText(component, "due"),
|
||||||
|
completedText: propertyTimeText(component, "completed"),
|
||||||
|
recurring: component.hasProperty("rrule") || component.hasProperty("rdate"),
|
||||||
|
alarms,
|
||||||
|
sequence: Number(textValue(component, "sequence") || 0),
|
||||||
|
stamp: textValue(component, "dtstamp"),
|
||||||
|
component,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalFromComponent(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
index: number,
|
||||||
|
): CalendarJournal {
|
||||||
|
const uid = textValue(component, "uid") || `missing-journal-uid-${index + 1}`;
|
||||||
|
return {
|
||||||
|
id: `${uid}:${index}`,
|
||||||
|
uid,
|
||||||
|
summary: textValue(component, "summary") || "(untitled journal entry)",
|
||||||
|
description: textValue(component, "description"),
|
||||||
|
status: textValue(component, "status"),
|
||||||
|
dateText: propertyTimeText(component, "dtstart"),
|
||||||
|
component,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freeBusyFromComponent(
|
||||||
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
|
index: number,
|
||||||
|
diagnostics: CalendarDiagnostic[],
|
||||||
|
): CalendarFreeBusy {
|
||||||
|
const uid =
|
||||||
|
textValue(component, "uid") || `missing-freebusy-uid-${index + 1}`;
|
||||||
|
const intervals: FreeBusyInterval[] = [];
|
||||||
|
for (const property of component.getAllProperties("freebusy")) {
|
||||||
|
const parameter = property.getParameter("fbtype");
|
||||||
|
const type = typeof parameter === "string" ? parameter : "BUSY";
|
||||||
|
for (const value of property.getValues()) {
|
||||||
|
if (!(value instanceof ICAL.Period)) continue;
|
||||||
|
const start = value.start;
|
||||||
|
const end = value.getEnd();
|
||||||
|
const startMs = start.toJSDate().getTime();
|
||||||
|
const endMs = end.toJSDate().getTime();
|
||||||
|
if (!(endMs > startMs)) {
|
||||||
|
diagnostics.push({
|
||||||
|
severity: "warning",
|
||||||
|
uid,
|
||||||
|
message: "VFREEBUSY contains a non-positive FREEBUSY interval.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
intervals.push({
|
||||||
|
startText: calendarTimeText(start),
|
||||||
|
endText: calendarTimeText(end),
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
type: type.toUpperCase(),
|
||||||
|
sourceUid: uid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `${uid}:${index}`,
|
||||||
|
uid,
|
||||||
|
organizer: textValue(component, "organizer"),
|
||||||
|
startText: propertyTimeText(component, "dtstart"),
|
||||||
|
endText: propertyTimeText(component, "dtend"),
|
||||||
|
intervals,
|
||||||
|
component,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function textValue(
|
function textValue(
|
||||||
component: InstanceType<typeof ICAL.Component>,
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -295,8 +499,17 @@ export function parseCalendar(source: string): CalendarDocument {
|
|||||||
zones.map((zone) => textValue(zone, "tzid")).filter(Boolean),
|
zones.map((zone) => textValue(zone, "tzid")).filter(Boolean),
|
||||||
);
|
);
|
||||||
const eventComponents = component.getAllSubcomponents("vevent");
|
const eventComponents = component.getAllSubcomponents("vevent");
|
||||||
if (eventComponents.length > MAX_EVENTS)
|
const taskComponents = component.getAllSubcomponents("vtodo");
|
||||||
throw new Error("Calendar input exceeds the 5,000-event limit.");
|
const journalComponents = component.getAllSubcomponents("vjournal");
|
||||||
|
const freeBusyComponents = component.getAllSubcomponents("vfreebusy");
|
||||||
|
if (
|
||||||
|
eventComponents.length +
|
||||||
|
taskComponents.length +
|
||||||
|
journalComponents.length +
|
||||||
|
freeBusyComponents.length >
|
||||||
|
MAX_EVENTS
|
||||||
|
)
|
||||||
|
throw new Error("Calendar input exceeds the 5,000-item limit.");
|
||||||
const events: CalendarEvent[] = [];
|
const events: CalendarEvent[] = [];
|
||||||
for (const [index, eventComponent] of eventComponents.entries()) {
|
for (const [index, eventComponent] of eventComponents.entries()) {
|
||||||
try {
|
try {
|
||||||
@@ -313,6 +526,19 @@ export function parseCalendar(source: string): CalendarDocument {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const tasks = taskComponents.map((item, index) =>
|
||||||
|
taskFromComponent(item, index, diagnostics),
|
||||||
|
);
|
||||||
|
const journals = journalComponents.map(journalFromComponent);
|
||||||
|
const freeBusy = freeBusyComponents.map((item, index) =>
|
||||||
|
freeBusyFromComponent(item, index, diagnostics),
|
||||||
|
);
|
||||||
|
const alarms = [
|
||||||
|
...events.flatMap((event) =>
|
||||||
|
alarmsFromComponent(event.component, "event", event.uid),
|
||||||
|
),
|
||||||
|
...tasks.flatMap((task) => task.alarms),
|
||||||
|
];
|
||||||
const version = textValue(component, "version");
|
const version = textValue(component, "version");
|
||||||
const prodid = textValue(component, "prodid");
|
const prodid = textValue(component, "prodid");
|
||||||
if (version !== "2.0")
|
if (version !== "2.0")
|
||||||
@@ -339,6 +565,10 @@ export function parseCalendar(source: string): CalendarDocument {
|
|||||||
return {
|
return {
|
||||||
component,
|
component,
|
||||||
events,
|
events,
|
||||||
|
tasks,
|
||||||
|
journals,
|
||||||
|
freeBusy,
|
||||||
|
alarms,
|
||||||
timezones,
|
timezones,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -350,30 +580,40 @@ export function parseCalendar(source: string): CalendarDocument {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function occurrenceFromTime(
|
function occurrenceFromDetails(
|
||||||
event: CalendarEvent,
|
fallback: CalendarEvent,
|
||||||
time: InstanceType<typeof ICAL.Time>,
|
details: ReturnType<InstanceType<typeof ICAL.Event>["getOccurrenceDetails"]>,
|
||||||
durationMs: number,
|
|
||||||
timezoneIds: Set<string>,
|
timezoneIds: Set<string>,
|
||||||
): Occurrence {
|
): Occurrence {
|
||||||
const startText = calendarTimeText(time);
|
const item = details.item;
|
||||||
|
const component = item.component;
|
||||||
|
const startProperty = component.getFirstProperty("dtstart");
|
||||||
|
const parameter = startProperty?.getParameter("tzid");
|
||||||
|
const timezone =
|
||||||
|
typeof parameter === "string"
|
||||||
|
? parameter
|
||||||
|
: details.startDate.zone?.tzid === "UTC"
|
||||||
|
? "UTC"
|
||||||
|
: fallback.timezone;
|
||||||
const startMs = resolveTime(
|
const startMs = resolveTime(
|
||||||
time,
|
details.startDate,
|
||||||
event.timezone === "floating" ? "" : event.timezone,
|
timezone === "floating" ? "" : timezone,
|
||||||
timezoneIds.has(event.timezone),
|
timezoneIds.has(timezone),
|
||||||
|
);
|
||||||
|
const endMs = resolveTime(
|
||||||
|
details.endDate,
|
||||||
|
timezone === "floating" ? "" : timezone,
|
||||||
|
timezoneIds.has(timezone),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
uid: event.uid,
|
uid: fallback.uid,
|
||||||
summary: event.summary,
|
summary: item.summary || fallback.summary,
|
||||||
startText,
|
startText: calendarTimeText(details.startDate),
|
||||||
endText:
|
endText: calendarTimeText(details.endDate),
|
||||||
startMs === null
|
|
||||||
? "unresolved"
|
|
||||||
: new Date(startMs + durationMs).toISOString(),
|
|
||||||
startMs,
|
startMs,
|
||||||
endMs: startMs === null ? null : startMs + durationMs,
|
endMs,
|
||||||
timezone: event.timezone,
|
timezone,
|
||||||
allDay: event.allDay,
|
allDay: details.startDate.isDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +630,7 @@ export function previewOccurrences(
|
|||||||
const occurrences: Occurrence[] = [];
|
const occurrences: Occurrence[] = [];
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
for (const event of document.events.filter((entry) => !entry.recurrenceId)) {
|
for (const event of document.events.filter((entry) => !entry.recurrenceId)) {
|
||||||
const duration =
|
if (event.status.toUpperCase() === "CANCELLED") continue;
|
||||||
event.startMs !== null && event.endMs !== null
|
|
||||||
? Math.max(0, event.endMs - event.startMs)
|
|
||||||
: 0;
|
|
||||||
if (!event.recurring) {
|
if (!event.recurring) {
|
||||||
occurrences.push({
|
occurrences.push({
|
||||||
uid: event.uid,
|
uid: event.uid,
|
||||||
@@ -407,13 +644,30 @@ export function previewOccurrences(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const iterator = new ICAL.Event(event.component).iterator();
|
const exceptions = document.events
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.uid === event.uid && candidate.recurrenceId.length > 0,
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.component);
|
||||||
|
const recurringEvent = new ICAL.Event(event.component, {
|
||||||
|
exceptions,
|
||||||
|
strictExceptions: true,
|
||||||
|
});
|
||||||
|
const iterator = recurringEvent.iterator();
|
||||||
const startBound = event.startMs ?? 0;
|
const startBound = event.startMs ?? 0;
|
||||||
const horizon = startBound + horizonDays * 86_400_000;
|
const horizon = startBound + horizonDays * 86_400_000;
|
||||||
for (let count = 0; count < limit; count += 1) {
|
for (let count = 0; count < limit; count += 1) {
|
||||||
const next = iterator.next();
|
const next = iterator.next();
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
const occurrence = occurrenceFromTime(event, next, duration, timezoneIds);
|
const details = recurringEvent.getOccurrenceDetails(next);
|
||||||
|
if (
|
||||||
|
textValue(details.item.component, "status").toUpperCase() ===
|
||||||
|
"CANCELLED"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const occurrence = occurrenceFromDetails(event, details, timezoneIds);
|
||||||
if (occurrence.startMs !== null && occurrence.startMs > horizon) break;
|
if (occurrence.startMs !== null && occurrence.startMs > horizon) break;
|
||||||
occurrences.push(occurrence);
|
occurrences.push(occurrence);
|
||||||
if (count === limit - 1) truncated = true;
|
if (count === limit - 1) truncated = true;
|
||||||
@@ -432,11 +686,13 @@ export function previewOccurrences(
|
|||||||
return { occurrences, truncated };
|
return { occurrences, truncated };
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventIdentity(
|
function componentIdentity(
|
||||||
component: InstanceType<typeof ICAL.Component>,
|
component: InstanceType<typeof ICAL.Component>,
|
||||||
): string | null {
|
): string | null {
|
||||||
const uid = textValue(component, "uid");
|
const uid = textValue(component, "uid");
|
||||||
return uid ? `${uid}|${textValue(component, "recurrence-id")}` : null;
|
return uid
|
||||||
|
? `${component.name}|${uid}|${textValue(component, "recurrence-id")}`
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newerEvent(
|
function newerEvent(
|
||||||
@@ -461,9 +717,9 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
|
|||||||
throw new Error("Merge accepts 1–20 calendars.");
|
throw new Error("Merge accepts 1–20 calendars.");
|
||||||
const root = new ICAL.Component("vcalendar");
|
const root = new ICAL.Component("vcalendar");
|
||||||
root.addPropertyWithValue("version", "2.0");
|
root.addPropertyWithValue("version", "2.0");
|
||||||
root.addPropertyWithValue("prodid", "-//add·ideas//Calendar Tools 0.1.0//EN");
|
root.addPropertyWithValue("prodid", "-//add·ideas//Calendar Tools 0.2.0//EN");
|
||||||
const zones = new Map<string, InstanceType<typeof ICAL.Component>>();
|
const zones = new Map<string, InstanceType<typeof ICAL.Component>>();
|
||||||
const events = new Map<string, InstanceType<typeof ICAL.Component>>();
|
const items = new Map<string, InstanceType<typeof ICAL.Component>>();
|
||||||
let deduplicated = 0;
|
let deduplicated = 0;
|
||||||
let missingUids = 0;
|
let missingUids = 0;
|
||||||
for (const [documentIndex, document] of documents.entries()) {
|
for (const [documentIndex, document] of documents.entries()) {
|
||||||
@@ -471,28 +727,32 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
|
|||||||
const id = textValue(zone, "tzid");
|
const id = textValue(zone, "tzid");
|
||||||
if (id && !zones.has(id)) zones.set(id, zone);
|
if (id && !zones.has(id)) zones.set(id, zone);
|
||||||
}
|
}
|
||||||
for (const [eventIndex, event] of document.component
|
const mergeable = document.component
|
||||||
.getAllSubcomponents("vevent")
|
.getAllSubcomponents()
|
||||||
.entries()) {
|
.filter((item) =>
|
||||||
const declaredIdentity = eventIdentity(event);
|
["vevent", "vtodo", "vjournal", "vfreebusy"].includes(item.name),
|
||||||
|
);
|
||||||
|
for (const [itemIndex, item] of mergeable.entries()) {
|
||||||
|
const declaredIdentity = componentIdentity(item);
|
||||||
const identity =
|
const identity =
|
||||||
declaredIdentity ?? `~missing-${documentIndex}-${eventIndex}`;
|
declaredIdentity ??
|
||||||
|
`~missing-${documentIndex}-${item.name}-${itemIndex}`;
|
||||||
if (!declaredIdentity) missingUids += 1;
|
if (!declaredIdentity) missingUids += 1;
|
||||||
const previous = events.get(identity);
|
const previous = items.get(identity);
|
||||||
if (previous) {
|
if (previous) {
|
||||||
events.set(identity, newerEvent(previous, event));
|
items.set(identity, newerEvent(previous, item));
|
||||||
deduplicated += 1;
|
deduplicated += 1;
|
||||||
} else events.set(identity, event);
|
} else items.set(identity, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const zone of [...zones.values()].sort((a, b) =>
|
for (const zone of [...zones.values()].sort((a, b) =>
|
||||||
textValue(a, "tzid").localeCompare(textValue(b, "tzid")),
|
textValue(a, "tzid").localeCompare(textValue(b, "tzid")),
|
||||||
))
|
))
|
||||||
root.addSubcomponent(new ICAL.Component(structuredClone(zone.toJSON())));
|
root.addSubcomponent(new ICAL.Component(structuredClone(zone.toJSON())));
|
||||||
for (const [, event] of [...events.entries()].sort(([left], [right]) =>
|
for (const [, item] of [...items.entries()].sort(([left], [right]) =>
|
||||||
left.localeCompare(right),
|
left.localeCompare(right),
|
||||||
))
|
))
|
||||||
root.addSubcomponent(new ICAL.Component(structuredClone(event.toJSON())));
|
root.addSubcomponent(new ICAL.Component(structuredClone(item.toJSON())));
|
||||||
return {
|
return {
|
||||||
source:
|
source:
|
||||||
root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") +
|
root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") +
|
||||||
@@ -500,13 +760,13 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
|
|||||||
diagnostics: [
|
diagnostics: [
|
||||||
{
|
{
|
||||||
severity: "info",
|
severity: "info",
|
||||||
message: `Merged ${documents.length} calendars; retained ${events.size} event identities and ${zones.size} timezone definitions.`,
|
message: `Merged ${documents.length} calendars; retained ${items.size} component identities and ${zones.size} timezone definitions.`,
|
||||||
},
|
},
|
||||||
...(missingUids
|
...(missingUids
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
severity: "warning" as const,
|
severity: "warning" as const,
|
||||||
message: `${missingUids} UID-less events were retained independently because they have no safe cross-calendar identity.`,
|
message: `${missingUids} UID-less items were retained independently because they have no safe cross-calendar identity.`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -515,6 +775,96 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FreeSlot {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function busyIntervals(
|
||||||
|
document: CalendarDocument,
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
): FreeBusyInterval[] {
|
||||||
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs)
|
||||||
|
throw new Error(
|
||||||
|
"Busy interval window must have a finite start before end.",
|
||||||
|
);
|
||||||
|
const eventIntervals = previewOccurrences(document, 500, 3_650)
|
||||||
|
.occurrences.filter(
|
||||||
|
(item) =>
|
||||||
|
item.startMs !== null &&
|
||||||
|
item.endMs !== null &&
|
||||||
|
item.endMs > startMs &&
|
||||||
|
item.startMs < endMs,
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
startText: item.startText,
|
||||||
|
endText: item.endText,
|
||||||
|
startMs: item.startMs!,
|
||||||
|
endMs: item.endMs!,
|
||||||
|
type: "BUSY",
|
||||||
|
sourceUid: item.uid,
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
...eventIntervals,
|
||||||
|
...document.freeBusy.flatMap((item) => item.intervals),
|
||||||
|
]
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.type !== "FREE" && item.endMs > startMs && item.startMs < endMs,
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
startMs: Math.max(startMs, item.startMs),
|
||||||
|
endMs: Math.min(endMs, item.endMs),
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
|
||||||
|
)
|
||||||
|
.slice(0, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFreeSlots(
|
||||||
|
document: CalendarDocument,
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
minimumMinutes = 30,
|
||||||
|
): FreeSlot[] {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(minimumMinutes) ||
|
||||||
|
minimumMinutes < 1 ||
|
||||||
|
minimumMinutes > 10_080
|
||||||
|
)
|
||||||
|
throw new Error("Minimum free-slot length must be 1–10,080 minutes.");
|
||||||
|
const occupied = busyIntervals(document, startMs, endMs);
|
||||||
|
const merged: Array<{ startMs: number; endMs: number }> = [];
|
||||||
|
for (const interval of occupied) {
|
||||||
|
const previous = merged.at(-1);
|
||||||
|
if (previous && interval.startMs <= previous.endMs)
|
||||||
|
previous.endMs = Math.max(previous.endMs, interval.endMs);
|
||||||
|
else merged.push({ startMs: interval.startMs, endMs: interval.endMs });
|
||||||
|
}
|
||||||
|
const result: FreeSlot[] = [];
|
||||||
|
let cursor = startMs;
|
||||||
|
for (const interval of merged) {
|
||||||
|
if (interval.startMs - cursor >= minimumMinutes * 60_000)
|
||||||
|
result.push({
|
||||||
|
startMs: cursor,
|
||||||
|
endMs: interval.startMs,
|
||||||
|
minutes: (interval.startMs - cursor) / 60_000,
|
||||||
|
});
|
||||||
|
cursor = Math.max(cursor, interval.endMs);
|
||||||
|
}
|
||||||
|
if (endMs - cursor >= minimumMinutes * 60_000)
|
||||||
|
result.push({
|
||||||
|
startMs: cursor,
|
||||||
|
endMs,
|
||||||
|
minutes: (endMs - cursor) / 60_000,
|
||||||
|
});
|
||||||
|
return result.slice(0, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
export function findConflicts(
|
export function findConflicts(
|
||||||
occurrences: readonly Occurrence[],
|
occurrences: readonly Occurrence[],
|
||||||
limit = 500,
|
limit = 500,
|
||||||
@@ -565,7 +915,7 @@ export function canonicalRepair(source: string): {
|
|||||||
if (!textValue(clone, "prodid")) {
|
if (!textValue(clone, "prodid")) {
|
||||||
clone.addPropertyWithValue(
|
clone.addPropertyWithValue(
|
||||||
"prodid",
|
"prodid",
|
||||||
"-//add·ideas//Calendar Tools 0.1.0//EN",
|
"-//add·ideas//Calendar Tools 0.2.0//EN",
|
||||||
);
|
);
|
||||||
repairs.push("Added PRODID.");
|
repairs.push("Added PRODID.");
|
||||||
}
|
}
|
||||||
|
|||||||
+463
-12
@@ -1,21 +1,34 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
import {
|
||||||
|
stableStringify,
|
||||||
|
triggerBlobDownload,
|
||||||
|
} from "@add-ideas/toolbox-helpers";
|
||||||
|
import {
|
||||||
|
appendJSCalendarItem,
|
||||||
|
exportJCal,
|
||||||
|
exportJSCalendar,
|
||||||
|
importJCal,
|
||||||
|
importJSCalendar,
|
||||||
|
} from "../calendar/interchange";
|
||||||
import {
|
import {
|
||||||
canonicalRepair,
|
canonicalRepair,
|
||||||
findConflicts,
|
findConflicts,
|
||||||
|
findFreeSlots,
|
||||||
localTimeCandidates,
|
localTimeCandidates,
|
||||||
mergeCalendars,
|
mergeCalendars,
|
||||||
parseCalendar,
|
parseCalendar,
|
||||||
previewOccurrences,
|
previewOccurrences,
|
||||||
timezoneDiagnostics,
|
timezoneDiagnostics,
|
||||||
type CalendarDocument,
|
type CalendarDocument,
|
||||||
|
type FreeSlot,
|
||||||
} from "../calendar/model";
|
} from "../calendar/model";
|
||||||
|
|
||||||
const sample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Calendar Tools sample//EN\r\nX-WR-CALNAME:Project calendar\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T100000\r\nDTEND;TZID=Europe/Berlin:20260901T110000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Weekly review\r\nLOCATION:Studio\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:design-session@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:Design session\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
const sample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Calendar Tools sample//EN\r\nX-WR-CALNAME:Project calendar\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T100000\r\nDTEND;TZID=Europe/Berlin:20260901T110000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Weekly review\r\nLOCATION:Studio\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:design-session@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:Design session\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||||
|
|
||||||
const secondSample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Second sample//EN\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nSEQUENCE:2\r\nDTSTAMP:20260802T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T103000\r\nDTEND;TZID=Europe/Berlin:20260901T113000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Updated weekly review\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
const secondSample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Second sample//EN\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nSEQUENCE:2\r\nDTSTAMP:20260802T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T103000\r\nDTEND;TZID=Europe/Berlin:20260901T113000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Updated weekly review\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||||
|
|
||||||
type Tab = "inspect" | "recurrence" | "merge" | "timezone";
|
type Tab =
|
||||||
|
"inspect" | "items" | "recurrence" | "merge" | "interchange" | "timezone";
|
||||||
|
|
||||||
function downloadIcs(value: string, name = "calendar.ics") {
|
function downloadIcs(value: string, name = "calendar.ics") {
|
||||||
triggerBlobDownload(
|
triggerBlobDownload(
|
||||||
@@ -24,6 +37,41 @@ function downloadIcs(value: string, name = "calendar.ics") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadJson(value: string, name: string) {
|
||||||
|
triggerBlobDownload(
|
||||||
|
new Blob([value], { type: "application/json;charset=utf-8" }),
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthCells(
|
||||||
|
month: string,
|
||||||
|
occurrences: ReturnType<typeof previewOccurrences>["occurrences"],
|
||||||
|
) {
|
||||||
|
const match = /^(\d{4})-(\d{2})$/u.exec(month);
|
||||||
|
if (!match) return [];
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const index = Number(match[2]) - 1;
|
||||||
|
const first = new Date(Date.UTC(year, index, 1));
|
||||||
|
const start = new Date(first);
|
||||||
|
start.setUTCDate(1 - first.getUTCDay());
|
||||||
|
return Array.from({ length: 42 }, (_, offset) => {
|
||||||
|
const date = new Date(start);
|
||||||
|
date.setUTCDate(start.getUTCDate() + offset);
|
||||||
|
const key = date.toISOString().slice(0, 10);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
day: date.getUTCDate(),
|
||||||
|
current: date.getUTCMonth() === index,
|
||||||
|
occurrences: occurrences.filter(
|
||||||
|
(item) =>
|
||||||
|
item.startMs !== null &&
|
||||||
|
new Date(item.startMs).toISOString().slice(0, 10) === key,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatInstant(value: number | null, fallback: string) {
|
function formatInstant(value: number | null, fallback: string) {
|
||||||
return value === null ? fallback : new Date(value).toISOString();
|
return value === null ? fallback : new Date(value).toISOString();
|
||||||
}
|
}
|
||||||
@@ -46,6 +94,10 @@ export function Workbench() {
|
|||||||
const [occurrenceState, setOccurrenceState] = useState(() =>
|
const [occurrenceState, setOccurrenceState] = useState(() =>
|
||||||
previewOccurrences(initial, 100, 730),
|
previewOccurrences(initial, 100, 730),
|
||||||
);
|
);
|
||||||
|
const [recurrenceView, setRecurrenceView] = useState<"agenda" | "month">(
|
||||||
|
"agenda",
|
||||||
|
);
|
||||||
|
const [month, setMonth] = useState("2026-09");
|
||||||
const [mergeLeft, setMergeLeft] = useState(sample);
|
const [mergeLeft, setMergeLeft] = useState(sample);
|
||||||
const [mergeRight, setMergeRight] = useState(secondSample);
|
const [mergeRight, setMergeRight] = useState(secondSample);
|
||||||
const [mergeOutput, setMergeOutput] = useState("");
|
const [mergeOutput, setMergeOutput] = useState("");
|
||||||
@@ -59,10 +111,30 @@ export function Workbench() {
|
|||||||
const [candidates, setCandidates] = useState(() =>
|
const [candidates, setCandidates] = useState(() =>
|
||||||
localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"),
|
localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"),
|
||||||
);
|
);
|
||||||
|
const [freeStart, setFreeStart] = useState("2026-09-01T08:00");
|
||||||
|
const [freeEnd, setFreeEnd] = useState("2026-09-01T18:00");
|
||||||
|
const [freeMinimum, setFreeMinimum] = useState(30);
|
||||||
|
const [freeSlots, setFreeSlots] = useState<FreeSlot[]>([]);
|
||||||
|
const [authorType, setAuthorType] = useState<"Event" | "Task">("Event");
|
||||||
|
const [authorUid, setAuthorUid] = useState("local-item-1");
|
||||||
|
const [authorTitle, setAuthorTitle] = useState("New local item");
|
||||||
|
const [authorStart, setAuthorStart] = useState("2026-09-01T14:00");
|
||||||
|
const [authorEnd, setAuthorEnd] = useState("2026-09-01T15:00");
|
||||||
|
const [authorTimezone, setAuthorTimezone] = useState("Europe/Berlin");
|
||||||
|
const [interchangeKind, setInterchangeKind] = useState<"jscalendar" | "jcal">(
|
||||||
|
"jscalendar",
|
||||||
|
);
|
||||||
|
const [interchangeText, setInterchangeText] = useState(() =>
|
||||||
|
stableStringify(exportJSCalendar(initial).data, 2),
|
||||||
|
);
|
||||||
const conflicts = useMemo(
|
const conflicts = useMemo(
|
||||||
() => findConflicts(occurrenceState.occurrences),
|
() => findConflicts(occurrenceState.occurrences),
|
||||||
[occurrenceState],
|
[occurrenceState],
|
||||||
);
|
);
|
||||||
|
const calendarMonth = useMemo(
|
||||||
|
() => monthCells(month, occurrenceState.occurrences),
|
||||||
|
[month, occurrenceState],
|
||||||
|
);
|
||||||
|
|
||||||
const accept = (value: string) => {
|
const accept = (value: string) => {
|
||||||
const next = parseCalendar(value);
|
const next = parseCalendar(value);
|
||||||
@@ -151,6 +223,78 @@ export function Workbench() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const inspectAvailability = () => {
|
||||||
|
try {
|
||||||
|
const start = new Date(freeStart).getTime();
|
||||||
|
const end = new Date(freeEnd).getTime();
|
||||||
|
setFreeSlots(findFreeSlots(document, start, end, freeMinimum));
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Free-time search failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addItem = () => {
|
||||||
|
try {
|
||||||
|
const start = new Date(authorStart);
|
||||||
|
const end = new Date(authorEnd);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()))
|
||||||
|
throw new TypeError("Start/end must be valid local date-times.");
|
||||||
|
const item =
|
||||||
|
authorType === "Event"
|
||||||
|
? {
|
||||||
|
"@type": "Event",
|
||||||
|
uid: authorUid,
|
||||||
|
title: authorTitle,
|
||||||
|
start: `${authorStart}:00`,
|
||||||
|
timeZone: authorTimezone,
|
||||||
|
duration: `PT${Math.max(0, Math.round((end.getTime() - start.getTime()) / 1_000))}S`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"@type": "Task",
|
||||||
|
uid: authorUid,
|
||||||
|
title: authorTitle,
|
||||||
|
start: `${authorStart}:00`,
|
||||||
|
due: `${authorEnd}:00`,
|
||||||
|
timeZone: authorTimezone,
|
||||||
|
};
|
||||||
|
const output = appendJSCalendarItem(document, item);
|
||||||
|
setSource(output);
|
||||||
|
accept(output);
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Item creation failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exportInterchange = () => {
|
||||||
|
const output =
|
||||||
|
interchangeKind === "jcal"
|
||||||
|
? exportJCal(document)
|
||||||
|
: exportJSCalendar(document).data;
|
||||||
|
setInterchangeText(stableStringify(output, 2));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
const importInterchange = () => {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(interchangeText);
|
||||||
|
const output =
|
||||||
|
interchangeKind === "jcal"
|
||||||
|
? importJCal(parsed)
|
||||||
|
: importJSCalendar(parsed);
|
||||||
|
setSource(output);
|
||||||
|
accept(output);
|
||||||
|
setTab("inspect");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "JSON calendar import failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="workbench">
|
<main className="workbench">
|
||||||
@@ -171,19 +315,25 @@ export function Workbench() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<nav className="workspace-tabs" aria-label="Calendar workspaces">
|
<nav className="workspace-tabs" aria-label="Calendar workspaces">
|
||||||
{(["inspect", "recurrence", "merge", "timezone"] as const).map(
|
{(
|
||||||
(value) => (
|
[
|
||||||
|
"inspect",
|
||||||
|
"items",
|
||||||
|
"recurrence",
|
||||||
|
"merge",
|
||||||
|
"interchange",
|
||||||
|
"timezone",
|
||||||
|
] as const
|
||||||
|
).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
aria-pressed={tab === value}
|
||||||
aria-selected={tab === value}
|
|
||||||
onClick={() => setTab(value)}
|
onClick={() => setTab(value)}
|
||||||
>
|
>
|
||||||
{value[0]!.toUpperCase() + value.slice(1)}
|
{value[0]!.toUpperCase() + value.slice(1)}
|
||||||
</button>
|
</button>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{tab === "inspect" && (
|
{tab === "inspect" && (
|
||||||
@@ -239,14 +389,16 @@ export function Workbench() {
|
|||||||
<strong>{document.events.length}</strong>
|
<strong>{document.events.length}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Recurring</span>
|
<span>Tasks / journals</span>
|
||||||
<strong>
|
<strong>
|
||||||
{document.events.filter((event) => event.recurring).length}
|
{document.tasks.length} / {document.journals.length}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>VTIMEZONE</span>
|
<span>Free/busy / alarms</span>
|
||||||
<strong>{document.timezones.length}</strong>
|
<strong>
|
||||||
|
{document.freeBusy.length} / {document.alarms.length}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Diagnostics</span>
|
<span>Diagnostics</span>
|
||||||
@@ -295,6 +447,33 @@ export function Workbench() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{document.tasks.length ? (
|
||||||
|
<details>
|
||||||
|
<summary>Tasks ({document.tasks.length})</summary>
|
||||||
|
<ul className="diagnostics">
|
||||||
|
{document.tasks.map((task) => (
|
||||||
|
<li key={task.id}>
|
||||||
|
<strong>{task.summary}</strong> · {task.status || "open"}
|
||||||
|
{task.dueText ? ` · due ${task.dueText}` : ""} ·{" "}
|
||||||
|
{task.percentComplete}% complete
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{document.journals.length ? (
|
||||||
|
<details>
|
||||||
|
<summary>Journal entries ({document.journals.length})</summary>
|
||||||
|
<ul className="diagnostics">
|
||||||
|
{document.journals.map((journal) => (
|
||||||
|
<li key={journal.id}>
|
||||||
|
<strong>{journal.summary}</strong> ·{" "}
|
||||||
|
{journal.dateText || "undated"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
Calendar and timezone diagnostics (
|
Calendar and timezone diagnostics (
|
||||||
@@ -322,6 +501,159 @@ export function Workbench() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "items" && (
|
||||||
|
<div className="split-layout">
|
||||||
|
<section className="panel workspace">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">VTODO · VJOURNAL · VALARM · VFREEBUSY</p>
|
||||||
|
<h2>Calendar components</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>Tasks</h3>
|
||||||
|
{document.tasks.length ? (
|
||||||
|
<ul className="item-cards">
|
||||||
|
{document.tasks.map((task) => (
|
||||||
|
<li key={task.id}>
|
||||||
|
<strong>{task.summary}</strong>
|
||||||
|
<span>
|
||||||
|
{task.status || "NEEDS-ACTION"} · {task.percentComplete}%
|
||||||
|
{task.dueText ? ` · due ${task.dueText}` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No VTODO components.</p>
|
||||||
|
)}
|
||||||
|
<h3>Journal and alarm inventory</h3>
|
||||||
|
<ul className="item-cards">
|
||||||
|
{document.journals.map((journal) => (
|
||||||
|
<li key={journal.id}>
|
||||||
|
<strong>{journal.summary}</strong>
|
||||||
|
<span>{journal.dateText || "undated VJOURNAL"}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{document.alarms.map((alarm, index) => (
|
||||||
|
<li key={`${alarm.ownerUid}-${alarm.trigger}-${index}`}>
|
||||||
|
<strong>
|
||||||
|
{alarm.action} alarm · {alarm.ownerUid}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
{alarm.trigger} relative to {alarm.related}
|
||||||
|
{alarm.repeat ? ` · repeats ${alarm.repeat}` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section className="panel workspace">
|
||||||
|
<p className="eyebrow">Bounded local authoring</p>
|
||||||
|
<h2>Add an event or task</h2>
|
||||||
|
<div className="author-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Type</span>
|
||||||
|
<select
|
||||||
|
value={authorType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAuthorType(event.target.value as "Event" | "Task")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option>Event</option>
|
||||||
|
<option>Task</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>UID</span>
|
||||||
|
<input
|
||||||
|
value={authorUid}
|
||||||
|
onChange={(event) => setAuthorUid(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field wide">
|
||||||
|
<span>Title</span>
|
||||||
|
<input
|
||||||
|
value={authorTitle}
|
||||||
|
onChange={(event) => setAuthorTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Start</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={authorStart}
|
||||||
|
onChange={(event) => setAuthorStart(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{authorType === "Event" ? "End" : "Due"}</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={authorEnd}
|
||||||
|
onChange={(event) => setAuthorEnd(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field wide">
|
||||||
|
<span>IANA timezone</span>
|
||||||
|
<input
|
||||||
|
value={authorTimezone}
|
||||||
|
onChange={(event) => setAuthorTimezone(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="primary" onClick={addItem}>
|
||||||
|
Add to current calendar
|
||||||
|
</button>
|
||||||
|
<hr />
|
||||||
|
<h2>Free-time finder</h2>
|
||||||
|
<div className="author-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Window start</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={freeStart}
|
||||||
|
onChange={(event) => setFreeStart(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Window end</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={freeEnd}
|
||||||
|
onChange={(event) => setFreeEnd(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Minimum minutes</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10080"
|
||||||
|
value={freeMinimum}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFreeMinimum(Number(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={inspectAvailability}>
|
||||||
|
Find free slots
|
||||||
|
</button>
|
||||||
|
<ul className="item-cards">
|
||||||
|
{freeSlots.map((slot) => (
|
||||||
|
<li key={`${slot.startMs}-${slot.endMs}`}>
|
||||||
|
<strong>{slot.minutes.toLocaleString()} minutes</strong>
|
||||||
|
<span>
|
||||||
|
{new Date(slot.startMs).toISOString()} –{" "}
|
||||||
|
{new Date(slot.endMs).toISOString()}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "recurrence" && (
|
{tab === "recurrence" && (
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
@@ -335,6 +667,28 @@ export function Workbench() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="control-row">
|
<div className="control-row">
|
||||||
|
<label className="field">
|
||||||
|
<span>View</span>
|
||||||
|
<select
|
||||||
|
value={recurrenceView}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRecurrenceView(event.target.value as "agenda" | "month")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="agenda">Agenda</option>
|
||||||
|
<option value="month">Month</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{recurrenceView === "month" ? (
|
||||||
|
<label className="field">
|
||||||
|
<span>Month</span>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={month}
|
||||||
|
onChange={(event) => setMonth(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Per-series limit</span>
|
<span>Per-series limit</span>
|
||||||
<input
|
<input
|
||||||
@@ -365,6 +719,7 @@ export function Workbench() {
|
|||||||
are intentionally not shown.
|
are intentionally not shown.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{recurrenceView === "agenda" ? (
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{occurrenceState.occurrences.map((occurrence, index) => (
|
{occurrenceState.occurrences.map((occurrence, index) => (
|
||||||
<article
|
<article
|
||||||
@@ -383,6 +738,33 @@ export function Workbench() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="month-view" aria-label={`Calendar month ${month}`}>
|
||||||
|
{(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const).map(
|
||||||
|
(day) => (
|
||||||
|
<strong className="month-weekday" key={day}>
|
||||||
|
{day}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{calendarMonth.map((cell) => (
|
||||||
|
<article
|
||||||
|
key={cell.key}
|
||||||
|
className={cell.current ? "" : "outside"}
|
||||||
|
>
|
||||||
|
<time dateTime={cell.key}>{cell.day}</time>
|
||||||
|
{cell.occurrences.slice(0, 4).map((occurrence, index) => (
|
||||||
|
<span key={`${occurrence.uid}-${index}`}>
|
||||||
|
{occurrence.summary}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{cell.occurrences.length > 4 ? (
|
||||||
|
<small>+{cell.occurrences.length - 4} more</small>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<details open={conflicts.length > 0}>
|
<details open={conflicts.length > 0}>
|
||||||
<summary>Conflicts ({conflicts.length})</summary>
|
<summary>Conflicts ({conflicts.length})</summary>
|
||||||
<ul className="diagnostics">
|
<ul className="diagnostics">
|
||||||
@@ -464,6 +846,75 @@ export function Workbench() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "interchange" && (
|
||||||
|
<section className="panel workspace">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">RFC 7265 jCal · RFC 8984 JSCalendar</p>
|
||||||
|
<h2>JSON interchange</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<label className="field">
|
||||||
|
<span>Format</span>
|
||||||
|
<select
|
||||||
|
value={interchangeKind}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInterchangeKind(
|
||||||
|
event.target.value as "jscalendar" | "jcal",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="jscalendar">JSCalendar Event/Task Group</option>
|
||||||
|
<option value="jcal">jCal lossless component tree</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={exportInterchange}>
|
||||||
|
Export current calendar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={importInterchange}
|
||||||
|
>
|
||||||
|
Import JSON into calendar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
downloadJson(
|
||||||
|
interchangeText,
|
||||||
|
interchangeKind === "jcal"
|
||||||
|
? "calendar.jcal.json"
|
||||||
|
: "calendar.jscalendar.json",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Download JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
aria-label="JSON calendar interchange"
|
||||||
|
value={interchangeText}
|
||||||
|
onChange={(event) => setInterchangeText(event.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="muted">
|
||||||
|
jCal preserves the full iCalendar component tree. The focused
|
||||||
|
JSCalendar adapter maps Event and Task fields and reports
|
||||||
|
unsupported component families by retaining them in the original ICS
|
||||||
|
only.
|
||||||
|
</p>
|
||||||
|
{interchangeKind === "jscalendar"
|
||||||
|
? exportJSCalendar(document).warnings.map((warning) => (
|
||||||
|
<p className="warning" key={warning}>
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "timezone" && (
|
{tab === "timezone" && (
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
|
|||||||
+65
-1
@@ -161,7 +161,7 @@ textarea {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.workspace-tabs button[aria-selected="true"] {
|
.workspace-tabs button[aria-pressed="true"] {
|
||||||
border-color: var(--toolbox-accent);
|
border-color: var(--toolbox-accent);
|
||||||
background: var(--toolbox-accent);
|
background: var(--toolbox-accent);
|
||||||
color: var(--toolbox-accent-contrast);
|
color: var(--toolbox-accent-contrast);
|
||||||
@@ -493,6 +493,70 @@ tbody td:first-child {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
.item-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.item-cards li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-left: 4px solid var(--toolbox-accent);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--toolbox-surface-soft);
|
||||||
|
}
|
||||||
|
.item-cards span {
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
.author-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.author-grid .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.month-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--toolbox-border);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
}
|
||||||
|
.month-weekday {
|
||||||
|
padding: 0.45rem;
|
||||||
|
background: var(--toolbox-surface-soft);
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.month-view article {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 7rem;
|
||||||
|
min-height: 7rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-top: 1px solid var(--toolbox-border);
|
||||||
|
border-right: 1px solid var(--toolbox-border);
|
||||||
|
}
|
||||||
|
.month-view article.outside {
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
.month-view article > span {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--toolbox-accent-soft);
|
||||||
|
color: var(--toolbox-accent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.merge-grid {
|
.merge-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.calendar-tools",
|
"id": "de.add-ideas.calendar-tools",
|
||||||
"name": "Calendar Tools",
|
"name": "Calendar Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -21,6 +21,32 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": false
|
"topLevelContext": false
|
||||||
},
|
},
|
||||||
|
"io": {
|
||||||
|
"accepts": [
|
||||||
|
{
|
||||||
|
"mediaType": "text/calendar",
|
||||||
|
"extensions": [".ics", ".ifb"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/jscalendar+json",
|
||||||
|
"extensions": [".json"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
{
|
||||||
|
"mediaType": "text/calendar",
|
||||||
|
"extensions": [".ics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/jscalendar+json",
|
||||||
|
"extensions": [".json"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"required": [],
|
||||||
|
"optional": []
|
||||||
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test("keeps prior inspection on failure and diagnoses a DST overlap", async ({
|
|||||||
await page.getByLabel("iCalendar source").fill("broken");
|
await page.getByLabel("iCalendar source").fill("broken");
|
||||||
await page.getByRole("button", { name: "Inspect calendar" }).click();
|
await page.getByRole("button", { name: "Inspect calendar" }).click();
|
||||||
await expect(page.getByRole("alert")).toBeVisible();
|
await expect(page.getByRole("alert")).toBeVisible();
|
||||||
await page.getByRole("tab", { name: "Timezone" }).click();
|
await page.getByRole("button", { name: "Timezone" }).click();
|
||||||
await page.getByRole("button", { name: "Diagnose" }).click();
|
await page.getByRole("button", { name: "Diagnose" }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/Overlap: this local time occurs 2 times/u),
|
page.getByText(/Overlap: this local time occurs 2 times/u),
|
||||||
@@ -55,7 +55,7 @@ test("serves release identity and hardened headers", async ({ request }) => {
|
|||||||
const manifest = await request.get("/deep/nested/calendar/toolbox-app.json");
|
const manifest = await request.get("/deep/nested/calendar/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.calendar-tools",
|
id: "de.add-ideas.calendar-tools",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
entry: "./",
|
entry: "./",
|
||||||
privacy: { processing: "local", telemetry: false },
|
privacy: { processing: "local", telemetry: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/deep/nested/calendar/");
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator("main .loading, main .workbench-loading"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
const widths = await page.evaluate(() => ({
|
||||||
|
content: document.documentElement.scrollWidth,
|
||||||
|
viewport: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||||
|
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||||
|
});
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
busyIntervals,
|
||||||
canonicalRepair,
|
canonicalRepair,
|
||||||
findConflicts,
|
findConflicts,
|
||||||
|
findFreeSlots,
|
||||||
localTimeCandidates,
|
localTimeCandidates,
|
||||||
mergeCalendars,
|
mergeCalendars,
|
||||||
parseCalendar,
|
parseCalendar,
|
||||||
previewOccurrences,
|
previewOccurrences,
|
||||||
timezoneDiagnostics,
|
timezoneDiagnostics,
|
||||||
} from "../../src/calendar/model";
|
} from "../../src/calendar/model";
|
||||||
|
import {
|
||||||
|
exportJCal,
|
||||||
|
exportJSCalendar,
|
||||||
|
importJCal,
|
||||||
|
importJSCalendar,
|
||||||
|
} from "../../src/calendar/interchange";
|
||||||
|
|
||||||
const recurring = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
const recurring = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||||
|
|
||||||
@@ -24,6 +32,26 @@ describe("calendar model", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies moved, cancelled, duration, and THISANDFUTURE exceptions", () => {
|
||||||
|
const source = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:series\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Original\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID:20260902T100000Z\r\nDTSTART:20260902T120000Z\r\nDTEND:20260902T133000Z\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID:20260903T100000Z\r\nDTSTART:20260903T100000Z\r\nDTEND:20260903T110000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260904T100000Z\r\nDTSTART:20260904T110000Z\r\nDTEND:20260904T130000Z\r\nSUMMARY:Shifted future\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||||
|
const preview = previewOccurrences(parseCalendar(source), 10, 30);
|
||||||
|
expect(preview.occurrences).toHaveLength(4);
|
||||||
|
expect(preview.occurrences.map((item) => item.startText)).toEqual([
|
||||||
|
"2026-09-01T10:00:00",
|
||||||
|
"2026-09-02T12:00:00",
|
||||||
|
"2026-09-04T11:00:00",
|
||||||
|
"2026-09-05T11:00:00",
|
||||||
|
]);
|
||||||
|
expect(preview.occurrences[1]).toMatchObject({
|
||||||
|
summary: "Moved",
|
||||||
|
endText: "2026-09-02T13:30:00",
|
||||||
|
});
|
||||||
|
expect(preview.occurrences[3]).toMatchObject({
|
||||||
|
summary: "Shifted future",
|
||||||
|
endText: "2026-09-05T13:00:00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("canonicalizes line endings and supplies required metadata", () => {
|
it("canonicalizes line endings and supplies required metadata", () => {
|
||||||
const input = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:x\nDTSTART:20260901T100000Z\nDTEND:20260901T110000Z\nEND:VEVENT\nEND:VCALENDAR\n`;
|
const input = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:x\nDTSTART:20260901T100000Z\nDTEND:20260901T110000Z\nEND:VEVENT\nEND:VCALENDAR\n`;
|
||||||
const repaired = canonicalRepair(input);
|
const repaired = canonicalRepair(input);
|
||||||
@@ -76,6 +104,69 @@ describe("calendar model", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("inspects tasks, journals, free-busy periods, and alarms", () => {
|
||||||
|
const source = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VTODO\r\nUID:task-1\r\nDTSTART:20260901T080000Z\r\nDUE:20260901T090000Z\r\nSUMMARY:Write tests\r\nPERCENT-COMPLETE:25\r\nPRIORITY:3\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Reminder\r\nEND:VALARM\r\nEND:VTODO\r\nBEGIN:VJOURNAL\r\nUID:journal-1\r\nDTSTART;VALUE=DATE:20260901\r\nSUMMARY:Daily note\r\nDESCRIPTION:Done locally\r\nEND:VJOURNAL\r\nBEGIN:VFREEBUSY\r\nUID:busy-1\r\nDTSTART:20260901T080000Z\r\nDTEND:20260901T120000Z\r\nFREEBUSY:20260901T100000Z/20260901T110000Z\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n`;
|
||||||
|
const document = parseCalendar(source);
|
||||||
|
expect(document.tasks[0]).toMatchObject({
|
||||||
|
uid: "task-1",
|
||||||
|
percentComplete: 25,
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
expect(document.alarms[0]).toMatchObject({
|
||||||
|
ownerUid: "task-1",
|
||||||
|
trigger: "-PT15M",
|
||||||
|
});
|
||||||
|
expect(document.journals[0]).toMatchObject({
|
||||||
|
uid: "journal-1",
|
||||||
|
dateText: "2026-09-01",
|
||||||
|
});
|
||||||
|
expect(document.freeBusy[0]?.intervals[0]).toMatchObject({
|
||||||
|
startMs: Date.parse("2026-09-01T10:00:00Z"),
|
||||||
|
endMs: Date.parse("2026-09-01T11:00:00Z"),
|
||||||
|
});
|
||||||
|
const windowStart = Date.parse("2026-09-01T09:00:00Z");
|
||||||
|
const windowEnd = Date.parse("2026-09-01T12:00:00Z");
|
||||||
|
expect(busyIntervals(document, windowStart, windowEnd)).toHaveLength(1);
|
||||||
|
expect(findFreeSlots(document, windowStart, windowEnd, 30)).toEqual([
|
||||||
|
{
|
||||||
|
startMs: windowStart,
|
||||||
|
endMs: Date.parse("2026-09-01T10:00:00Z"),
|
||||||
|
minutes: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startMs: Date.parse("2026-09-01T11:00:00Z"),
|
||||||
|
endMs: windowEnd,
|
||||||
|
minutes: 60,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips jCal and focused JSCalendar Event/Task groups", () => {
|
||||||
|
const document = parseCalendar(recurring);
|
||||||
|
expect(parseCalendar(importJCal(exportJCal(document))).events).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const exported = exportJSCalendar(document);
|
||||||
|
expect(exported.data).toMatchObject({ "@type": "Group" });
|
||||||
|
const imported = parseCalendar(importJSCalendar(exported.data));
|
||||||
|
expect(imported.events).toHaveLength(2);
|
||||||
|
|
||||||
|
const task = parseCalendar(
|
||||||
|
importJSCalendar({
|
||||||
|
"@type": "Task",
|
||||||
|
uid: "task-json",
|
||||||
|
title: "Imported task",
|
||||||
|
due: "2026-09-02T12:00:00Z",
|
||||||
|
progress: 0.5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(task.tasks[0]).toMatchObject({
|
||||||
|
uid: "task-json",
|
||||||
|
summary: "Imported task",
|
||||||
|
percentComplete: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unbounded and malformed inputs", () => {
|
it("rejects unbounded and malformed inputs", () => {
|
||||||
expect(() => previewOccurrences(parseCalendar(recurring), 501, 30)).toThrow(
|
expect(() => previewOccurrences(parseCalendar(recurring), 501, 30)).toThrow(
|
||||||
/1–500/u,
|
/1–500/u,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe("Calendar Tools", () => {
|
|||||||
await screen.findByRole("heading", { name: "Calendar Tools" }),
|
await screen.findByRole("heading", { name: "Calendar Tools" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
await screen.findByRole("tab", { name: "Recurrence" }),
|
await screen.findByRole("button", { name: "Recurrence" }),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "Occurrence and conflict preview" }),
|
screen.getByRole("heading", { name: "Occurrence and conflict preview" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user