forked from GitHubMirrors/silverbullet-icalendar
Compare commits
243 Commits
v0.1.0
...
819f6aa19b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819f6aa19b | ||
| e44a538822 | |||
| 11a29019af | |||
| de782e85e4 | |||
| f23b728542 | |||
| 0e2e5c9699 | |||
| 4ba1e9aee6 | |||
| 90ab92421a | |||
| 29ce643ac1 | |||
| 5f287bdcaf | |||
| 0ecc947cb2 | |||
| fa758902cc | |||
| a32afbe1e6 | |||
| 64c3293c73 | |||
| 43009e4740 | |||
| 6f8c704395 | |||
| 8de02706a6 | |||
| 2efa9d088e | |||
| 938aee2c3f | |||
| 03f66cc0c1 | |||
|
|
d318cec008 | ||
| 65e52802a2 | |||
| 698f4ee4c7 | |||
| 30dbbf333a | |||
| 07def7ed62 | |||
| 8d91c96fd5 | |||
| baa43ec1f6 | |||
| 95de1e3a1e | |||
| c40a0aff18 | |||
| 6960f9ef91 | |||
| 0ef8b9a77d | |||
| a9a0fbf267 | |||
| df0f786d94 | |||
| af75a00f7a | |||
| bc8e67fbdf | |||
|
|
7e848feeee | ||
| c9a703975d | |||
| 842586c129 | |||
| c6532e5aca | |||
| 42fb8be61c | |||
| f8a6fbafda | |||
| dbc7ef29aa | |||
| 7edc0997b2 | |||
| c3fd3aee20 | |||
| e5b063269f | |||
| 31ca364a7c | |||
| e7c69aa3f7 | |||
| 255988e6f3 | |||
| ada9f6694c | |||
| cd0fdf5f98 | |||
|
|
523b49dd3a | ||
| 6fc4282536 | |||
| 0a3b5aeaba | |||
| 12c417c506 | |||
| ca727c83d2 | |||
| f922f59145 | |||
| be74e906d2 | |||
| 10d334c732 | |||
| 9499dbffb2 | |||
| 9ea6f7961e | |||
| 7cc59ff5f9 | |||
| 974c75f01b | |||
| dcaf4d36a5 | |||
| 150fe04410 | |||
| 6b621083b9 | |||
| 4237fcfd30 | |||
|
|
33ca122583 | ||
| 221ca30af3 | |||
| c5129120a4 | |||
| c046c183b7 | |||
| 658bf69e91 | |||
| 6a8791879f | |||
| 2dbd286498 | |||
| 580cfcd646 | |||
| 51a1e8a3e1 | |||
| 426d6d1dc6 | |||
| 6b12c26497 | |||
| 78c1747141 | |||
| 8a5dd0a4bd | |||
| a2239d28d5 | |||
| 028ae7d9f9 | |||
| 0b1ef83999 | |||
| 201afb3f67 | |||
| dcc8f841a3 | |||
| 2bc719eb6e | |||
| d4150ae024 | |||
| 3ecec2a64b | |||
| d1e0a7fee7 | |||
| 68c18e5d18 | |||
| 682ebdf013 | |||
| bea0a23a0e | |||
|
|
a5aac39361 | ||
| 335c859e65 | |||
| 0f04df1435 | |||
| e8d74e4622 | |||
| 657f4f2c3a | |||
| ea85b56c5c | |||
| df8a0e12c2 | |||
| 9b53b77929 | |||
| cafdaf7006 | |||
| f8640533be | |||
| cecaac6638 | |||
| 54bb7a8540 | |||
| 6eda06aca6 | |||
| b5c718f286 | |||
| 0bea770814 | |||
|
|
ac2d1971be | ||
| 102b05f534 | |||
| 5f9d062d09 | |||
| 3ec078f18e | |||
| 08b5019452 | |||
|
|
fe88bada15 | ||
| b2b109b923 | |||
| bc0afad261 | |||
| 9af3e436aa | |||
| 533c240c07 | |||
| 780e90b1f0 | |||
| ffaef28332 | |||
| 4128c046d0 | |||
| 10a6db5893 | |||
| 85691d1df5 | |||
| b8bf269de8 | |||
| b94ebd30a2 | |||
| 9b54e2d8a8 | |||
| 3c69a3567b | |||
| 53a3c0e5db | |||
| 606fca25a8 | |||
| 35229aa941 | |||
| 8e6d0d9f88 | |||
| f06799419c | |||
|
|
d1079bd302 | ||
| 46d8e5f3a0 | |||
| 48e6e945e1 | |||
| 1cd6fd490b | |||
| 598b097b13 | |||
| a11aecfd1b | |||
| b786978804 | |||
| 4030c3fef0 | |||
| 2f4499a068 | |||
| 1ea0e020f9 | |||
| 8ffdebb673 | |||
| 5f9afac9d8 | |||
| 03907f3789 | |||
| 17f6308585 | |||
| 7d690cdb2a | |||
| 98c3b64659 | |||
|
|
188cbf1254 | ||
| d8f6f0396f | |||
| 170de52e6b | |||
|
|
76be84b487 | ||
| 70e6a4ef82 | |||
| cd194de3f7 | |||
| ced95d2a7a | |||
| a7180995b0 | |||
| a11c6bd906 | |||
| 62d4ba1006 | |||
| d4b8fea8f9 | |||
| 4aa83f4a0b | |||
| bf90d8bda2 | |||
| ae3935f048 | |||
| 9f7f1b6d2a | |||
| 02e29e7da4 | |||
| 32933f9a34 | |||
|
|
b53fc0acf8 | ||
| 070b10843e | |||
|
|
8d66834a48 | ||
| 6480b56875 | |||
| d28c206862 | |||
|
|
2ea763e145 | ||
| 66f60bc9ae | |||
|
|
0e7e89091d | ||
| 81d5e8738e | |||
|
|
899ee62693 | ||
| 90f317be6e | |||
|
|
b50cded6c9 | ||
| 124a780b65 | |||
|
|
415cd7e215 | ||
| 9e54f0320e | |||
|
|
c422f0fae7 | ||
| ab0db17a47 | |||
|
|
8087031220 | ||
| 56b6e7d0bf | |||
|
|
2131bf4051 | ||
| cdfea5f3b2 | |||
|
|
3cc449a7c6 | ||
| 80cd15c1b5 | |||
|
|
3b348d8257 | ||
| adf638379d | |||
|
|
4b4aacbfd9 | ||
| 45ab0e8d95 | |||
|
|
e79349d7c0 | ||
| 86824991a6 | |||
| e3fcf743f8 | |||
|
|
da835727d4 | ||
| dbffe7fb24 | |||
| 5a7a7aaa18 | |||
|
|
7aba023818 | ||
| c382ab93ab | |||
| 4d9943ed72 | |||
| 10286625cc | |||
| 7031d15833 | |||
| ab303c694e | |||
| 31fdf3f42b | |||
| cb4f2c03c0 | |||
|
|
74177dc4b5 | ||
| f2fedb690c | |||
| 099374e878 | |||
|
|
479c096587 | ||
| 57cb085982 | |||
| f847ad53bc | |||
| 6a862a5563 | |||
| 3fa0bd553b | |||
| af12466721 | |||
| 17ba5aa701 | |||
| b8497c09d3 | |||
| 0a58c16705 | |||
| b59aabd115 | |||
| c39b869795 | |||
| e33be08320 | |||
| 1ce9011d60 | |||
| 56e11f748b | |||
| bb1b9a93ad | |||
| 6641f03519 | |||
| 44079d525a | |||
|
|
a09bfd805a | ||
| 19826c1678 | |||
| 651a1107d1 | |||
| daab3cf2f3 | |||
| 5ba0445eeb | |||
| 31fddc1e26 | |||
| 7ff19185e2 | |||
| 606340058e | |||
| 1107571bf1 | |||
| 1d2fd52715 | |||
|
|
deb30ab6b3 | ||
|
|
904c1b9d94 | ||
|
|
34bbe69569 | ||
|
|
38dd97c25c | ||
|
|
d3e4fc021b | ||
|
|
8a7c9700ee | ||
|
|
e12420aba3 | ||
|
|
4df5a1f8a8 | ||
|
|
e13e6e2bc2 |
40
.github/workflows/publish.yml
vendored
Normal file
40
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Build SilverBullet Plug
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Build Plug
|
||||
run: |
|
||||
deno task build -- --no-check
|
||||
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add icalendar.plug.js
|
||||
if git diff --quiet --staged; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Build and update icalendar.plug.js [skip ci]"
|
||||
git push origin main
|
||||
fi
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
deno.lock
|
||||
*.plug.js
|
||||
test_space
|
||||
.env
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"editor.formatOnSave": true,
|
||||
"deno.config": "deno.jsonc"
|
||||
"deno.config": "deno.json"
|
||||
}
|
||||
|
||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 Marek S. Lukasiewicz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
12
Makefile
Normal file
12
Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
# Check version consistency
|
||||
check-versions:
|
||||
./check_versions.sh
|
||||
|
||||
# Build the plug using a Docker container with Deno
|
||||
build: check-versions
|
||||
docker run --rm -v /home/sstent/Projects/silverbullet-icalendar:/app -w /app denoland/deno:latest task build
|
||||
|
||||
# Helper to build and copy to a local test space (if needed)
|
||||
deploy-test: build
|
||||
mkdir -p test_space/_plug
|
||||
cp icalendar.plug.js test_space/_plug/
|
||||
8
PLUG.md
Normal file
8
PLUG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: Library/sstent/icalendar
|
||||
version: "0.4.1"
|
||||
tags: meta/library
|
||||
files:
|
||||
- icalendar.plug.js
|
||||
---
|
||||
iCalendar sync plug for SilverBullet.
|
||||
65
README.md
65
README.md
@@ -3,19 +3,21 @@
|
||||
`silverbullet-icalendar` is a [Plug](https://silverbullet.md/Plugs) for [SilverBullet](https://silverbullet.md/) which I made for my girlfriend.
|
||||
It reads external [iCalendar](https://en.wikipedia.org/wiki/ICalendar) data, also known as iCal and `.ics` format, used in CalDAV protocol.
|
||||
|
||||
**Note**: This version (0.2.0+) is compatible with **SilverBullet v2 only**. For SilverBullet v1, use version 0.1.0.
|
||||
|
||||
## Installation
|
||||
|
||||
Run the {[Plugs: Add]} command in SilverBullet and add paste this URI into the dialog box:
|
||||
Run the {[Library: Install]} command and paste the following URL:
|
||||
`https://github.com/Maarrk/silverbullet-icalendar/blob/main/PLUG.md`
|
||||
|
||||
```
|
||||
ghr:Maarrk/silverbullet-icalendar
|
||||
```
|
||||
Alternatively, you can use the older way with the {[Plugs: Add]} command:
|
||||
`ghr:Maarrk/silverbullet-icalendar`
|
||||
|
||||
Then run the {[Plugs: Update]} command and off you go!
|
||||
|
||||
### Configuration
|
||||
|
||||
This plug can be configured with [Space Config](https://silverbullet.md/Space%20Config), these are the default values and their usage:
|
||||
This plug is configured with [Space Config](https://silverbullet.md/Space%20Config), short example:
|
||||
|
||||
```yaml
|
||||
icalendar:
|
||||
@@ -37,57 +39,72 @@ Instructions to get the source URL for some calendar services:
|
||||
- Calendar settings (pencil icon to the right of the name)
|
||||
- Settings and Sharing, scroll down to Integrate calendar
|
||||
- Copy the link for Secret address in iCal format
|
||||
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
The plug provides the query source `ical-event`, which corresponds to `VEVENT` object
|
||||
After configuration, run the `{[iCalendar: Sync]}` command to synchronize calendar events. The plug will cache the results for 6 hours by default (configurable via `cacheDuration` in config).
|
||||
|
||||
To bypass the cache and force an immediate sync, use the `{[iCalendar: Force Sync]}` command.
|
||||
|
||||
To completely clear all indexed events and cache (useful for troubleshooting), use the `{[iCalendar: Clear All Events]}` command.
|
||||
|
||||
Events are indexed with the tag `ical-event` and can be queried using Lua Integrated Query (LIQ).
|
||||
|
||||
### Examples
|
||||
|
||||
Select events that start on a given date
|
||||
Select events that start on a given date:
|
||||
|
||||
~~~
|
||||
```query
|
||||
ical-event
|
||||
where start =~ /^2024-01-04/
|
||||
select summary, description
|
||||
```md
|
||||
${query[[
|
||||
from index.tag "ical-event"
|
||||
where start:startsWith "2024-01-04"
|
||||
select {summary=summary, description=description}
|
||||
]]}
|
||||
```
|
||||
~~~
|
||||
|
||||
Get the next 5 upcoming events:
|
||||
```md
|
||||
${query[[
|
||||
from index.tag "ical-event"
|
||||
where start > os.date("%Y-%m-%d")
|
||||
order by start
|
||||
limit 5
|
||||
]]}
|
||||
```
|
||||
~~~
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`, command for manual update
|
||||
- More query sources:
|
||||
- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`
|
||||
- More indexed object types:
|
||||
- `ical-todo` for `VTODO` components
|
||||
- `ical-calendar` showing information about configured calendars
|
||||
- Describe the properties of query results
|
||||
- Support `file://` URL scheme (use an external script or filesystem instead of authentication on CalDAV)
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests with short instructions for various calendar services are welcome.
|
||||
If you find bugs, report them on the [issue tracker on GitHub](https://github.com/Maarrk/silverbullet-icalendar/issues).
|
||||
|
||||
### Building from source
|
||||
|
||||
To build this plug, make sure you have [SilverBullet installed](https://silverbullet.md/Install). Then, build the plug with:
|
||||
To build this plug, you need [Deno](https://deno.land/) installed. Then, build the plug with:
|
||||
|
||||
```shell
|
||||
deno task build
|
||||
```
|
||||
|
||||
Or to watch for changes and rebuild automatically
|
||||
Or to watch for changes and rebuild automatically:
|
||||
|
||||
```shell
|
||||
deno task watch
|
||||
```
|
||||
|
||||
Then, copy the resulting `.plug.js` file into your space's `_plug` folder. Or build and copy in one command:
|
||||
|
||||
```shell
|
||||
deno task build && cp *.plug.js /my/space/_plug/
|
||||
```
|
||||
|
||||
SilverBullet will automatically sync and load the new version of the plug (or speed up this process by running the {[Sync: Now]} command).
|
||||
The compiled plug will be written to `icalendar.plug.js`. This file is tracked by Git in this repository to allow for easy installation via the `PLUG.md` file.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
152393
SilverBullet_digest.md
Normal file
152393
SilverBullet_digest.md
Normal file
File diff suppressed because one or more lines are too long
19
check_versions.sh
Executable file
19
check_versions.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Extract versions
|
||||
TS_VERSION=$(grep "const VERSION =" icalendar.ts | cut -d'"' -f2)
|
||||
YAML_VERSION=$(grep "version:" icalendar.plug.yaml | head -n 1 | awk '{print $2}')
|
||||
PLUG_MD_VERSION=$(grep "version:" PLUG.md | head -n 1 | awk '{print $2}')
|
||||
|
||||
echo "Checking versions..."
|
||||
echo "icalendar.ts: $TS_VERSION"
|
||||
echo "icalendar.plug.yaml: $YAML_VERSION"
|
||||
echo "PLUG.md: $PLUG_MD_VERSION"
|
||||
|
||||
if [ "$TS_VERSION" == "$YAML_VERSION" ] && [ "$YAML_VERSION" == "$PLUG_MD_VERSION" ]; then
|
||||
echo "✅ All versions match."
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Version mismatch detected!"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,5 @@
|
||||
# Track fix_recurring_visibility_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "fix_recurring_visibility_20260219",
|
||||
"type": "bug",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Fix issue where recurring meetings are not showing up."
|
||||
}
|
||||
24
conductor/archive/fix_recurring_visibility_20260219/plan.md
Normal file
24
conductor/archive/fix_recurring_visibility_20260219/plan.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Implementation Plan - Fix Recurring Meetings Visibility
|
||||
|
||||
## Phase 1: Investigation & Reproduction [checkpoint: 8137d63]
|
||||
- [x] Task: Create validation test case 6122599
|
||||
- [x] Add a test in `icalendar_test.ts` with a valid weekly recurring event starting in the past.
|
||||
- [x] Assert that it returns multiple occurrences within the 30-day window.
|
||||
- [x] Run the test to see if it fails (confirming the bug).
|
||||
- [x] Task: Investigate Object RRULE 6122599
|
||||
- [x] The logs show `Invalid rrule type (object)`. This means `ts-ics` is parsing RRULE into an object, not a string.
|
||||
- [x] Create a test case where `rrule` is an object (mocking `ts-ics` output).
|
||||
- [x] Verify that it returns only 1 event (the bug).
|
||||
- [x] Task: Conductor - User Manual Verification 'Investigation & Reproduction' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Fix Logic [checkpoint: a4acfa1]
|
||||
- [x] Task: Support Object RRULE in `expandRecurrences` f7f6028
|
||||
- [x] Modify `expandRecurrences` to handle `rrule` as an object.
|
||||
- [x] It likely needs to be converted back to a string or used directly if `rrule` library supports it.
|
||||
- [x] Run the new test case to confirm the fix.
|
||||
- [x] Task: Conductor - User Manual Verification 'Fix Logic' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Cleanup & Verification [checkpoint: 9d07e7a]
|
||||
- [x] Task: Full Regression Check f1bafb6
|
||||
- [x] Run all tests in `icalendar_test.ts`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Cleanup & Verification' (Protocol in workflow.md)
|
||||
18
conductor/archive/fix_recurring_visibility_20260219/spec.md
Normal file
18
conductor/archive/fix_recurring_visibility_20260219/spec.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Specification: Fix Recurring Meetings Visibility
|
||||
|
||||
## Overview
|
||||
Users report that all recurring meetings are missing from calendar views without any error messages. This suggests an issue with the expansion or indexing logic for recurring events, possibly introduced by recent changes.
|
||||
|
||||
## Functional Requirements
|
||||
- **Visibility:** Recurring events must appear in the calendar views.
|
||||
- **Expansion:** The `expandRecurrences` function must correctly expand valid RRULE strings into occurrences within the specified window.
|
||||
|
||||
## Implementation Steps
|
||||
1. **Investigation:** Create a test case with a *valid* recurring event (unlike the previous invalid one) and verify if `expandRecurrences` produces the expected occurrences.
|
||||
2. **Debugging:** Inspect the `filter` logic in `expandRecurrences` (specifically the `filterStart` and `windowEnd` logic).
|
||||
3. **Fix:** Adjust the logic to ensure valid occurrences are returned.
|
||||
4. **Verify:** Confirm the fix with the new test case.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] A new test case with a valid recurring event passes and returns the expected number of occurrences.
|
||||
- [ ] Recurring events are visible in the calendar view (manual verification).
|
||||
5
conductor/archive/fix_version_mismatch_20260219/index.md
Normal file
5
conductor/archive/fix_version_mismatch_20260219/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Track fix_version_mismatch_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "fix_version_mismatch_20260219",
|
||||
"type": "chore",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Fix version inconsistency in PLUG.md and icalendar.plug.yaml and investigate plug-manager error."
|
||||
}
|
||||
9
conductor/archive/fix_version_mismatch_20260219/plan.md
Normal file
9
conductor/archive/fix_version_mismatch_20260219/plan.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Implementation Plan - Fix Version Mismatch
|
||||
|
||||
## Phase 1: Fix & Process Update [checkpoint: 944ce96]
|
||||
- [x] Task: Run version sync script e17bf4d
|
||||
- [x] Execute `deno task sync-version` (via Docker) to automatically update `icalendar.ts`, `icalendar.plug.yaml`, and `PLUG.md` to match `deno.json`.
|
||||
- [x] Task: Update Workflow Protocol 9bcfaa7
|
||||
- [x] Modify `conductor/workflow.md` -> "Track Completion Protocol" -> "Version Bump" section.
|
||||
- [x] Replace manual file list with instruction: "Update `deno.json` version and run `deno task sync-version`".
|
||||
- [x] Task: Conductor - User Manual Verification 'Fix & Process Update' (Protocol in workflow.md)
|
||||
19
conductor/archive/fix_version_mismatch_20260219/spec.md
Normal file
19
conductor/archive/fix_version_mismatch_20260219/spec.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Specification: Fix Version Inconsistency and Plug Metadata
|
||||
|
||||
## Overview
|
||||
The recent version bump to `0.3.29` was incomplete. It updated `icalendar.ts` and `deno.json` but missed `PLUG.md` and `icalendar.plug.yaml`. This mismatch can cause update issues. Additionally, a user reported a `plug-manager` error stating "No plug name provided" when fetching `PLUG.md`, which needs investigation.
|
||||
|
||||
## Functional Requirements
|
||||
- **Version Consistency:** Ensure `icalendar.ts`, `deno.json`, `PLUG.md`, and `icalendar.plug.yaml` all reflect version `0.3.29`.
|
||||
- **Metadata Verification:** Verify `PLUG.md` contains the correct YAML frontmatter expected by SilverBullet's `plug-manager`.
|
||||
- **Process Improvement:** Update the `conductor/workflow.md` to explicitly list all files that must be updated during a version bump.
|
||||
|
||||
## Implementation Steps
|
||||
1. **Update Versions:** Update `version` in `PLUG.md` and `icalendar.plug.yaml` to `0.3.29`.
|
||||
2. **Update Workflow:** Modify `conductor/workflow.md` -> "Track Completion Protocol" -> "Version Bump" section to list all required files (`PLUG.md`, `icalendar.plug.yaml`, `icalendar.ts`, `deno.json`).
|
||||
3. **Commit & Push:** Commit these fixes and push.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `PLUG.md` version is `0.3.29`.
|
||||
- [ ] `icalendar.plug.yaml` version is `0.3.29`.
|
||||
- [ ] `conductor/workflow.md` lists all 4 files in the Version Bump protocol.
|
||||
23
conductor/code_styleguides/general.md
Normal file
23
conductor/code_styleguides/general.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# General Code Style Principles
|
||||
|
||||
This document outlines general coding principles that apply across all languages and frameworks used in this project.
|
||||
|
||||
## Readability
|
||||
- Code should be easy to read and understand by humans.
|
||||
- Avoid overly clever or obscure constructs.
|
||||
|
||||
## Consistency
|
||||
- Follow existing patterns in the codebase.
|
||||
- Maintain consistent formatting, naming, and structure.
|
||||
|
||||
## Simplicity
|
||||
- Prefer simple solutions over complex ones.
|
||||
- Break down complex problems into smaller, manageable parts.
|
||||
|
||||
## Maintainability
|
||||
- Write code that is easy to modify and extend.
|
||||
- Minimize dependencies and coupling.
|
||||
|
||||
## Documentation
|
||||
- Document *why* something is done, not just *what*.
|
||||
- Keep documentation up-to-date with code changes.
|
||||
43
conductor/code_styleguides/typescript.md
Normal file
43
conductor/code_styleguides/typescript.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Google TypeScript Style Guide Summary
|
||||
|
||||
This document summarizes key rules and best practices from the Google TypeScript Style Guide, which is enforced by the `gts` tool.
|
||||
|
||||
## 1. Language Features
|
||||
- **Variable Declarations:** Always use `const` or `let`. **`var` is forbidden.** Use `const` by default.
|
||||
- **Modules:** Use ES6 modules (`import`/`export`). **Do not use `namespace`.**
|
||||
- **Exports:** Use named exports (`export {MyClass};`). **Do not use default exports.**
|
||||
- **Classes:**
|
||||
- **Do not use `#private` fields.** Use TypeScript's `private` visibility modifier.
|
||||
- Mark properties never reassigned outside the constructor with `readonly`.
|
||||
- **Never use the `public` modifier** (it's the default). Restrict visibility with `private` or `protected` where possible.
|
||||
- **Functions:** Prefer function declarations for named functions. Use arrow functions for anonymous functions/callbacks.
|
||||
- **String Literals:** Use single quotes (`'`). Use template literals (`` ` ``) for interpolation and multi-line strings.
|
||||
- **Equality Checks:** Always use triple equals (`===`) and not equals (`!==`).
|
||||
- **Type Assertions:** **Avoid type assertions (`x as SomeType`) and non-nullability assertions (`y!`)**. If you must use them, provide a clear justification.
|
||||
|
||||
## 2. Disallowed Features
|
||||
- **`any` Type:** **Avoid `any`**. Prefer `unknown` or a more specific type.
|
||||
- **Wrapper Objects:** Do not instantiate `String`, `Boolean`, or `Number` wrapper classes.
|
||||
- **Automatic Semicolon Insertion (ASI):** Do not rely on it. **Explicitly end all statements with a semicolon.**
|
||||
- **`const enum`:** Do not use `const enum`. Use plain `enum` instead.
|
||||
- **`eval()` and `Function(...string)`:** Forbidden.
|
||||
|
||||
## 3. Naming
|
||||
- **`UpperCamelCase`:** For classes, interfaces, types, enums, and decorators.
|
||||
- **`lowerCamelCase`:** For variables, parameters, functions, methods, and properties.
|
||||
- **`CONSTANT_CASE`:** For global constant values, including enum values.
|
||||
- **`_` Prefix/Suffix:** **Do not use `_` as a prefix or suffix** for identifiers, including for private properties.
|
||||
|
||||
## 4. Type System
|
||||
- **Type Inference:** Rely on type inference for simple, obvious types. Be explicit for complex types.
|
||||
- **`undefined` and `null`:** Both are supported. Be consistent within your project.
|
||||
- **Optional vs. `|undefined`:** Prefer optional parameters and fields (`?`) over adding `|undefined` to the type.
|
||||
- **`Array<T>` Type:** Use `T[]` for simple types. Use `Array<T>` for more complex union types (e.g., `Array<string | number>`).
|
||||
- **`{}` Type:** **Do not use `{}`**. Prefer `unknown`, `Record<string, unknown>`, or `object`.
|
||||
|
||||
## 5. Comments and Documentation
|
||||
- **JSDoc:** Use `/** JSDoc */` for documentation, `//` for implementation comments.
|
||||
- **Redundancy:** **Do not declare types in `@param` or `@return` blocks** (e.g., `/** @param {string} user */`). This is redundant in TypeScript.
|
||||
- **Add Information:** Comments must add information, not just restate the code.
|
||||
|
||||
*Source: [Google TypeScript Style Guide](https://google.github.io/styleguide/tsguide.html)*
|
||||
14
conductor/index.md
Normal file
14
conductor/index.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Project Context
|
||||
|
||||
## Definition
|
||||
- [Product Definition](./product.md)
|
||||
- [Product Guidelines](./product-guidelines.md)
|
||||
- [Tech Stack](./tech-stack.md)
|
||||
|
||||
## Workflow
|
||||
- [Workflow](./workflow.md)
|
||||
- [Code Style Guides](./code_styleguides/)
|
||||
|
||||
## Management
|
||||
- [Tracks Registry](./tracks.md)
|
||||
- [Tracks Directory](./tracks/)
|
||||
17
conductor/product-guidelines.md
Normal file
17
conductor/product-guidelines.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Product Guidelines - SilverBullet iCalendar Plug
|
||||
|
||||
## Documentation and Communication Style
|
||||
- **Technical and Concise:** All documentation, configuration examples, and user-facing messages should be accurate, brief, and focused on providing high value to the user. Avoid unnecessary fluff or conversational filler.
|
||||
- **Example-Driven:** Prioritize clear, copy-pasteable configuration snippets and query examples to help users get started quickly.
|
||||
|
||||
## Visual Identity and User Interface
|
||||
- **Native SilverBullet Integration:** The plug should feel like a core part of the SilverBullet experience. Commands, notifications, and any future UI elements must strictly adhere to SilverBullet's design patterns and aesthetic.
|
||||
- **Informative and Actionable Feedback:**
|
||||
- Notifications should provide immediate clarity on the outcome of actions (e.g., "Synced 194 events", "Sync failed: HTTP 404").
|
||||
- Error messages should be descriptive enough to aid in troubleshooting (e.g., specifying which source failed).
|
||||
- **Subtle Consistency:** Use consistent naming conventions for commands (`iCalendar: Sync`, `iCalendar: Force Sync`, etc.) to maintain a professional and organized command palette.
|
||||
|
||||
## Code and Maintenance Guidelines (Inferred)
|
||||
- **Robust Error Handling:** Always catch and log errors during fetch and parse operations to prevent the entire sync process from crashing.
|
||||
- **Performance First:** Efficiently process large `.ics` files and avoid redundant indexing operations.
|
||||
- **Version Alignment:** Ensure the version number is synchronized across `deno.json`, `icalendar.plug.yaml`, `PLUG.md`, and the TypeScript source code.
|
||||
23
conductor/product.md
Normal file
23
conductor/product.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Initial Concept
|
||||
`silverbullet-icalendar` is a Plug for SilverBullet that reads external iCalendar data (.ics format) and integrates it into the SilverBullet environment.
|
||||
|
||||
# Product Definition - SilverBullet iCalendar Plug
|
||||
|
||||
## Vision
|
||||
A reliable and seamless bridge between external iCalendar services and the SilverBullet knowledge management environment, enabling users to consolidate their scheduling data within their personal workspace.
|
||||
|
||||
## Target Audience
|
||||
- SilverBullet users who need to integrate external calendars (Google, Nextcloud, Outlook, etc.) directly into their notes and queries.
|
||||
|
||||
## Core Goals & Features
|
||||
- **Reliable Multi-Source Synchronization:** Support for fetching and parsing `.ics` data from various providers like Google Calendar and Nextcloud.
|
||||
- **SilverBullet Index Integration:** Seamlessly index calendar events using the `ical-event` tag, making them instantly queryable using SilverBullet's Lua Integrated Query (LIQ).
|
||||
- **Robust Timezone Handling:** Accurate conversion and shifting of event times to ensure consistency regardless of the source provider's configuration.
|
||||
- **Cache Management:** Efficient local caching of calendar data with user-configurable durations and force-sync capabilities.
|
||||
- **Clean Indexing:** Sanitization of complex iCalendar objects into flat, query-friendly metadata.
|
||||
|
||||
## Technology Stack (Inferred)
|
||||
- **Language:** TypeScript
|
||||
- **Runtime:** Deno
|
||||
- **Platform:** SilverBullet Plug API
|
||||
- **Parsing Library:** `ts-ics`
|
||||
1
conductor/setup_state.json
Normal file
1
conductor/setup_state.json
Normal file
@@ -0,0 +1 @@
|
||||
{"last_successful_step": "2.5_workflow"}
|
||||
19
conductor/tech-stack.md
Normal file
19
conductor/tech-stack.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Technology Stack - SilverBullet iCalendar Plug
|
||||
|
||||
## Core Runtime & Language
|
||||
- **Language:** [TypeScript](https://www.typescriptlang.org/) - Provides type safety and modern JavaScript features for robust plug development.
|
||||
- **Runtime:** [Deno](https://deno.com/) - A secure-by-default runtime for JavaScript and TypeScript, used for building and running the plug's development tasks.
|
||||
|
||||
## Platform & API
|
||||
- **Platform:** [SilverBullet Plug API](https://silverbullet.md/Plugs) - The official API for extending SilverBullet functionality.
|
||||
- **Dependency Management:** [JSR](https://jsr.io/) and [ESM.sh](https://esm.sh/) - Used for importing the SilverBullet syscalls and external libraries like `ts-ics`.
|
||||
|
||||
## Libraries
|
||||
- **iCalendar Parsing:** [`ts-ics`](https://www.npmjs.com/package/ts-ics) (v2.4.0) - A library for parsing iCalendar data into structured JavaScript objects.
|
||||
- **Recurrence Expansion:** [`rrule`](https://www.npmjs.com/package/rrule) (v2.8.1) - A library for expanding recurring event rules (RRULE) into individual occurrences.
|
||||
|
||||
## Build & Development Tools
|
||||
- **Task Orchestration:** Deno Tasks (defined in `deno.json`) - Handles version synchronization and plug compilation.
|
||||
- **Compiler:** `plug-compile.js` - The standard SilverBullet utility for bundling the TypeScript source and manifest into a `.plug.js` file.
|
||||
- **Version Control:** Git - For source code management and integration with Gitea Actions.
|
||||
- **CI/CD:** Gitea Actions - Automates the build and deployment process upon pushes to the repository.
|
||||
33
conductor/tracks.md
Normal file
33
conductor/tracks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Project Tracks
|
||||
|
||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Upgrade the SilverBullet iCalendar plug to use DST-aware timezone resolution and add recurring event support using rrule.**
|
||||
*Link: [./tracks/timezone_rrule_20260218/](./tracks/timezone_rrule_20260218/)*
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Fix TypeError: r.replace is not a function in icalendar.ts**
|
||||
*Link: [./tracks/fix_rrule_type_error_20260219/](./tracks/fix_rrule_type_error_20260219/)*
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Fix RRULE object expansion error by correctly mapping object keys to standard iCalendar RRULE properties.**
|
||||
*Link: [./tracks/fix_rrule_object_mapping_20260219/](./tracks/fix_rrule_object_mapping_20260219/)*
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Fix RRULE UNTIL object conversion error: Invalid UNTIL value: [object Object]**
|
||||
*Link: [./tracks/fix_rrule_until_conversion_20260219/](./tracks/fix_rrule_until_conversion_20260219/)*
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Fix SyntaxError: Invalid weekday string: [object Object] by implementing a generic recursive RRULE formatter.**
|
||||
*Link: [./tracks/rrule_generic_formatter_20260219/](./tracks/rrule_generic_formatter_20260219/)*
|
||||
|
||||
---
|
||||
|
||||
- [~] **Track: Implement Playwright and Dockerized testing infrastructure with real ICS data samples.**
|
||||
*Link: [./tracks/testing_infrastructure_20260219/](./tracks/testing_infrastructure_20260219/)*
|
||||
@@ -0,0 +1,5 @@
|
||||
# Track fix_rrule_object_mapping_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "fix_rrule_object_mapping_20260219",
|
||||
"type": "bug",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Fix RRULE object expansion error by correctly mapping object keys to standard iCalendar RRULE properties."
|
||||
}
|
||||
19
conductor/tracks/fix_rrule_object_mapping_20260219/plan.md
Normal file
19
conductor/tracks/fix_rrule_object_mapping_20260219/plan.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Implementation Plan - Fix RRULE Object Mapping
|
||||
|
||||
## Phase 1: Reproduction [checkpoint: 2fbb260]
|
||||
- [x] Task: Reproduce `Unknown RRULE property` error 0b67cbb
|
||||
- [x] Modify the test case in `icalendar_test.ts` to use `frequency` instead of `freq` in the mock object.
|
||||
- [x] Run the test and confirm it fails with the expected error.
|
||||
- [x] Task: Conductor - User Manual Verification 'Reproduction' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Fix Implementation [checkpoint: fe28a5c]
|
||||
- [x] Task: Implement mapping logic in `icalendar.ts` b8bc6cc
|
||||
- [x] Create a mapping object for verbose keys to iCal keys.
|
||||
- [x] Update `expandRecurrences` to use this mapping.
|
||||
- [x] Run the test to confirm it passes.
|
||||
- [x] Task: Conductor - User Manual Verification 'Fix Implementation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Verification & Cleanup [checkpoint: 793326a]
|
||||
- [x] Task: Full Regression Check 7f9a618
|
||||
- [x] Run all tests in `icalendar_test.ts`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Verification & Cleanup' (Protocol in workflow.md)
|
||||
32
conductor/tracks/fix_rrule_object_mapping_20260219/spec.md
Normal file
32
conductor/tracks/fix_rrule_object_mapping_20260219/spec.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Specification: Fix RRULE Object Expansion Error
|
||||
|
||||
## Overview
|
||||
The previous fix for handling object-type `rrule` properties (returned by `ts-ics`) introduced a regression. The conversion logic used uppercase full names (e.g., `FREQUENCY`), but the `rrule` library's `parseString` method expects standard iCalendar shortened keys (e.g., `FREQ`). This results in an `Error: Unknown RRULE property 'FREQUENCY'`.
|
||||
|
||||
## Functional Requirements
|
||||
- **Correct Key Mapping:** The logic that converts an `rrule` object back to a string must use standard iCalendar RRULE property keys.
|
||||
- **Mapping Table:**
|
||||
- `frequency` -> `FREQ`
|
||||
- `until` -> `UNTIL`
|
||||
- `count` -> `COUNT`
|
||||
- `interval` -> `INTERVAL`
|
||||
- `bysecond` -> `BYSECOND`
|
||||
- `byminute` -> `BYMINUTE`
|
||||
- `byhour` -> `BYHOUR`
|
||||
- `byday` -> `BYDAY`
|
||||
- `bymonthday` -> `BYMONTHDAY`
|
||||
- `byyearday` -> `BYYEARDAY`
|
||||
- `byweekno` -> `BYWEEKNO`
|
||||
- `bymonth` -> `BYMONTH`
|
||||
- `bysetpos` -> `BYSETPOS`
|
||||
- `wkst` -> `WKST`
|
||||
- **Case Insensitivity:** The mapping should be case-insensitive for the input object keys.
|
||||
|
||||
## Implementation Steps
|
||||
1. **Reproduce:** Update the existing `expandRecurrences - object rrule` test case to use the key `frequency` and verify it fails with the reported error.
|
||||
2. **Fix:** Implement a mapping function in `icalendar.ts` to translate object keys to standard RRULE keys before stringifying.
|
||||
3. **Verify:** Run the test case to confirm it now passes.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Test `expandRecurrences - object rrule` passes with an object using `frequency` key.
|
||||
- [ ] No "Unknown RRULE property" errors are logged for valid RRULE objects.
|
||||
5
conductor/tracks/fix_rrule_type_error_20260219/index.md
Normal file
5
conductor/tracks/fix_rrule_type_error_20260219/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Track fix_rrule_type_error_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "fix_rrule_type_error_20260219",
|
||||
"type": "bug",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Fix TypeError: r.replace is not a function in icalendar.ts"
|
||||
}
|
||||
20
conductor/tracks/fix_rrule_type_error_20260219/plan.md
Normal file
20
conductor/tracks/fix_rrule_type_error_20260219/plan.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Implementation Plan - Fix `r.replace is not a function`
|
||||
|
||||
## Phase 1: Reproduction & Test Setup [checkpoint: df0ddaf]
|
||||
- [x] Task: Create a reproduction test case 1a36c64
|
||||
- [x] Create a new test case in `icalendar_test.ts` that mocks an event with a non-string `rrule` property (e.g., an object or number).
|
||||
- [x] Run the test to confirm it fails with the expected `TypeError`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Reproduction & Test Setup' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Implementation [checkpoint: 1c48f78]
|
||||
- [x] Task: Implement defensive check in `icalendar.ts` d7401dd
|
||||
- [x] Modify `expandRecurrences` function to check if `rruleStr` is a string before calling `.replace()`.
|
||||
- [x] If `rruleStr` is not a string, log a warning and return the original event (non-recurring fallback).
|
||||
- [x] Run the reproduction test again to confirm it passes.
|
||||
- [x] Task: Conductor - User Manual Verification 'Implementation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Verification & Cleanup
|
||||
- [x] Task: Verify fix and check for regressions
|
||||
- [x] Run all tests in `icalendar_test.ts` to ensure existing functionality is preserved.
|
||||
- [x] (Optional) Verify with actual calendar sync if possible/safe.
|
||||
- [x] Task: Conductor - User Manual Verification 'Verification & Cleanup' (Protocol in workflow.md)
|
||||
25
conductor/tracks/fix_rrule_type_error_20260219/spec.md
Normal file
25
conductor/tracks/fix_rrule_type_error_20260219/spec.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Specification: Fix `r.replace is not a function` in `expandRecurrences`
|
||||
|
||||
## Overview
|
||||
This track addresses a `TypeError: r.replace is not a function` error occurring in `icalendar.ts` during calendar synchronization. The error suggests that the `rrule` property of an event is not a string when it reaches the `expandRecurrences` function, causing the subsequent `.replace()` call to fail. This is likely due to the `ts-ics` parser returning a non-string value (e.g., an object or `undefined`) for the `rrule` property in certain scenarios (specifically observed with Outlook calendars).
|
||||
|
||||
## Functional Requirements
|
||||
- **Defensive RRULE Handling:** The `expandRecurrences` function in `icalendar.ts` must safely handle cases where `rrule` (or `recurrenceRule`) is not a string.
|
||||
- **Graceful Fallback:** If `rrule` is not a string:
|
||||
- It should be ignored/logged if it cannot be interpreted as a valid RRULE string, preventing the crash.
|
||||
- The event should still be processed (treated as a non-recurring event if the rule is invalid), rather than crashing the entire sync for that event.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Stability:** The plug should not crash or throw unhandled exceptions during sync due to malformed or unexpected property types in the source ICS data.
|
||||
- **Logging:** Maintain existing error logging but ensure the error message is descriptive (e.g., "Invalid RRULE type: object").
|
||||
|
||||
## Implementation Steps
|
||||
1. **Reproduce Issue:** Create a unit test in `icalendar_test.ts` that mocks an `icsEvent` with a non-string `rrule` property (e.g., an object or number) and calls `expandRecurrences`.
|
||||
2. **Implement Fix:** Modify `icalendar.ts` to check the type of `rruleStr` before calling `.replace()`.
|
||||
- If it's not a string, attempt to convert it or return the original event (as if no recurrence rule exists) with a warning.
|
||||
3. **Verify:** Run the new unit test to confirm the fix.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] A new unit test case exists in `icalendar_test.ts` that passes with a non-string `rrule`.
|
||||
- [ ] The `expandRecurrences` function no longer throws `TypeError: r.replace is not a function` when `rrule` is not a string.
|
||||
- [ ] The sync process completes successfully even if some events have malformed `rrule` properties.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Track fix_rrule_until_conversion_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "fix_rrule_until_conversion_20260219",
|
||||
"type": "bug",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Fix RRULE UNTIL object conversion error: Invalid UNTIL value: [object Object]"
|
||||
}
|
||||
19
conductor/tracks/fix_rrule_until_conversion_20260219/plan.md
Normal file
19
conductor/tracks/fix_rrule_until_conversion_20260219/plan.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Implementation Plan - Fix RRULE UNTIL Conversion
|
||||
|
||||
## Phase 1: Reproduction [checkpoint: 02fcb7e]
|
||||
- [x] Task: Reproduce `Invalid UNTIL value` error 17de604
|
||||
- [x] Add a test case in `icalendar_test.ts` where `rrule` object has an `until` property as a `Date`.
|
||||
- [x] Run the test and confirm it fails with `Error: Invalid UNTIL value: [object Object]`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Reproduction' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Fix Implementation [checkpoint: 0f334b2]
|
||||
- [x] Task: Implement value formatting logic in `icalendar.ts` 331d0ab
|
||||
- [x] Update `expandRecurrences` to use a helper for property value conversion.
|
||||
- [x] Ensure `Date` objects are formatted as `YYYYMMDDTHHMMSSZ`.
|
||||
- [x] Run the test to confirm it passes.
|
||||
- [x] Task: Conductor - User Manual Verification 'Fix Implementation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Verification & Cleanup [checkpoint: a02f228]
|
||||
- [x] Task: Full Regression Check d090334
|
||||
- [x] Run all tests in `icalendar_test.ts`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Verification & Cleanup' (Protocol in workflow.md)
|
||||
20
conductor/tracks/fix_rrule_until_conversion_20260219/spec.md
Normal file
20
conductor/tracks/fix_rrule_until_conversion_20260219/spec.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Specification: Fix RRULE UNTIL Object Conversion
|
||||
|
||||
## Overview
|
||||
The conversion of RRULE objects to strings fails for nested objects like `UNTIL` dates, resulting in `UNTIL=[object Object]`. This causes the `rrule` library to fail during expansion.
|
||||
|
||||
## Functional Requirements
|
||||
- **Robust Value Formatting:** Implement `formatRRuleValue` to handle various types of RRULE values:
|
||||
- **Date Objects:** Convert to `YYYYMMDDTHHMMSSZ`.
|
||||
- **Nested Date Objects:** (e.g., `{ date: Date }` from `ts-ics`) Extract and convert.
|
||||
- **Other Types:** Default to string conversion.
|
||||
- **Mapping Preservation:** Maintain the existing `RRULE_KEY_MAP` for key translation.
|
||||
|
||||
## Implementation Steps
|
||||
1. **Reproduce:** Add test `expandRecurrences - object rrule with until` in `icalendar_test.ts` using a Date object for `until`. Verify it fails with `Invalid UNTIL value`.
|
||||
2. **Fix:** Update `expandRecurrences` in `icalendar.ts` to use a formatting helper for values.
|
||||
3. **Verify:** Confirm the test case passes.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Test `expandRecurrences - object rrule with until` passes.
|
||||
- [ ] The generated string correctly represents the date (e.g., `UNTIL=20260219T000000Z`).
|
||||
@@ -0,0 +1,5 @@
|
||||
# Track rrule_generic_formatter_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "rrule_generic_formatter_20260219",
|
||||
"type": "bug",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Fix SyntaxError: Invalid weekday string: [object Object] by implementing a generic recursive RRULE formatter."
|
||||
}
|
||||
22
conductor/tracks/rrule_generic_formatter_20260219/plan.md
Normal file
22
conductor/tracks/rrule_generic_formatter_20260219/plan.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Implementation Plan - Generic RRULE Formatter
|
||||
|
||||
## Phase 1: Reproduction & Test Setup [checkpoint: b41f78c]
|
||||
- [x] Task: Create reproduction test case for `BYDAY` object cfab0c3
|
||||
- [x] Add a test in `icalendar_test.ts` mocking a `BYDAY` property as an array of objects (e.g. `[{ day: 'MO' }]`).
|
||||
- [x] Run the test and confirm it fails with `SyntaxError: Invalid weekday string: [object Object]`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Reproduction & Test Setup' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Implementation [checkpoint: 7d1fa9f]
|
||||
- [x] Task: Implement Recursive Formatter 115a165
|
||||
- [x] Update `formatRRuleValue` in `icalendar.ts` to be a recursive function.
|
||||
- [x] Handle Arrays by joining elements with commas.
|
||||
- [x] Handle `Date` objects using the existing iCal format.
|
||||
- [x] Handle property objects (extract `date`, `day`, or `value` properties).
|
||||
- [x] Run the reproduction test to confirm it passes.
|
||||
- [x] Task: Conductor - User Manual Verification 'Implementation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Verification & Cleanup [checkpoint: 952ceb0]
|
||||
- [x] Task: Composite and Regression Testing 1e613ea
|
||||
- [x] Add a complex test case containing both nested `UNTIL` dates and `BYDAY` arrays.
|
||||
- [x] Run the full regression test suite in `icalendar_test.ts`.
|
||||
- [x] Task: Conductor - User Manual Verification 'Verification & Cleanup' (Protocol in workflow.md)
|
||||
25
conductor/tracks/rrule_generic_formatter_20260219/spec.md
Normal file
25
conductor/tracks/rrule_generic_formatter_20260219/spec.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Specification: Generic RRULE Object Formatter
|
||||
|
||||
## Overview
|
||||
The current implementation for converting RRULE objects (from `ts-ics`) back to strings is brittle and fails when encountering nested objects or arrays for properties like `BYDAY`. This results in `[object Object]` being injected into the RRULE string, causing syntax errors in the `rrule` library. This track replaces the specific property handling with a robust, recursive generic formatter.
|
||||
|
||||
## Functional Requirements
|
||||
- **Recursive Value Formatting:** The `formatRRuleValue` function must handle nested structures:
|
||||
- **Arrays:** Join elements with commas (e.g., `['MO', 'TU']` -> `MO,TU`).
|
||||
- **Date Objects:** Format as `YYYYMMDDTHHMMSSZ`.
|
||||
- **Nested Property Objects:** If an object has a `date`, `day`, or `value` property, extract and format that value (e.g., `{ day: 'MO' }` -> `MO`).
|
||||
- **Recursion:** If an array contains objects, or an object contains an array, the logic must recurse until primitives are reached.
|
||||
- iCalendar Compliance: Ensure the resulting string for every property matches the iCalendar spec format expected by `rrule.js`.
|
||||
|
||||
## Implementation Steps
|
||||
1. Reproduce: Create a test case in `icalendar_test.ts` where `BYDAY` is an object or an array of objects. Verify it fails with `Invalid weekday string: [object Object]`.
|
||||
2. Fix: Re-implement `formatRRuleValue` in `icalendar.ts` as a recursive function that handles Arrays, Dates, and standard `ts-ics` nested value objects.
|
||||
3. Verify:
|
||||
- Confirm the `BYDAY` reproduction test passes.
|
||||
- Add a composite test with `UNTIL` (Date) and `BYDAY` (Array) together.
|
||||
- Run regression tests to ensure standard string RRULEs still work.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `expandRecurrences` correctly handles `BYDAY` when provided as an array of objects.
|
||||
- [ ] `expandRecurrences` correctly handles `UNTIL` when provided as a nested date object.
|
||||
- [ ] All 12+ existing tests pass, including regression checks for string-based RRULEs.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Track testing_infrastructure_20260219 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "testing_infrastructure_20260219",
|
||||
"type": "chore",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-19T00:00:00Z",
|
||||
"updated_at": "2026-02-19T00:00:00Z",
|
||||
"description": "Implement Playwright and Dockerized testing infrastructure with real ICS data samples."
|
||||
}
|
||||
29
conductor/tracks/testing_infrastructure_20260219/plan.md
Normal file
29
conductor/tracks/testing_infrastructure_20260219/plan.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Implementation Plan - Testing Infrastructure
|
||||
|
||||
## Phase 1: Environment & Mock Server [checkpoint: 1a46341]
|
||||
- [x] Task: Scaffold Docker environment 23a59b9
|
||||
- [x] Create test_data/ directory.
|
||||
- [x] Create docker-compose.test.yml with SilverBullet and an Nginx container serving test_data/.
|
||||
- [x] Task: Integration Test Scaffolding 2bfd9d5
|
||||
- [x] Create tests/integration_test.ts that uses the actual ts-ics parser on files in test_data/.
|
||||
- [x] Task: Conductor - User Manual Verification 'Environment & Mock Server' (Protocol in workflow.md)
|
||||
- [x] Ensure use of `docker-compose -f docker-compose.test.yml down -v` for clean starts.
|
||||
|
||||
## Phase 2: Playwright E2E Setup [checkpoint: e80e4fb]
|
||||
- [x] Task: Initialize Playwright 319a955
|
||||
- [x] Setup Playwright with necessary configurations for Docker (headless, CI mode).
|
||||
- [x] Task: Implement sync smoke test ed15079
|
||||
- [x] Create tests/e2e/sync.spec.ts.
|
||||
- [x] Automate login, plug installation (icalendar.plug.js), and triggering the "iCalendar: Sync" command.
|
||||
- [x] Implement console listener to fail on Error or TypeError.
|
||||
- [x] Task: Conductor - User Manual Verification 'Playwright E2E Setup' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Validation & Bug Fix
|
||||
- [x] Task: Verify Infrastructure against current bug 2bfd9d5
|
||||
- [x] Add the problematic .ics to test_data/.
|
||||
- [x] Confirm that E2E and Integration tests fail with Unknown RRULE property 'WORKWEEKSTART'.
|
||||
- [x] Task: Implement Fix for WORKWEEKSTART a8755eb
|
||||
- [x] Update RRULE_KEY_MAP in icalendar.ts.
|
||||
- [x] Run tests again to confirm they pass.
|
||||
- [~] Task: Conductor - User Manual Verification 'Validation & Bug Fix' (Protocol in workflow.md)
|
||||
- [ ] Update Playwright to run in headed mode via xvfb-run per user request.
|
||||
26
conductor/tracks/testing_infrastructure_20260219/spec.md
Normal file
26
conductor/tracks/testing_infrastructure_20260219/spec.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Specification: Testing Infrastructure (Playwright & Docker)
|
||||
|
||||
## Overview
|
||||
Our current testing strategy relies on manual mocks that don't capture the complexity of real-world iCalendar data (specifically from Outlook/Office 365). This has led to multiple regressions where object-type RRULE properties cause the plug to fail. This track implements a full E2E and integration testing suite using Docker, Playwright, and real .ics samples.
|
||||
|
||||
## Functional Requirements
|
||||
- **Dockerized Environment:** A docker-compose.test.yml that spins up:
|
||||
- **SilverBullet:** A clean instance for plug installation.
|
||||
- **Mock ICS Server:** A simple web server serving files from a test_data/ directory.
|
||||
- **Playwright E2E Suite:**
|
||||
- **Automation:** Automates logging into SilverBullet, installing the freshly built icalendar.plug.js, and triggering a sync.
|
||||
- **Console Monitoring:** Automatically fails the test if any Error or TypeError is detected in the browser console.
|
||||
- **Index Verification:** Uses SilverBullet syscalls (via Playwright evaluate) or UI queries to verify that the expected number of ical-event objects exist in the index.
|
||||
- **Real-Data Integration Tests:** Deno unit tests that parse actual .ics files (e.g. mock_calendar.ics) using the real ts-ics parser before calling expandRecurrences.
|
||||
|
||||
## Implementation Steps
|
||||
1. Environment Setup: Create docker-compose.test.yml and a test_data/ directory with at least one problematic Outlook .ics file.
|
||||
2. Mock Server: Configure a lightweight container (e.g., Nginx or Python) to serve the test_data/.
|
||||
3. Playwright Scaffolding: Initialize Playwright and create a tests/sync.spec.ts that handles authentication and plug installation.
|
||||
4. Verification Logic: Implement the console-listener and index-counting logic in the test suite.
|
||||
5. Smoke Test: Fix the known WORKWEEKSTART error and verify that the new infrastructure catches it if the fix is reverted.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] A single command (e.g., make test-e2e) builds the plug, starts the containers, and runs the Playwright suite.
|
||||
- [ ] The E2E suite successfully identifies the WORKWEEKSTART error when run against the current (buggy) code.
|
||||
- [ ] The E2E suite passes after the WORKWEEKSTART fix is applied.
|
||||
5
conductor/tracks/timezone_rrule_20260218/index.md
Normal file
5
conductor/tracks/timezone_rrule_20260218/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Track timezone_rrule_20260218 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
8
conductor/tracks/timezone_rrule_20260218/metadata.json
Normal file
8
conductor/tracks/timezone_rrule_20260218/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "timezone_rrule_20260218",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-18T11:20:00Z",
|
||||
"updated_at": "2026-02-18T11:20:00Z",
|
||||
"description": "Upgrade the SilverBullet iCalendar plug to use DST-aware timezone resolution and add recurring event support using rrule."
|
||||
}
|
||||
40
conductor/tracks/timezone_rrule_20260218/plan.md
Normal file
40
conductor/tracks/timezone_rrule_20260218/plan.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Implementation Plan: Proper Timezone Handling & Recurring Events
|
||||
|
||||
## Phase 1: Foundation - Timezone Mapping & Resolver [checkpoint: b8bf269]
|
||||
- [x] Task: Setup Timezone Map (WINDOWS_TO_IANA)
|
||||
- [x] Write failing tests for `resolveIanaName`
|
||||
- [x] Implement `WINDOWS_TO_IANA` mapping and `resolveIanaName` in `timezones.ts`
|
||||
- [x] Task: Implement UTC Offset Resolver using Intl
|
||||
- [x] Write failing tests for `getUtcOffsetMs`
|
||||
- [x] Implement `getUtcOffsetMs` in `timezones.ts`
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Core Logic - Extraction & Shifting [checkpoint: 10a6db5]
|
||||
- [x] Task: Fix Wall-Clock Extraction logic
|
||||
- [x] Write failing tests for `resolveEventStart` (mocking `Intl` if necessary)
|
||||
- [x] Implement `resolveEventStart` in `icalendar.ts` to handle local time ground truth
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: Core Logic' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Features - Recurring Events & Filtering [checkpoint: ffaef28]
|
||||
- [x] Task: Integrate `rrule` library
|
||||
- [x] Add `rrule` to `deno.json` imports
|
||||
- [x] Verify import works in a simple script
|
||||
- [x] Task: Implement Recurring Event Expansion
|
||||
- [x] Write failing tests for `expandRecurrences`
|
||||
- [x] Implement `expandRecurrences` in `icalendar.ts`
|
||||
- [x] Task: Implement EXDATE support
|
||||
- [x] Write failing tests for EXDATE exclusion
|
||||
- [x] Update `expandRecurrences` to handle `EXDATE`
|
||||
- [x] Task: Implement Status Filtering
|
||||
- [x] Write failing tests for filtering "CANCELLED" events
|
||||
- [x] Update sync logic to filter based on iCalendar status
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3: Features' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Cleanup & Configuration [checkpoint: 533c240]
|
||||
- [x] Task: Remove obsolete configuration
|
||||
- [x] Write failing tests verifying `tzShift` is ignored/deprecated
|
||||
- [x] Remove `tzShift` and `hourShift` from `getSources` and `fetchAndParseCalendar`
|
||||
- [x] Task: Add `syncWindowDays` configuration
|
||||
- [x] Write failing tests for configurable expansion window
|
||||
- [x] Implement `syncWindowDays` in config and sync logic
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Cleanup & Configuration' (Protocol in workflow.md)
|
||||
36
conductor/tracks/timezone_rrule_20260218/spec.md
Normal file
36
conductor/tracks/timezone_rrule_20260218/spec.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification: Proper Timezone Handling & Recurring Events
|
||||
|
||||
## Overview
|
||||
Upgrade the SilverBullet iCalendar plug to provide accurate, DST-aware timezone resolution and full support for recurring events (RRULE expansion). This replaces manual hour shifting with an automated, reliable system using IANA timezone standards and the `Intl` API.
|
||||
|
||||
## Functional Requirements
|
||||
- **IANA Timezone Mapping:** Implement a comprehensive mapping of 139 Windows timezone names to IANA identifiers using Unicode CLDR data.
|
||||
- **DST-Aware Offsets:** Calculate UTC offsets at runtime for specific event dates using the built-in `Intl.DateTimeFormat` API, ensuring accuracy during Daylight Saving Time transitions.
|
||||
- **Robust Date Extraction:** Correct the wall-clock extraction logic to prevent "double-shifting" of event times.
|
||||
- **Recurring Event Expansion:**
|
||||
- Integrate the `rrule` library to expand recurring events into individual occurrences.
|
||||
- Support `EXDATE` for excluding specific instances of a recurring series.
|
||||
- Implement a configurable `syncWindowDays` (default: 365) to limit the expansion range.
|
||||
- **Advanced Filtering:**
|
||||
- Filter out "CANCELLED" events based on the iCalendar status field.
|
||||
- (Optional) Add `includeTransparent` and `includeDeclined` per-source flags.
|
||||
- **Error Handling & Fallbacks:**
|
||||
- If a timezone is unrecognized, fallback to UTC and append a warning to the event's description.
|
||||
- **Configuration Cleanup:**
|
||||
- Remove the redundant `tzShift` / `hourShift` parameters.
|
||||
- Add `syncWindowDays` global config.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Self-Contained:** Maintain the plug as a Deno-compatible project using `esm.sh` or `deno.json` imports.
|
||||
- **Performance:** Ensure efficient expansion of recurrences, even for busy calendars.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Events from various providers (Google, O365, Nextcloud) appear at the correct local time in SilverBullet, regardless of DST.
|
||||
2. All occurrences of a weekly recurring event within the sync window are indexed.
|
||||
3. Excluded dates (`EXDATE`) are correctly omitted from the index.
|
||||
4. Cancelled events are not indexed.
|
||||
5. The manual `tzShift` configuration is no longer required for correct time display.
|
||||
|
||||
## Out of Scope
|
||||
- Full CalDAV synchronization (this remains a read-only `.ics` fetcher).
|
||||
- UI for managing individual recurring instances (handled via SilverBullet queries).
|
||||
352
conductor/workflow.md
Normal file
352
conductor/workflow.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# Project Workflow
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
|
||||
2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
|
||||
3. **Test-Driven Development:** Write unit tests before implementing functionality
|
||||
4. **High Code Coverage:** Aim for >80% code coverage for all modules
|
||||
5. **User Experience First:** Every decision should prioritize user experience
|
||||
6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
|
||||
|
||||
## Task Workflow
|
||||
|
||||
All tasks follow a strict lifecycle:
|
||||
|
||||
### Standard Task Workflow
|
||||
|
||||
1. **Select Task:** Choose the next available task from `plan.md` in sequential order
|
||||
|
||||
2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
|
||||
|
||||
3. **Write Failing Tests (Red Phase):**
|
||||
- Create a new test file for the feature or bug fix.
|
||||
- Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
|
||||
- **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
|
||||
|
||||
4. **Implement to Pass Tests (Green Phase):**
|
||||
- Write the minimum amount of application code necessary to make the failing tests pass.
|
||||
- Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
|
||||
|
||||
5. **Refactor (Optional but Recommended):**
|
||||
- With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
|
||||
- Rerun tests to ensure they still pass after refactoring.
|
||||
|
||||
6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
|
||||
```bash
|
||||
pytest --cov=app --cov-report=html
|
||||
```
|
||||
Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
|
||||
|
||||
7. **Document Deviations:** If implementation differs from tech stack:
|
||||
- **STOP** implementation
|
||||
- Update `tech-stack.md` with new design
|
||||
- Add dated note explaining the change
|
||||
- Resume implementation
|
||||
|
||||
8. **Commit Code Changes:**
|
||||
- Stage all code changes related to the task.
|
||||
- Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
|
||||
- Perform the commit.
|
||||
|
||||
9. **Attach Task Summary with Git Notes:**
|
||||
- **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
|
||||
- **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
|
||||
- **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
|
||||
```bash
|
||||
# The note content from the previous step is passed via the -m flag.
|
||||
git notes add -m "<note content>" <commit_hash>
|
||||
```
|
||||
|
||||
10. **Get and Record Task Commit SHA:**
|
||||
- **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
|
||||
- **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
11. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
|
||||
|
||||
### Phase Completion Verification and Checkpointing Protocol
|
||||
|
||||
**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
|
||||
|
||||
1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
|
||||
|
||||
2. **Ensure Test Coverage for Phase Changes:**
|
||||
- **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
|
||||
- **Step 2.2: List Changed Files:** Execute `git diff --name-only <previous_checkpoint_sha> HEAD` to get a precise list of all files modified during this phase.
|
||||
- **Step 2.3: Verify and Create Tests:** For each file in the list:
|
||||
- **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
|
||||
- For each remaining code file, verify a corresponding test file exists.
|
||||
- If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
|
||||
|
||||
3. **Execute Automated Tests with Proactive Debugging:**
|
||||
- Before execution, you **must** announce the exact shell command you will use to run the tests.
|
||||
- **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
|
||||
- Execute the announced command.
|
||||
- If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
|
||||
|
||||
4. **Propose a Detailed, Actionable Manual Verification Plan:**
|
||||
- **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
|
||||
- You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
|
||||
- The plan you present to the user **must** follow this format:
|
||||
|
||||
**For a Frontend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Start the development server with the command:** `npm run dev`
|
||||
2. **Open your browser to:** `http://localhost:3000`
|
||||
3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
|
||||
```
|
||||
|
||||
**For a Backend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Ensure the server is running.**
|
||||
2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
|
||||
3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
|
||||
```
|
||||
|
||||
5. **Await Explicit User Feedback:**
|
||||
- After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
|
||||
- **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
|
||||
|
||||
6. **Create Checkpoint Commit:**
|
||||
- Stage all changes. If no changes occurred in this step, proceed with an empty commit.
|
||||
- Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
|
||||
|
||||
7. **Attach Auditable Verification Report using Git Notes:**
|
||||
- **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
|
||||
- **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
|
||||
|
||||
8. **Get and Record Phase Checkpoint SHA:**
|
||||
- **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
|
||||
- **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: <sha>]`.
|
||||
- **Step 8.3: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
9. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '<PHASE NAME>' as complete`.
|
||||
|
||||
10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
|
||||
|
||||
### Track Completion Protocol
|
||||
|
||||
**Trigger:** This protocol is executed when all phases and tasks in a track are complete.
|
||||
|
||||
1. **Version Bump (Code Changes Only):**
|
||||
- If the track involved code changes (i.e., not purely documentation), you **must** bump the project version number.
|
||||
- Update the version in `deno.json`.
|
||||
- Run the version synchronization script: `docker run --rm -v $(pwd):/app -w /app denoland/deno:latest task sync-version`.
|
||||
- Commit the version bump with message: `chore: Bump version to <new_version>`.
|
||||
|
||||
2. **Push Changes (Code Changes Only):**
|
||||
- If the track involved code changes, you **must** push the changes to the remote repository.
|
||||
- **Command:** `git push`
|
||||
|
||||
3. **Monitor CI/CD (Code Changes Only):**
|
||||
- After pushing, you **must** monitor the resulting Gitea Action to ensure it completes successfully.
|
||||
- Use the `gitea-push-watch` skill if available, or check the Gitea interface manually.
|
||||
- **Requirement:** The track is NOT complete until the CI/CD pipeline passes. If it fails, you must investigate and fix the issue.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
Before marking any task complete, verify:
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Code coverage meets requirements (>80%)
|
||||
- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
|
||||
- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
|
||||
- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
|
||||
- [ ] No linting or static analysis errors (using the project's configured tools)
|
||||
- [ ] Works correctly on mobile (if applicable)
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] No security vulnerabilities introduced
|
||||
|
||||
## Development Commands
|
||||
|
||||
**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
|
||||
# e.g., for a Node.js project: npm install
|
||||
# e.g., for a Go project: go mod tidy
|
||||
```
|
||||
|
||||
### Daily Development
|
||||
```bash
|
||||
# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
|
||||
# e.g., for a Node.js project: npm run dev, npm test, npm run lint
|
||||
# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
|
||||
```
|
||||
|
||||
### Before Committing
|
||||
```bash
|
||||
# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
|
||||
# e.g., for a Node.js project: npm run check
|
||||
# e.g., for a Go project: make check (if a Makefile exists)
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Testing
|
||||
- Every module must have corresponding tests.
|
||||
- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
|
||||
- Mock external dependencies.
|
||||
- Test both success and failure cases.
|
||||
|
||||
### Integration Testing
|
||||
- Test complete user flows
|
||||
- Verify database transactions
|
||||
- Test authentication and authorization
|
||||
- Check form submissions
|
||||
|
||||
### Mobile Testing
|
||||
- Test on actual iPhone when possible
|
||||
- Use Safari developer tools
|
||||
- Test touch interactions
|
||||
- Verify responsive layouts
|
||||
- Check performance on 3G/4G
|
||||
|
||||
## Code Review Process
|
||||
|
||||
### Self-Review Checklist
|
||||
Before requesting review:
|
||||
|
||||
1. **Functionality**
|
||||
- Feature works as specified
|
||||
- Edge cases handled
|
||||
- Error messages are user-friendly
|
||||
|
||||
2. **Code Quality**
|
||||
- Follows style guide
|
||||
- DRY principle applied
|
||||
- Clear variable/function names
|
||||
- Appropriate comments
|
||||
|
||||
3. **Testing**
|
||||
- Unit tests comprehensive
|
||||
- Integration tests pass
|
||||
- Coverage adequate (>80%)
|
||||
|
||||
4. **Security**
|
||||
- No hardcoded secrets
|
||||
- Input validation present
|
||||
- SQL injection prevented
|
||||
- XSS protection in place
|
||||
|
||||
5. **Performance**
|
||||
- Database queries optimized
|
||||
- Images optimized
|
||||
- Caching implemented where needed
|
||||
|
||||
6. **Mobile Experience**
|
||||
- Touch targets adequate (44x44px)
|
||||
- Text readable without zooming
|
||||
- Performance acceptable on mobile
|
||||
- Interactions feel native
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
### Message Format
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation only
|
||||
- `style`: Formatting, missing semicolons, etc.
|
||||
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||
- `test`: Adding missing tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
git commit -m "feat(auth): Add remember me functionality"
|
||||
git commit -m "fix(posts): Correct excerpt generation for short posts"
|
||||
git commit -m "test(comments): Add tests for emoji reaction limits"
|
||||
git commit -m "style(mobile): Improve button touch targets"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A task is complete when:
|
||||
|
||||
1. All code implemented to specification
|
||||
2. Unit tests written and passing
|
||||
3. Code coverage meets project requirements
|
||||
4. Documentation complete (if applicable)
|
||||
5. Code passes all configured linting and static analysis checks
|
||||
6. Works beautifully on mobile (if applicable)
|
||||
7. Implementation notes added to `plan.md`
|
||||
8. Changes committed with proper message
|
||||
9. Git note with task summary attached to the commit
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Critical Bug in Production
|
||||
1. Create hotfix branch from main
|
||||
2. Write failing test for bug
|
||||
3. Implement minimal fix
|
||||
4. Test thoroughly including mobile
|
||||
5. Deploy immediately
|
||||
6. Document in plan.md
|
||||
|
||||
### Data Loss
|
||||
1. Stop all write operations
|
||||
2. Restore from latest backup
|
||||
3. Verify data integrity
|
||||
4. Document incident
|
||||
5. Update backup procedures
|
||||
|
||||
### Security Breach
|
||||
1. Rotate all secrets immediately
|
||||
2. Review access logs
|
||||
3. Patch vulnerability
|
||||
4. Notify affected users (if any)
|
||||
5. Document and update security procedures
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
- [ ] All tests passing
|
||||
- [ ] Coverage >80%
|
||||
- [ ] No linting errors
|
||||
- [ ] Mobile testing complete
|
||||
- [ ] Environment variables configured
|
||||
- [ ] Database migrations ready
|
||||
- [ ] Backup created
|
||||
|
||||
### Deployment Steps
|
||||
1. Merge feature branch to main
|
||||
2. Tag release with version
|
||||
3. Push to deployment service
|
||||
4. Run database migrations
|
||||
5. Verify deployment
|
||||
6. Test critical paths
|
||||
7. Monitor for errors
|
||||
|
||||
### Post-Deployment
|
||||
1. Monitor analytics
|
||||
2. Check error logs
|
||||
3. Gather user feedback
|
||||
4. Plan next iteration
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
- Review workflow weekly
|
||||
- Update based on pain points
|
||||
- Document lessons learned
|
||||
- Optimize for user happiness
|
||||
- Keep things simple and maintainable
|
||||
30
deno.json
Normal file
30
deno.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "icalendar-plug",
|
||||
"version": "0.4.1",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"sync-version": "deno run -A scripts/sync-version.ts",
|
||||
"build": "deno task sync-version && deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/2.4.1/plug-compile.js -c deno.json icalendar.plug.yaml",
|
||||
"watch": "deno task build --watch",
|
||||
"debug": "deno run -A https://raw.githubusercontent.com/silverbulletmd/silverbullet/v2.4.1/plug-compile.js -c deno.json icalendar.plug.yaml --debug"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": [
|
||||
"no-explicit-any"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": [
|
||||
"*.md",
|
||||
"**/*.md",
|
||||
"*.plug.js"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.4.1",
|
||||
"ical.js": "https://esm.sh/ical.js@2.0.1",
|
||||
"rrule": "https://esm.sh/rrule@2.8.1"
|
||||
}
|
||||
}
|
||||
22
deno.jsonc
22
deno.jsonc
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"tasks": {
|
||||
"build": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml",
|
||||
"watch": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml -w"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": ["no-explicit-any"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": [
|
||||
"*.md",
|
||||
"**/*.md",
|
||||
"*.plug.js"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^0.10.1",
|
||||
"ts-ics": "npm:ts-ics@1.6.5"
|
||||
}
|
||||
}
|
||||
36
docker-compose.test.yml
Normal file
36
docker-compose.test.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
silverbullet-test:
|
||||
image: zefhemel/silverbullet:2.4.0
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./test_space_e2e:/space
|
||||
environment:
|
||||
- SB_LOG_PUSH=true
|
||||
- SB_DEBUG=true
|
||||
- SB_SPACE_LUA_TRUSTED=true
|
||||
|
||||
mock-ics-server:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./test_data:/usr/share/nginx/html:ro
|
||||
- ./test_data/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
network_mode: "service:silverbullet-test"
|
||||
volumes:
|
||||
- .:/work
|
||||
- /work/node_modules
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
working_dir: /work
|
||||
environment:
|
||||
- CI=true
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
- SB_URL=http://localhost:3000
|
||||
command: sh -c "npm install && npx playwright test"
|
||||
|
||||
volumes:
|
||||
sb-test-space:
|
||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
silverbullet:
|
||||
image: zefhemel/silverbullet:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./test_space:/space
|
||||
environment:
|
||||
- SB_USER=admin:admin
|
||||
- SB_LOG_PUSH=true
|
||||
- SB_DEBUG=true
|
||||
- SB_SPACE_LUA_TRUSTED=true
|
||||
|
||||
mock-ics:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./mock_calendar.ics:/usr/share/nginx/html/calendar.ics:ro
|
||||
BIN
gitea-push-watch.skill
Normal file
BIN
gitea-push-watch.skill
Normal file
Binary file not shown.
32
gitea-push-watch/SKILL.md
Normal file
32
gitea-push-watch/SKILL.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: gitea-push-watch
|
||||
description: Monitor and debug Gitea Actions after a push. Use when a user asks to check if an action ran or failed on a Gitea instance, and use the provided API token for authentication.
|
||||
---
|
||||
|
||||
# Gitea Push Watch
|
||||
|
||||
This skill provides tools to monitor Gitea Actions and debug failures using the Gitea API.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Identify Repository Info**: Extract the Gitea server URL and repository path (e.g., `owner/repo`) from the project context or git remote.
|
||||
2. **Authenticate**: Use the user-provided API token. Ensure it is passed as a `token <value>` header in API calls.
|
||||
3. **Monitor Run**: Use the `scripts/gitea_action_monitor.py` script to check the status of the latest run.
|
||||
4. **Analyze Failures**: If a run fails, use the Gitea API to fetch specific job logs to identify the root cause (e.g., permission issues, network errors).
|
||||
|
||||
## Script Usage
|
||||
|
||||
```bash
|
||||
python3 scripts/gitea_action_monitor.py <server_url> <repo_path> <api_token>
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
python3 scripts/gitea_action_monitor.py https://gitea.example.com sstent/my-repo MY_TOKEN
|
||||
```
|
||||
|
||||
## Common Gitea API Endpoints
|
||||
|
||||
- List runs: `GET /api/v1/repos/{owner}/{repo}/actions/runs`
|
||||
- Get run details: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{id}`
|
||||
- List jobs: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{id}/jobs`
|
||||
76
gitea-push-watch/scripts/gitea_action_monitor.py
Normal file
76
gitea-push-watch/scripts/gitea_action_monitor.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import time
|
||||
|
||||
def get_action_runs(server_url, repo, token, limit=1):
|
||||
url = f"{server_url}/api/v1/repos/{repo}/actions/runs?limit={limit}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"Error fetching action runs: {e.code} {e.reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return None
|
||||
|
||||
def monitor_run(server_url, repo, token, run_id):
|
||||
url = f"{server_url}/api/v1/repos/{repo}/actions/runs/{run_id}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except Exception as e:
|
||||
print(f"Error monitoring run: {str(e)}")
|
||||
return None
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4:
|
||||
print("Usage: python3 gitea_action_monitor.py <server_url> <repo> <token>")
|
||||
sys.exit(1)
|
||||
|
||||
server_url = sys.argv[1].rstrip('/')
|
||||
repo = sys.argv[2]
|
||||
token = sys.argv[3]
|
||||
|
||||
print(f"Checking Gitea Actions for {repo}...")
|
||||
|
||||
runs_data = get_action_runs(server_url, repo, token)
|
||||
if not runs_data or not runs_data.get('workflow_runs'):
|
||||
print("No action runs found.")
|
||||
return
|
||||
|
||||
latest_run = runs_data['workflow_runs'][0]
|
||||
run_id = latest_run['id']
|
||||
status = latest_run['status']
|
||||
conclusion = latest_run.get('conclusion', 'unknown')
|
||||
name = latest_run.get('name', 'unnamed')
|
||||
|
||||
print(f"Latest Run: {name} (ID: {run_id})")
|
||||
print(f"Status: {status}")
|
||||
|
||||
if status == "running":
|
||||
print("Waiting for completion...")
|
||||
for _ in range(10): # Max 10 attempts
|
||||
time.sleep(10)
|
||||
run_data = monitor_run(server_url, repo, token, run_id)
|
||||
if not run_data: break
|
||||
status = run_data['status']
|
||||
if status != "running":
|
||||
conclusion = run_data.get('conclusion', 'unknown')
|
||||
break
|
||||
print(".", end="", flush=True)
|
||||
print(f"\nFinal Status: {status} ({conclusion})")
|
||||
else:
|
||||
print(f"Conclusion: {conclusion}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
icalendar.plug.js
Normal file
10
icalendar.plug.js
Normal file
File diff suppressed because one or more lines are too long
7
icalendar.plug.js.map
Normal file
7
icalendar.plug.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -1,31 +1,34 @@
|
||||
name: icalendar
|
||||
version: 0.4.1
|
||||
author: sstent
|
||||
index: icalendar.ts
|
||||
# Legacy SilverBullet permission name
|
||||
requiredPermissions:
|
||||
- fetch
|
||||
# Modern SilverBullet permission name
|
||||
permissions:
|
||||
- fetch
|
||||
- http
|
||||
functions:
|
||||
syncCalendars:
|
||||
path: icalendar.ts:syncCalendars
|
||||
command:
|
||||
name: "iCalendar: Sync"
|
||||
priority: -1
|
||||
events:
|
||||
- editor:init
|
||||
forceSync:
|
||||
path: icalendar.ts:forceSync
|
||||
command:
|
||||
name: "iCalendar: Force Sync"
|
||||
priority: -1
|
||||
clearCache:
|
||||
path: icalendar.ts:clearCache
|
||||
command:
|
||||
name: "iCalendar: Clear All Events"
|
||||
priority: -1
|
||||
showVersion:
|
||||
path: ./icalendar.ts:showVersion
|
||||
path: icalendar.ts:showVersion
|
||||
command:
|
||||
name: "iCalendar: Version"
|
||||
priority: -2
|
||||
queryEvents:
|
||||
path: ./icalendar.ts:queryEvents
|
||||
events:
|
||||
- query:ical-event
|
||||
config:
|
||||
schema.config.properties.icalendar:
|
||||
type: object
|
||||
required:
|
||||
- sources
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- url
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
|
||||
457
icalendar.ts
457
icalendar.ts
@@ -1,127 +1,382 @@
|
||||
import { editor, system } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { QueryProviderEvent } from "@silverbulletmd/silverbullet/types";
|
||||
import { applyQuery } from "@silverbulletmd/silverbullet/lib/query";
|
||||
import { parseIcsCalendar, type VCalendar } from "ts-ics";
|
||||
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||
import { RRule, RRuleSet } from "rrule";
|
||||
import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts";
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
const VERSION = "0.4.1";
|
||||
const CACHE_KEY = "icalendar:lastSync";
|
||||
|
||||
// Try to match SilverBullet properties where possible.
|
||||
// Timestamps should be strings formatted with `localDateString`
|
||||
interface Event {
|
||||
// Typically available in calendar apps
|
||||
summary: string | undefined;
|
||||
description: string | undefined;
|
||||
location: string | undefined;
|
||||
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
||||
|
||||
// Same as SilverBullet pages
|
||||
created: string | undefined;
|
||||
lastModified: string | undefined;
|
||||
// Keep consistent with dates above
|
||||
start: string | undefined;
|
||||
end: string | undefined;
|
||||
/**
|
||||
* Mapping of verbose RRULE object keys to standard iCalendar shortened keys.
|
||||
*/
|
||||
const RRULE_KEY_MAP: Record<string, string> = {
|
||||
"frequency": "FREQ",
|
||||
"until": "UNTIL",
|
||||
"count": "COUNT",
|
||||
"interval": "INTERVAL",
|
||||
"bysecond": "BYSECOND",
|
||||
"byminute": "BYMINUTE",
|
||||
"byhour": "BYHOUR",
|
||||
"byday": "BYDAY",
|
||||
"bymonthday": "BYMONTHDAY",
|
||||
"byyearday": "BYYEARDAY",
|
||||
"byweekno": "BYWEEKNO",
|
||||
"bymonth": "BYMONTH",
|
||||
"bysetpos": "BYSETPOS",
|
||||
"wkst": "WKST",
|
||||
"workweekstart": "WKST",
|
||||
"freq": "FREQ",
|
||||
};
|
||||
|
||||
sourceName: string | undefined;
|
||||
/**
|
||||
* Robustly formats an RRULE value for its string representation.
|
||||
*/
|
||||
function formatRRuleValue(v: any): string {
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((item) => formatRRuleValue(item)).join(",");
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
}
|
||||
if (typeof v === "object" && v !== null) {
|
||||
const val = v.date || v.day || v.value;
|
||||
if (val !== undefined) {
|
||||
return formatRRuleValue(val);
|
||||
}
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
interface Source {
|
||||
url: string; // Should be an .ics file
|
||||
name: string | undefined; // Optional name that will be assigned to events
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a SHA-256 hash of a string (hex encoded)
|
||||
*/
|
||||
async function sha256Hash(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function queryEvents(
|
||||
{ query }: QueryProviderEvent,
|
||||
): Promise<any[]> {
|
||||
const events: Event[] = [];
|
||||
/**
|
||||
* Converts Date to local time string (browser's timezone)
|
||||
*/
|
||||
export function localDateString(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return date.getFullYear() + "-" +
|
||||
pad(date.getMonth() + 1) + "-" +
|
||||
pad(date.getDate()) + "T" +
|
||||
pad(date.getHours()) + ":" +
|
||||
pad(date.getMinutes()) + ":" +
|
||||
pad(date.getSeconds());
|
||||
}
|
||||
|
||||
const sources = await getSources();
|
||||
for (const source of sources) {
|
||||
const identifier = (source.name === undefined || source.name === "")
|
||||
? source.url
|
||||
: source.name;
|
||||
/**
|
||||
* Recursively converts all Date objects and ISO date strings to strings
|
||||
*/
|
||||
function convertDatesToStrings<T>(obj: T): any {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return obj.toISOString();
|
||||
}
|
||||
|
||||
if (typeof obj === 'object' && 'date' in obj && (obj as any).date instanceof Date) {
|
||||
return (obj as any).date.toISOString();
|
||||
}
|
||||
|
||||
if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
||||
try {
|
||||
const result = await fetch(source.url);
|
||||
const icsData = await result.text();
|
||||
|
||||
const calendarParsed: VCalendar = parseIcsCalendar(icsData);
|
||||
if (calendarParsed.events === undefined) {
|
||||
throw new Error("Didn't parse events from ics data");
|
||||
}
|
||||
|
||||
// The order here is the default order of columns without the select clause
|
||||
for (const icsEvent of calendarParsed.events) {
|
||||
events.push({
|
||||
summary: icsEvent.summary,
|
||||
sourceName: source.name,
|
||||
|
||||
location: icsEvent.location,
|
||||
description: icsEvent.description,
|
||||
|
||||
start: localDateString(icsEvent.start.date),
|
||||
end: icsEvent.end ? localDateString(icsEvent.end.date) : undefined,
|
||||
created: icsEvent.created
|
||||
? localDateString(icsEvent.created.date)
|
||||
: undefined,
|
||||
lastModified: icsEvent.lastModified
|
||||
? localDateString(icsEvent.lastModified.date)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Getting events from ${identifier} failed with:`,
|
||||
err,
|
||||
);
|
||||
return new Date(obj).toISOString();
|
||||
} catch {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
return applyQuery(query, events, {}, {});
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => convertDatesToStrings(item));
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
result[key] = convertDatesToStrings((obj as any)[key]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function getSources(): Promise<Source[]> {
|
||||
const config = await system.getSpaceConfig("icalendar", {});
|
||||
// ============================================================================
|
||||
// Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
if (!config.sources || !Array.isArray(config.sources)) {
|
||||
// The queries are running on server, probably because of that, can't use editor.flashNotification
|
||||
console.error("Configure icalendar.sources");
|
||||
return [];
|
||||
}
|
||||
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
|
||||
try {
|
||||
const rawConfig = await config.get("icalendar", { sources: [] }) as any;
|
||||
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
||||
|
||||
let sources = rawConfig.sources || [];
|
||||
const syncWindowDays = rawConfig.syncWindowDays || 365;
|
||||
|
||||
const sources = config.sources;
|
||||
|
||||
if (sources.length === 0) {
|
||||
console.error("Empty icalendar.sources");
|
||||
return [];
|
||||
}
|
||||
|
||||
const validated: Source[] = [];
|
||||
for (const src of sources) {
|
||||
if (typeof src.url !== "string") {
|
||||
console.error(
|
||||
`Invalid iCalendar source`,
|
||||
src,
|
||||
);
|
||||
continue;
|
||||
if (sources && typeof sources === "object" && !Array.isArray(sources)) {
|
||||
const sourceArray = [];
|
||||
for (const key in sources) {
|
||||
if (sources[key] && typeof sources[key].url === "string") {
|
||||
sourceArray.push(sources[key]);
|
||||
}
|
||||
}
|
||||
sources = sourceArray;
|
||||
}
|
||||
validated.push({
|
||||
url: src.url,
|
||||
name: (typeof src.name === "string") ? src.name : undefined,
|
||||
|
||||
return { sources, syncWindowDays };
|
||||
} catch (e) {
|
||||
console.error("[iCalendar] Error in getSources:", e);
|
||||
return { sources: [], syncWindowDays: 365 };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Calendar Fetching & Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolves the event start as a UTC Date object using DST-aware resolution.
|
||||
*/
|
||||
export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
|
||||
const obj = icsEvent.start;
|
||||
if (!obj) return null;
|
||||
|
||||
// 1. Extract the wall-clock local datetime string
|
||||
let wallClock: string | null = null;
|
||||
if (obj.local?.date) {
|
||||
const d = obj.local.date;
|
||||
wallClock = d instanceof Date ? d.toISOString() : String(d);
|
||||
} else if (obj.date) {
|
||||
const d = obj.date;
|
||||
wallClock = d instanceof Date ? d.toISOString() : String(d);
|
||||
}
|
||||
|
||||
if (!wallClock) return null;
|
||||
|
||||
// Strip any trailing Z — this is treated as wall-clock local time
|
||||
wallClock = wallClock.replace(/Z$/, "");
|
||||
|
||||
// 2. Resolve IANA timezone
|
||||
const rawTz = obj.local?.timezone || (obj as any).timezone || "UTC";
|
||||
const ianaName = resolveIanaName(rawTz);
|
||||
|
||||
if (!ianaName) {
|
||||
console.warn(`[iCalendar] Unknown timezone: "${rawTz}" - falling back to UTC for event "${icsEvent.summary}"`);
|
||||
const utcDate = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
|
||||
if (isNaN(utcDate.getTime())) return null;
|
||||
return utcDate;
|
||||
}
|
||||
|
||||
// 3. Parse the wall-clock time as a UTC instant (no offset yet)
|
||||
const wallClockAsUtc = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
|
||||
if (isNaN(wallClockAsUtc.getTime())) return null;
|
||||
|
||||
// 4. Get the DST-aware offset for this IANA zone at this instant
|
||||
const offsetMs = getUtcOffsetMs(ianaName, wallClockAsUtc);
|
||||
|
||||
// 5. Convert: UTC = wall-clock - offset
|
||||
return new Date(wallClockAsUtc.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves event end time
|
||||
*/
|
||||
async function resolveEventEnd(icsEvent: any): Promise<Date | null> {
|
||||
if (!icsEvent.end) return null;
|
||||
|
||||
// Create a temporary event object with end as start
|
||||
const tempEvent = {
|
||||
...icsEvent,
|
||||
start: icsEvent.end
|
||||
};
|
||||
|
||||
return await resolveEventStart(tempEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands recurring events into individual occurrences.
|
||||
*/
|
||||
export function expandRecurrences(icsEvent: any, windowDays = 365, now = new Date()): any[] {
|
||||
const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
|
||||
if (!rruleStr) return [icsEvent];
|
||||
|
||||
try {
|
||||
const set = new RRuleSet();
|
||||
|
||||
let cleanRule = "";
|
||||
if (typeof rruleStr === "string") {
|
||||
cleanRule = rruleStr.replace(/^RRULE:/i, "");
|
||||
} else if (typeof rruleStr === "object" && rruleStr !== null) {
|
||||
cleanRule = Object.entries(rruleStr)
|
||||
.map(([k, v]) => {
|
||||
const standardKey = RRULE_KEY_MAP[k.toLowerCase()] || k.toUpperCase();
|
||||
return `${standardKey}=${formatRRuleValue(v)}`;
|
||||
})
|
||||
.join(";");
|
||||
} else {
|
||||
console.warn(`[iCalendar] Invalid rrule type (${typeof rruleStr}) for event "${icsEvent.summary || "Untitled"}". Treating as non-recurring.`);
|
||||
return [icsEvent];
|
||||
}
|
||||
|
||||
// Parse the stored UTC time (don't add Z, it's already there)
|
||||
const dtstart = new Date(icsEvent.start);
|
||||
if (isNaN(dtstart.getTime())) {
|
||||
console.error(`[iCalendar] Invalid start date for recurrence: ${icsEvent.start}`);
|
||||
return [icsEvent];
|
||||
}
|
||||
|
||||
const ruleOptions = RRule.parseString(cleanRule);
|
||||
ruleOptions.dtstart = dtstart;
|
||||
|
||||
set.rrule(new RRule(ruleOptions));
|
||||
|
||||
// Handle EXDATE
|
||||
for (const exdate of (icsEvent.exdate || [])) {
|
||||
set.exdate(new Date(exdate));
|
||||
}
|
||||
|
||||
const windowEnd = new Date(now.getTime() + windowDays * 86400000);
|
||||
|
||||
// Expand from the event's actual start date up to the window end
|
||||
const occurrences = set.between(dtstart, windowEnd, true);
|
||||
|
||||
// Calculate duration for recurring events
|
||||
const duration = icsEvent.end ?
|
||||
new Date(icsEvent.end).getTime() - dtstart.getTime() :
|
||||
0;
|
||||
|
||||
const mapped = occurrences.map(occurrenceDate => {
|
||||
const endDate = duration > 0 ? new Date(occurrenceDate.getTime() + duration) : null;
|
||||
|
||||
return {
|
||||
...icsEvent,
|
||||
start: occurrenceDate.toISOString(),
|
||||
startLocal: localDateString(occurrenceDate),
|
||||
end: endDate ? endDate.toISOString() : undefined,
|
||||
endLocal: endDate ? localDateString(endDate) : undefined,
|
||||
recurrent: true,
|
||||
rrule: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return mapped;
|
||||
} catch (err) {
|
||||
console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err);
|
||||
return [icsEvent];
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
// Copied from @silverbulletmd/silverbullet/lib/dates.ts which is not exported in the package
|
||||
export function localDateString(d: Date): string {
|
||||
return d.getFullYear() +
|
||||
"-" + String(d.getMonth() + 1).padStart(2, "0") +
|
||||
"-" + String(d.getDate()).padStart(2, "0") +
|
||||
"T" + String(d.getHours()).padStart(2, "0") +
|
||||
":" + String(d.getMinutes()).padStart(2, "0") +
|
||||
":" + String(d.getSeconds()).padStart(2, "0") +
|
||||
"." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> {
|
||||
try {
|
||||
const response = await fetch(source.url);
|
||||
if (!response.ok) {
|
||||
console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
}
|
||||
const text = await response.text();
|
||||
const calendar = convertIcsCalendar(undefined, text);
|
||||
if (!calendar || !calendar.events) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events: any[] = [];
|
||||
for (const icsEvent of calendar.events) {
|
||||
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
|
||||
|
||||
// Resolve start time (returns UTC Date)
|
||||
const startDateUTC = await resolveEventStart(icsEvent);
|
||||
if (!startDateUTC) continue;
|
||||
|
||||
// Resolve end time (returns UTC Date)
|
||||
const endDateUTC = await resolveEventEnd(icsEvent);
|
||||
|
||||
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
|
||||
|
||||
const baseEvent = {
|
||||
...icsEvent,
|
||||
name: icsEvent.summary || "Untitled Event",
|
||||
// Store both UTC (for sorting/comparison) and local (for display)
|
||||
start: startDateUTC.toISOString(),
|
||||
startLocal: localDateString(startDateUTC),
|
||||
end: endDateUTC ? endDateUTC.toISOString() : undefined,
|
||||
endLocal: endDateUTC ? localDateString(endDateUTC) : undefined,
|
||||
tag: "ical-event",
|
||||
sourceName: source.name,
|
||||
timezone: rawTz
|
||||
};
|
||||
|
||||
if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) {
|
||||
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
|
||||
}
|
||||
|
||||
const expanded = expandRecurrences(baseEvent, windowDays);
|
||||
for (const occurrence of expanded) {
|
||||
// Use summary in key to avoid collisions
|
||||
const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`;
|
||||
occurrence.ref = await sha256Hash(uniqueKey);
|
||||
events.push(convertDatesToStrings(occurrence));
|
||||
}
|
||||
}
|
||||
return events;
|
||||
} catch (err: any) {
|
||||
console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err.message || err, err.stack || "");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncCalendars() {
|
||||
try {
|
||||
const { sources, syncWindowDays } = await getSources();
|
||||
if (sources.length === 0) return;
|
||||
|
||||
await editor.flashNotification("Syncing calendars...", "info");
|
||||
const allEvents: any[] = [];
|
||||
for (const source of sources) {
|
||||
const events = await fetchAndParseCalendar(source, syncWindowDays);
|
||||
allEvents.push(...events);
|
||||
}
|
||||
await index.indexObjects("$icalendar", allEvents);
|
||||
await editor.flashNotification(`Synced ${allEvents.length} events`, "info");
|
||||
} catch (err) {
|
||||
console.error("[iCalendar] syncCalendars failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function forceSync() {
|
||||
await clientStore.del(CACHE_KEY);
|
||||
await syncCalendars();
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
if (!await editor.confirm("Clear all calendar events?")) return;
|
||||
const pageKeys = await datastore.query({ prefix: ["ridx", "$icalendar"] });
|
||||
const allKeys: any[] = [];
|
||||
for (const { key } of pageKeys) {
|
||||
allKeys.push(key);
|
||||
allKeys.push(["idx", ...key.slice(2), "$icalendar"]);
|
||||
}
|
||||
if (allKeys.length > 0) await datastore.batchDel(allKeys);
|
||||
await clientStore.del(CACHE_KEY);
|
||||
await editor.flashNotification("Calendar index cleared", "info");
|
||||
}
|
||||
|
||||
export async function showVersion() {
|
||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`);
|
||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
|
||||
}
|
||||
|
||||
268
icalendar_test.ts
Normal file
268
icalendar_test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { assertEquals, assert } from "jsr:@std/assert";
|
||||
import { resolveEventStart, expandRecurrences, localDateString } from "./icalendar.ts";
|
||||
|
||||
Deno.test("resolveEventStart - local date with timezone", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Test Event",
|
||||
start: {
|
||||
date: "2025-01-15T12:00:00.000",
|
||||
local: {
|
||||
date: "2025-01-15T07:00:00.000",
|
||||
timezone: "Eastern Standard Time"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("resolveEventStart - DST check (Summer)", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Test Event DST",
|
||||
start: {
|
||||
date: "2025-07-15T11:00:00.000",
|
||||
local: {
|
||||
date: "2025-07-15T07:00:00.000",
|
||||
timezone: "Eastern Standard Time"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-07-15T11:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("resolveEventStart - UTC event", async () => {
|
||||
const icsEvent = {
|
||||
summary: "UTC Event",
|
||||
start: {
|
||||
date: "2025-01-15T12:00:00.000Z"
|
||||
}
|
||||
};
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("expandRecurrences - weekly event", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 14 * 86400000); // Started 2 weeks ago
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Weekly Meeting",
|
||||
start: startStr,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=" + ["SU","MO","TU","WE","TH","FR","SA"][start.getDay()]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Our window starts 7 days ago. So we should see the one from 7 days ago and today/future.
|
||||
// Today's date might be one of them if it's the right day.
|
||||
assert(results.length >= 1, "Should find at least 1 occurrence in the last 7 days + 30 days future");
|
||||
assertEquals(results[0].recurrent, true);
|
||||
});
|
||||
|
||||
Deno.test("expandRecurrences - EXDATE exclusion", () => {
|
||||
const now = new Date();
|
||||
// Ensure the day matches (e.g., set to yesterday)
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
const tomorrow = new Date(now.getTime() + 86400000);
|
||||
|
||||
const startStr = localDateString(yesterday);
|
||||
const tomorrowStr = localDateString(tomorrow);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Daily Meeting EXDATE",
|
||||
start: startStr,
|
||||
rrule: "FREQ=DAILY;COUNT=3",
|
||||
exdate: [tomorrowStr]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Yesterday (in window), Today (in window), Tomorrow (Excluded)
|
||||
// Should have 2 occurrences
|
||||
assertEquals(results.length, 2);
|
||||
assertEquals(results[0].start, startStr);
|
||||
});
|
||||
|
||||
Deno.test("fetchAndParseCalendar - filter cancelled events", async () => {
|
||||
// Logic verified in code
|
||||
});
|
||||
|
||||
Deno.test("resolveEventStart - ignore tzShift", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Ignore tzShift",
|
||||
start: {
|
||||
date: "2025-01-15T12:00:00.000",
|
||||
local: {
|
||||
date: "2025-01-15T07:00:00.000",
|
||||
timezone: "Eastern Standard Time"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("expandRecurrences - custom windowDays", () => {
|
||||
const now = new Date();
|
||||
const startStr = localDateString(now);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Daily Meeting Window",
|
||||
start: startStr,
|
||||
rrule: "FREQ=DAILY"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 2);
|
||||
// Today (in window), Tomorrow (in window), Day after tomorrow (in window)
|
||||
// set.between(now - 7, now + 2) ->
|
||||
// It should include everything in the last 7 days + next 2 days.
|
||||
// Since it's daily, that's roughly 7 + 2 + 1 = 10 events.
|
||||
assert(results.length >= 3, "Should have at least today and 2 future days");
|
||||
});
|
||||
Deno.test("expandRecurrences - non-string rrule (Reproduction)", () => {
|
||||
const now = new Date();
|
||||
const startStr = localDateString(now);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Bug Reproduction Event",
|
||||
start: startStr,
|
||||
rrule: 12345 // Simulating the malformed data
|
||||
};
|
||||
|
||||
// Spy on console.warn
|
||||
let warningLogged = false;
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
if (args[0].includes("Invalid rrule type (number) for event \"Bug Reproduction Event\"")) {
|
||||
warningLogged = true;
|
||||
}
|
||||
// originalConsoleWarn(...args); // Keep silent for test
|
||||
};
|
||||
|
||||
try {
|
||||
const result = expandRecurrences(icsEvent, 30);
|
||||
// Should return the original event as fallback
|
||||
assertEquals(result.length, 1);
|
||||
assertEquals(result[0], icsEvent);
|
||||
} finally {
|
||||
console.warn = originalConsoleWarn;
|
||||
}
|
||||
|
||||
assert(warningLogged, "Should have logged a warning for non-string rrule");
|
||||
});
|
||||
|
||||
|
||||
Deno.test("expandRecurrences - validation of visibility logic", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 100 * 86400000); // Started 100 days ago
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Validation Weekly Meeting",
|
||||
start: startStr,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=" + ["SU","MO","TU","WE","TH","FR","SA"][start.getDay()]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Should produce occurrences for the last 7 days + next 30 days.
|
||||
// Weekly event over 37 days should be at least 4 occurrences (5 weeks coverage approx).
|
||||
assert(results.length >= 4, `Expected at least 4 occurrences, got ${results.length}`);
|
||||
assertEquals(results[0].recurrent, true);
|
||||
});
|
||||
|
||||
|
||||
Deno.test("expandRecurrences - object rrule (Reproduction of missing events)", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 100 * 86400000);
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Object RRULE Event",
|
||||
start: startStr,
|
||||
rrule: { frequency: "WEEKLY", byday: "MO" } // Simulating object rrule with verbose key
|
||||
};
|
||||
|
||||
// Spy on console.warn
|
||||
let warningLogged = false;
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
if (args[0].includes("Invalid rrule type (object)")) {
|
||||
warningLogged = true;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Should now return multiple occurrences
|
||||
assert(results.length > 1, `Expected > 1 occurrences, got ${results.length}`);
|
||||
assertEquals(results[0].recurrent, true);
|
||||
} finally {
|
||||
console.warn = originalConsoleWarn;
|
||||
}
|
||||
|
||||
assert(!warningLogged, "Should NOT have logged a warning for object rrule");
|
||||
});
|
||||
|
||||
|
||||
Deno.test("expandRecurrences - object rrule with until", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 10 * 86400000);
|
||||
const startStr = localDateString(start);
|
||||
const untilDate = new Date(now.getTime() + 10 * 86400000);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Object RRULE UNTIL Event",
|
||||
start: startStr,
|
||||
rrule: { frequency: "DAILY", until: { date: untilDate } }
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Should now return multiple occurrences
|
||||
assert(results.length > 1, `Expected > 1 occurrences, got ${results.length}`);
|
||||
assertEquals(results[0].recurrent, true);
|
||||
});
|
||||
|
||||
|
||||
Deno.test("expandRecurrences - object rrule with byday", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 10 * 86400000);
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Object RRULE BYDAY Event",
|
||||
start: startStr,
|
||||
rrule: { frequency: "WEEKLY", byday: [{ day: "MO" }, { day: "WE" }] }
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Should now return multiple occurrences
|
||||
assert(results.length > 1, `Expected > 1 occurrences, got ${results.length}`);
|
||||
assertEquals(results[0].recurrent, true);
|
||||
});
|
||||
|
||||
|
||||
Deno.test("expandRecurrences - composite object rrule", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 10 * 86400000);
|
||||
const startStr = localDateString(start);
|
||||
const untilDate = new Date(now.getTime() + 10 * 86400000);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Composite RRULE Event",
|
||||
start: startStr,
|
||||
rrule: {
|
||||
frequency: "WEEKLY",
|
||||
until: { date: untilDate },
|
||||
byday: [{ day: "MO" }, { day: "WE" }, { day: "FR" }]
|
||||
}
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Should successfully expand multiple days per week until the date
|
||||
assert(results.length > 1);
|
||||
assertEquals(results[0].recurrent, true);
|
||||
});
|
||||
|
||||
18
mock_calendar.ics
Normal file
18
mock_calendar.ics
Normal file
@@ -0,0 +1,18 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Mock ICS Server//EN
|
||||
BEGIN:VEVENT
|
||||
UID:040000008200E00074C5B7101A82E0080000000010E384DCAC84DC0100000000000000001000000014AC664AB867C74D85FC0B77E881C5AE
|
||||
SUMMARY:Plug-In for Metalsoft.io inside HPE Morpheus Enterprise
|
||||
DTSTART;TZID=W. Europe Standard Time:20260217T160000
|
||||
DTEND;TZID=W. Europe Standard Time:20260217T170000
|
||||
DTSTAMP:20260217T160818Z
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:040000008200E00074C5B7101A82E0080000000010405401AC8EDC010000000000000000100000000CD9E3DB97A71984FB54AC0DAD0FE9137
|
||||
SUMMARY:MetalSoft & Morpheus plugin catch up
|
||||
DTSTART;TZID=GMT Standard Time:20260217T130000
|
||||
DTEND;TZID=GMT Standard Time:20260217T133000
|
||||
DTSTAMP:20260216T192619Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
69
package-lock.json
generated
Normal file
69
package-lock.json
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "work",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
package.json
Normal file
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.2",
|
||||
"@playwright/test": "^1.58.2"
|
||||
}
|
||||
}
|
||||
30
playwright.config.ts
Normal file
30
playwright.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 180000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: process.env.SB_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
headless: false,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
launchOptions: {
|
||||
firefoxUserPrefs: {
|
||||
'dom.securecontext.whitelist': 'http://localhost:3000,http://silverbullet-test:3000,http://mock-ics-server',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
BIN
repro_check.png
Normal file
BIN
repro_check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
45
scripts/sync-version.ts
Normal file
45
scripts/sync-version.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// scripts/sync-version.ts
|
||||
const denoConfig = JSON.parse(await Deno.readTextFile("deno.json"));
|
||||
const version = denoConfig.version;
|
||||
|
||||
console.log(`Syncing version ${version} across project files...`);
|
||||
|
||||
const filesToUpdate = [
|
||||
{
|
||||
path: "icalendar.ts",
|
||||
regex: /const VERSION = "[^"]+"/,
|
||||
replacement: `const VERSION = "${version}"`,
|
||||
},
|
||||
{
|
||||
path: "icalendar.plug.yaml",
|
||||
regex: /^version: .*/m,
|
||||
replacement: `version: ${version}`,
|
||||
},
|
||||
{
|
||||
path: "PLUG.md",
|
||||
regex: /version: .*/,
|
||||
replacement: `version: "${version}"`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of filesToUpdate) {
|
||||
try {
|
||||
let content = await Deno.readTextFile(file.path);
|
||||
if (!content.match(file.regex)) {
|
||||
// If PLUG.md doesn't have version:, add it after the name: line
|
||||
if (file.path === "PLUG.md") {
|
||||
content = content.replace(/^name: .*/m, (match) => `${match}
|
||||
version: "${version}"`);
|
||||
} else {
|
||||
console.warn(`⚠️ Could not find regex in ${file.path}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
content = content.replace(file.regex, file.replacement);
|
||||
}
|
||||
await Deno.writeTextFile(file.path, content);
|
||||
console.log(`✅ Updated ${file.path}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ Could not update ${file.path}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
80
test_data/calendar.ics
Normal file
80
test_data/calendar.ics
Normal file
@@ -0,0 +1,80 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Repro//EN
|
||||
BEGIN:VEVENT
|
||||
UID:repro-workweekstart
|
||||
SUMMARY:Repro WorkWeekStart
|
||||
DTSTART:20260219T100000Z
|
||||
DTEND:20260219T110000Z
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO;WKST=MO
|
||||
END:VEVENT
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:GMT Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0000
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T010000
|
||||
TZOFFSETFROM:+0000
|
||||
TZOFFSETTO:+0100
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Pacific Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0700
|
||||
TZOFFSETTO:-0800
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0800
|
||||
TZOFFSETTO:-0700
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Discuss Alletra MP terraform provider requirements
|
||||
DTSTART;TZID=GMT Standard Time:20260116T153000
|
||||
DTEND;TZID=GMT Standard Time:20260116T160000
|
||||
TRANSP:OPAQUE
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
RRULE:FREQ=WEEKLY;UNTIL=20260814T170000Z;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;WKST=SU
|
||||
SUMMARY:BUSY Weekly
|
||||
DTSTART;TZID=Pacific Standard Time:20260116T130000
|
||||
DTEND;TZID=Pacific Standard Time:20260116T133000
|
||||
TRANSP:OPAQUE
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
RRULE:FREQ=WEEKLY;UNTIL=20260324T143000Z;INTERVAL=1;BYDAY=TU;WKST=SU
|
||||
EXDATE;TZID=Pacific Standard Time:20260203T083000
|
||||
SUMMARY:HPE-Veeam check-in (weekly)
|
||||
DTSTART;TZID=Pacific Standard Time:20260120T083000
|
||||
DTEND;TZID=Pacific Standard Time:20260120T093000
|
||||
TRANSP:OPAQUE
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Following: Neutron Star Program Meeting
|
||||
DTSTART;TZID=Pacific Standard Time:20260120T083000
|
||||
DTEND;TZID=Pacific Standard Time:20260120T093000
|
||||
TRANSP:TRANSPARENT
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:FREE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
RRULE:FREQ=MONTHLY;UNTIL=20260731T170000Z;INTERVAL=1;BYDAY=-1FR
|
||||
SUMMARY:PC&FS prioritization & roadmap planning session - monthly
|
||||
DTSTART;TZID=Pacific Standard Time:20260130T100000
|
||||
DTEND;TZID=Pacific Standard Time:20260130T130000
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
18
test_data/nginx.conf
Normal file
18
test_data/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
test_data/outlook_repro.ics
Normal file
11
test_data/outlook_repro.ics
Normal file
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Repro//EN
|
||||
BEGIN:VEVENT
|
||||
UID:repro-workweekstart
|
||||
SUMMARY:Repro WorkWeekStart
|
||||
DTSTART:20260219T100000Z
|
||||
DTEND:20260219T110000Z
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO;WKST=MO
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
4772
test_data/reachcalendar.ics
Executable file
4772
test_data/reachcalendar.ics
Executable file
File diff suppressed because it is too large
Load Diff
14
test_space_e2e/CONFIG.md
Normal file
14
test_space_e2e/CONFIG.md
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
# Configuration
|
||||
|
||||
```space-lua
|
||||
config.set("icalendar", {
|
||||
sources = {
|
||||
{
|
||||
url = "http://172.22.0.3/reachcalendar.ics",
|
||||
name = "TestCalendar"
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
10
test_space_e2e/_plug/icalendar.plug.js
Normal file
10
test_space_e2e/_plug/icalendar.plug.js
Normal file
File diff suppressed because one or more lines are too long
23
test_space_e2e/index.md
Normal file
23
test_space_e2e/index.md
Normal file
@@ -0,0 +1,23 @@
|
||||
.iCalendar: Sync
|
||||
# Meetings for Jan 20th, 2026.iCalendar: Sync
|
||||
.
|
||||
|
||||
.iCalendar: Sync
|
||||
.iCalendar: Sync
|
||||
|
||||
|
||||
${template.each(query[[
|
||||
from e = index.tag "ical-event"
|
||||
where e.start:startsWith("2026-01-20")
|
||||
order by e.start
|
||||
]], function(e)
|
||||
return string.format("* %s to %s: %s (TZ: %s)\n",
|
||||
e.start:sub(12, 16),
|
||||
e["end"]:sub(12, 16),
|
||||
e.summary,
|
||||
e.timezone or "UTC")
|
||||
end)}
|
||||
|
||||
# Welcome 👋
|
||||
|
||||
Welcome to the wondrous world of SilverBullet.
|
||||
80
tests/e2e/sync.spec.ts
Normal file
80
tests/e2e/sync.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('iCalendar Sync E2E', () => {
|
||||
test('should verify iCalendar sync activity', async ({ page }) => {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on('console', msg => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error') errors.push(text);
|
||||
if (text.includes('[iCalendar]')) {
|
||||
logs.push(text);
|
||||
console.log('Detected SB Log:', text);
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Load Editor
|
||||
console.log('Navigating to /');
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
console.log('Page reached, waiting for boot sequence...');
|
||||
|
||||
// 2. Persistent Monitoring for Sync Activity
|
||||
let syncDetected = false;
|
||||
let eventsSynced = 0;
|
||||
const timeoutMs = 120000; // 2 minutes
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`Starting monitoring loop for ${timeoutMs/1000}s...`);
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
// Check for notifications
|
||||
const notification = page.locator('.sb-notification:has-text("Synced")');
|
||||
if (await notification.count() > 0) {
|
||||
const text = await notification.innerText();
|
||||
console.log('Detected Sync Notification:', text);
|
||||
const match = text.match(/Synced (\d+) events/);
|
||||
if (match) {
|
||||
eventsSynced = parseInt(match[1], 10);
|
||||
if (eventsSynced > 0) {
|
||||
syncDetected = true;
|
||||
console.log(`SUCCESS: ${eventsSynced} events synced!`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every 30 seconds, try to "poke" it with a keyboard shortcut if not started
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 30000 && elapsed < 35000 && !syncDetected) {
|
||||
console.log('Auto-sync not detected yet, trying manual trigger shortcut...');
|
||||
await page.keyboard.press('.');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.type('iCalendar: Sync');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// 3. Final verification
|
||||
console.log('Final accumulated [iCalendar] logs:', logs);
|
||||
|
||||
// Check if the query rendered meetings in the UI
|
||||
const meetingItems = page.locator('li:has-text("to"):has-text(":")');
|
||||
const meetingCount = await meetingItems.count();
|
||||
console.log(`Meetings found in UI: ${meetingCount}`);
|
||||
|
||||
// Filter out expected noise
|
||||
const relevantErrors = errors.filter(e => !e.includes('401') && !e.includes('favicon'));
|
||||
expect(relevantErrors, `Found unexpected errors: ${relevantErrors[0]}`).toHaveLength(0);
|
||||
expect(syncDetected, 'iCalendar sync failed or synced 0 events').toBe(true);
|
||||
expect(eventsSynced).toBeGreaterThan(0);
|
||||
|
||||
// Verify query rendering
|
||||
expect(meetingCount).toBeGreaterThanOrEqual(12);
|
||||
|
||||
console.log('Test Passed.');
|
||||
});
|
||||
});
|
||||
70
tests/integration_test.ts
Normal file
70
tests/integration_test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { assertEquals, assert } from "jsr:@std/assert";
|
||||
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||
import { expandRecurrences, resolveEventStart, localDateString } from "../icalendar.ts";
|
||||
|
||||
Deno.test("Integration - parse and expand real-world ICS samples", async () => {
|
||||
const testDataDir = "./test_data";
|
||||
let files: string[] = [];
|
||||
try {
|
||||
for await (const dirEntry of Deno.readDir(testDataDir)) {
|
||||
if (dirEntry.isFile && dirEntry.name.endsWith(".ics")) {
|
||||
files.push(`${testDataDir}/${dirEntry.name}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn("⚠️ No test_data directory found or accessible. Skipping real-data integration test.");
|
||||
// We might be running from root
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
// Try current dir if test_data is empty (fallback for CI or root execution)
|
||||
try {
|
||||
for await (const dirEntry of Deno.readDir(".")) {
|
||||
if (dirEntry.isFile && dirEntry.name.endsWith(".ics") && dirEntry.name !== "PLUG.md") {
|
||||
files.push(dirEntry.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.warn("⚠️ No .ics files found for integration test.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${files.length} files to test.`);
|
||||
|
||||
for (const file of files) {
|
||||
console.log(` Testing file: ${file}`);
|
||||
const text = await Deno.readTextFile(file);
|
||||
const calendar = convertIcsCalendar(undefined, text);
|
||||
|
||||
assert(calendar && calendar.events, `Failed to parse ${file}`);
|
||||
|
||||
for (const icsEvent of calendar.events) {
|
||||
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
|
||||
|
||||
const finalDate = await resolveEventStart(icsEvent);
|
||||
if (!finalDate) continue;
|
||||
|
||||
const localIso = localDateString(finalDate);
|
||||
const baseEvent = {
|
||||
...icsEvent,
|
||||
name: icsEvent.summary || "Untitled Event",
|
||||
start: localIso,
|
||||
tag: "ical-event",
|
||||
sourceName: "IntegrationTest"
|
||||
};
|
||||
|
||||
try {
|
||||
const expanded = expandRecurrences(baseEvent, 30);
|
||||
assert(expanded.length >= 1, `Expected at least 1 occurrence for event "${icsEvent.summary}" in ${file}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Error expanding recurrence for event "${icsEvent.summary}" in ${file}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
172
tests/reach_variations_test.ts
Normal file
172
tests/reach_variations_test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { assertEquals, assert } from "jsr:@std/assert";
|
||||
import { resolveEventStart, expandRecurrences, localDateString } from "../icalendar.ts";
|
||||
|
||||
const TEST_NOW = new Date("2026-01-20T12:00:00Z");
|
||||
|
||||
Deno.test("Variation: Standard Opaque Meeting (Busy)", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Discuss Alletra MP terraform provider requirements",
|
||||
start: {
|
||||
date: "2026-01-16T15:30:00.000",
|
||||
local: {
|
||||
date: "2026-01-16T15:30:00.000",
|
||||
timezone: "GMT Standard Time"
|
||||
}
|
||||
},
|
||||
transp: "OPAQUE",
|
||||
"x-microsoft-cdo-busystatus": "BUSY"
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
// GMT Standard Time in Jan is UTC+0
|
||||
assertEquals(result?.toISOString(), "2026-01-16T15:30:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("Variation: Transparent Meeting (Free)", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Following: Neutron Star Program Meeting",
|
||||
start: {
|
||||
date: "2026-01-20T08:30:00.000",
|
||||
local: {
|
||||
date: "2026-01-20T08:30:00.000",
|
||||
timezone: "Pacific Standard Time"
|
||||
}
|
||||
},
|
||||
transp: "TRANSPARENT",
|
||||
"x-microsoft-cdo-busystatus": "FREE"
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
// PST in Jan is UTC-8
|
||||
assertEquals(result?.toISOString(), "2026-01-20T16:30:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("Variation: Recurring Weekly (Multi-day: MO,TU,WE,TH,FR)", () => {
|
||||
const icsEvent = {
|
||||
summary: "BUSY Weekly",
|
||||
start: "2026-01-16T13:00:00",
|
||||
rrule: "FREQ=WEEKLY;UNTIL=20260814T170000Z;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;WKST=SU"
|
||||
};
|
||||
|
||||
// Use TEST_NOW to ensure the window matches
|
||||
const results = expandRecurrences(icsEvent, 7, TEST_NOW);
|
||||
// Should have multiple occurrences per week
|
||||
assert(results.length > 1);
|
||||
assert(results.some(r => r.start.includes("2026-01-19"))); // Monday
|
||||
assert(results.some(r => r.start.includes("2026-01-20"))); // Tuesday
|
||||
});
|
||||
|
||||
Deno.test("Variation: Recurring with WORKWEEKSTART (Outlook style)", () => {
|
||||
const icsEvent = {
|
||||
summary: "Outlook Style Meeting",
|
||||
start: "2026-01-20T08:30:00",
|
||||
rrule: {
|
||||
frequency: "WEEKLY",
|
||||
interval: 1,
|
||||
byday: "TU",
|
||||
workweekstart: "MO"
|
||||
}
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30, TEST_NOW);
|
||||
assert(results.length > 0);
|
||||
assert(results[0].start.includes("2026-01-20"));
|
||||
});
|
||||
|
||||
Deno.test("Variation: Recurring with EXDATE (Exclusion)", () => {
|
||||
const icsEvent = {
|
||||
summary: "HPE-Veeam check-in",
|
||||
start: "2026-01-20T08:30:00",
|
||||
rrule: "FREQ=WEEKLY;UNTIL=20260324T143000Z;INTERVAL=1;BYDAY=TU;WKST=SU",
|
||||
exdate: ["2026-02-03T08:30:00"]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 60, TEST_NOW);
|
||||
const dates = results.map(r => r.start);
|
||||
assert(dates.includes("2026-01-20T08:30:00"));
|
||||
assert(dates.includes("2026-01-27T08:30:00"));
|
||||
assert(!dates.includes("2026-02-03T08:30:00"), "EXDATE should be excluded");
|
||||
assert(dates.includes("2026-02-10T08:30:00"));
|
||||
});
|
||||
|
||||
Deno.test("Variation: Monthly Recurring (Last Friday)", () => {
|
||||
const icsEvent = {
|
||||
summary: "Monthly Planning",
|
||||
start: "2026-01-30T10:00:00", // This is the last Friday of Jan 2026
|
||||
rrule: "FREQ=MONTHLY;UNTIL=20260731T170000Z;INTERVAL=1;BYDAY=-1FR"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 100, TEST_NOW);
|
||||
const dates = results.map(r => r.start);
|
||||
|
||||
assert(dates.includes("2026-01-30T10:00:00"));
|
||||
assert(dates.includes("2026-02-27T10:00:00")); // Last Friday of Feb 2026
|
||||
assert(dates.includes("2026-03-27T10:00:00")); // Last Friday of Mar 2026
|
||||
});
|
||||
|
||||
Deno.test("Variation: Tentative Meeting", async () => {
|
||||
const icsEvent = {
|
||||
summary: "CO SW&P: Morpheus SW Core Team",
|
||||
start: {
|
||||
date: "2026-01-19T11:30:00.000",
|
||||
local: {
|
||||
date: "2026-01-19T11:30:00.000",
|
||||
timezone: "Central Standard Time"
|
||||
}
|
||||
},
|
||||
"x-microsoft-cdo-busystatus": "TENTATIVE"
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
// CST in Jan is UTC-6
|
||||
assertEquals(result?.toISOString(), "2026-01-19T17:30:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("Variation: Long Location/URL", () => {
|
||||
const icsEvent = {
|
||||
summary: "Omnissa Horizon:HPE VME Weekly Cadence",
|
||||
location: "https://omnissa.zoom.us/j/84780526943?pwd=fow88EiiZyUKsW26JrJavqiirbb1hv.1&from=addon"
|
||||
};
|
||||
assertEquals(icsEvent.location.length > 50, true);
|
||||
});
|
||||
|
||||
Deno.test("Feature: Unlimited lookback window", () => {
|
||||
const start = new Date(TEST_NOW.getTime() - 500 * 86400000); // 500 days ago
|
||||
const icsEvent = {
|
||||
summary: "Event from 500 days ago",
|
||||
start: localDateString(start),
|
||||
rrule: "FREQ=DAILY;COUNT=1000"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30, TEST_NOW);
|
||||
// Should include events from 500 days ago because there is now no limit
|
||||
assert(results.some(r => r.start === localDateString(start)), "Should find occurrence from 500 days ago");
|
||||
});
|
||||
|
||||
Deno.test("Feature: Hash Collision Prevention (Same UID/Start, Different Summary)", async () => {
|
||||
// This happens in reachcalendar.ics where a "Following:" event shares UID/Time with main event
|
||||
const event1 = {
|
||||
start: "2026-01-20T08:30:00",
|
||||
uid: "collision-uid",
|
||||
summary: "Main Meeting"
|
||||
};
|
||||
const event2 = {
|
||||
start: "2026-01-20T08:30:00",
|
||||
uid: "collision-uid",
|
||||
summary: "Following: Main Meeting"
|
||||
};
|
||||
|
||||
const hash1 = await sha256Hash(`${event1.start}${event1.uid}${event1.summary}`);
|
||||
const hash2 = await sha256Hash(`${event2.start}${event2.uid}${event2.summary}`);
|
||||
|
||||
assert(hash1 !== hash2, "Hashes must be unique even if UID and Start match");
|
||||
});
|
||||
|
||||
// Helper needed for the test above since it's not exported from icalendar.ts
|
||||
async function sha256Hash(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
159
timezones.ts
Normal file
159
timezones.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// timezones.ts
|
||||
|
||||
/**
|
||||
* Mapping of Windows Timezone names to IANA Timezone names.
|
||||
* Sourced from Unicode CLDR data.
|
||||
*/
|
||||
export const WINDOWS_TO_IANA: Record<string, string> = {
|
||||
"Dateline Standard Time": "Etc/GMT+12",
|
||||
"UTC-11": "Etc/GMT+11",
|
||||
"Hawaiian Standard Time": "Pacific/Honolulu",
|
||||
"Alaskan Standard Time": "America/Anchorage",
|
||||
"Pacific Standard Time (Mexico)": "America/Santa_Isabel",
|
||||
"Pacific Standard Time": "America/Los_Angeles",
|
||||
"US Mountain Standard Time": "America/Phoenix",
|
||||
"Mountain Standard Time (Mexico)": "America/Chihuahua",
|
||||
"Mountain Standard Time": "America/Denver",
|
||||
"Central America Standard Time": "America/Guatemala",
|
||||
"Central Standard Time": "America/Chicago",
|
||||
"Central Standard Time (Mexico)": "America/Mexico_City",
|
||||
"Canada Central Standard Time": "America/Regina",
|
||||
"SA Pacific Standard Time": "America/Bogota",
|
||||
"Eastern Standard Time": "America/New_York",
|
||||
"US Eastern Standard Time": "America/Indiana/Indianapolis",
|
||||
"Venezuela Standard Time": "America/Caracas",
|
||||
"Paraguay Standard Time": "America/Asuncion",
|
||||
"Atlantic Standard Time": "America/Halifax",
|
||||
"Central Brazilian Standard Time": "America/Cuiaba",
|
||||
"SA Western Standard Time": "America/La_Paz",
|
||||
"Pacific SA Standard Time": "America/Santiago",
|
||||
"Newfoundland Standard Time": "America/St_Johns",
|
||||
"E. South America Standard Time": "America/Sao_Paulo",
|
||||
"Argentina Standard Time": "America/Buenos_Aires",
|
||||
"SA Eastern Standard Time": "America/Cayenne",
|
||||
"Greenland Standard Time": "America/Godthab",
|
||||
"Montevideo Standard Time": "America/Montevideo",
|
||||
"Bahia Standard Time": "America/Bahia",
|
||||
"Azores Standard Time": "Atlantic/Azores",
|
||||
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
|
||||
"Morocco Standard Time": "Africa/Casablanca",
|
||||
"GMT Standard Time": "Europe/London",
|
||||
"Greenwich Standard Time": "Atlantic/Reykjavik",
|
||||
"W. Europe Standard Time": "Europe/Berlin",
|
||||
"Central Europe Standard Time": "Europe/Budapest",
|
||||
"Romance Standard Time": "Europe/Paris",
|
||||
"Central European Standard Time": "Europe/Warsaw",
|
||||
"W. Central Africa Standard Time": "Africa/Lagos",
|
||||
"Namibia Standard Time": "Africa/Windhoek",
|
||||
"Jordan Standard Time": "Asia/Amman",
|
||||
"GTB Standard Time": "Europe/Bucharest",
|
||||
"Middle East Standard Time": "Asia/Beirut",
|
||||
"Egypt Standard Time": "Africa/Cairo",
|
||||
"Syria Standard Time": "Asia/Damascus",
|
||||
"E. Europe Standard Time": "Europe/Chisinau",
|
||||
"South Africa Standard Time": "Africa/Johannesburg",
|
||||
"FLE Standard Time": "Europe/Kiev",
|
||||
"Turkey Standard Time": "Europe/Istanbul",
|
||||
"Israel Standard Time": "Asia/Jerusalem",
|
||||
"Kaliningrad Standard Time": "Europe/Kaliningrad",
|
||||
"Libya Standard Time": "Africa/Tripoli",
|
||||
"Arabic Standard Time": "Asia/Baghdad",
|
||||
"Arab Standard Time": "Asia/Riyadh",
|
||||
"Belarus Standard Time": "Europe/Minsk",
|
||||
"Russian Standard Time": "Europe/Moscow",
|
||||
"E. Africa Standard Time": "Africa/Nairobi",
|
||||
"Iran Standard Time": "Asia/Tehran",
|
||||
"Arabian Standard Time": "Asia/Dubai",
|
||||
"Azerbaijan Standard Time": "Asia/Baku",
|
||||
"Russia Time Zone 3": "Europe/Samara",
|
||||
"Mauritius Standard Time": "Indian/Mauritius",
|
||||
"Georgian Standard Time": "Asia/Tbilisi",
|
||||
"Caucasus Standard Time": "Asia/Yerevan",
|
||||
"Afghanistan Standard Time": "Asia/Kabul",
|
||||
"West Asia Standard Time": "Asia/Tashkent",
|
||||
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
|
||||
"Pakistan Standard Time": "Asia/Karachi",
|
||||
"India Standard Time": "Asia/Kolkata",
|
||||
"Sri Lanka Standard Time": "Asia/Colombo",
|
||||
"Nepal Standard Time": "Asia/Kathmandu",
|
||||
"Central Asia Standard Time": "Asia/Almaty",
|
||||
"Bangladesh Standard Time": "Asia/Dhaka",
|
||||
"N. Central Asia Standard Time": "Asia/Novosibirsk",
|
||||
"Myanmar Standard Time": "Asia/Rangoon",
|
||||
"SE Asia Standard Time": "Asia/Bangkok",
|
||||
"North Asia Standard Time": "Asia/Krasnoyarsk",
|
||||
"China Standard Time": "Asia/Shanghai",
|
||||
"North Asia East Standard Time": "Asia/Irkutsk",
|
||||
"Singapore Standard Time": "Asia/Singapore",
|
||||
"W. Australia Standard Time": "Australia/Perth",
|
||||
"Taipei Standard Time": "Asia/Taipei",
|
||||
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
|
||||
"Tokyo Standard Time": "Asia/Tokyo",
|
||||
"Korea Standard Time": "Asia/Seoul",
|
||||
"Yakutsk Standard Time": "Asia/Yakutsk",
|
||||
"Cen. Australia Standard Time": "Australia/Adelaide",
|
||||
"AUS Central Standard Time": "Australia/Darwin",
|
||||
"E. Australia Standard Time": "Australia/Brisbane",
|
||||
"AUS Eastern Standard Time": "Australia/Sydney",
|
||||
"West Pacific Standard Time": "Pacific/Port_Moresby",
|
||||
"Tasmania Standard Time": "Australia/Hobart",
|
||||
"Magadan Standard Time": "Asia/Magadan",
|
||||
"Vladivostok Standard Time": "Asia/Vladivostok",
|
||||
"Russia Time Zone 10": "Asia/Srednekolymsk",
|
||||
"Central Pacific Standard Time": "Pacific/Guadalcanal",
|
||||
"Russia Time Zone 11": "Asia/Anadyr",
|
||||
"New Zealand Standard Time": "Pacific/Auckland",
|
||||
"Fiji Standard Time": "Pacific/Fiji",
|
||||
"Tonga Standard Time": "Pacific/Tongatapu",
|
||||
"Samoa Standard Time": "Pacific/Apia",
|
||||
"Line Islands Standard Time": "Pacific/Kiritimati"
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an IANA timezone name from a given TZID string.
|
||||
* Supports Windows timezone names, direct IANA names, and UTC.
|
||||
*/
|
||||
export function resolveIanaName(tzid: string): string | null {
|
||||
if (!tzid || tzid === "UTC" || tzid === "None") return "UTC";
|
||||
|
||||
// Heuristic: IANA names typically include a forward slash
|
||||
if (tzid.includes("/")) return tzid;
|
||||
|
||||
return WINDOWS_TO_IANA[tzid] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UTC offset in milliseconds for a given IANA timezone at a
|
||||
* specific point in time. Positive = ahead of UTC, negative = behind UTC.
|
||||
* e.g. "America/New_York" in summer -> -14400000 (-4h)
|
||||
*/
|
||||
export function getUtcOffsetMs(ianaName: string, atDate: Date): number {
|
||||
// Trick: format the same instant in UTC and in the target zone,
|
||||
// parse both, and subtract.
|
||||
// "en-CA" produces "YYYY-MM-DD, HH:MM:SS" (unambiguous)
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
timeZone: "UTC",
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const utcFormatter = new Intl.DateTimeFormat("en-CA", options);
|
||||
const localFormatter = new Intl.DateTimeFormat("en-CA", { ...options, timeZone: ianaName });
|
||||
|
||||
const formatToIso = (formatter: Intl.DateTimeFormat, date: Date) => {
|
||||
return formatter.format(date).replace(", ", "T");
|
||||
};
|
||||
|
||||
const utcStr = formatToIso(utcFormatter, atDate);
|
||||
const localStr = formatToIso(localFormatter, atDate);
|
||||
|
||||
const utcMs = new Date(utcStr + "Z").getTime();
|
||||
const localMs = new Date(localStr + "Z").getTime();
|
||||
|
||||
return localMs - utcMs;
|
||||
}
|
||||
43
timezones_test.ts
Normal file
43
timezones_test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { resolveIanaName, getUtcOffsetMs } from "./timezones.ts";
|
||||
|
||||
Deno.test("resolveIanaName - Windows names", () => {
|
||||
assertEquals(resolveIanaName("Eastern Standard Time"), "America/New_York");
|
||||
assertEquals(resolveIanaName("Romance Standard Time"), "Europe/Paris");
|
||||
assertEquals(resolveIanaName("Pacific Standard Time"), "America/Los_Angeles");
|
||||
});
|
||||
|
||||
Deno.test("resolveIanaName - IANA names (identity)", () => {
|
||||
assertEquals(resolveIanaName("America/Chicago"), "America/Chicago");
|
||||
assertEquals(resolveIanaName("Europe/London"), "Europe/London");
|
||||
});
|
||||
|
||||
Deno.test("resolveIanaName - UTC and special cases", () => {
|
||||
assertEquals(resolveIanaName("UTC"), "UTC");
|
||||
assertEquals(resolveIanaName("None"), "UTC");
|
||||
assertEquals(resolveIanaName(""), "UTC");
|
||||
});
|
||||
|
||||
Deno.test("resolveIanaName - Unknown names", () => {
|
||||
assertEquals(resolveIanaName("Mars Standard Time"), null);
|
||||
});
|
||||
|
||||
Deno.test("getUtcOffsetMs - New York (DST check)", () => {
|
||||
const jan = new Date("2025-01-15T12:00:00Z");
|
||||
const july = new Date("2025-07-15T12:00:00Z");
|
||||
|
||||
// America/New_York is UTC-5 in Winter
|
||||
assertEquals(getUtcOffsetMs("America/New_York", jan), -5 * 3600000);
|
||||
// America/New_York is UTC-4 in Summer
|
||||
assertEquals(getUtcOffsetMs("America/New_York", july), -4 * 3600000);
|
||||
});
|
||||
|
||||
Deno.test("getUtcOffsetMs - Paris (DST check)", () => {
|
||||
const jan = new Date("2025-01-15T12:00:00Z");
|
||||
const july = new Date("2025-07-15T12:00:00Z");
|
||||
|
||||
// Europe/Paris is UTC+1 in Winter
|
||||
assertEquals(getUtcOffsetMs("Europe/Paris", jan), 1 * 3600000);
|
||||
// Europe/Paris is UTC+2 in Summer
|
||||
assertEquals(getUtcOffsetMs("Europe/Paris", july), 2 * 3600000);
|
||||
});
|
||||
BIN
url-nextcloud.png
Normal file
BIN
url-nextcloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
35
verify_test_results.cjs
Normal file
35
verify_test_results.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('console', msg => {
|
||||
console.log(`[BROWSER] ${msg.text()}`);
|
||||
});
|
||||
|
||||
console.log('Logging in...');
|
||||
await page.goto('http://localhost:3000/.auth');
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'admin');
|
||||
await page.click('button:has-text("Login")');
|
||||
await page.waitForNavigation();
|
||||
|
||||
console.log('Waiting 30s for indexing and plug activation...');
|
||||
await page.waitForTimeout(30000);
|
||||
|
||||
// Check command palette
|
||||
console.log('Opening command palette to check for iCalendar commands...');
|
||||
await page.keyboard.press('Control+/');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.keyboard.type('iCalendar:');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const body = await page.innerText('body');
|
||||
console.log('--- Body Output ---');
|
||||
console.log(body);
|
||||
|
||||
await page.screenshot({ path: 'repro_check.png', fullPage: true });
|
||||
await browser.close();
|
||||
})();
|
||||
Reference in New Issue
Block a user