Skip to content

Commit 06966f8

Browse files
ralyodioclaude
andcommitted
settings: share Search/Markets/Sports/Feeds between extension + web (one source)
The cloud settings page only had AI keys; the extension options page had feeds, markets, sports and search. Extract those into a single shared module (settings-sections.js + feeds.js) driven by a tiny store adapter: - extension -> chrome.storage.local (+ cloud push, cache invalidation) - website -> the /api/settings object (Turso) options.js now mounts the shared module instead of its own copy; the web settings page gains the same sections + a full RSS feed editor / OPML import- export. Canonical files live in the extension and are copied to apps/web/public by scripts/sync-shared-settings.mjs (--check fails on drift). 6 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e5b4149 commit 06966f8

8 files changed

Lines changed: 432 additions & 74 deletions

File tree

‎apps/desktop/extensions/ai-sidebar/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "TronBrowser",
4-
"version": "0.2.14",
4+
"version": "0.2.15",
55
"description": "TronBrowser — privacy-first, AI-native. Branded new tab, private search, CoinPay login, and a bring-your-own-keys AI sidebar.",
66
"icons": {
77
"16": "icons/icon-16.png",

‎apps/desktop/extensions/ai-sidebar/options.js‎

Lines changed: 17 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PROVIDERS, KNOWN_MODELS, listModels } from './providers.js';
2-
import { DEFAULT_FEEDS, parseOpml, toOpml, loadFeeds, saveFeeds } from './feeds.js';
2+
import { mountSettingsSections } from './settings-sections.js';
33
import { coinpaySignIn, coinpayState, coinpaySignOut, emailSignIn, emailSignUp } from './coinpay-auth.js';
44
import { pushSettings, pullSettings } from './settings-store.js';
55
import { encryptVault, decryptVault } from './vault.js';
@@ -142,23 +142,21 @@ el('btrConnect').addEventListener('click', async () => {
142142
await renderBtr();
143143
});
144144

145-
/* ---------- Search engine ---------- */
146-
el('saveSearch').addEventListener('click', async () => {
147-
await chrome.storage.local.set({ searchEngine: el('searchEngine').value });
148-
await pushSettings();
149-
flash('savedSearch', 'saved ✓');
150-
});
151-
152-
/* ---------- Markets & Sports (new-tab widgets) ---------- */
153-
el('saveMarkets').addEventListener('click', async () => {
154-
const tickers = el('tickers').value.trim();
155-
const leagues = el('leagues').value.trim();
156-
await chrome.storage.local.set({ tickers, leagues });
157-
// Invalidate the new-tab caches so the change shows immediately.
158-
await chrome.storage.local.remove(['marketCache', 'sportsCache']);
159-
await pushSettings();
160-
flash('savedMarkets', 'saved ✓');
161-
});
145+
/* ---------- Search / Markets / Sports / Feeds — the SHARED module ---------- */
146+
// Same UI + logic as the website settings page. Persist locally, invalidate the
147+
// matching new-tab caches, and push to the cloud; the shared module renders it.
148+
const sectionsStore = {
149+
get: (keys) => chrome.storage.local.get(keys),
150+
set: async (obj) => {
151+
await chrome.storage.local.set(obj);
152+
const inval = [];
153+
if ('feeds' in obj) inval.push('feedCache');
154+
if ('tickers' in obj || 'leagues' in obj) inval.push('marketCache', 'sportsCache');
155+
if (inval.length) await chrome.storage.local.remove(inval);
156+
await pushSettings();
157+
},
158+
};
159+
const mountSections = () => mountSettingsSections({ store: sectionsStore, el, flash });
162160

163161
/* ---------- Account + sync (CoinPay or email — same as the website) ---------- */
164162
async function renderAccount() {
@@ -213,56 +211,6 @@ el('emailSignup').addEventListener('click', async () => {
213211
} catch (e) { flash('emailMsg', e.message); }
214212
});
215213

216-
/* ---------- Feeds ---------- */
217-
async function renderFeeds() {
218-
const feeds = await loadFeeds();
219-
const list = el('feedlist');
220-
list.innerHTML = '';
221-
feeds.forEach((f, i) => {
222-
const li = document.createElement('li');
223-
li.innerHTML = `<span><b>${escape(f.title)}</b> <span class="cat">${escape(f.category || '')}</span><br><span class="cat">${escape(f.xmlUrl)}</span></span>`;
224-
const x = document.createElement('button');
225-
x.className = 'x'; x.textContent = '✕'; x.title = 'Remove';
226-
x.addEventListener('click', async () => { feeds.splice(i, 1); await persistFeeds(feeds); });
227-
li.appendChild(x);
228-
list.appendChild(li);
229-
});
230-
}
231-
async function persistFeeds(feeds) {
232-
await saveFeeds(feeds);
233-
await chrome.storage.local.remove('feedCache');
234-
await pushSettings();
235-
await renderFeeds();
236-
}
237-
el('addFeed').addEventListener('click', async () => {
238-
const url = el('fUrl').value.trim();
239-
if (!url) return flash('feedMsg', 'URL required');
240-
const feeds = await loadFeeds();
241-
feeds.push({ title: el('fTitle').value.trim() || url, category: el('fCat').value.trim() || 'Feeds', xmlUrl: url, htmlUrl: url });
242-
el('fTitle').value = el('fCat').value = el('fUrl').value = '';
243-
await persistFeeds(feeds);
244-
flash('feedMsg', 'added ✓');
245-
});
246-
el('importOpml').addEventListener('click', async () => {
247-
let xml = el('opmlText').value.trim();
248-
const file = el('opmlFile').files[0];
249-
if (!xml && file) xml = await file.text();
250-
if (!xml) return flash('feedMsg', 'paste OPML or pick a file');
251-
const parsed = parseOpml(xml);
252-
if (!parsed.length) return flash('feedMsg', 'no feeds found in OPML');
253-
await persistFeeds(parsed);
254-
el('opmlText').value = '';
255-
flash('feedMsg', `imported ${parsed.length} feeds ✓`);
256-
});
257-
el('exportOpml').addEventListener('click', async () => {
258-
const opml = toOpml(await loadFeeds());
259-
const url = URL.createObjectURL(new Blob([opml], { type: 'text/xml' }));
260-
const a = document.createElement('a');
261-
a.href = url; a.download = 'tronbrowser-feeds.opml'; a.click();
262-
URL.revokeObjectURL(url);
263-
});
264-
el('resetFeeds').addEventListener('click', async () => { await persistFeeds(DEFAULT_FEEDS.slice()); flash('feedMsg', 'reset ✓'); });
265-
266214
/* ---------- masked passphrase dialog (prompt() can't hide input) ---------- */
267215
function promptPassword(message) {
268216
return new Promise((resolve) => {
@@ -310,12 +258,8 @@ async function loadAll() {
310258
buildProviders(provs, aiDefault);
311259
el('cpClient').value = coinpayConfig?.clientId || '';
312260
el('syncUrl').value = syncConfig?.url || '';
313-
const mkt = await chrome.storage.local.get(['tickers', 'leagues', 'searchEngine']);
314-
el('tickers').value = mkt.tickers ?? '';
315-
el('leagues').value = mkt.leagues ?? '';
316-
el('searchEngine').value = mkt.searchEngine ?? 'xprivo';
317261
await renderAccount();
318-
await renderFeeds();
262+
await mountSections(); // Search / Markets / Sports / RSS feeds (shared module)
319263
await renderBtr();
320264
}
321265
el('cpClient').addEventListener('change', async () => {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Shared settings sections (Search / Markets / Sports / RSS feeds) used by BOTH
2+
// the extension options page and the website settings page, so there is ONE
3+
// implementation. Storage is abstracted behind a `store` adapter so each host
4+
// persists however it likes:
5+
// extension -> chrome.storage.local (+ cloud push, cache invalidation)
6+
// website -> the /api/settings object (Turso)
7+
//
8+
// Canonical file: apps/desktop/extensions/ai-sidebar/settings-sections.js
9+
// Copied to apps/web/public/ by scripts/sync-shared-settings.mjs — edit here only.
10+
import { DEFAULT_FEEDS, parseOpml, toOpml } from './feeds.js';
11+
12+
let ctx = null; // { store, el, flash } — refreshed on every mount
13+
let feeds = []; // current feed list (module-level so listeners stay in sync)
14+
let wired = false; // listeners attached once
15+
16+
const esc = (s) => { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; };
17+
18+
function renderFeeds() {
19+
const list = ctx.el('feedlist');
20+
if (!list) return;
21+
list.innerHTML = '';
22+
feeds.forEach((f, i) => {
23+
const li = document.createElement('li');
24+
li.innerHTML = `<span><b>${esc(f.title)}</b> <span class="cat">${esc(f.category || '')}</span><br><span class="cat">${esc(f.xmlUrl)}</span></span>`;
25+
const x = document.createElement('button');
26+
x.className = 'x'; x.textContent = '✕'; x.title = 'Remove';
27+
x.addEventListener('click', async () => { feeds.splice(i, 1); await persist(); });
28+
li.appendChild(x);
29+
list.appendChild(li);
30+
});
31+
}
32+
async function persist() { await ctx.store.set({ feeds }); renderFeeds(); }
33+
34+
/**
35+
* @param {object} o
36+
* @param {{get:(keys:string[])=>Promise<Record<string,any>>, set:(obj:Record<string,any>)=>Promise<void>}} o.store
37+
* @param {(id:string)=>HTMLElement|null} o.el id -> element
38+
* @param {(id:string,msg:string)=>void} o.flash transient message
39+
*/
40+
export async function mountSettingsSections({ store, el, flash }) {
41+
ctx = { store, el, flash };
42+
const cur = await store.get(['feeds', 'tickers', 'leagues', 'searchEngine']);
43+
44+
// Populate current values (on every mount, e.g. after a cloud pull).
45+
if (el('searchEngine')) el('searchEngine').value = cur.searchEngine || 'xprivo';
46+
if (el('tickers')) el('tickers').value = cur.tickers ?? '';
47+
if (el('leagues')) el('leagues').value = cur.leagues ?? '';
48+
feeds = Array.isArray(cur.feeds) && cur.feeds.length ? cur.feeds.slice() : DEFAULT_FEEDS.slice();
49+
renderFeeds();
50+
51+
if (wired) return; // attach listeners exactly once
52+
wired = true;
53+
54+
el('saveSearch')?.addEventListener('click', async () => {
55+
await ctx.store.set({ searchEngine: el('searchEngine').value });
56+
ctx.flash('savedSearch', 'saved ✓');
57+
});
58+
el('saveMarkets')?.addEventListener('click', async () => {
59+
await ctx.store.set({ tickers: el('tickers').value.trim(), leagues: el('leagues').value.trim() });
60+
ctx.flash('savedMarkets', 'saved ✓');
61+
});
62+
el('addFeed')?.addEventListener('click', async () => {
63+
const url = el('fUrl').value.trim();
64+
if (!url) return ctx.flash('feedMsg', 'URL required');
65+
feeds.push({ title: el('fTitle').value.trim() || url, category: el('fCat').value.trim() || 'Feeds', xmlUrl: url, htmlUrl: url });
66+
el('fTitle').value = el('fCat').value = el('fUrl').value = '';
67+
await persist();
68+
ctx.flash('feedMsg', 'added ✓');
69+
});
70+
el('importOpml')?.addEventListener('click', async () => {
71+
let xml = el('opmlText') ? el('opmlText').value.trim() : '';
72+
const file = el('opmlFile')?.files?.[0];
73+
if (!xml && file) xml = await file.text();
74+
if (!xml) return ctx.flash('feedMsg', 'paste OPML or pick a file');
75+
const parsed = parseOpml(xml);
76+
if (!parsed.length) return ctx.flash('feedMsg', 'no feeds found in OPML');
77+
feeds = parsed;
78+
await persist();
79+
if (el('opmlText')) el('opmlText').value = '';
80+
ctx.flash('feedMsg', `imported ${parsed.length} feeds ✓`);
81+
});
82+
el('exportOpml')?.addEventListener('click', () => {
83+
const url = URL.createObjectURL(new Blob([toOpml(feeds)], { type: 'text/xml' }));
84+
const a = document.createElement('a');
85+
a.href = url; a.download = 'tronbrowser-feeds.opml'; a.click();
86+
URL.revokeObjectURL(url);
87+
});
88+
el('resetFeeds')?.addEventListener('click', async () => { feeds = DEFAULT_FEEDS.slice(); await persist(); ctx.flash('feedMsg', 'reset ✓'); });
89+
}

‎apps/web/public/feeds.js‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Default RSS subscriptions (from the user's OPML). Overridable via OPML import
2+
// in Settings (stored in chrome.storage.local under "feeds").
3+
export const DEFAULT_FEEDS = [
4+
{ category: 'Food', title: 'Taste of Home', xmlUrl: 'https://www.tasteofhome.com/feed/', htmlUrl: 'https://www.tasteofhome.com/' },
5+
{ category: 'Profullstack, Inc.', title: 'BitTorrented Blog', xmlUrl: 'https://bittorrented.com/blog/rss.xml', htmlUrl: 'https://bittorrented.com/blog' },
6+
{ category: 'Profullstack, Inc.', title: 'bl0ggers Blog', xmlUrl: 'https://bl0ggers.com/blog/rss.xml', htmlUrl: 'https://bl0ggers.com/blog' },
7+
{ category: 'Profullstack, Inc.', title: 'c0mpute blog', xmlUrl: 'https://c0mpute.com/blog/rss.xml', htmlUrl: 'https://c0mpute.com/blog' },
8+
{ category: 'Profullstack, Inc.', title: 'c0upons Blog', xmlUrl: 'https://c0upons.com/blog/rss.xml', htmlUrl: 'https://c0upons.com/blog' },
9+
{ category: 'Profullstack, Inc.', title: 'CoinPay Blog', xmlUrl: 'https://coinpayportal.com/blog/rss.xml', htmlUrl: 'https://coinpayportal.com/blog' },
10+
{ category: 'Profullstack, Inc.', title: 'CrawlProof blog', xmlUrl: 'https://crawlproof.com/blog/rss.xml', htmlUrl: 'https://crawlproof.com/blog' },
11+
{ category: 'Profullstack, Inc.', title: 'd0rz blog', xmlUrl: 'https://d0rz.com/blog/rss.xml', htmlUrl: 'https://d0rz.com/blog' },
12+
{ category: 'Profullstack, Inc.', title: 'PairUX Blog', xmlUrl: 'https://pairux.com/blog/rss.xml', htmlUrl: 'https://pairux.com/blog' },
13+
{ category: 'Profullstack, Inc.', title: 'QryptChat Blog', xmlUrl: 'https://qrypt.chat/blog/rss.xml', htmlUrl: 'https://qrypt.chat/blog' },
14+
{ category: 'Profullstack, Inc.', title: 'SaaSRow Blog', xmlUrl: 'https://saasrow.com/blog/rss.xml', htmlUrl: 'https://saasrow.com/blog' },
15+
{ category: 'Profullstack, Inc.', title: 'sh1pt Blog', xmlUrl: 'https://sh1pt.com/blog/rss.xml', htmlUrl: 'https://sh1pt.com/blog' },
16+
{ category: 'Profullstack, Inc.', title: 'ThreatCrush Blog', xmlUrl: 'https://threatcrush.com/blog/rss.xml', htmlUrl: 'https://threatcrush.com/blog' },
17+
{ category: 'Profullstack, Inc.', title: 'ugig blog', xmlUrl: 'https://ugig.net/blog/rss.xml', htmlUrl: 'https://ugig.net/blog' },
18+
{ category: 'Profullstack, Inc.', title: 'vu1nz Blog', xmlUrl: 'https://vu1nz.com/blog/rss.xml', htmlUrl: 'https://vu1nz.com/blog' },
19+
{ category: 'Projects', title: 'MLT', xmlUrl: 'https://www.mltframework.org/feed.xml', htmlUrl: 'https://mltframework.org/' },
20+
];
21+
22+
/** Parse an OPML string into the feeds array. */
23+
export function parseOpml(xml) {
24+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
25+
const out = [];
26+
for (const node of doc.querySelectorAll('outline[xmlUrl]')) {
27+
const parent = node.parentElement;
28+
const category = parent && parent.tagName === 'outline'
29+
? parent.getAttribute('text') || parent.getAttribute('title') || 'Feeds'
30+
: 'Feeds';
31+
out.push({
32+
category,
33+
title: node.getAttribute('text') || node.getAttribute('title') || node.getAttribute('xmlUrl'),
34+
xmlUrl: node.getAttribute('xmlUrl'),
35+
htmlUrl: node.getAttribute('htmlUrl') || node.getAttribute('xmlUrl'),
36+
});
37+
}
38+
return out;
39+
}
40+
41+
/** Parse an RSS/Atom feed document into {title, link, date, image} items. */
42+
export function parseFeed(xml) {
43+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
44+
const items = [];
45+
// RSS
46+
for (const it of doc.querySelectorAll('item')) {
47+
items.push({
48+
title: text(it, 'title'),
49+
link: text(it, 'link'),
50+
date: text(it, 'pubDate') || text(it, 'date'),
51+
image: image(it),
52+
});
53+
}
54+
// Atom
55+
if (items.length === 0) {
56+
for (const e of doc.querySelectorAll('entry')) {
57+
const link = e.querySelector('link');
58+
items.push({
59+
title: text(e, 'title'),
60+
link: link ? link.getAttribute('href') : '',
61+
date: text(e, 'updated') || text(e, 'published'),
62+
image: image(e),
63+
});
64+
}
65+
}
66+
return items;
67+
}
68+
69+
function text(parent, tag) {
70+
const el = parent.querySelector(tag);
71+
return el ? el.textContent.trim() : '';
72+
}
73+
74+
const IMG_RE = /\.(jpe?g|png|gif|webp|avif)(\?|#|$)/i;
75+
function okUrl(u) { return /^https?:\/\//i.test(u || '') ? u : ''; }
76+
77+
// Best-effort thumbnail for a feed item: Media RSS, image enclosure, podcast art,
78+
// or the first <img> embedded in the content. Returns '' when none is available.
79+
function image(it) {
80+
const tag = (n) => it.getElementsByTagName(n)[0];
81+
// Media RSS: <media:thumbnail url> / <media:content url medium="image">
82+
const mt = tag('media:thumbnail');
83+
if (mt && okUrl(mt.getAttribute('url'))) return mt.getAttribute('url');
84+
for (const mc of it.getElementsByTagName('media:content')) {
85+
const url = mc.getAttribute('url');
86+
const medium = mc.getAttribute('medium') || mc.getAttribute('type') || '';
87+
if (okUrl(url) && (/image/i.test(medium) || IMG_RE.test(url))) return url;
88+
}
89+
// <enclosure type="image/..."> or an image-looking url
90+
for (const enc of it.getElementsByTagName('enclosure')) {
91+
const url = enc.getAttribute('url');
92+
const type = enc.getAttribute('type') || '';
93+
if (okUrl(url) && (/^image\//i.test(type) || IMG_RE.test(url))) return url;
94+
}
95+
// Podcast/iTunes artwork
96+
const ii = tag('itunes:image');
97+
if (ii && okUrl(ii.getAttribute('href'))) return ii.getAttribute('href');
98+
// First <img src> inside content:encoded or description HTML
99+
const html = (tag('content:encoded')?.textContent) || text(it, 'description') || '';
100+
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i);
101+
if (m && okUrl(m[1])) return m[1];
102+
return '';
103+
}
104+
105+
/** Serialize feeds back to OPML (grouped by category). */
106+
export function toOpml(feeds) {
107+
const byCat = {};
108+
for (const f of feeds) (byCat[f.category || 'Feeds'] ||= []).push(f);
109+
const esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
110+
let body = '';
111+
for (const [cat, items] of Object.entries(byCat)) {
112+
body += ` <outline text="${esc(cat)}" title="${esc(cat)}">\n`;
113+
for (const f of items) {
114+
body += ` <outline text="${esc(f.title)}" title="${esc(f.title)}" type="rss" xmlUrl="${esc(f.xmlUrl)}" htmlUrl="${esc(f.htmlUrl)}"/>\n`;
115+
}
116+
body += ' </outline>\n';
117+
}
118+
return `<?xml version="1.0" encoding="UTF-8"?>
119+
<opml version="2.0">
120+
<head>
121+
<title>TronBrowser RSS Subscriptions</title>
122+
<dateCreated>${new Date().toUTCString()}</dateCreated>
123+
</head>
124+
<body>
125+
${body} </body>
126+
</opml>
127+
`;
128+
}
129+
130+
/** Load feeds from storage, falling back to the OPML defaults. */
131+
export async function loadFeeds() {
132+
const { feeds } = await chrome.storage.local.get('feeds');
133+
return Array.isArray(feeds) && feeds.length ? feeds : DEFAULT_FEEDS;
134+
}
135+
136+
export async function saveFeeds(feeds) {
137+
await chrome.storage.local.set({ feeds });
138+
}

0 commit comments

Comments
 (0)