9 Commits
v0.1.0 ... main

Author SHA1 Message Date
Alexandre Nicolaie
deb30ab6b3 Migrate to ts-ics 2.4.0 API and fix duplicate recurring events
ts-ics 2.4.0 changed API from parseIcsCalendar to convertIcsCalendar
and VCalendar to IcsCalendar. The new API returns Date objects and
nested date structures that require recursive conversion to strings
for SilverBullet indexing.

Recurring events were creating duplicate refs because the hash only
used the UID, which is identical across occurrences. Including the
start date in the unique key ensures each occurrence gets a distinct
ref.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Alexandre Nicolaie <xunleii@users.noreply.github.com>
2025-10-18 16:15:46 +02:00
Alexandre Nicolaie
904c1b9d94 Add clear all events functionality
Add 'iCalendar: Clear All Events' command to completely remove
all indexed calendar events and cache. Useful for maintenance
and troubleshooting.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Alexandre Nicolaie <xunleii@users.noreply.github.com>
2025-10-18 16:15:46 +02:00
Alexandre Nicolaie
34bbe69569 Add automatic calendar sync on editor initialization
Calendars now sync automatically when the editor starts, eliminating the
need for manual sync after opening SilverBullet.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Alexandre Nicolaie <xunleii@users.noreply.github.com>
2025-10-18 16:15:46 +02:00
Alexandre Nicolaie
38dd97c25c Add force sync command
Add 'iCalendar: Force Sync' command to bypass cache and
immediately synchronize calendar events. Useful when you
need fresh data before the cache expires.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Alexandre Nicolaie <xunleii@users.noreply.github.com>
2025-10-18 16:15:46 +02:00
Alexandre Nicolaie
d3e4fc021b Migrate to SilverBullet v2 indexing system
Replace deprecated query provider with index-based architecture.
Events are now indexed using index.indexObjects() and queryable
via Lua Integrated Query (LIQ).

Breaking changes:
- Plugin now requires SilverBullet v2 (use v0.1.0 for SB v1)
- Old query syntax no longer works (use LIQ instead)
- Manual sync required via 'iCalendar: Sync' command
- Events cached for 6h by default (was real-time)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Alexandre Nicolaie <xunleii@users.noreply.github.com>
2025-10-18 16:15:46 +02:00
Marek S. Lukasiewicz
8a7c9700ee Update README 2025-01-05 19:22:02 +01:00
Marek S. Lukasiewicz
e12420aba3 Add Nextcloud screenshot 2025-01-05 19:09:48 +01:00
Marek S. Łukasiewicz
4df5a1f8a8 Update README.md 2025-01-05 18:20:40 +01:00
Marek S. Lukasiewicz
e13e6e2bc2 Add LICENSE 2025-01-05 17:59:16 +01:00
6 changed files with 339 additions and 111 deletions

7
LICENSE Normal file
View 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.

View File

@@ -3,6 +3,8 @@
`silverbullet-icalendar` is a [Plug](https://silverbullet.md/Plugs) for [SilverBullet](https://silverbullet.md/) which I made for my girlfriend. `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. 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 ## Installation
Run the {[Plugs: Add]} command in SilverBullet and add paste this URI into the dialog box: Run the {[Plugs: Add]} command in SilverBullet and add paste this URI into the dialog box:
@@ -15,7 +17,7 @@ Then run the {[Plugs: Update]} command and off you go!
### Configuration ### 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 ```yaml
icalendar: icalendar:
@@ -37,34 +39,55 @@ Instructions to get the source URL for some calendar services:
- Calendar settings (pencil icon to the right of the name) - Calendar settings (pencil icon to the right of the name)
- Settings and Sharing, scroll down to Integrate calendar - Settings and Sharing, scroll down to Integrate calendar
- Copy the link for Secret address in iCal format - Copy the link for Secret address in iCal format
![Screenshot of getting the URL from Nextcloud Calendar](./url-nextcloud.png)
## Usage ## 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 ### Examples
Select events that start on a given date Select events that start on a given date:
~~~ ~~~
```query ```md
ical-event ${query[[
where start =~ /^2024-01-04/ from index.tag "ical-event"
select summary, description 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 ## Roadmap
- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`, command for manual update - Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`
- More query sources: - More indexed object types:
- `ical-todo` for `VTODO` components - `ical-todo` for `VTODO` components
- `ical-calendar` showing information about configured calendars - `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) - Support `file://` URL scheme (use an external script or filesystem instead of authentication on CalDAV)
## Contributing ## 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). If you find bugs, report them on the [issue tracker on GitHub](https://github.com/Maarrk/silverbullet-icalendar/issues).
### Building from source ### Building from source

View File

@@ -1,11 +1,14 @@
{ {
"tasks": { "tasks": {
"build": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml", "build": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml",
"build:debug": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml --debug",
"watch": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml -w" "watch": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml -w"
}, },
"lint": { "lint": {
"rules": { "rules": {
"exclude": ["no-explicit-any"] "exclude": [
"no-explicit-any"
]
} }
}, },
"fmt": { "fmt": {
@@ -16,7 +19,7 @@
] ]
}, },
"imports": { "imports": {
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^0.10.1", "@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.0.0",
"ts-ics": "npm:ts-ics@1.6.5" "ts-ics": "npm:ts-ics@2.4.0"
} }
} }

View File

@@ -2,15 +2,28 @@ name: icalendar
requiredPermissions: requiredPermissions:
- fetch - fetch
functions: 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: showVersion:
path: ./icalendar.ts:showVersion path: ./icalendar.ts:showVersion
command: command:
name: "iCalendar: Version" name: "iCalendar: Version"
priority: -2 priority: -2
queryEvents:
path: ./icalendar.ts:queryEvents
events:
- query:ical-event
config: config:
schema.config.properties.icalendar: schema.config.properties.icalendar:
type: object type: object
@@ -29,3 +42,6 @@ config:
type: string type: string
name: name:
type: string type: string
cacheDuration:
type: number
description: "Interval between two calendar synchronizations (default: 21600 = 6 hours)"

View File

@@ -1,127 +1,306 @@
import { editor, system } from "@silverbulletmd/silverbullet/syscalls"; import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
import { QueryProviderEvent } from "@silverbulletmd/silverbullet/types"; import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
import { applyQuery } from "@silverbulletmd/silverbullet/lib/query"; import { convertIcsCalendar, type IcsCalendar, type IcsEvent, type IcsDateObjects } from "ts-ics";
import { parseIcsCalendar, type VCalendar } from "ts-ics";
const VERSION = "0.1.0"; // ============================================================================
// Constants
// ============================================================================
// Try to match SilverBullet properties where possible. const VERSION = "0.2.1";
// Timestamps should be strings formatted with `localDateString` const CACHE_KEY = "icalendar:lastSync";
interface Event { const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours
// Typically available in calendar apps
summary: string | undefined;
description: string | undefined;
location: string | undefined;
// Same as SilverBullet pages // ============================================================================
created: string | undefined; // Types
lastModified: string | undefined; // ============================================================================
// Keep consistent with dates above
start: string | undefined;
end: string | undefined;
/**
* Recursively converts all Date objects to strings in a type
*/
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;
/**
* Configuration for a calendar source
*/
interface Source {
url: string;
name: string | undefined;
}
/**
* Plugin configuration structure
*/
interface PlugConfig {
sources: Source[];
cacheDuration: number | undefined;
}
/**
* Calendar event object indexed in SilverBullet
* Queryable via: `ical-event` from index
*
* Extends IcsEvent with all Date fields converted to strings recursively
*/
interface CalendarEvent extends DateToString<IcsEvent> {
ref: string;
tag: "ical-event";
sourceName: string | undefined; sourceName: string | undefined;
} }
interface Source { // ============================================================================
url: string; // Should be an .ics file // Utility Functions
name: string | undefined; // Optional name that will be assigned to events // ============================================================================
/**
* Type guard for IcsDateObjects
*/
function isIcsDateObjects(obj: any): obj is IcsDateObjects {
return obj && typeof obj === 'object' && ('date' in obj && 'type' in obj);
} }
export async function queryEvents( /**
{ query }: QueryProviderEvent, * Creates a SHA-256 hash of a string (hex encoded)
): Promise<any[]> { */
const events: Event[] = []; 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("");
}
const sources = await getSources(); /**
for (const source of sources) { * Recursively converts all Date objects and ISO date strings to strings
const identifier = (source.name === undefined || source.name === "") * Handles nested objects like {date: Date, local: {date: Date, timezone: string}}
? source.url */
: source.name; function convertDatesToStrings<T>(obj: T): DateToString<T> {
if (obj === null || obj === undefined) {
try { return obj as DateToString<T>;
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, {}, {});
if (obj instanceof Date) {
return localDateString(obj) as DateToString<T>;
}
if (isIcsDateObjects(obj) && obj.date instanceof Date) {
return localDateString(obj.date) as DateToString<T>;
}
if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
return localDateString(new Date(obj)) as DateToString<T>;
}
if (Array.isArray(obj)) {
return obj.map(item => convertDatesToStrings(item)) 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]);
}
}
return result as DateToString<T>;
}
return obj as DateToString<T>;
} }
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 * Retrieves and validates configured calendar sources
console.error("Configure icalendar.sources"); */
async function getSources(): Promise<Source[]> {
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
if (!plugConfig.sources || !Array.isArray(plugConfig.sources)) {
console.error("[iCalendar] Invalid configuration:", { plugConfig });
return []; return [];
} }
const sources = config.sources; if (plugConfig.sources.length === 0) {
if (sources.length === 0) {
console.error("Empty icalendar.sources");
return []; return [];
} }
const validated: Source[] = []; const validated: Source[] = [];
for (const src of sources) { for (const src of plugConfig.sources) {
if (typeof src.url !== "string") { if (typeof src.url !== "string") {
console.error( console.error("[iCalendar] Invalid source (missing url):", src);
`Invalid iCalendar source`,
src,
);
continue; continue;
} }
validated.push({ validated.push({
url: src.url, url: src.url,
name: (typeof src.name === "string") ? src.name : undefined, name: typeof src.name === "string" ? src.name : undefined,
}); });
} }
return validated; return validated;
} }
// Copied from @silverbulletmd/silverbullet/lib/dates.ts which is not exported in the package // ============================================================================
export function localDateString(d: Date): string { // Calendar Fetching & Parsing
return d.getFullYear() + // ============================================================================
"-" + String(d.getMonth() + 1).padStart(2, "0") +
"-" + String(d.getDate()).padStart(2, "0") + /**
"T" + String(d.getHours()).padStart(2, "0") + * Fetches and parses events from a single calendar source
":" + String(d.getMinutes()).padStart(2, "0") + */
":" + String(d.getSeconds()).padStart(2, "0") + async function fetchAndParseCalendar(source: Source): Promise<CalendarEvent[]> {
"." + String(d.getMilliseconds()).padStart(3, "0"); const response = await fetch(source.url);
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
console.error(`[iCalendar] HTTP error:`, { source, status: response.status, statusText: response.statusText });
throw error;
}
const icsData = await response.text();
const calendar: IcsCalendar = convertIcsCalendar(undefined, icsData);
if (!calendar.events || calendar.events.length === 0) {
return [];
}
return await Promise.all(calendar.events.map(async (icsEvent: IcsEvent): Promise<CalendarEvent> => {
// Create unique ref by start date with UID or summary (handles recurring events)
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,
});
}));
} }
export async function showVersion() { // ============================================================================
await editor.flashNotification(`iCalendar Plug ${VERSION}`); // Exported Commands
// ============================================================================
/**
* Synchronizes calendar events from configured sources and indexes them
*/
export async function syncCalendars() {
try {
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
const cacheDurationSeconds = plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS;
const cacheDurationMs = cacheDurationSeconds * 1000;
const sources = await getSources();
if (sources.length === 0) {
return;
}
const lastSync = await clientStore.get(CACHE_KEY);
const now = Date.now();
if (lastSync && (now - lastSync) < cacheDurationMs) {
const ageSeconds = Math.round((now - lastSync) / 1000);
console.log(`[iCalendar] Using cached data (${ageSeconds}s old)`);
return;
}
console.log(`[iCalendar] Syncing ${sources.length} calendar source(s)...`);
await editor.flashNotification("Syncing calendars...", "info");
const allEvents: CalendarEvent[] = [];
let successCount = 0;
for (const source of sources) {
const identifier = source.name || source.url;
try {
const events = await fetchAndParseCalendar(source);
allEvents.push(...events);
successCount++;
} catch (err) {
console.error(`[iCalendar] Failed to sync "${identifier}":`, err);
await editor.flashNotification(
`Failed to sync "${identifier}"`,
"error"
);
}
}
await index.indexObjects("$icalendar", allEvents);
await clientStore.set(CACHE_KEY, now);
const summary = `Synced ${allEvents.length} events from ${successCount}/${sources.length} source(s)`;
console.log(`[iCalendar] ${summary}`);
await editor.flashNotification(summary, "info");
} catch (err) {
console.error("[iCalendar] Sync failed:", err);
await editor.flashNotification("Failed to sync calendars", "error");
}
}
/**
* Forces a fresh sync by clearing cache and syncing calendars
*/
export async function forceSync() {
await clientStore.del(CACHE_KEY);
console.log("[iCalendar] Cache cleared, forcing fresh sync");
await editor.flashNotification("Forcing fresh calendar sync...", "info");
await syncCalendars();
}
/**
* Clears all indexed calendar events and cache
*/
export async function clearCache() {
if (!await editor.confirm(
"Are you sure you want to clear all calendar events and cache? This will remove all indexed calendar data."
)) {
return;
}
try {
const fileName = "$icalendar";
console.log("[iCalendar] Clearing index for", fileName);
const indexKey = "idx";
const pageKey = "ridx";
const allKeys: any[] = [];
const pageKeys = await datastore.query({
prefix: [pageKey, fileName],
});
for (const { key } of pageKeys) {
allKeys.push(key);
allKeys.push([indexKey, ...key.slice(2), fileName]);
}
if (allKeys.length > 0) {
await datastore.batchDel(allKeys);
console.log("[iCalendar] Deleted", allKeys.length, "entries");
}
await clientStore.del(CACHE_KEY);
console.log("[iCalendar] Calendar index and cache cleared");
await editor.flashNotification("Calendar index and cache cleared", "info");
} catch (err) {
console.error("[iCalendar] Failed to clear cache:", err);
await editor.flashNotification(
`Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`,
"error"
);
}
}
/**
* Shows the plugin version
*/
export async function showVersion() {
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
} }

BIN
url-nextcloud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB