forked from GitHubMirrors/silverbullet-icalendar
Compare commits
77 Commits
v0.1.0
...
6480b56875
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
deno.lock
|
||||
*.plug.js
|
||||
test_space
|
||||
.env
|
||||
|
||||
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.
|
||||
34
Makefile
Normal file
34
Makefile
Normal file
@@ -0,0 +1,34 @@
|
||||
.PHONY: build up down logs clean
|
||||
|
||||
# Build the plug using a Docker container with Deno
|
||||
build:
|
||||
docker run --rm -v $(PWD):/app -w /app denoland/deno:latest task build
|
||||
|
||||
# Start the SilverBullet test container
|
||||
up:
|
||||
mkdir -p test_space
|
||||
docker compose up -d
|
||||
|
||||
# Stop the SilverBullet test container
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# View logs from the SilverBullet container
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Watch for changes and rebuild automatically using Deno's internal watch
|
||||
watch:
|
||||
@echo "Starting watch in background..."
|
||||
docker run -d --name ical-watch -v $(PWD):/app -w /app denoland/deno:latest task watch
|
||||
@echo "Watching... Use 'docker logs -f ical-watch' to see build progress."
|
||||
|
||||
# Stop the watch container
|
||||
stop-watch:
|
||||
docker rm -f ical-watch
|
||||
|
||||
# Clean up build artifacts and test space
|
||||
clean:
|
||||
rm -f *.plug.js
|
||||
# Be careful with test_space if you have notes there you want to keep
|
||||
# rm -rf test_space
|
||||
8
PLUG.md
Normal file
8
PLUG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: Library/sstent/icalendar/PLUG
|
||||
version: 0.2.14
|
||||
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
|
||||
|
||||
|
||||
25
deno.json
Normal file
25
deno.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"tasks": {
|
||||
"build": "deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/edge/plug-compile.js -c deno.json icalendar.plug.yaml",
|
||||
"watch": "deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/edge/plug-compile.js -c deno.json icalendar.plug.yaml -w",
|
||||
"debug": "deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/edge/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",
|
||||
"ts-ics": "npm:ts-ics@2.4.0"
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
silverbullet:
|
||||
image: zefhemel/silverbullet:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./test_space:/space
|
||||
- ./icalendar.plug.js:/space/_plug/icalendar.plug.js:ro
|
||||
environment:
|
||||
- SB_USER=admin:admin # Default for easy local testing
|
||||
10
icalendar.plug.js
Normal file
10
icalendar.plug.js
Normal file
File diff suppressed because one or more lines are too long
@@ -2,15 +2,28 @@ name: icalendar
|
||||
requiredPermissions:
|
||||
- fetch
|
||||
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
|
||||
command:
|
||||
name: "iCalendar: Version"
|
||||
priority: -2
|
||||
queryEvents:
|
||||
path: ./icalendar.ts:queryEvents
|
||||
events:
|
||||
- query:ical-event
|
||||
config:
|
||||
schema.config.properties.icalendar:
|
||||
type: object
|
||||
@@ -29,3 +42,6 @@ config:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
cacheDuration:
|
||||
type: number
|
||||
description: "Interval between two calendar synchronizations (default: 21600 = 6 hours)"
|
||||
|
||||
336
icalendar.ts
336
icalendar.ts
@@ -1,127 +1,235 @@
|
||||
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, type IcsCalendar, type IcsEvent, type IcsDateObjects } from "ts-ics";
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
const VERSION = "0.2.15";
|
||||
const CACHE_KEY = "icalendar:lastSync";
|
||||
const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours
|
||||
|
||||
// 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;
|
||||
// Mapping of common Windows/Outlook timezones to their standard offsets (in hours)
|
||||
const TIMEZONE_OFFSETS: Record<string, number> = {
|
||||
"GMT Standard Time": 0,
|
||||
"W. Europe Standard Time": 1,
|
||||
"Central Europe Standard Time": 1,
|
||||
"Romance Standard Time": 1,
|
||||
"Central European Standard Time": 1,
|
||||
"Eastern Standard Time": -5,
|
||||
"Central Standard Time": -6,
|
||||
"Mountain Standard Time": -7,
|
||||
"Pacific Standard Time": -8,
|
||||
"UTC": 0,
|
||||
"None": 0
|
||||
};
|
||||
|
||||
// Same as SilverBullet pages
|
||||
created: string | undefined;
|
||||
lastModified: string | undefined;
|
||||
// Keep consistent with dates above
|
||||
start: string | undefined;
|
||||
end: string | undefined;
|
||||
console.log(`[iCalendar] Plug loading (Version ${VERSION})...`);
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DateToString<T> = T extends Date ? string
|
||||
: T extends IcsDateObjects ? string
|
||||
: T extends object ? { [K in keyof T]: DateToString<T[K]> }
|
||||
: T extends Array<infer U> ? Array<DateToString<U>>
|
||||
: T;
|
||||
|
||||
interface Source {
|
||||
url: string;
|
||||
name: string | undefined;
|
||||
}
|
||||
|
||||
interface PlugConfig {
|
||||
sources: Source[];
|
||||
cacheDuration: number | undefined;
|
||||
tzShift: number | undefined;
|
||||
}
|
||||
|
||||
interface CalendarEvent extends DateToString<IcsEvent> {
|
||||
ref: string;
|
||||
tag: "ical-event";
|
||||
sourceName: string | undefined;
|
||||
}
|
||||
|
||||
interface Source {
|
||||
url: string; // Should be an .ics file
|
||||
name: string | undefined; // Optional name that will be assigned to events
|
||||
}
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export async function queryEvents(
|
||||
{ query }: QueryProviderEvent,
|
||||
): Promise<any[]> {
|
||||
const events: Event[] = [];
|
||||
|
||||
const sources = await getSources();
|
||||
for (const source of sources) {
|
||||
const identifier = (source.name === undefined || source.name === "")
|
||||
? source.url
|
||||
: source.name;
|
||||
|
||||
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 applyQuery(query, events, {}, {});
|
||||
}
|
||||
|
||||
async function getSources(): Promise<Source[]> {
|
||||
const config = await system.getSpaceConfig("icalendar", {});
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
validated.push({
|
||||
url: src.url,
|
||||
name: (typeof src.name === "string") ? src.name : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
// Copied from @silverbulletmd/silverbullet/lib/dates.ts which is not exported in the package
|
||||
export function localDateString(d: Date): string {
|
||||
/**
|
||||
* Standard SilverBullet local date string formatter
|
||||
*/
|
||||
function toLocalISO(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
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") +
|
||||
"-" + pad(d.getMonth() + 1) +
|
||||
"-" + pad(d.getDate()) +
|
||||
"T" + pad(d.getHours()) +
|
||||
":" + pad(d.getMinutes()) +
|
||||
":" + pad(d.getSeconds()) +
|
||||
"." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
}
|
||||
|
||||
export async function showVersion() {
|
||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`);
|
||||
/**
|
||||
* Robustly converts an ICS date object to a localized PST string
|
||||
*/
|
||||
function processIcsDate(obj: any, manualShift = 0): string {
|
||||
if (!obj) return "";
|
||||
|
||||
// 1. Get the "Wall Time" (the hour shown in the organizer's calendar)
|
||||
// ts-ics often puts this in obj.local.date but marks it with 'Z'
|
||||
let wallTimeStr = (obj.local && typeof obj.local.date === "string")
|
||||
? obj.local.date
|
||||
: (typeof obj.date === "string" ? obj.date : "");
|
||||
|
||||
if (!wallTimeStr) return "";
|
||||
|
||||
// Remove any 'Z' to treat it as a raw floating time initially
|
||||
wallTimeStr = wallTimeStr.replace("Z", "");
|
||||
|
||||
// Parse as UTC so we have a stable starting point
|
||||
const baseDate = new Date(wallTimeStr + "Z");
|
||||
|
||||
// 2. Identify the Source Timezone
|
||||
const tzName = obj.local?.timezone || obj.timezone || "UTC";
|
||||
const sourceOffset = TIMEZONE_OFFSETS[tzName] ?? 0;
|
||||
|
||||
// 3. Calculate True UTC
|
||||
// UTC = WallTime - SourceOffset
|
||||
// Example: 16:00 WallTime in GMT+1 (+1) -> 15:00 UTC
|
||||
const utcMillis = baseDate.getTime() - (sourceOffset * 3600000);
|
||||
|
||||
const envOffset = new Date().getTimezoneOffset();
|
||||
console.log(`[iCalendar] Date Calc: Wall=${wallTimeStr}, TZ=${tzName}, SourceOffset=${sourceOffset}, EnvOffset=${envOffset}, UTC=${new Date(utcMillis).toISOString()}`);
|
||||
|
||||
// 4. Apply User's Manual Shift (if any)
|
||||
const finalMillis = utcMillis + (manualShift * 3600000);
|
||||
|
||||
// 5. Localize to environment
|
||||
return toLocalISO(new Date(finalMillis));
|
||||
}
|
||||
|
||||
function isIcsDateObjects(obj: any): obj is IcsDateObjects {
|
||||
return obj && typeof obj === 'object' && ('date' in obj && 'type' in obj);
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
function convertDatesToStrings<T>(obj: T, hourShift = 0): DateToString<T> {
|
||||
if (obj === null || obj === undefined) return obj as DateToString<T>;
|
||||
|
||||
if (isIcsDateObjects(obj)) {
|
||||
return processIcsDate(obj, hourShift) as DateToString<T>;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return toLocalISO(new Date(obj.getTime() + (hourShift * 3600000))) as DateToString<T>;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => convertDatesToStrings(item, hourShift)) as DateToString<T>;
|
||||
}
|
||||
|
||||
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], hourShift);
|
||||
}
|
||||
}
|
||||
return result as DateToString<T>;
|
||||
}
|
||||
|
||||
return obj as DateToString<T>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration & Commands
|
||||
// ============================================================================
|
||||
|
||||
async function getSources(): Promise<Source[]> {
|
||||
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
||||
if (!plugConfig.sources) return [];
|
||||
let sources = plugConfig.sources;
|
||||
if (!Array.isArray(sources)) sources = [sources as unknown as Source];
|
||||
return sources.filter(s => typeof s.url === "string");
|
||||
}
|
||||
|
||||
async function fetchAndParseCalendar(source: Source, hourShift = 0): Promise<CalendarEvent[]> {
|
||||
let url = source.url.trim();
|
||||
if (url.includes(" ")) url = encodeURI(url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const icsData = await response.text();
|
||||
const calendar: IcsCalendar = convertIcsCalendar(undefined, icsData);
|
||||
|
||||
if (!calendar.events) return [];
|
||||
|
||||
return await Promise.all(calendar.events.map(async (icsEvent: IcsEvent): Promise<CalendarEvent> => {
|
||||
const uniqueKey = `${icsEvent.start?.date || ''}${icsEvent.uid || icsEvent.summary || ''}`;
|
||||
const ref = await sha256Hash(uniqueKey);
|
||||
|
||||
return convertDatesToStrings({
|
||||
...icsEvent,
|
||||
ref,
|
||||
tag: "ical-event" as const,
|
||||
sourceName: source.name,
|
||||
}, hourShift);
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncCalendars() {
|
||||
try {
|
||||
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
||||
const hourShift = plugConfig.tzShift ?? 0;
|
||||
const sources = await getSources();
|
||||
if (sources.length === 0) return;
|
||||
|
||||
await editor.flashNotification("Syncing calendars...", "info");
|
||||
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const events = await fetchAndParseCalendar(source, hourShift);
|
||||
allEvents.push(...events);
|
||||
} catch (err) {
|
||||
console.error(`[iCalendar] Failed to sync ${source.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await index.indexObjects("$icalendar", allEvents);
|
||||
await editor.flashNotification(`Synced ${allEvents.length} events`, "info");
|
||||
} catch (err) {
|
||||
console.error("[iCalendar] Sync 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}`, "info");
|
||||
}
|
||||
BIN
url-nextcloud.png
Normal file
BIN
url-nextcloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Reference in New Issue
Block a user