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.
Files changed (150) hide show
  1. package/README.md +7 -0
  2. package/core/activate.js +27 -0
  3. package/core/adopt.js +71 -0
  4. package/core/auth-core.js +73 -0
  5. package/core/auth-fetch.js +154 -0
  6. package/core/component-mount.js +110 -0
  7. package/core/defaults.js +48 -0
  8. package/core/define.js +15 -0
  9. package/core/display-target.js +166 -0
  10. package/core/edit-placements.js +28 -0
  11. package/core/editor-self.js +127 -0
  12. package/core/editor.js +162 -0
  13. package/core/events.js +27 -0
  14. package/core/extension-points.js +189 -0
  15. package/core/form-utils.js +210 -0
  16. package/core/from-query.js +138 -0
  17. package/core/from-rdf.js +52 -0
  18. package/core/here.js +33 -0
  19. package/core/include-core.js +73 -0
  20. package/core/inrupt-global.js +18 -0
  21. package/core/menu-consumer.js +41 -0
  22. package/core/menu-rdf.js +154 -0
  23. package/core/pod-ops.js +392 -0
  24. package/core/pod-registry.js +82 -0
  25. package/core/popup-proxy.js +255 -0
  26. package/core/rdf-core.js +280 -0
  27. package/core/rdf-render.js +136 -0
  28. package/core/rdf-utils.js +411 -0
  29. package/core/rdf.js +154 -0
  30. package/core/services.js +106 -0
  31. package/core/shape-to-form.js +741 -0
  32. package/core/sparql-safety.js +20 -0
  33. package/core/utils.js +196 -0
  34. package/dist/importmap-cdn.json +49 -0
  35. package/dist/importmap-local.json +49 -0
  36. package/dist/sol-loader.manifest.json +140 -0
  37. package/dist/vendor/@comunica-query-sparql.js +137851 -0
  38. package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
  39. package/dist/vendor/dompurify.js +1476 -0
  40. package/dist/vendor/ical.js.js +9739 -0
  41. package/dist/vendor/marked.js +85 -0
  42. package/dist/vendor/n3.js +14670 -0
  43. package/dist/vendor/rdf-validate-shacl.js +6970 -0
  44. package/dist/vendor/rdflib.js +35172 -0
  45. package/dist/vendor/solid-logic.js +6819 -0
  46. package/dist/vendor/solid-ui.js +21945 -0
  47. package/node/sol-form.js +133 -0
  48. package/node/sol-include.js +55 -0
  49. package/node/sol-login.js +632 -0
  50. package/node/sol-menu.js +639 -0
  51. package/node/sol-query.js +116 -0
  52. package/package.json +133 -0
  53. package/web/menu-from-rdf.js +23 -0
  54. package/web/scripts/prefs.js +25 -0
  55. package/web/sol-accordion.js +114 -0
  56. package/web/sol-basic.js +50 -0
  57. package/web/sol-breadcrumb.js +131 -0
  58. package/web/sol-button.js +244 -0
  59. package/web/sol-calendar.js +465 -0
  60. package/web/sol-default.js +118 -0
  61. package/web/sol-dropdown-button.js +222 -0
  62. package/web/sol-feed.js +1336 -0
  63. package/web/sol-form.js +949 -0
  64. package/web/sol-full.js +43 -0
  65. package/web/sol-gallery.js +303 -0
  66. package/web/sol-include.js +246 -0
  67. package/web/sol-live-edit.js +415 -0
  68. package/web/sol-login.js +856 -0
  69. package/web/sol-menu.js +593 -0
  70. package/web/sol-modal.js +377 -0
  71. package/web/sol-pod-extras.js +17 -0
  72. package/web/sol-pod-ops.js +680 -0
  73. package/web/sol-pod.js +1039 -0
  74. package/web/sol-query.js +546 -0
  75. package/web/sol-rolodex.js +95 -0
  76. package/web/sol-search.js +402 -0
  77. package/web/sol-settings.js +199 -0
  78. package/web/sol-solidos.js +93 -0
  79. package/web/sol-tabs.js +445 -0
  80. package/web/sol-time.js +194 -0
  81. package/web/sol-tree-edit.js +492 -0
  82. package/web/sol-wac.js +456 -0
  83. package/web/sol-weather.js +337 -0
  84. package/web/sol-window.js +142 -0
  85. package/web/styles/buttons-css.js +108 -0
  86. package/web/styles/help.css +242 -0
  87. package/web/styles/root.css +112 -0
  88. package/web/styles/sol-accordion-css.js +97 -0
  89. package/web/styles/sol-calendar-css.js +154 -0
  90. package/web/styles/sol-feed-css.js +475 -0
  91. package/web/styles/sol-form-css.js +471 -0
  92. package/web/styles/sol-gallery-css.js +181 -0
  93. package/web/styles/sol-include-css.js +95 -0
  94. package/web/styles/sol-live-edit-css.js +84 -0
  95. package/web/styles/sol-live-edit.css +101 -0
  96. package/web/styles/sol-login-css.js +116 -0
  97. package/web/styles/sol-menu-css.js +145 -0
  98. package/web/styles/sol-modal-css.js +134 -0
  99. package/web/styles/sol-pod-css.js +187 -0
  100. package/web/styles/sol-pod-modal-css.js +203 -0
  101. package/web/styles/sol-query-css.js +140 -0
  102. package/web/styles/sol-query-help.css +267 -0
  103. package/web/styles/sol-query-one-pager.css +67 -0
  104. package/web/styles/sol-search-css.js +157 -0
  105. package/web/styles/sol-solidos-css.js +7 -0
  106. package/web/styles/sol-tabs-css.js +114 -0
  107. package/web/styles/sol-time-css.js +30 -0
  108. package/web/styles/sol-wac-css.js +73 -0
  109. package/web/styles/sol-weather-css.js +59 -0
  110. package/web/styles/solid-logo.svg +9 -0
  111. package/web/styles/view-accordion-css.js +66 -0
  112. package/web/styles/view-anchorlist-css.js +22 -0
  113. package/web/styles/view-autocomplete-css.js +59 -0
  114. package/web/styles/view-rolodex-css.js +102 -0
  115. package/web/styles/view-select-css.js +21 -0
  116. package/web/utils/calendar-fetch.js +388 -0
  117. package/web/utils/code-mirror-editor.js +82 -0
  118. package/web/utils/commons-fetch.js +108 -0
  119. package/web/utils/feed-edit.js +159 -0
  120. package/web/utils/feed-edit.smoke.mjs +74 -0
  121. package/web/utils/feed-fetch.js +573 -0
  122. package/web/utils/live-edit-help/csv.js +64 -0
  123. package/web/utils/live-edit-help/graphviz.js +41 -0
  124. package/web/utils/live-edit-help/jsonld.js +55 -0
  125. package/web/utils/live-edit-help/markdown.js +52 -0
  126. package/web/utils/live-edit-help/mermaid.js +48 -0
  127. package/web/utils/live-edit-help/turtle.js +85 -0
  128. package/web/utils/rdf-config.js +125 -0
  129. package/web/utils/renderers/csv.js +124 -0
  130. package/web/utils/renderers/d3-force.js +82 -0
  131. package/web/utils/renderers/graphviz.js +13 -0
  132. package/web/utils/renderers/html.js +10 -0
  133. package/web/utils/renderers/jsonld.js +63 -0
  134. package/web/utils/renderers/markdown.js +19 -0
  135. package/web/utils/renderers/mermaid.js +54 -0
  136. package/web/utils/renderers/turtle.js +51 -0
  137. package/web/utils/sol-query-triple-patterns.js +151 -0
  138. package/web/utils/sol-query-ui.js +250 -0
  139. package/web/utils/sol-query-views.js +32 -0
  140. package/web/views/_helpers.js +34 -0
  141. package/web/views/accordion.js +133 -0
  142. package/web/views/anchorlist.js +59 -0
  143. package/web/views/auto-complete.js +183 -0
  144. package/web/views/dl.js +38 -0
  145. package/web/views/list.js +19 -0
  146. package/web/views/menu.js +56 -0
  147. package/web/views/rolodex.js +126 -0
  148. package/web/views/select.js +79 -0
  149. package/web/views/table.js +73 -0
  150. package/web/views/tabs.js +57 -0
@@ -0,0 +1,1336 @@
1
+ /**
2
+ * <sol-feed> — RSS / Atom feed viewer web component.
3
+ *
4
+ * A single shadow-DOM element with three layouts, chosen by the `view`
5
+ * attribute:
6
+ *
7
+ * view="feed" — render one feed (the `source` URL) as a link list.
8
+ * view="topic" — sources picker scoped to one topic and its
9
+ * `bk:subTopicOf` subtree; a side list of feeds, and
10
+ * clicking one shows its articles in the content pane.
11
+ * view="all" — Google-News-like grid scoped to a root topic and its
12
+ * full subtree; tick which sources you want and their
13
+ * articles are merged, randomised, and shown as image
14
+ * cards with a description that reveals on hover or
15
+ * keyboard focus.
16
+ * view="topics" — a "newsstand": one column per topic in the subtree,
17
+ * each listing that topic's sources. Clicking a source
18
+ * shows its articles as image cards (same cards as
19
+ * `all`) in a grid below the columns.
20
+ *
21
+ * For `topic`, `topics` and `all` the `source` must include a `#TopicName` fragment
22
+ * pointing at a `bk:Topic` in an RDF/Turtle bookmark document (see
23
+ * data/feeds.ttl). Feeds whose `bk:hasTopic` is outside the subtree or
24
+ * isn't a defined topic show up under a catch-all "Other" group.
25
+ *
26
+ * Attributes:
27
+ * view feed | topic | all (default: feed)
28
+ * source feed URL (feed) or "<rdfFile>#<Topic>" (topic / all)
29
+ * proxy CORS proxy pattern, prepended to cross-origin fetches
30
+ * default-selected (view="all" only) comma- or pipe-separated list
31
+ * of feed labels / URL substrings to auto-check
32
+ * on the user's first visit (when localStorage is
33
+ * empty). Case-insensitive substring match against
34
+ * each feed's label OR URL. Falls back to checking
35
+ * the first feed when no item matches.
36
+ * (view="topics" auto-opens the first source on a cold start when nothing
37
+ * is remembered, so the reader lands on real articles.)
38
+ *
39
+ * @element sol-feed
40
+ *
41
+ * @example
42
+ * <sol-feed view="feed" source="https://example.org/rss.xml"></sol-feed>
43
+ * <sol-feed view="topic" source="data/feeds.ttl#News"></sol-feed>
44
+ * <sol-feed view="all" source="data/feeds.ttl#Feeds"></sol-feed>
45
+ */
46
+ import { adopt } from '../core/adopt.js';
47
+ import { define } from '../core/define.js';
48
+ import { CSS as FEED_CSS, sheet as FEED_SHEET } from './styles/sol-feed-css.js';
49
+ import {
50
+ renameTopicEdit, recategorizeEdit, addFeedEdit, deleteToBinEdit, restoreEdit,
51
+ setPositionsEdit, mintFeedUri, patchDoc, purgeFeed, binUriFor,
52
+ } from './utils/feed-edit.js';
53
+ import { getFeedItems, parseSourceList } from './utils/feed-fetch.js';
54
+ import { getDefault, onDefaultChange } from '../core/defaults.js';
55
+
56
+ /** Human-readable date, or '' when the string is missing / unparseable. */
57
+ function formatDate(s) {
58
+ if (!s) return '';
59
+ const d = new Date(s);
60
+ return Number.isNaN(d.getTime())
61
+ ? ''
62
+ : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
63
+ }
64
+
65
+ /** Parse a pubDate string to milliseconds; missing / unparseable → 0. */
66
+ function dateMs(s) {
67
+ if (!s) return 0;
68
+ const t = new Date(s).getTime();
69
+ return Number.isNaN(t) ? 0 : t;
70
+ }
71
+
72
+ /** Build a `.sol-feed-empty` placeholder with the given message. */
73
+ function emptyEl(msg) {
74
+ const div = document.createElement('div');
75
+ div.className = 'sol-feed-empty';
76
+ div.textContent = msg;
77
+ return div;
78
+ }
79
+
80
+ /** Sanitise a label into a URI-safe fragment (alnum + _.-). */
81
+ function sanitizeFragment(label) {
82
+ return label.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, '') || 'topic';
83
+ }
84
+
85
+ /** Standard RDF / SKOS / bookmark predicates and types used for writing. */
86
+ const W = {
87
+ rdfType: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type',
88
+ bkTopic: 'http://www.w3.org/2002/01/bookmark#Topic',
89
+ bkSubTopicOf: 'http://www.w3.org/2002/01/bookmark#subTopicOf',
90
+ bkHasTopic: 'http://www.w3.org/2002/01/bookmark#hasTopic',
91
+ bkRecalls: 'http://www.w3.org/2002/01/bookmark#recalls',
92
+ uiLink: 'http://www.w3.org/ns/ui#Link',
93
+ uiLabel: 'http://www.w3.org/ns/ui#label',
94
+ skosConcept: 'http://www.w3.org/2004/02/skos/core#Concept',
95
+ skosBroader: 'http://www.w3.org/2004/02/skos/core#broader',
96
+ skosPrefLabel: 'http://www.w3.org/2004/02/skos/core#prefLabel',
97
+ dctTitle: 'http://purl.org/dc/terms/title',
98
+ dctSubject: 'http://purl.org/dc/terms/subject',
99
+ rssChannel: 'http://purl.org/rss/1.0/channel',
100
+ };
101
+
102
+ /**
103
+ * Re-fetch the bookmark/SKOS file, add the supplied (s,p,o) triples to its
104
+ * rdflib store, serialise it back to Turtle, and PUT it to the same URL.
105
+ * Returns true on success, throws on failure (the caller surfaces the
106
+ * error in the UI). For files served read-only (static dev servers, plain
107
+ * GitHub Pages, etc.) the PUT will fail with HTTP 405 and the caller
108
+ * should treat that as expected.
109
+ */
110
+ async function writeRdfAdditions(fileUri, triples) {
111
+ const { rdf } = await import('../core/rdf.js');
112
+ if (!rdf.isReady()) throw new Error('rdflib is not available');
113
+ // Route both the GET and the PUT through solFetch so a protected
114
+ // source triggers the chrome's login flow + auto-retry instead of
115
+ // a silent 401.
116
+ const { solFetch } = await import('../core/auth-fetch.js');
117
+
118
+ const resp = await solFetch(fileUri);
119
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching source`);
120
+ const text = await resp.text();
121
+
122
+ const store = rdf.graph();
123
+ rdf.parse(text, store, fileUri, 'text/turtle');
124
+
125
+ for (const [s, p, o] of triples) {
126
+ store.add(rdf.sym(s), rdf.sym(p), o.literal ? rdf.literal(o.value) : rdf.sym(o));
127
+ }
128
+
129
+ // rdflib.serialize is sync when no callback is supplied; allow async fallback.
130
+ let serialized;
131
+ try {
132
+ serialized = rdf.serialize(null, store, fileUri, 'text/turtle');
133
+ } catch {
134
+ serialized = await new Promise((resolve, reject) => {
135
+ rdf.serialize(null, store, fileUri, 'text/turtle', (err, out) => {
136
+ if (err) reject(err); else resolve(out);
137
+ });
138
+ });
139
+ }
140
+
141
+ const put = await solFetch(fileUri, {
142
+ method: 'PUT',
143
+ headers: { 'content-type': 'text/turtle' },
144
+ body: serialized,
145
+ });
146
+ if (!put.ok) throw new Error(`HTTP ${put.status} saving to source`);
147
+ return true;
148
+ }
149
+
150
+ /** The shared "reader" window — the object window.open() returns. */
151
+ let readerWindow = null;
152
+
153
+ /**
154
+ * Window features for the reader window: a 1024×640 window flush against
155
+ * the right edge of the screen and centred vertically. Passing features
156
+ * (the 3rd window.open arg) is what makes it a real window, not a tab.
157
+ */
158
+ function readerFeatures() {
159
+ const w = 1024, h = 640;
160
+ const left = Math.max(0, window.screen.availWidth - w); // flush right
161
+ const top = Math.max(0, Math.round((window.screen.availHeight - h) / 2)); // centred
162
+ return `width=${w},height=${h},left=${left},top=${top}`;
163
+ }
164
+
165
+ /**
166
+ * Open an article in the shared "reader" window.
167
+ *
168
+ * window.open() with the features argument creates a real window on the
169
+ * first click. It can't be found again by name later — browsers clear a
170
+ * window's name once it navigates to another origin — so the window object
171
+ * window.open() returns is kept and navigated directly, replacing its
172
+ * content in place instead of opening anything new.
173
+ *
174
+ * @returns {boolean} true when the reader window handled the article; false
175
+ * when window.open was blocked — the caller then lets the click fall
176
+ * through and open the link the normal way.
177
+ */
178
+ function openInReader(url) {
179
+ if (!url || url === '#') return false;
180
+ if (readerWindow && !readerWindow.closed) {
181
+ readerWindow.location.href = url; // reuse — replace content
182
+ readerWindow.focus();
183
+ return true;
184
+ }
185
+ readerWindow = window.open(url, 'sol-feed-reader', readerFeatures()); // create
186
+ if (readerWindow) {
187
+ readerWindow.focus();
188
+ return true;
189
+ }
190
+ return false; // window.open blocked — fall back to a normal link open
191
+ }
192
+
193
+ /**
194
+ * RSS / Atom feed viewer web component.
195
+ *
196
+ * @class SolFeed
197
+ * @extends HTMLElement
198
+ */
199
+ class SolFeed extends HTMLElement {
200
+ /**
201
+ * sol-feed's own picker IS the editor — adding/removing feeds and
202
+ * toggling which sources are shown happens inline in the rendered
203
+ * UI. `{ inline: true }` signals to discovery surfaces (dk-settings)
204
+ * and to the editor-self gear helper to skip this component.
205
+ */
206
+ static get editor() { return { inline: true }; }
207
+
208
+ constructor() {
209
+ super();
210
+ this.attachShadow({ mode: 'open' });
211
+ /** feed URL → parsed items, populated lazily by the news view. */
212
+ this._cache = new Map();
213
+ /** current topics-view mode ('topics' | 'bin') — survives reload so a
214
+ * reload racing a viewDeleted click can't clobber the open bin. */
215
+ this._view = 'topics';
216
+ /** monotonic render token; async renders bail if superseded. */
217
+ this._nav = 0;
218
+ }
219
+
220
+ async connectedCallback() {
221
+ // Reset shadow root on re-entry (e.g. reload() after sol-default change).
222
+ this.shadowRoot.adoptedStyleSheets = [];
223
+ this.shadowRoot.innerHTML = '';
224
+
225
+ const view = (this.getAttribute('view') || 'feed').toLowerCase();
226
+ this.proxy = this.getAttribute('proxy') || getDefault('proxy') || '';
227
+ this.source = this.getAttribute('source') || '';
228
+
229
+ // Re-fetch when <sol-default>'s proxy changes at runtime. Cleaned up
230
+ // in disconnectedCallback. Guard so reconnects don't stack handlers.
231
+ if (!this._unsubDefaults) {
232
+ this._unsubDefaults = onDefaultChange((name) => {
233
+ if (name === 'proxy') this.reload().catch(() => {});
234
+ });
235
+ }
236
+
237
+ adopt(this.shadowRoot, { sheet: FEED_SHEET, css: FEED_CSS });
238
+
239
+ this._status = document.createElement('div');
240
+ this._status.className = 'sol-feed-status';
241
+ this._status.setAttribute('role', 'status');
242
+ this._status.setAttribute('aria-live', 'polite');
243
+ this._status.style.display = 'none';
244
+
245
+ this._root = document.createElement('div');
246
+ this._root.className = 'sol-feed';
247
+
248
+ this.shadowRoot.append(this._status, this._root);
249
+
250
+ try {
251
+ if (view === 'topic') await this.renderTopic();
252
+ // Re-entering while the deleted bin is open (e.g. a reload races a
253
+ // viewDeleted click) must re-render the BIN, not clobber it with the
254
+ // normal columns — the bin is the user's current view.
255
+ else if (view === 'topics') await (this._view === 'bin' ? this._openBin() : this.renderTopics());
256
+ else if (view === 'all') await this.renderAll();
257
+ else await this.renderFeed();
258
+ } catch (e) {
259
+ this.setStatus(e.message || String(e), true);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Re-read attributes (including the proxy fallback from <sol-default>)
265
+ * and rebuild the shadow DOM + refetch. Public hook for external
266
+ * editors and for default-change reactions.
267
+ */
268
+ async reload() {
269
+ this._cache?.clear();
270
+ await this.connectedCallback();
271
+ }
272
+
273
+ disconnectedCallback() {
274
+ if (this._unsubDefaults) { this._unsubDefaults(); this._unsubDefaults = null; }
275
+ if (this._scrollIO) { this._scrollIO.disconnect(); this._scrollIO = null; }
276
+ }
277
+
278
+ /** Update the polite live region. Pass `isError` to colour it. */
279
+ setStatus(msg, isError = false) {
280
+ this._status.textContent = msg || '';
281
+ this._status.style.display = msg ? '' : 'none';
282
+ if (isError) this._status.setAttribute('data-error', '');
283
+ else this._status.removeAttribute('data-error');
284
+ }
285
+
286
+ /** Resolve the configured feed list from the RDF bookmark source. */
287
+ async resolveSources() {
288
+ return parseSourceList(this.source, { proxy: this.proxy });
289
+ }
290
+
291
+ /**
292
+ * Group feeds by their `topic`, preserving first-seen topic order. When
293
+ * no feed carries a topic the result is a single untitled group, so
294
+ * callers can render grouped and ungrouped lists the same way.
295
+ *
296
+ * @param {Array<{topic?:string}>} feeds
297
+ * @returns {Array<{topic:string, feeds:Array}>}
298
+ */
299
+ groupByTopic(feeds) {
300
+ const order = [];
301
+ const groups = new Map();
302
+ for (const f of feeds) {
303
+ const topic = f.topic || '';
304
+ if (!groups.has(topic)) { groups.set(topic, []); order.push(topic); }
305
+ groups.get(topic).push(f);
306
+ }
307
+ return order.map(topic => ({ topic, feeds: groups.get(topic) }));
308
+ }
309
+
310
+ /** Build a `<ul class="feed-items">` of article links. */
311
+ itemsList(items) {
312
+ const ul = document.createElement('ul');
313
+ ul.className = 'feed-items';
314
+ ul.setAttribute('aria-label', 'Articles');
315
+
316
+ if (!items.length) {
317
+ const li = document.createElement('li');
318
+ li.className = 'sol-feed-empty';
319
+ li.textContent = 'No articles';
320
+ ul.appendChild(li);
321
+ return ul;
322
+ }
323
+
324
+ for (const it of items) {
325
+ const li = document.createElement('li');
326
+ const a = document.createElement('a');
327
+ a.className = 'feed-link';
328
+ a.href = it.link || '#';
329
+ a.textContent = it.title || '(untitled)';
330
+ a.addEventListener('click', ev => { if (openInReader(a.href)) ev.preventDefault(); });
331
+
332
+ const meta = [it.source, formatDate(it.pubDate)].filter(Boolean).join(' · ');
333
+ if (meta) {
334
+ const span = document.createElement('span');
335
+ span.className = 'feed-link-meta';
336
+ span.textContent = meta;
337
+ a.appendChild(span);
338
+ }
339
+ li.appendChild(a);
340
+ ul.appendChild(li);
341
+ }
342
+ return ul;
343
+ }
344
+
345
+ /* ── view: feed ───────────────────────────────────────────────────── */
346
+
347
+ async renderFeed() {
348
+ if (!this.source) { this.setStatus('No feed source specified', true); return; }
349
+ this.setStatus('Loading feed…');
350
+ const items = await getFeedItems(this.source, { proxy: this.proxy });
351
+ const wrap = document.createElement('div');
352
+ wrap.className = 'sol-feed-list feed';
353
+ wrap.appendChild(this.itemsList(items));
354
+ this._root.replaceChildren(wrap);
355
+ this.setStatus(items.length ? '' : 'Feed has no items');
356
+ }
357
+
358
+ /* ── view: topic ──────────────────────────────────────────────────── */
359
+
360
+ async renderTopic() {
361
+ // Build the layout first so loading messages land in the article area
362
+ // rather than the status strip above the source pane.
363
+ const wrap = document.createElement('div');
364
+ wrap.className = 'sol-feed-list topic';
365
+
366
+ const sourcesNav = document.createElement('nav');
367
+ sourcesNav.className = 'feed-sources';
368
+ sourcesNav.setAttribute('aria-label', 'Feeds');
369
+
370
+ let itemsUl = document.createElement('ul');
371
+ itemsUl.className = 'feed-items';
372
+ itemsUl.setAttribute('aria-label', 'Articles');
373
+
374
+ /** Replace the article list with a single message line — used for
375
+ * loading and error states so they show where the articles will. */
376
+ const showItemsMessage = (msg, isError = false) => {
377
+ const fresh = document.createElement('ul');
378
+ fresh.className = 'feed-items';
379
+ fresh.setAttribute('aria-label', 'Articles');
380
+ const li = document.createElement('li');
381
+ li.className = 'sol-feed-empty';
382
+ if (isError) li.setAttribute('data-error', '');
383
+ li.textContent = msg;
384
+ fresh.appendChild(li);
385
+ itemsUl.replaceWith(fresh);
386
+ itemsUl = fresh;
387
+ };
388
+
389
+ showItemsMessage('Loading feeds…');
390
+ wrap.append(sourcesNav, itemsUl);
391
+ this._root.replaceChildren(wrap);
392
+
393
+ let sources;
394
+ try {
395
+ sources = await this.resolveSources();
396
+ } catch (e) {
397
+ showItemsMessage(e.message || String(e), true);
398
+ return;
399
+ }
400
+ if (!sources.length) {
401
+ showItemsMessage('No feeds found', true);
402
+ return;
403
+ }
404
+
405
+ const allLinks = [];
406
+
407
+ const selectSource = async (src, anchor) => {
408
+ allLinks.forEach(a => {
409
+ const on = a === anchor;
410
+ a.classList.toggle('selected', on);
411
+ if (on) a.setAttribute('aria-current', 'true');
412
+ else a.removeAttribute('aria-current');
413
+ });
414
+ showItemsMessage(`Loading ${src.label}…`);
415
+ try {
416
+ const items = await getFeedItems(src.url, { proxy: this.proxy });
417
+ const fresh = this.itemsList(items);
418
+ itemsUl.replaceWith(fresh);
419
+ itemsUl = fresh;
420
+ } catch (e) {
421
+ showItemsMessage(`${src.label}: ${e.message}`, true);
422
+ }
423
+ };
424
+
425
+ // Flat source list — topic view is scoped to one subtree, so the
426
+ // per-topic headings are omitted by user request.
427
+ const sourceListUl = document.createElement('ul');
428
+ sourceListUl.className = 'feed-source-list';
429
+ for (const src of sources) {
430
+ const li = document.createElement('li');
431
+ const a = document.createElement('a');
432
+ a.className = 'feed-link';
433
+ a.href = src.url;
434
+ a.textContent = src.label;
435
+ a.addEventListener('click', ev => { ev.preventDefault(); selectSource(src, a); });
436
+ allLinks.push(a);
437
+ li.appendChild(a);
438
+ sourceListUl.appendChild(li);
439
+ }
440
+ sourcesNav.appendChild(sourceListUl);
441
+
442
+ selectSource(sources[0], allLinks[0]);
443
+ }
444
+
445
+ /* ── view: all ────────────────────────────────────────────────────── */
446
+
447
+ async renderAll() {
448
+ this.setStatus('Loading feeds…');
449
+ const sources = await this.resolveSources();
450
+ if (!sources.length) { this.setStatus('No feeds found', true); return; }
451
+
452
+ const remembered = this.loadSelection();
453
+
454
+ // Top bar: one button per selected source (flush left) + the gear
455
+ // toggle pushed to the right edge.
456
+ const bar = document.createElement('div');
457
+ bar.className = 'feed-top-bar';
458
+ // Expose as a shadow part so host pages can style the top-bar
459
+ // strip (source-pills + gear) via ::part(top-bar).
460
+ bar.setAttribute('part', 'top-bar');
461
+ const sourceButtons = document.createElement('div');
462
+ sourceButtons.className = 'feed-source-buttons';
463
+ sourceButtons.setAttribute('role', 'tablist');
464
+ sourceButtons.setAttribute('aria-label', 'Selected feeds');
465
+ const toggle = document.createElement('button');
466
+ toggle.type = 'button';
467
+ toggle.className = 'feed-picker-toggle';
468
+ toggle.textContent = '⚙';
469
+ const pickerId = `sol-feed-picker-${Math.random().toString(36).slice(2, 8)}`;
470
+ toggle.setAttribute('aria-controls', pickerId);
471
+ bar.append(sourceButtons, toggle);
472
+
473
+ const picker = document.createElement('div');
474
+ picker.className = 'feed-picker';
475
+ picker.id = pickerId;
476
+
477
+ const articles = document.createElement('div');
478
+ articles.className = 'feed-articles';
479
+ // Expose as a shadow part so host pages can style the article
480
+ // grid background / padding / etc. via ::part(articles) without
481
+ // having to wrap the whole component.
482
+ articles.setAttribute('part', 'articles');
483
+ articles.setAttribute('aria-label', 'Articles');
484
+
485
+ /** Render one feed's items into the articles container, newest first. */
486
+ const renderArticles = (items) => {
487
+ if (!items.length) {
488
+ articles.replaceChildren(emptyEl('No articles'));
489
+ return;
490
+ }
491
+ const sorted = items.slice().sort(
492
+ (a, b) => dateMs(b.pubDate) - dateMs(a.pubDate),
493
+ );
494
+ articles.replaceChildren(...sorted.map(it => this.newsCard(it)));
495
+ };
496
+
497
+ /** Make the given source the active tab — highlight its button,
498
+ * fetch its items if needed, and render them. */
499
+ const selectSource = async (src) => {
500
+ [...sourceButtons.children].forEach(btn => {
501
+ const on = btn.dataset.feedUrl === src.url;
502
+ btn.classList.toggle('selected', on);
503
+ if (on) btn.setAttribute('aria-current', 'true');
504
+ else btn.removeAttribute('aria-current');
505
+ });
506
+ this.setStatus(`Loading ${src.label}…`);
507
+ try {
508
+ if (!this._cache.has(src.url)) await this.ensureSource(src);
509
+ renderArticles(this._cache.get(src.url) || []);
510
+ this.setStatus('');
511
+ } catch (e) {
512
+ this.setStatus(`${src.label}: ${e.message}`, true);
513
+ }
514
+ };
515
+
516
+ const addSourceButton = (src) => {
517
+ const btn = document.createElement('button');
518
+ btn.type = 'button';
519
+ btn.className = 'feed-source-btn';
520
+ btn.dataset.feedUrl = src.url;
521
+ btn.textContent = src.label;
522
+ btn.setAttribute('role', 'tab');
523
+ btn.addEventListener('click', () => selectSource(src));
524
+ sourceButtons.appendChild(btn);
525
+ return btn;
526
+ };
527
+
528
+ /** Currently-checked sources in `bk:hasTopic` order — every group
529
+ * from groupByTopic, only the ones with a ticked checkbox. */
530
+ const chosenInTopicOrder = () => {
531
+ const checked = new Set(
532
+ [...picker.querySelectorAll('input:checked')].map(cb => cb.value),
533
+ );
534
+ const out = [];
535
+ for (const group of this.groupByTopic(sources)) {
536
+ for (const src of group.feeds) {
537
+ if (checked.has(src.url)) out.push(src);
538
+ }
539
+ }
540
+ return out;
541
+ };
542
+
543
+ /** Rebuild the top-bar buttons from scratch, in topic order. The caller
544
+ * is responsible for re-applying the .selected highlight afterwards. */
545
+ const rebuildSourceButtons = () => {
546
+ sourceButtons.replaceChildren();
547
+ for (const src of chosenInTopicOrder()) addSourceButton(src);
548
+ };
549
+
550
+ /** Picker checkbox change — rebuild the top-bar in topic order, then
551
+ * point the selection at either the newly-ticked source or the new
552
+ * topic-first when the previously-selected one has just been removed. */
553
+ const onPickerChange = async (src, checked) => {
554
+ this.saveSelection();
555
+ const prevSelectedUrl = sourceButtons.querySelector('.feed-source-btn.selected')
556
+ ?.dataset.feedUrl;
557
+ rebuildSourceButtons();
558
+ if (checked) {
559
+ await selectSource(src);
560
+ } else if (prevSelectedUrl === src.url) {
561
+ const firstBtn = sourceButtons.querySelector('.feed-source-btn');
562
+ if (firstBtn) {
563
+ const fallback = sources.find(s => s.url === firstBtn.dataset.feedUrl);
564
+ if (fallback) await selectSource(fallback);
565
+ } else {
566
+ articles.replaceChildren(emptyEl('Select a feed to see articles'));
567
+ this.setStatus('');
568
+ }
569
+ } else if (prevSelectedUrl) {
570
+ const sameBtn = [...sourceButtons.children]
571
+ .find(b => b.dataset.feedUrl === prevSelectedUrl);
572
+ if (sameBtn) sameBtn.classList.add('selected');
573
+ }
574
+ };
575
+
576
+ // Two columns: left is the topic fieldsets the user ticks; right is
577
+ // the "add topic / add feed" forms that mint new triples (attempted
578
+ // PUT-back to the RDF source).
579
+ const pickerLeft = document.createElement('div');
580
+ pickerLeft.className = 'feed-picker-left';
581
+
582
+ const pickerRight = document.createElement('div');
583
+ pickerRight.className = 'feed-picker-right';
584
+
585
+ let cbIdx = 0;
586
+ /** fieldset per topic label, keyed by topic label so add-source can
587
+ * insert a new checkbox into the right group. */
588
+ const fieldsetByTopic = new Map();
589
+ const buildFieldset = (topicLabel) => {
590
+ const fieldset = document.createElement('fieldset');
591
+ fieldset.className = 'feed-topic';
592
+ const legend = document.createElement('legend');
593
+ legend.textContent = topicLabel || 'Sources';
594
+ fieldset.appendChild(legend);
595
+ fieldsetByTopic.set(topicLabel || '', fieldset);
596
+ pickerLeft.appendChild(fieldset);
597
+ return fieldset;
598
+ };
599
+ const addCheckbox = (fieldset, src) => {
600
+ const label = document.createElement('label');
601
+ const cb = document.createElement('input');
602
+ cb.type = 'checkbox';
603
+ cb.id = `sol-feed-src-${cbIdx++}`;
604
+ cb.value = src.url;
605
+ cb.checked = remembered.includes(src.url);
606
+ cb.addEventListener('change', () => onPickerChange(src, cb.checked));
607
+ label.append(cb, document.createTextNode(' ' + src.label));
608
+ fieldset.appendChild(label);
609
+ return cb;
610
+ };
611
+ for (const group of this.groupByTopic(sources)) {
612
+ const fieldset = buildFieldset(group.topic);
613
+ for (const src of group.feeds) addCheckbox(fieldset, src);
614
+ }
615
+
616
+ // ── Add-topic / add-source forms ───────────────────────────────────
617
+ // Topic URIs come from the RDF parse (attached by parseSourceList).
618
+ /** @type {Array<{uri:string,label:string}>} */
619
+ const topicList = (sources.topics || []).slice();
620
+ const ontology = sources.ontology || 'bookmark';
621
+ const fileUri = sources.fileUri || this.source.split('#')[0];
622
+ const focusUri = sources.focusUri || this.source;
623
+ const status = document.createElement('p');
624
+ status.className = 'feed-picker-note';
625
+ status.setAttribute('role', 'status');
626
+ status.setAttribute('aria-live', 'polite');
627
+
628
+ const refreshTopicSelects = () => {
629
+ // Feeds (the root concept scheme / focus topic) is never an option —
630
+ // new feeds attach to a leaf topic.
631
+ const options = topicList.filter(t => t.uri !== focusUri);
632
+ for (const sel of pickerRight.querySelectorAll('[data-role="topic-select"]')) {
633
+ const current = sel.value;
634
+ sel.replaceChildren(...options.map(t => {
635
+ const opt = document.createElement('option');
636
+ opt.value = t.uri;
637
+ opt.textContent = t.label;
638
+ return opt;
639
+ }));
640
+ if ([...sel.options].some(o => o.value === current)) sel.value = current;
641
+ }
642
+ };
643
+
644
+ const topicForm = document.createElement('form');
645
+ topicForm.className = 'feed-add-wrap';
646
+ topicForm.innerHTML = `
647
+ <fieldset class="feed-add-form">
648
+ <legend>Add topic</legend>
649
+ <label>Label
650
+ <input name="label" required>
651
+ </label>
652
+ <button type="submit">Add topic</button>
653
+ </fieldset>
654
+ `;
655
+
656
+ const sourceForm = document.createElement('form');
657
+ sourceForm.className = 'feed-add-wrap';
658
+ sourceForm.innerHTML = `
659
+ <fieldset class="feed-add-form">
660
+ <legend>Add feed</legend>
661
+ <label>Feed URL
662
+ <input name="url" type="url" required placeholder="https://example.org/rss.xml">
663
+ </label>
664
+ <label>Label
665
+ <input name="label" required>
666
+ </label>
667
+ <label>Topic
668
+ <select name="topic" data-role="topic-select"></select>
669
+ </label>
670
+ <button type="submit">Add feed</button>
671
+ </fieldset>
672
+ `;
673
+
674
+ pickerRight.append(topicForm, sourceForm, status);
675
+ refreshTopicSelects();
676
+
677
+ /** Apply an addition: update the local DOM immediately, then attempt
678
+ * to persist the new triples back to the source file. */
679
+ const applyAddition = async ({ kind, src, topic, triples }) => {
680
+ // Local UI update first — the user sees the addition even if
681
+ // the PUT fails (which is normal for read-only static demos).
682
+ if (kind === 'topic') {
683
+ topicList.push(topic);
684
+ refreshTopicSelects();
685
+ if (!fieldsetByTopic.has(topic.label)) buildFieldset(topic.label);
686
+ } else if (kind === 'source' && src) {
687
+ let fieldset = fieldsetByTopic.get(src.topic);
688
+ if (!fieldset) fieldset = buildFieldset(src.topic);
689
+ const cb = addCheckbox(fieldset, src);
690
+ cb.checked = true;
691
+ cb.dispatchEvent(new Event('change'));
692
+ }
693
+ // Try to persist.
694
+ status.removeAttribute('data-error');
695
+ status.textContent = 'Saving…';
696
+ try {
697
+ await writeRdfAdditions(fileUri, triples);
698
+ status.textContent = 'Saved to feed library.';
699
+ } catch (e) {
700
+ status.setAttribute('data-error', '');
701
+ status.textContent = `Added locally; save failed (${e.message}).`;
702
+ }
703
+ };
704
+
705
+ topicForm.addEventListener('submit', async (ev) => {
706
+ ev.preventDefault();
707
+ const data = new FormData(topicForm);
708
+ const labelVal = String(data.get('label') || '').trim();
709
+ // All new topics nest under the focus (Feeds) — no user choice.
710
+ const parentUri = focusUri;
711
+ if (!labelVal) return;
712
+ // Mint a URI for the new topic; ensure uniqueness.
713
+ let frag = sanitizeFragment(labelVal);
714
+ let uri = `${fileUri}#${frag}`;
715
+ let n = 2;
716
+ while (topicList.some(t => t.uri === uri)) uri = `${fileUri}#${frag}_${n++}`;
717
+ const lit = { literal: true, value: labelVal };
718
+ const triples = ontology === 'skos'
719
+ ? [
720
+ [uri, W.rdfType, W.skosConcept],
721
+ [uri, W.skosPrefLabel, lit],
722
+ [uri, W.skosBroader, parentUri],
723
+ ]
724
+ : [
725
+ [uri, W.rdfType, W.bkTopic],
726
+ [uri, W.uiLabel, lit],
727
+ [uri, W.bkSubTopicOf, parentUri],
728
+ ];
729
+ await applyAddition({ kind: 'topic', topic: { uri, label: labelVal }, triples });
730
+ topicForm.reset();
731
+ });
732
+
733
+ sourceForm.addEventListener('submit', async (ev) => {
734
+ ev.preventDefault();
735
+ const data = new FormData(sourceForm);
736
+ const url = String(data.get('url') || '').trim();
737
+ const labelVal = String(data.get('label') || '').trim();
738
+ const topicUri = String(data.get('topic') || '');
739
+ if (!url || !labelVal || !topicUri) return;
740
+ const topicLabel = (topicList.find(t => t.uri === topicUri) || {}).label || '';
741
+ const lit = { literal: true, value: labelVal };
742
+ // New feed: add as the URL itself plus an `a rss:channel` typing.
743
+ const triples = [[url, W.rdfType, W.rssChannel]];
744
+ if (ontology === 'skos') {
745
+ triples.push([url, W.dctTitle, lit]);
746
+ triples.push([url, W.dctSubject, topicUri]);
747
+ } else {
748
+ // Bookmark: also create a ui:Link proxy keyed off a derived id.
749
+ const proxy = `${fileUri}#feed-${sanitizeFragment(labelVal)}-${Date.now().toString(36)}`;
750
+ triples.push([proxy, W.rdfType, W.uiLink]);
751
+ triples.push([proxy, W.uiLabel, lit]);
752
+ triples.push([proxy, W.bkRecalls, url]);
753
+ triples.push([proxy, W.bkHasTopic, topicUri]);
754
+ }
755
+ const newSrc = { label: labelVal, url, topic: topicLabel, topicUri };
756
+ sources.push(newSrc);
757
+ await applyAddition({ kind: 'source', src: newSrc, triples });
758
+ sourceForm.reset();
759
+ });
760
+
761
+ picker.append(pickerLeft, pickerRight);
762
+
763
+ const setExpanded = open => {
764
+ picker.hidden = !open;
765
+ toggle.setAttribute('aria-expanded', String(open));
766
+ toggle.setAttribute('aria-label', open ? 'Hide feeds' : 'Show feeds');
767
+ };
768
+ toggle.addEventListener('click', () => setExpanded(picker.hidden));
769
+ setExpanded(false);
770
+
771
+ if (!remembered.length) {
772
+ // First visit on this page — nothing in localStorage yet.
773
+ // Honour an explicit `default-selected` attribute: a comma- or
774
+ // pipe-separated list of feed labels (or any unique substring
775
+ // of a label / URL). Matching is case-insensitive and uses
776
+ // substring containment so "guardian" picks "The Guardian" and
777
+ // "boingboing.net" works just as well. Falls back to the first
778
+ // checkbox when the attribute is absent or matches nothing.
779
+ const defaults = (this.getAttribute('default-selected') || '')
780
+ .split(/[|,]/)
781
+ .map(s => s.trim().toLowerCase())
782
+ .filter(Boolean);
783
+
784
+ let matched = false;
785
+ if (defaults.length) {
786
+ for (const cb of picker.querySelectorAll('input[type=checkbox]')) {
787
+ const src = sources.find(s => s.url === cb.value);
788
+ if (!src) continue;
789
+ const hay = `${(src.label || '').toLowerCase()} ${(src.url || '').toLowerCase()}`;
790
+ if (defaults.some(d => hay.includes(d))) {
791
+ cb.checked = true;
792
+ matched = true;
793
+ }
794
+ }
795
+ }
796
+ if (!matched) {
797
+ const firstCb = picker.querySelector('input[type=checkbox]');
798
+ if (firstCb) firstCb.checked = true;
799
+ }
800
+ }
801
+
802
+ this._root.replaceChildren(bar, picker, articles);
803
+
804
+ const chosen = chosenInTopicOrder();
805
+ rebuildSourceButtons();
806
+ if (chosen.length) {
807
+ articles.setAttribute('aria-busy', 'true');
808
+ await Promise.all(chosen.map(s => this.ensureSource(s)));
809
+ articles.setAttribute('aria-busy', 'false');
810
+ this.saveSelection();
811
+ await selectSource(chosen[0]);
812
+ } else {
813
+ articles.replaceChildren(emptyEl('Select a feed to see articles'));
814
+ this.setStatus('');
815
+ }
816
+ }
817
+
818
+ /* ── view: topics ─────────────────────────────────────────────────── */
819
+
820
+ /**
821
+ * "Newsstand" view: one column per topic in the source subtree, each
822
+ * listing that topic's sources. Clicking a source loads its articles
823
+ * into an image-card grid below the columns — the same cards as the
824
+ * `all` view (so `newsCard` wires the shared reader window for free).
825
+ * No source is auto-selected, so mounting issues no feed network calls
826
+ * until the user picks one.
827
+ */
828
+ async renderTopics() {
829
+ const wrap = document.createElement('div');
830
+ wrap.className = 'sol-feed-list topics';
831
+
832
+ const columns = document.createElement('div');
833
+ columns.className = 'feed-topic-columns';
834
+ columns.setAttribute('role', 'tablist');
835
+ columns.setAttribute('aria-label', 'Topics');
836
+
837
+ const articles = document.createElement('div');
838
+ articles.className = 'feed-articles';
839
+ articles.setAttribute('part', 'articles');
840
+ articles.setAttribute('aria-label', 'Articles');
841
+
842
+ wrap.append(columns, articles);
843
+ this._root.replaceChildren(wrap);
844
+
845
+ /** Loading / error / empty messages land in the articles area (where
846
+ * the cards will appear) rather than the status strip above the
847
+ * topic columns. */
848
+ const showMsg = (text, isError = false) => {
849
+ const el = emptyEl(text);
850
+ if (isError) el.setAttribute('data-error', '');
851
+ articles.replaceChildren(el);
852
+ };
853
+
854
+ if (!this.source) { showMsg('No feed source specified', true); return; }
855
+
856
+ showMsg('Loading feeds…');
857
+ let sources;
858
+ try {
859
+ sources = await this.resolveSources();
860
+ } catch (e) {
861
+ showMsg(e.message || String(e), true);
862
+ return;
863
+ }
864
+ if (!sources.length) { showMsg('No feeds found', true); return; }
865
+
866
+ /** Every source anchor (to clear others' highlight) plus src↔anchor
867
+ * pairs (to restore the remembered selection on mount). */
868
+ const allLinks = [];
869
+ const entries = [];
870
+
871
+ /** Render one source's items into the grid, newest first. */
872
+ const renderArticles = (items) => {
873
+ if (!items.length) { articles.replaceChildren(emptyEl('No articles')); return; }
874
+ const sorted = items.slice().sort((a, b) => dateMs(b.pubDate) - dateMs(a.pubDate));
875
+ articles.replaceChildren(...sorted.map(it => this.newsCard(it)));
876
+ };
877
+
878
+ const selectSource = async (src, anchor) => {
879
+ allLinks.forEach(a => {
880
+ const on = a === anchor;
881
+ a.classList.toggle('selected', on);
882
+ if (on) a.setAttribute('aria-current', 'true');
883
+ else a.removeAttribute('aria-current');
884
+ });
885
+ try { localStorage.setItem(this.topicsSelectionKey, src.url); } catch {}
886
+ articles.setAttribute('aria-busy', 'true');
887
+ showMsg(`Loading ${src.label}…`);
888
+ try {
889
+ if (!this._cache.has(src.url)) await this.ensureSource(src);
890
+ renderArticles(this._cache.get(src.url) || []);
891
+ } catch (e) {
892
+ showMsg(`${src.label}: ${e.message}`, true);
893
+ } finally {
894
+ articles.setAttribute('aria-busy', 'false');
895
+ }
896
+ };
897
+
898
+ // Editing context (used by the edit helpers below) + the column set.
899
+ const editable = this.editable;
900
+ this._fileUri = sources.fileUri;
901
+ this._catalogUri = sources.catalogUri;
902
+ this._binUri = binUriFor(sources.fileUri);
903
+ this._allTopics = (sources.topics || []).filter(t => t.uri !== sources.focusUri);
904
+ this._allFeedUris = sources.map(f => f.uri).filter(Boolean);
905
+
906
+ // Non-editable: one column per topic that HAS feeds (first-seen order).
907
+ // Editable: one column per topic in the scheme — including empty ones —
908
+ // so you can rename / add anywhere; carry the topic IRI for edits.
909
+ let groups;
910
+ if (editable) {
911
+ const byUri = new Map();
912
+ for (const f of sources) (byUri.get(f.topicUri) || byUri.set(f.topicUri, []).get(f.topicUri)).push(f);
913
+ groups = this._allTopics.map(t => ({ topic: t.label, topicUri: t.uri, feeds: byUri.get(t.uri) || [] }));
914
+ } else {
915
+ groups = this.groupByTopic(sources).map(g => ({ ...g, topicUri: g.feeds[0]?.topicUri }));
916
+ }
917
+
918
+ // Honour a saved order (schema:position); items without one keep file
919
+ // order (stable sort). Stash per-topic ordered lists so reorder can
920
+ // recompute positions.
921
+ for (const g of groups) g.feeds = [...g.feeds].sort((a, b) => (a.position ?? 1e9) - (b.position ?? 1e9));
922
+ if (editable) this._feedsByTopic = new Map(groups.map(g => [g.topicUri, g.feeds]));
923
+
924
+ for (const group of groups) {
925
+ const col = document.createElement('section');
926
+ col.className = 'feed-topic-column';
927
+ if (editable && group.topicUri) this._wireColumnDrop(col, group.topicUri);
928
+
929
+ const head = document.createElement('h2');
930
+ head.className = 'feed-topic-head';
931
+ head.textContent = group.topic || 'Sources';
932
+
933
+ if (editable && group.topicUri) {
934
+ head.classList.add('editable');
935
+ head.title = 'Click to rename';
936
+ head.addEventListener('click', () => this._renameTopicInline(head, group));
937
+ const addBtn = document.createElement('button');
938
+ addBtn.type = 'button';
939
+ addBtn.className = 'feed-add-source';
940
+ addBtn.textContent = '+';
941
+ addBtn.title = `Add a feed to ${group.topic}`;
942
+ addBtn.setAttribute('aria-label', addBtn.title);
943
+ addBtn.addEventListener('click', (e) => { e.stopPropagation(); this._addFeedForm(col, group); });
944
+ const headWrap = document.createElement('div');
945
+ headWrap.className = 'feed-topic-headwrap';
946
+ headWrap.append(head, addBtn);
947
+ col.appendChild(headWrap);
948
+ } else {
949
+ col.appendChild(head);
950
+ }
951
+
952
+ const list = document.createElement('ul');
953
+ list.className = 'feed-source-list feed-topic-col-list';
954
+ for (const src of group.feeds) {
955
+ const li = document.createElement('li');
956
+ const a = document.createElement('a');
957
+ a.className = 'feed-link';
958
+ a.href = src.url;
959
+ a.textContent = src.label;
960
+ a.setAttribute('role', 'tab');
961
+ a.addEventListener('click', ev => { ev.preventDefault(); selectSource(src, a); });
962
+ allLinks.push(a);
963
+ entries.push({ src, a });
964
+ li.appendChild(a);
965
+ if (editable) {
966
+ li.classList.add('editable-row');
967
+ this._wireSourceDrag(li, src);
968
+ this._wireRowDrop(li, src);
969
+ li.appendChild(this._deleteButton(src, li));
970
+ }
971
+ list.appendChild(li);
972
+ }
973
+ col.appendChild(list);
974
+ columns.appendChild(col);
975
+ }
976
+
977
+ // Restore the last-selected source (and reload its articles) if it's
978
+ // still present; otherwise prompt for a pick.
979
+ let remembered = null;
980
+ try { remembered = localStorage.getItem(this.topicsSelectionKey); } catch {}
981
+ const match = remembered && entries.find(e => e.src.url === remembered);
982
+ let selected = null;
983
+ if (match) {
984
+ selectSource(match.src, match.a);
985
+ selected = match.a;
986
+ } else if (entries.length) {
987
+ // With nothing remembered, open the first source so a cold start lands
988
+ // on real articles rather than an empty "pick a source" prompt.
989
+ selectSource(entries[0].src, entries[0].a);
990
+ selected = entries[0].a;
991
+ }
992
+ if (selected) this._scrollSourceIntoView(selected);
993
+ }
994
+
995
+ /** Bring a source anchor into view within its scrollable column. At startup
996
+ * the feed is usually rendered inside a still-hidden tab pane (sol-tabs
997
+ * keep-alive renders every pane while hidden), where scrollIntoView is a
998
+ * no-op for lack of a layout box. So when hidden, wait for the host to
999
+ * become visible (one-shot IntersectionObserver) and scroll then. */
1000
+ _scrollSourceIntoView(anchor) {
1001
+ if (!anchor) return;
1002
+ const doScroll = () => requestAnimationFrame(() => anchor.scrollIntoView({ block: 'nearest' }));
1003
+ if (this.offsetParent !== null) { doScroll(); return; } // already visible
1004
+ this._scrollIO?.disconnect();
1005
+ this._scrollIO = new IntersectionObserver((entries) => {
1006
+ if (entries.some(e => e.isIntersecting)) {
1007
+ this._scrollIO.disconnect(); this._scrollIO = null;
1008
+ doScroll();
1009
+ }
1010
+ });
1011
+ this._scrollIO.observe(this);
1012
+ }
1013
+
1014
+ /* ── editing (view="topics" + the `editable` attribute) ─────────────────
1015
+ * All edits PATCH the same-origin feeds file (sparql-update) then reload so
1016
+ * the view re-renders from the saved doc. Owner-gating is the host's job
1017
+ * (it sets/clears the `editable` attribute). */
1018
+
1019
+ get editable() { return this.hasAttribute('editable'); }
1020
+
1021
+ /** Host hook routed from the app chrome (⋮ → "View deleted"). */
1022
+ appAction(name) {
1023
+ if (name === 'viewDeleted') return this._openBin();
1024
+ }
1025
+
1026
+ /** PATCH one edit, then reload the normal view. */
1027
+ async _edit(editObj) {
1028
+ this._view = 'topics'; // a normal-view edit returns to the columns
1029
+ try {
1030
+ await patchDoc(this._fileUri, editObj);
1031
+ await this.reload();
1032
+ } catch (e) {
1033
+ this.setStatus(e.message || 'Edit failed', true);
1034
+ }
1035
+ }
1036
+
1037
+ /** Replace a topic head with an inline rename input. */
1038
+ _renameTopicInline(head, group) {
1039
+ const old = group.topic;
1040
+ const input = document.createElement('input');
1041
+ input.className = 'feed-topic-rename';
1042
+ input.value = old;
1043
+ head.replaceWith(input);
1044
+ input.focus(); input.select();
1045
+ let done = false;
1046
+ const commit = () => {
1047
+ if (done) return; done = true;
1048
+ const val = input.value.trim();
1049
+ if (val && val !== old) this._edit(renameTopicEdit(group.topicUri, old, val));
1050
+ else input.replaceWith(head); // unchanged → restore
1051
+ };
1052
+ input.addEventListener('keydown', (e) => {
1053
+ if (e.key === 'Enter') { e.preventDefault(); commit(); }
1054
+ else if (e.key === 'Escape') { done = true; input.replaceWith(head); }
1055
+ });
1056
+ input.addEventListener('blur', commit);
1057
+ }
1058
+
1059
+ /** Inline "add a feed to this topic" form, inserted under the topic head. */
1060
+ _addFeedForm(col, group) {
1061
+ if (col.querySelector('.feed-add-form')) return;
1062
+ const form = document.createElement('form');
1063
+ form.className = 'feed-add-form';
1064
+ const title = document.createElement('input');
1065
+ title.className = 'feed-add-input'; title.placeholder = 'Feed name'; title.required = true;
1066
+ const url = document.createElement('input');
1067
+ url.className = 'feed-add-input'; url.type = 'url'; url.placeholder = 'RSS URL'; url.required = true;
1068
+ const row = document.createElement('div'); row.className = 'feed-add-row';
1069
+ const ok = document.createElement('button'); ok.type = 'submit'; ok.className = 'primary'; ok.textContent = 'Add';
1070
+ const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = 'Cancel';
1071
+ row.append(ok, cancel);
1072
+ form.append(title, url, row);
1073
+ col.insertBefore(form, col.children[1] || null); // after the head wrap
1074
+ title.focus();
1075
+ cancel.addEventListener('click', () => form.remove());
1076
+ form.addEventListener('submit', (e) => {
1077
+ e.preventDefault();
1078
+ const t = title.value.trim(), u = url.value.trim();
1079
+ if (!t || !u) return;
1080
+ const feedUri = mintFeedUri(this._fileUri, t, this._allFeedUris);
1081
+ this._edit(addFeedEdit(feedUri, { title: t, url: u, topicUri: group.topicUri, catalogUri: this._catalogUri }));
1082
+ });
1083
+ }
1084
+
1085
+ /** Make a source row draggable (records the dragged feed + its topic). */
1086
+ _wireSourceDrag(li, src) {
1087
+ li.draggable = true;
1088
+ li.addEventListener('dragstart', (e) => {
1089
+ this._dragFeed = { uri: src.uri, fromTopicUri: src.topicUri };
1090
+ e.dataTransfer.effectAllowed = 'move';
1091
+ try { e.dataTransfer.setData('text/plain', src.uri); } catch {}
1092
+ li.classList.add('dragging');
1093
+ });
1094
+ li.addEventListener('dragend', () => { li.classList.remove('dragging'); this._dragFeed = null; });
1095
+ }
1096
+
1097
+ /** Make a topic column a drop target. Cross-topic drop = re-categorize;
1098
+ * same-topic drop on empty column area = reorder to the end. (Row-level
1099
+ * drops handle precise reorder and stopPropagation, so this only fires on
1100
+ * the empty space below the list.) */
1101
+ _wireColumnDrop(col, topicUri) {
1102
+ col.addEventListener('dragover', (e) => {
1103
+ if (!this._dragFeed) return;
1104
+ e.preventDefault();
1105
+ col.classList.add('drop-target');
1106
+ });
1107
+ col.addEventListener('dragleave', (e) => { if (!col.contains(e.relatedTarget)) col.classList.remove('drop-target'); });
1108
+ col.addEventListener('drop', (e) => {
1109
+ col.classList.remove('drop-target');
1110
+ const d = this._dragFeed;
1111
+ if (!d) return;
1112
+ e.preventDefault();
1113
+ if (d.fromTopicUri === topicUri) this._reorder(topicUri, d.uri, null, false); // → end
1114
+ else this._edit(recategorizeEdit(d.uri, d.fromTopicUri, topicUri));
1115
+ });
1116
+ }
1117
+
1118
+ /** A source row as a drop target: same-topic → reorder (insert before/after
1119
+ * by cursor position); cross-topic → re-categorize. stopPropagation keeps
1120
+ * the column handler for empty-area drops only. */
1121
+ _wireRowDrop(li, src) {
1122
+ const before = (e) => {
1123
+ const r = li.getBoundingClientRect();
1124
+ return (e.clientY - r.top) < r.height / 2;
1125
+ };
1126
+ li.addEventListener('dragover', (e) => {
1127
+ const d = this._dragFeed;
1128
+ if (!d || d.uri === src.uri) return;
1129
+ e.preventDefault(); e.stopPropagation();
1130
+ const b = before(e);
1131
+ li.classList.toggle('drop-before', b);
1132
+ li.classList.toggle('drop-after', !b);
1133
+ });
1134
+ li.addEventListener('dragleave', () => li.classList.remove('drop-before', 'drop-after'));
1135
+ li.addEventListener('drop', (e) => {
1136
+ const d = this._dragFeed;
1137
+ li.classList.remove('drop-before', 'drop-after');
1138
+ if (!d || d.uri === src.uri) return;
1139
+ e.preventDefault(); e.stopPropagation();
1140
+ if (d.fromTopicUri === src.topicUri) this._reorder(src.topicUri, d.uri, src.uri, before(e));
1141
+ else this._edit(recategorizeEdit(d.uri, d.fromTopicUri, src.topicUri));
1142
+ });
1143
+ }
1144
+
1145
+ /** Move `draggedUri` before/after `targetUri` (or to the end when null)
1146
+ * within a topic, then re-number schema:position for that topic. */
1147
+ _reorder(topicUri, draggedUri, targetUri, before) {
1148
+ const feeds = this._feedsByTopic?.get(topicUri) || [];
1149
+ const oldPos = {};
1150
+ feeds.forEach((f) => { if (f.position != null) oldPos[f.uri] = f.position; });
1151
+ const order = feeds.map((f) => f.uri).filter((u) => u !== draggedUri);
1152
+ let idx = targetUri ? order.indexOf(targetUri) : order.length;
1153
+ if (idx < 0) idx = order.length;
1154
+ if (!before && targetUri) idx += 1;
1155
+ order.splice(idx, 0, draggedUri);
1156
+ this._edit(setPositionsEdit(order, oldPos));
1157
+ }
1158
+
1159
+ /** The ✕ delete control on a source row → asks to confirm first. */
1160
+ _deleteButton(src, li) {
1161
+ const b = document.createElement('button');
1162
+ b.type = 'button';
1163
+ b.className = 'feed-del';
1164
+ b.textContent = '✕';
1165
+ b.title = `Delete ${src.label}`;
1166
+ b.setAttribute('aria-label', `Delete ${src.label}`);
1167
+ b.addEventListener('click', (e) => { e.stopPropagation(); this._confirmDelete(li, src); });
1168
+ return b;
1169
+ }
1170
+
1171
+ /** Inline confirm replacing the row: «Delete "X"? [Delete] [Cancel]». */
1172
+ _confirmDelete(li, src) {
1173
+ if (li.querySelector('.feed-del-confirm')) return;
1174
+ const orig = [...li.childNodes];
1175
+ li.classList.add('confirming');
1176
+ const wrap = document.createElement('div');
1177
+ wrap.className = 'feed-del-confirm';
1178
+ const q = document.createElement('span');
1179
+ q.className = 'feed-del-q';
1180
+ q.textContent = `Delete “${src.label}”?`;
1181
+ const yes = document.createElement('button');
1182
+ yes.type = 'button'; yes.className = 'feed-del-yes'; yes.textContent = 'Delete';
1183
+ const no = document.createElement('button');
1184
+ no.type = 'button'; no.className = 'feed-del-no'; no.textContent = 'Cancel';
1185
+ wrap.append(q, yes, no);
1186
+ li.replaceChildren(wrap);
1187
+ no.focus();
1188
+ no.addEventListener('click', () => { li.classList.remove('confirming'); li.replaceChildren(...orig); });
1189
+ yes.addEventListener('click', () => this._edit(deleteToBinEdit(src.uri, src.topicUri, this._binUri)));
1190
+ }
1191
+
1192
+ /** Render the deleted bin: each deleted feed with a "restore to <topic>". */
1193
+ async _openBin() {
1194
+ this._view = 'bin'; // sticky: a reload re-renders the bin
1195
+ const nav = ++this._nav;
1196
+ if (!this._fileUri) { // not rendered yet — derive
1197
+ const abs = new URL(this.source, location.href).href;
1198
+ this._fileUri = abs.split('#')[0];
1199
+ this._binUri = binUriFor(this._fileUri);
1200
+ }
1201
+ const wrap = document.createElement('div');
1202
+ wrap.className = 'sol-feed-list topics feed-bin-view';
1203
+ const bar = document.createElement('div'); bar.className = 'feed-bin-bar';
1204
+ const back = document.createElement('button');
1205
+ back.type = 'button'; back.className = 'feed-bin-back'; back.textContent = '← Back to feeds';
1206
+ back.addEventListener('click', () => { this._view = 'topics'; this.reload(); });
1207
+ const title = document.createElement('span'); title.className = 'feed-bin-title'; title.textContent = 'Deleted feeds';
1208
+ bar.append(back, title);
1209
+ const list = document.createElement('ul'); list.className = 'feed-source-list feed-bin-list';
1210
+ wrap.append(bar, list);
1211
+ this._root.replaceChildren(wrap);
1212
+
1213
+ let binFeeds = [];
1214
+ try { binFeeds = await parseSourceList(this._binUri, { proxy: this.proxy }); } catch { /* empty bin */ }
1215
+ if (nav !== this._nav) return; // superseded by a newer navigation
1216
+ if (!binFeeds.length) {
1217
+ const li = document.createElement('li'); li.className = 'sol-feed-empty'; li.textContent = 'Nothing deleted.';
1218
+ list.appendChild(li); return;
1219
+ }
1220
+ const topics = this._allTopics || [];
1221
+ for (const src of binFeeds) {
1222
+ const li = document.createElement('li'); li.className = 'feed-bin-row';
1223
+ const name = document.createElement('span'); name.className = 'feed-bin-name'; name.textContent = src.label;
1224
+ const sel = document.createElement('select'); sel.className = 'feed-bin-restore-to'; sel.setAttribute('aria-label', 'Restore to topic');
1225
+ for (const t of topics) { const o = document.createElement('option'); o.value = t.uri; o.textContent = t.label; sel.appendChild(o); }
1226
+ const restore = document.createElement('button');
1227
+ restore.type = 'button'; restore.className = 'feed-bin-restore'; restore.textContent = 'Restore';
1228
+ restore.addEventListener('click', async () => {
1229
+ try { await patchDoc(this._fileUri, restoreEdit(src.uri, this._binUri, sel.value)); await this._openBin(); }
1230
+ catch (e) { this.setStatus(e.message, true); }
1231
+ });
1232
+ const purge = document.createElement('button');
1233
+ purge.type = 'button'; purge.className = 'feed-bin-purge'; purge.textContent = 'Delete forever';
1234
+ purge.addEventListener('click', () => this._confirmPurge(li, src));
1235
+ li.append(name, sel, restore, purge);
1236
+ list.appendChild(li);
1237
+ }
1238
+ }
1239
+
1240
+ /** Inline confirm for a PERMANENT delete from the bin (no undo). */
1241
+ _confirmPurge(li, src) {
1242
+ if (li.querySelector('.feed-del-confirm')) return;
1243
+ const orig = [...li.childNodes];
1244
+ const wrap = document.createElement('div');
1245
+ wrap.className = 'feed-del-confirm';
1246
+ const q = document.createElement('span');
1247
+ q.className = 'feed-del-q';
1248
+ q.textContent = `Permanently delete “${src.label}”? This can't be undone.`;
1249
+ const yes = document.createElement('button');
1250
+ yes.type = 'button'; yes.className = 'feed-del-yes'; yes.textContent = 'Delete forever';
1251
+ const no = document.createElement('button');
1252
+ no.type = 'button'; no.className = 'feed-del-no'; no.textContent = 'Cancel';
1253
+ wrap.append(q, yes, no);
1254
+ li.replaceChildren(wrap);
1255
+ no.focus();
1256
+ no.addEventListener('click', () => li.replaceChildren(...orig));
1257
+ yes.addEventListener('click', async () => {
1258
+ try { await purgeFeed(this._fileUri, src.uri, { catalogUri: this._catalogUri }); await this._openBin(); }
1259
+ catch (e) { this.setStatus(e.message, true); }
1260
+ });
1261
+ }
1262
+
1263
+ /** localStorage key for the topics-view selected source (one URL). */
1264
+ get topicsSelectionKey() {
1265
+ return `sol-feed:topic-source:${this.source || location.pathname}`;
1266
+ }
1267
+
1268
+ /** localStorage key for this element's all-view source selection. */
1269
+ get selectionKey() {
1270
+ return `sol-feed:selected:${this.source || location.pathname}`;
1271
+ }
1272
+
1273
+ /** Read the remembered set of selected feed URLs (empty on any failure). */
1274
+ loadSelection() {
1275
+ try {
1276
+ const raw = localStorage.getItem(this.selectionKey);
1277
+ const list = raw ? JSON.parse(raw) : [];
1278
+ return Array.isArray(list) ? list : [];
1279
+ } catch { return []; }
1280
+ }
1281
+
1282
+ /** Persist the currently-checked feed URLs to localStorage. */
1283
+ saveSelection() {
1284
+ const urls = Array.from(this.shadowRoot.querySelectorAll('.feed-picker input:checked'))
1285
+ .map(cb => cb.value);
1286
+ try { localStorage.setItem(this.selectionKey, JSON.stringify(urls)); }
1287
+ catch { /* storage unavailable / full — selection just won't persist */ }
1288
+ }
1289
+
1290
+ /** Fetch a source into `_cache` once; failures cache as empty. */
1291
+ async ensureSource(src) {
1292
+ if (this._cache.has(src.url)) return;
1293
+ try {
1294
+ this._cache.set(src.url, await getFeedItems(src.url, { proxy: this.proxy }));
1295
+ } catch (e) {
1296
+ this._cache.set(src.url, []);
1297
+ console.warn(`[sol-feed] ${src.url}: ${e.message}`);
1298
+ }
1299
+ }
1300
+
1301
+ /** Build one news card — an image link with a reveal-on-focus overlay. */
1302
+ newsCard(it) {
1303
+ const a = document.createElement('a');
1304
+ a.className = 'feed-card';
1305
+ a.href = it.link || '#';
1306
+ a.addEventListener('click', ev => { if (openInReader(a.href)) ev.preventDefault(); });
1307
+ // The visible title + overlay would make a very long accessible name;
1308
+ // pin the link's name to the article title instead.
1309
+ a.setAttribute('aria-label', it.title);
1310
+
1311
+ if (it.image) {
1312
+ const img = document.createElement('img');
1313
+ img.className = 'feed-card-img';
1314
+ img.src = it.image;
1315
+ img.alt = '';
1316
+ img.loading = 'lazy';
1317
+ img.addEventListener('error', () => { img.remove(); a.classList.add('no-image'); });
1318
+ a.appendChild(img);
1319
+ } else {
1320
+ a.classList.add('no-image');
1321
+ }
1322
+
1323
+ // Title only — the source name is the row header, so the card just
1324
+ // shows the article title over the image (gradient scrim at the bottom).
1325
+ const title = document.createElement('div');
1326
+ title.className = 'feed-card-title';
1327
+ title.textContent = it.title || '';
1328
+ a.appendChild(title);
1329
+
1330
+ return a;
1331
+ }
1332
+ }
1333
+
1334
+ define('sol-feed', SolFeed);
1335
+
1336
+ export { SolFeed };