sol-components 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/core/activate.js +27 -0
- package/core/adopt.js +71 -0
- package/core/auth-core.js +73 -0
- package/core/auth-fetch.js +154 -0
- package/core/component-mount.js +110 -0
- package/core/defaults.js +48 -0
- package/core/define.js +15 -0
- package/core/display-target.js +166 -0
- package/core/edit-placements.js +28 -0
- package/core/editor-self.js +127 -0
- package/core/editor.js +162 -0
- package/core/events.js +27 -0
- package/core/extension-points.js +189 -0
- package/core/form-utils.js +210 -0
- package/core/from-query.js +138 -0
- package/core/from-rdf.js +52 -0
- package/core/here.js +33 -0
- package/core/include-core.js +73 -0
- package/core/inrupt-global.js +18 -0
- package/core/menu-consumer.js +41 -0
- package/core/menu-rdf.js +154 -0
- package/core/pod-ops.js +392 -0
- package/core/pod-registry.js +82 -0
- package/core/popup-proxy.js +255 -0
- package/core/rdf-core.js +280 -0
- package/core/rdf-render.js +136 -0
- package/core/rdf-utils.js +411 -0
- package/core/rdf.js +154 -0
- package/core/services.js +106 -0
- package/core/shape-to-form.js +741 -0
- package/core/sparql-safety.js +20 -0
- package/core/utils.js +196 -0
- package/dist/importmap-cdn.json +49 -0
- package/dist/importmap-local.json +49 -0
- package/dist/sol-loader.manifest.json +140 -0
- package/dist/vendor/@comunica-query-sparql.js +137851 -0
- package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
- package/dist/vendor/dompurify.js +1476 -0
- package/dist/vendor/ical.js.js +9739 -0
- package/dist/vendor/marked.js +85 -0
- package/dist/vendor/n3.js +14670 -0
- package/dist/vendor/rdf-validate-shacl.js +6970 -0
- package/dist/vendor/rdflib.js +35172 -0
- package/dist/vendor/solid-logic.js +6819 -0
- package/dist/vendor/solid-ui.js +21945 -0
- package/node/sol-form.js +133 -0
- package/node/sol-include.js +55 -0
- package/node/sol-login.js +632 -0
- package/node/sol-menu.js +639 -0
- package/node/sol-query.js +116 -0
- package/package.json +133 -0
- package/web/menu-from-rdf.js +23 -0
- package/web/scripts/prefs.js +25 -0
- package/web/sol-accordion.js +114 -0
- package/web/sol-basic.js +50 -0
- package/web/sol-breadcrumb.js +131 -0
- package/web/sol-button.js +244 -0
- package/web/sol-calendar.js +465 -0
- package/web/sol-default.js +118 -0
- package/web/sol-dropdown-button.js +222 -0
- package/web/sol-feed.js +1336 -0
- package/web/sol-form.js +949 -0
- package/web/sol-full.js +43 -0
- package/web/sol-gallery.js +303 -0
- package/web/sol-include.js +246 -0
- package/web/sol-live-edit.js +415 -0
- package/web/sol-login.js +856 -0
- package/web/sol-menu.js +593 -0
- package/web/sol-modal.js +377 -0
- package/web/sol-pod-extras.js +17 -0
- package/web/sol-pod-ops.js +680 -0
- package/web/sol-pod.js +1039 -0
- package/web/sol-query.js +546 -0
- package/web/sol-rolodex.js +95 -0
- package/web/sol-search.js +402 -0
- package/web/sol-settings.js +199 -0
- package/web/sol-solidos.js +93 -0
- package/web/sol-tabs.js +445 -0
- package/web/sol-time.js +194 -0
- package/web/sol-tree-edit.js +492 -0
- package/web/sol-wac.js +456 -0
- package/web/sol-weather.js +337 -0
- package/web/sol-window.js +142 -0
- package/web/styles/buttons-css.js +108 -0
- package/web/styles/help.css +242 -0
- package/web/styles/root.css +112 -0
- package/web/styles/sol-accordion-css.js +97 -0
- package/web/styles/sol-calendar-css.js +154 -0
- package/web/styles/sol-feed-css.js +475 -0
- package/web/styles/sol-form-css.js +471 -0
- package/web/styles/sol-gallery-css.js +181 -0
- package/web/styles/sol-include-css.js +95 -0
- package/web/styles/sol-live-edit-css.js +84 -0
- package/web/styles/sol-live-edit.css +101 -0
- package/web/styles/sol-login-css.js +116 -0
- package/web/styles/sol-menu-css.js +145 -0
- package/web/styles/sol-modal-css.js +134 -0
- package/web/styles/sol-pod-css.js +187 -0
- package/web/styles/sol-pod-modal-css.js +203 -0
- package/web/styles/sol-query-css.js +140 -0
- package/web/styles/sol-query-help.css +267 -0
- package/web/styles/sol-query-one-pager.css +67 -0
- package/web/styles/sol-search-css.js +157 -0
- package/web/styles/sol-solidos-css.js +7 -0
- package/web/styles/sol-tabs-css.js +114 -0
- package/web/styles/sol-time-css.js +30 -0
- package/web/styles/sol-wac-css.js +73 -0
- package/web/styles/sol-weather-css.js +59 -0
- package/web/styles/solid-logo.svg +9 -0
- package/web/styles/view-accordion-css.js +66 -0
- package/web/styles/view-anchorlist-css.js +22 -0
- package/web/styles/view-autocomplete-css.js +59 -0
- package/web/styles/view-rolodex-css.js +102 -0
- package/web/styles/view-select-css.js +21 -0
- package/web/utils/calendar-fetch.js +388 -0
- package/web/utils/code-mirror-editor.js +82 -0
- package/web/utils/commons-fetch.js +108 -0
- package/web/utils/feed-edit.js +159 -0
- package/web/utils/feed-edit.smoke.mjs +74 -0
- package/web/utils/feed-fetch.js +573 -0
- package/web/utils/live-edit-help/csv.js +64 -0
- package/web/utils/live-edit-help/graphviz.js +41 -0
- package/web/utils/live-edit-help/jsonld.js +55 -0
- package/web/utils/live-edit-help/markdown.js +52 -0
- package/web/utils/live-edit-help/mermaid.js +48 -0
- package/web/utils/live-edit-help/turtle.js +85 -0
- package/web/utils/rdf-config.js +125 -0
- package/web/utils/renderers/csv.js +124 -0
- package/web/utils/renderers/d3-force.js +82 -0
- package/web/utils/renderers/graphviz.js +13 -0
- package/web/utils/renderers/html.js +10 -0
- package/web/utils/renderers/jsonld.js +63 -0
- package/web/utils/renderers/markdown.js +19 -0
- package/web/utils/renderers/mermaid.js +54 -0
- package/web/utils/renderers/turtle.js +51 -0
- package/web/utils/sol-query-triple-patterns.js +151 -0
- package/web/utils/sol-query-ui.js +250 -0
- package/web/utils/sol-query-views.js +32 -0
- package/web/views/_helpers.js +34 -0
- package/web/views/accordion.js +133 -0
- package/web/views/anchorlist.js +59 -0
- package/web/views/auto-complete.js +183 -0
- package/web/views/dl.js +38 -0
- package/web/views/list.js +19 -0
- package/web/views/menu.js +56 -0
- package/web/views/rolodex.js +126 -0
- package/web/views/select.js +79 -0
- package/web/views/table.js +73 -0
- package/web/views/tabs.js +57 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* calendar-fetch.js — fetch + parse iCalendar (RFC 5545) into plain JS
|
|
3
|
+
* event objects. Thin translation layer over ical.js; nothing in the
|
|
4
|
+
* render layer should ever import ical.js directly.
|
|
5
|
+
*
|
|
6
|
+
* The library handles the parts of RFC 5545 that are genuinely subtle —
|
|
7
|
+
* embedded VTIMEZONE blocks, recurrence (`RRULE` + `EXDATE` +
|
|
8
|
+
* `RECURRENCE-ID` overrides), date-vs-datetime distinction, line
|
|
9
|
+
* unfolding, escape sequences — so this module is mostly bookkeeping:
|
|
10
|
+
* given an URL or a text blob, hand back `Event[]` with recurrences
|
|
11
|
+
* already expanded into a `[start, start + windowDays)` window.
|
|
12
|
+
*
|
|
13
|
+
* Companion to `feed-fetch.js` (RSS/Atom) and `rdf-config.js` (TTL
|
|
14
|
+
* PropertyValue config). Same CORS-then-proxy fetch pattern as
|
|
15
|
+
* `feed-fetch.js` — most provider ICS endpoints (Google, Apple,
|
|
16
|
+
* Outlook, Proton) don't send `Access-Control-Allow-Origin`, so the
|
|
17
|
+
* `proxy` option does real work in practice.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import ICAL from 'ical.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Plain JS event shape returned to callers. ical.js's `Time`,
|
|
24
|
+
* `Duration`, etc. are converted to native `Date` here so the render
|
|
25
|
+
* layer doesn't need to know about them.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {Object} CalendarEvent
|
|
28
|
+
* @property {string} uid stable identity; recurring instances share the master UID
|
|
29
|
+
* @property {string} summary event title (may be empty for malformed ICS)
|
|
30
|
+
* @property {string=} description
|
|
31
|
+
* @property {string=} location
|
|
32
|
+
* @property {string=} url
|
|
33
|
+
* @property {Date} start in the JS engine's local timezone
|
|
34
|
+
* @property {Date} end
|
|
35
|
+
* @property {boolean} allDay DTSTART was a DATE (no time component)
|
|
36
|
+
* @property {boolean} isRecurringInstance true if produced by RRULE expansion
|
|
37
|
+
* @property {string=} calendar human label for multi-calendar merges (v2)
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a publicly-fetchable ICS URL from a provider + calendar id.
|
|
42
|
+
* Only `google` actually composes a URL today — the rest of the
|
|
43
|
+
* known providers expect the user to paste the share link directly,
|
|
44
|
+
* but they still drive the header label and (later) any provider-
|
|
45
|
+
* specific quirks in the fetch path.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} provider "google" | "apple" | "outlook" | "proton" | "ics"
|
|
48
|
+
* @param {string} calendarId provider-specific; for Google, the calendar email/id
|
|
49
|
+
* @returns {string|null} URL when we can build one, null when the caller should
|
|
50
|
+
* fall back to its own explicit URL
|
|
51
|
+
*/
|
|
52
|
+
export function buildProviderUrl(provider, calendarId) {
|
|
53
|
+
if (!calendarId) return null;
|
|
54
|
+
switch ((provider || '').toLowerCase()) {
|
|
55
|
+
case 'google':
|
|
56
|
+
return `https://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics`;
|
|
57
|
+
default:
|
|
58
|
+
// apple / outlook / proton / ics — pass-through, caller has the URL.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply a proxy pattern to a URL. Mirrors `feed-fetch.js`: if the
|
|
65
|
+
* pattern contains the literal token `{url}`, the encoded target
|
|
66
|
+
* replaces it; otherwise the encoded target is appended to the end
|
|
67
|
+
* of the pattern.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} proxy pattern (empty / null → no proxy)
|
|
70
|
+
* @param {string} target absolute URL to fetch
|
|
71
|
+
* @returns {string} the URL to actually request
|
|
72
|
+
*/
|
|
73
|
+
export function applyProxy(proxy, target) {
|
|
74
|
+
if (!proxy) return target;
|
|
75
|
+
if (proxy.includes('{url}')) return proxy.replace('{url}', encodeURIComponent(target));
|
|
76
|
+
return proxy + encodeURIComponent(target);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch an ICS document, falling back to the proxy on CORS / network
|
|
81
|
+
* failure. Returns the raw text body.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} url
|
|
84
|
+
* @param {{proxy?: string, signal?: AbortSignal}} opts
|
|
85
|
+
*/
|
|
86
|
+
async function fetchICSText(url, { proxy = '', signal } = {}) {
|
|
87
|
+
// Try bare first — Solid pods and self-hosted ICS often do CORS, and
|
|
88
|
+
// a successful bare fetch saves a hop through the proxy.
|
|
89
|
+
try {
|
|
90
|
+
const resp = await fetch(url, { signal });
|
|
91
|
+
if (resp.ok) {
|
|
92
|
+
const text = await resp.text();
|
|
93
|
+
// Sniff: some servers respond 200 to misrouted requests with HTML.
|
|
94
|
+
if (text.includes('BEGIN:VCALENDAR')) return text;
|
|
95
|
+
}
|
|
96
|
+
// fall through to proxy attempt — server returned non-ICS or non-2xx
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// Network / CORS error — try the proxy if one is configured.
|
|
99
|
+
if (!proxy) throw e;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!proxy) throw new Error(`Couldn't fetch calendar — try setting proxy=`);
|
|
103
|
+
const proxied = applyProxy(proxy, url);
|
|
104
|
+
const resp = await fetch(proxied, { signal });
|
|
105
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching calendar`);
|
|
106
|
+
const text = await resp.text();
|
|
107
|
+
if (!text.includes('BEGIN:VCALENDAR')) {
|
|
108
|
+
throw new Error('Proxy returned a non-ICS body');
|
|
109
|
+
}
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Convert an ical.js Time to a native JS Date in the local TZ. */
|
|
114
|
+
function timeToDate(t) {
|
|
115
|
+
// `toJSDate()` on a date-only Time still returns a Date pinned to
|
|
116
|
+
// 00:00 local; the caller flags it via `allDay` rather than dropping
|
|
117
|
+
// the time component.
|
|
118
|
+
return t.toJSDate();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Known video-meeting host substrings, in preference order. The
|
|
122
|
+
* first match in the DESCRIPTION wins over arbitrary other URLs
|
|
123
|
+
* (so a "see gitter.im/x for chat" line never beats the actual
|
|
124
|
+
* meet.jit.si link). */
|
|
125
|
+
const MEETING_HOSTS = [
|
|
126
|
+
'meet.jit.si',
|
|
127
|
+
'zoom.us',
|
|
128
|
+
'meet.google.com',
|
|
129
|
+
'teams.microsoft.com',
|
|
130
|
+
'whereby.com',
|
|
131
|
+
'us02web.zoom.us', 'us04web.zoom.us', 'us05web.zoom.us', 'us06web.zoom.us',
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/** Match any `http(s)://…` URL up to whitespace / quote / angle-bracket
|
|
135
|
+
* / common ICS line-ending punctuation. Tuned to be greedy enough for
|
|
136
|
+
* query strings but not so greedy that trailing punctuation (".", ",",
|
|
137
|
+
* ")") becomes part of the URL. */
|
|
138
|
+
const URL_RE = /https?:\/\/[^\s<>"'\\]+/g;
|
|
139
|
+
|
|
140
|
+
/** Trim trailing punctuation that's commonly stuck to a URL when it
|
|
141
|
+
* sits at the end of a sentence in DESCRIPTION text. */
|
|
142
|
+
function trimUrlTail(u) {
|
|
143
|
+
return u.replace(/[.,;:!?\])]+$/, '');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pick the best "click target" URL for an event, in priority order:
|
|
148
|
+
*
|
|
149
|
+
* 1. The standard `URL` iCal property.
|
|
150
|
+
* 2. LOCATION if the whole field is a URL (frequent for Jitsi-only
|
|
151
|
+
* calendars like the Solid CG one).
|
|
152
|
+
* 3. The first URL in DESCRIPTION that matches a known meeting host.
|
|
153
|
+
* 4. The first URL in DESCRIPTION of any host (so the W3C
|
|
154
|
+
* events-page link still becomes a click target on calendars
|
|
155
|
+
* that don't carry the join URL inline).
|
|
156
|
+
*
|
|
157
|
+
* Also reports whether LOCATION was consumed as the URL — the caller
|
|
158
|
+
* suppresses the location text-row in that case so the URL doesn't
|
|
159
|
+
* render twice (once as the summary's href, once below as plain text).
|
|
160
|
+
*
|
|
161
|
+
* @param {ICAL.Event} event
|
|
162
|
+
* @returns {{ url: string|undefined, locationIsUrl: boolean }}
|
|
163
|
+
*/
|
|
164
|
+
function pickMeetingUrl(event) {
|
|
165
|
+
const explicitUrl = event.component.getFirstPropertyValue('url');
|
|
166
|
+
if (explicitUrl) return { url: String(explicitUrl), locationIsUrl: false };
|
|
167
|
+
|
|
168
|
+
const loc = event.location || '';
|
|
169
|
+
if (/^https?:\/\/\S+$/i.test(loc.trim())) {
|
|
170
|
+
return { url: trimUrlTail(loc.trim()), locationIsUrl: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const desc = event.description || '';
|
|
174
|
+
const urls = (desc.match(URL_RE) || []).map(trimUrlTail);
|
|
175
|
+
if (!urls.length) return { url: undefined, locationIsUrl: false };
|
|
176
|
+
|
|
177
|
+
for (const u of urls) {
|
|
178
|
+
try {
|
|
179
|
+
const host = new URL(u).hostname.toLowerCase();
|
|
180
|
+
if (MEETING_HOSTS.some(h => host === h || host.endsWith('.' + h) || host.endsWith(h))) {
|
|
181
|
+
return { url: u, locationIsUrl: false };
|
|
182
|
+
}
|
|
183
|
+
} catch { /* skip malformed */ }
|
|
184
|
+
}
|
|
185
|
+
return { url: urls[0], locationIsUrl: false };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Translate one ical.js Event (master + an optional recurrence-instance
|
|
190
|
+
* detail) into the flat shape we expose. `details` is what
|
|
191
|
+
* `event.iterator()` hands back when expanding recurrences; for a
|
|
192
|
+
* non-recurring event it's just `{ startDate, endDate }` synthesised
|
|
193
|
+
* from the master.
|
|
194
|
+
*/
|
|
195
|
+
function toCalendarEvent(event, details, { calendar } = {}) {
|
|
196
|
+
const startTime = details ? details.startDate : event.startDate;
|
|
197
|
+
const endTime = details ? details.endDate : event.endDate;
|
|
198
|
+
const { url, locationIsUrl } = pickMeetingUrl(event);
|
|
199
|
+
return {
|
|
200
|
+
uid: event.uid || '',
|
|
201
|
+
summary: event.summary || '',
|
|
202
|
+
description: event.description || undefined,
|
|
203
|
+
// Drop the location text when it WAS the meeting URL — otherwise
|
|
204
|
+
// the same link renders twice (once as the summary's href, once
|
|
205
|
+
// as plain text below).
|
|
206
|
+
location: locationIsUrl ? undefined : (event.location || undefined),
|
|
207
|
+
url,
|
|
208
|
+
start: timeToDate(startTime),
|
|
209
|
+
end: endTime ? timeToDate(endTime) : timeToDate(startTime),
|
|
210
|
+
allDay: !!startTime.isDate,
|
|
211
|
+
// True for every occurrence produced via recurrence expansion —
|
|
212
|
+
// including RECURRENCE-ID overrides, which are themselves single
|
|
213
|
+
// events (so `event.isRecurring()` is false on them) but only
|
|
214
|
+
// exist as part of a recurring series.
|
|
215
|
+
isRecurringInstance: !!details,
|
|
216
|
+
calendar,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse an ICS text blob and expand recurrences inside the given
|
|
222
|
+
* window. The window is `[start, start + windowDays)`, half-open at
|
|
223
|
+
* the end so an event whose start equals `start + windowDays` is
|
|
224
|
+
* excluded.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} text
|
|
227
|
+
* @param {{start?: Date, windowDays?: number, maxEvents?: number, calendar?: string}} [opts]
|
|
228
|
+
* @returns {CalendarEvent[]} sorted ascending by start
|
|
229
|
+
*/
|
|
230
|
+
export function parseICS(text, opts = {}) {
|
|
231
|
+
const start = opts.start instanceof Date ? opts.start : new Date();
|
|
232
|
+
const windowDays = Number.isFinite(opts.windowDays) ? opts.windowDays : 30;
|
|
233
|
+
const maxEvents = Number.isFinite(opts.maxEvents) ? opts.maxEvents : 1000;
|
|
234
|
+
const calendar = opts.calendar;
|
|
235
|
+
|
|
236
|
+
const winStart = start;
|
|
237
|
+
const winEnd = new Date(start.getTime() + windowDays * 86_400_000);
|
|
238
|
+
|
|
239
|
+
const jcal = ICAL.parse(text);
|
|
240
|
+
const root = new ICAL.Component(jcal);
|
|
241
|
+
|
|
242
|
+
// Embedded VTIMEZONE blocks need to be registered so any DTSTART
|
|
243
|
+
// with a matching TZID resolves correctly.
|
|
244
|
+
for (const vt of root.getAllSubcomponents('vtimezone')) {
|
|
245
|
+
const tz = new ICAL.Timezone(vt);
|
|
246
|
+
if (!ICAL.TimezoneService.has(tz.tzid)) ICAL.TimezoneService.register(tz.tzid, tz);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const events = [];
|
|
250
|
+
const vevents = root.getAllSubcomponents('vevent');
|
|
251
|
+
|
|
252
|
+
// Two passes for recurring events: first collect any RECURRENCE-ID
|
|
253
|
+
// override instances (they shadow specific occurrences of the master),
|
|
254
|
+
// then iterate the master and substitute overrides as they come up.
|
|
255
|
+
const overridesByUid = new Map(); // uid → Map<recurrence-id ISO, event>
|
|
256
|
+
const masters = [];
|
|
257
|
+
for (const ve of vevents) {
|
|
258
|
+
const ev = new ICAL.Event(ve);
|
|
259
|
+
if (ev.isRecurrenceException()) {
|
|
260
|
+
const key = ev.uid;
|
|
261
|
+
let bucket = overridesByUid.get(key);
|
|
262
|
+
if (!bucket) overridesByUid.set(key, bucket = new Map());
|
|
263
|
+
bucket.set(ev.recurrenceId.toString(), ev);
|
|
264
|
+
} else {
|
|
265
|
+
masters.push(ev);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Cap on iterations per master — guards against pathological RRULEs
|
|
270
|
+
// (e.g. `FREQ=SECONDLY` someone might paste in) producing millions of
|
|
271
|
+
// instances before the window check trims them.
|
|
272
|
+
const ITER_CAP = 5000;
|
|
273
|
+
|
|
274
|
+
for (const ev of masters) {
|
|
275
|
+
if (events.length >= maxEvents) break;
|
|
276
|
+
|
|
277
|
+
if (ev.isRecurring()) {
|
|
278
|
+
const overrides = overridesByUid.get(ev.uid);
|
|
279
|
+
const iter = ev.iterator();
|
|
280
|
+
let next;
|
|
281
|
+
let n = 0;
|
|
282
|
+
while ((next = iter.next()) && n++ < ITER_CAP) {
|
|
283
|
+
const occStartDate = next.toJSDate();
|
|
284
|
+
if (occStartDate >= winEnd) break;
|
|
285
|
+
// Find the duration on either the master or the per-occurrence
|
|
286
|
+
// override; getOccurrenceDetails on the master returns the right
|
|
287
|
+
// end-date and substitutes the override summary/location etc.
|
|
288
|
+
const details = ev.getOccurrenceDetails(next);
|
|
289
|
+
if (details.endDate.toJSDate() < winStart) continue;
|
|
290
|
+
const useEv = overrides && overrides.get(next.toString())
|
|
291
|
+
? overrides.get(next.toString())
|
|
292
|
+
: ev;
|
|
293
|
+
events.push(toCalendarEvent(useEv, details, { calendar }));
|
|
294
|
+
if (events.length >= maxEvents) break;
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
const evStart = timeToDate(ev.startDate);
|
|
298
|
+
const evEnd = ev.endDate ? timeToDate(ev.endDate) : evStart;
|
|
299
|
+
if (evEnd < winStart || evStart >= winEnd) continue;
|
|
300
|
+
events.push(toCalendarEvent(ev, null, { calendar }));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
events.sort((a, b) => a.start - b.start);
|
|
305
|
+
return events;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Top-level helper: resolve the URL (provider URL builder or pass-
|
|
310
|
+
* through), fetch the ICS text (with proxy fallback), and parse it
|
|
311
|
+
* into the windowed `CalendarEvent[]`.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} url
|
|
314
|
+
* @param {{provider?: string, calendarId?: string, proxy?: string,
|
|
315
|
+
* start?: Date, windowDays?: number, maxEvents?: number,
|
|
316
|
+
* calendar?: string, signal?: AbortSignal}} [opts]
|
|
317
|
+
*/
|
|
318
|
+
export async function getCalendarEvents(url, opts = {}) {
|
|
319
|
+
const built = buildProviderUrl(opts.provider, opts.calendarId);
|
|
320
|
+
const target = built || url;
|
|
321
|
+
if (!target) throw new Error('No calendar URL — supply source= or calendar-id=');
|
|
322
|
+
const text = await fetchICSText(target, { proxy: opts.proxy, signal: opts.signal });
|
|
323
|
+
return parseICS(text, opts);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Multi-source helper: fetch every URL in parallel and merge the
|
|
328
|
+
* resulting events into one sorted-by-start list. A flaky single feed
|
|
329
|
+
* doesn't blank the whole result — its error is reported in `errors`
|
|
330
|
+
* and the surviving feeds still render.
|
|
331
|
+
*
|
|
332
|
+
* Each merged event carries a `calendar` label derived from the URL
|
|
333
|
+
* (host + first path segment), so the render layer can show the
|
|
334
|
+
* origin if it wants to.
|
|
335
|
+
*
|
|
336
|
+
* @param {string[]} urls
|
|
337
|
+
* @param {{provider?: string, proxy?: string, start?: Date,
|
|
338
|
+
* windowDays?: number, maxEvents?: number,
|
|
339
|
+
* signal?: AbortSignal}} [opts]
|
|
340
|
+
* @returns {Promise<{events: CalendarEvent[], errors: {url: string, message: string}[]}>}
|
|
341
|
+
*/
|
|
342
|
+
export async function getMergedCalendarEvents(urls, opts = {}) {
|
|
343
|
+
if (!urls || !urls.length) {
|
|
344
|
+
throw new Error('No calendar URLs — supply at least one source');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const settled = await Promise.allSettled(
|
|
348
|
+
urls.map(async (u) => {
|
|
349
|
+
const text = await fetchICSText(u, { proxy: opts.proxy, signal: opts.signal });
|
|
350
|
+
return parseICS(text, { ...opts, calendar: shortLabelFromUrl(u) });
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const events = [];
|
|
355
|
+
const errors = [];
|
|
356
|
+
settled.forEach((r, i) => {
|
|
357
|
+
if (r.status === 'fulfilled') events.push(...r.value);
|
|
358
|
+
else errors.push({ url: urls[i], message: r.reason?.message || String(r.reason) });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
events.sort((a, b) => a.start - b.start);
|
|
362
|
+
// The per-feed maxEvents already clipped each one; cap the merged
|
|
363
|
+
// total separately so a couple of big calendars don't blow the cap.
|
|
364
|
+
const cap = Number.isFinite(opts.maxEvents) ? opts.maxEvents : 1000;
|
|
365
|
+
if (events.length > cap) events.length = cap;
|
|
366
|
+
return { events, errors };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Short, human-ish label for an ICS URL — host + first path segment
|
|
370
|
+
* trimmed, used as the per-event `calendar` field when merging.
|
|
371
|
+
* "https://www.w3.org/groups/cg/solid/calendar/export/" → "w3.org/solid"
|
|
372
|
+
* "https://calendar.google.com/.../basic.ics" → "google" */
|
|
373
|
+
function shortLabelFromUrl(url) {
|
|
374
|
+
try {
|
|
375
|
+
const u = new URL(url);
|
|
376
|
+
const host = u.hostname.replace(/^www\./, '');
|
|
377
|
+
if (host === 'calendar.google.com' || host.endsWith('.google.com')) return 'google';
|
|
378
|
+
// Pick a useful path token: prefer something that isn't generic
|
|
379
|
+
// ("groups", "calendar", "export", etc.). Falls back to the host.
|
|
380
|
+
const skip = new Set(['groups', 'calendar', 'export', 'public', 'ical', 'cg', 'wg']);
|
|
381
|
+
const tok = u.pathname.split('/').find(s => s && !skip.has(s.toLowerCase()));
|
|
382
|
+
return tok ? `${host.split('.').slice(-2, -1)[0] || host}/${tok}` : host;
|
|
383
|
+
} catch {
|
|
384
|
+
return url;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export default getCalendarEvents;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal CodeMirror 6 editor builder.
|
|
3
|
+
* Used by sol-live-edit when running standalone (not inside the podz bundle).
|
|
4
|
+
* podz itself uses podz-editor.js which has keybinding options and language support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ESM = 'https://esm.sh';
|
|
8
|
+
|
|
9
|
+
const LANG_MAP = {
|
|
10
|
+
ttl: [`${ESM}/codemirror-lang-turtle`, m => m.turtle],
|
|
11
|
+
n3: [`${ESM}/codemirror-lang-turtle`, m => m.turtle],
|
|
12
|
+
jsonld: [`${ESM}/@codemirror/lang-json@6`, m => m.json],
|
|
13
|
+
json: [`${ESM}/@codemirror/lang-json@6`, m => m.json],
|
|
14
|
+
md: [`${ESM}/@codemirror/lang-markdown@6`, m => m.markdown],
|
|
15
|
+
html: [`${ESM}/@codemirror/lang-html@6`, m => m.html],
|
|
16
|
+
htm: [`${ESM}/@codemirror/lang-html@6`, m => m.html],
|
|
17
|
+
js: [`${ESM}/@codemirror/lang-javascript@6`, m => m.javascript],
|
|
18
|
+
mjs: [`${ESM}/@codemirror/lang-javascript@6`, m => m.javascript],
|
|
19
|
+
css: [`${ESM}/@codemirror/lang-css@6`, m => m.css],
|
|
20
|
+
xml: [`${ESM}/@codemirror/lang-xml@6`, m => m.xml],
|
|
21
|
+
svg: [`${ESM}/@codemirror/lang-xml@6`, m => m.xml],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a minimal CodeMirror 6 EditorView in `parent`.
|
|
26
|
+
* @param {Element} parent — container DOM element
|
|
27
|
+
* @param {string} ext — file extension key (e.g. 'ttl', 'md', 'html')
|
|
28
|
+
* @param {ShadowRoot|null} root — shadow root for CSS injection (or null for light DOM)
|
|
29
|
+
* @param {Function} onChange — called when content changes
|
|
30
|
+
* @returns {Promise<EditorView>}
|
|
31
|
+
*/
|
|
32
|
+
export async function buildEditor(parent, ext, root, onChange) {
|
|
33
|
+
const { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection }
|
|
34
|
+
= await import(`${ESM}/@codemirror/view@6`);
|
|
35
|
+
const { EditorState }
|
|
36
|
+
= await import(`${ESM}/@codemirror/state@6`);
|
|
37
|
+
const { defaultKeymap, history, historyKeymap, indentWithTab }
|
|
38
|
+
= await import(`${ESM}/@codemirror/commands@6`);
|
|
39
|
+
const { syntaxHighlighting, defaultHighlightStyle }
|
|
40
|
+
= await import(`${ESM}/@codemirror/language@6`);
|
|
41
|
+
|
|
42
|
+
let langExt = [];
|
|
43
|
+
const langEntry = ext && LANG_MAP[ext];
|
|
44
|
+
if (langEntry) {
|
|
45
|
+
try {
|
|
46
|
+
const [url, pick] = langEntry;
|
|
47
|
+
const m = await import(url);
|
|
48
|
+
const fn = pick(m);
|
|
49
|
+
if (fn) langExt = [fn()];
|
|
50
|
+
} catch (_) { /* CDN miss — degrade silently */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const updateListener = EditorView.updateListener.of(u => {
|
|
54
|
+
if (u.docChanged && onChange) onChange();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const view = new EditorView({
|
|
58
|
+
state: EditorState.create({
|
|
59
|
+
doc: '',
|
|
60
|
+
extensions: [
|
|
61
|
+
history(),
|
|
62
|
+
lineNumbers(),
|
|
63
|
+
highlightActiveLine(),
|
|
64
|
+
drawSelection(),
|
|
65
|
+
syntaxHighlighting(defaultHighlightStyle),
|
|
66
|
+
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
|
67
|
+
...langExt,
|
|
68
|
+
...(ext === 'md' ? [EditorView.lineWrapping] : []),
|
|
69
|
+
updateListener,
|
|
70
|
+
EditorView.theme({
|
|
71
|
+
'&': { height: '100%' },
|
|
72
|
+
'.cm-editor': { height: '100%' },
|
|
73
|
+
'.cm-scroller': { overflow: 'auto', fontFamily: 'inherit' },
|
|
74
|
+
}),
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
parent,
|
|
78
|
+
root: root || undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return view;
|
|
82
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commons-fetch.js — Wikimedia Commons category → image list, for <sol-gallery>.
|
|
3
|
+
*
|
|
4
|
+
* Each gallery "collection" is a Commons *category* URL (e.g.
|
|
5
|
+
* https://commons.wikimedia.org/wiki/Category:Tarot_1JJ). The Commons
|
|
6
|
+
* MediaWiki API serves CORS-enabled JSON when called with `origin=*`, so —
|
|
7
|
+
* unlike RSS — no proxy is needed. One `generator=categorymembers` +
|
|
8
|
+
* `prop=imageinfo` call returns thumbnail URLs, full-size URLs, and license
|
|
9
|
+
* metadata together; an opaque `continue` token pages the rest.
|
|
10
|
+
*
|
|
11
|
+
* Returns plain data only (strings / numbers) — extmetadata HTML is reduced
|
|
12
|
+
* to text — so callers carry no sanitization burden.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const COMMONS_API = 'https://commons.wikimedia.org/w/api.php';
|
|
16
|
+
|
|
17
|
+
/** Collapse an HTML fragment (extmetadata values are HTML) to plain text. */
|
|
18
|
+
function toText(html) {
|
|
19
|
+
if (!html) return '';
|
|
20
|
+
const doc = new DOMParser().parseFromString(String(html), 'text/html');
|
|
21
|
+
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the `Category:Title` page title from a Commons category URL.
|
|
26
|
+
* Accepts the `/wiki/Category:Foo` and `?title=Category:Foo` forms and
|
|
27
|
+
* returns the decoded title with spaces (the API accepts either spaces or
|
|
28
|
+
* underscores). Returns '' when the URL isn't a category.
|
|
29
|
+
*/
|
|
30
|
+
export function categoryTitleFromUrl(url) {
|
|
31
|
+
if (!url) return '';
|
|
32
|
+
let title = '';
|
|
33
|
+
try {
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
const m = u.pathname.match(/\/wiki\/(.+)$/);
|
|
36
|
+
title = m ? m[1] : (u.searchParams.get('title') || '');
|
|
37
|
+
} catch {
|
|
38
|
+
const m = String(url).match(/Category:[^?#]+/);
|
|
39
|
+
title = m ? m[0] : '';
|
|
40
|
+
}
|
|
41
|
+
try { title = decodeURIComponent(title); } catch { /* leave as-is */ }
|
|
42
|
+
title = title.replace(/_/g, ' ').trim();
|
|
43
|
+
return /^Category:/i.test(title) ? title : '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetch one page of file members of a Commons category, with thumbnails.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} categoryUrl a Commons `…/wiki/Category:X` URL
|
|
50
|
+
* @param {object} [opts]
|
|
51
|
+
* @param {number} [opts.thumbWidth=300] thumbnail width in px
|
|
52
|
+
* @param {number} [opts.limit=60] members per page (API max 500)
|
|
53
|
+
* @param {string} [opts.cont] continue token from a previous call
|
|
54
|
+
* @param {AbortSignal} [opts.signal]
|
|
55
|
+
* @returns {Promise<{images:Array<{title,name,thumb,full,width,height,descUrl,artist,license}>, cont:?string}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function getCategoryImages(categoryUrl, opts = {}) {
|
|
58
|
+
const { thumbWidth = 300, limit = 60, cont, signal } = opts;
|
|
59
|
+
const title = categoryTitleFromUrl(categoryUrl);
|
|
60
|
+
if (!title) throw new Error('Not a Commons category URL');
|
|
61
|
+
|
|
62
|
+
const params = new URLSearchParams({
|
|
63
|
+
action: 'query',
|
|
64
|
+
format: 'json',
|
|
65
|
+
origin: '*',
|
|
66
|
+
generator: 'categorymembers',
|
|
67
|
+
gcmtitle: title,
|
|
68
|
+
gcmtype: 'file',
|
|
69
|
+
gcmlimit: String(limit),
|
|
70
|
+
prop: 'imageinfo',
|
|
71
|
+
iiprop: 'url|size|extmetadata',
|
|
72
|
+
iiurlwidth: String(thumbWidth),
|
|
73
|
+
iiextmetadatafilter: 'Artist|LicenseShortName',
|
|
74
|
+
});
|
|
75
|
+
if (cont) params.set('gcmcontinue', cont);
|
|
76
|
+
|
|
77
|
+
const resp = await fetch(`${COMMONS_API}?${params}`, { signal });
|
|
78
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} from Commons`);
|
|
79
|
+
const data = await resp.json();
|
|
80
|
+
if (data.error) throw new Error(data.error.info || 'Commons API error');
|
|
81
|
+
|
|
82
|
+
const pages = data.query && data.query.pages ? Object.values(data.query.pages) : [];
|
|
83
|
+
// categorymembers preserves sort order via the `index` the generator adds.
|
|
84
|
+
pages.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
85
|
+
|
|
86
|
+
const images = [];
|
|
87
|
+
for (const p of pages) {
|
|
88
|
+
const ii = p.imageinfo && p.imageinfo[0];
|
|
89
|
+
if (!ii || !ii.thumburl) continue;
|
|
90
|
+
const meta = ii.extmetadata || {};
|
|
91
|
+
images.push({
|
|
92
|
+
title: (p.title || '').replace(/^File:/, ''),
|
|
93
|
+
name: p.title || '',
|
|
94
|
+
thumb: ii.thumburl,
|
|
95
|
+
full: ii.url,
|
|
96
|
+
width: ii.thumbwidth || 0,
|
|
97
|
+
height: ii.thumbheight || 0,
|
|
98
|
+
descUrl: ii.descriptionurl || '',
|
|
99
|
+
artist: toText(meta.Artist && meta.Artist.value),
|
|
100
|
+
license: toText(meta.LicenseShortName && meta.LicenseShortName.value),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const nextCont = data.continue && data.continue.gcmcontinue
|
|
105
|
+
? data.continue.gcmcontinue
|
|
106
|
+
: null;
|
|
107
|
+
return { images, cont: nextCont };
|
|
108
|
+
}
|