Skip to content

Commit 5407539

Browse files
author
Lukas Geiger
committed
fix: deduplicate feeds within one OPML import
1 parent 3b98a98 commit 5407539

3 files changed

Lines changed: 49 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Booting the service worker now refreshes stored alarm diagnostics even before `onStartup` or manual settings changes run.
1515
- Fixed alarm updates for manual-only feeds when global interval is disabled but other feeds define per-feed intervals.
1616
- Fixed RSS and Atom text parsing so CDATA wrappers are removed from feed titles, item titles, links, and Atom dates.
17+
- Fixed OPML imports so duplicate feed URLs inside the same file are only imported once.
1718
- Fixed RSS 1.0/RDF parsing so item blocks outside the channel block are no longer ignored.
1819
- Normalized the German locale file to real UTF-8 Umlaute instead of escaped code points.
1920

tests/options-export-ui.test.mjs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ afterEach(() => {
1010
delete globalThis.chrome;
1111
delete globalThis.document;
1212
delete globalThis.window;
13+
delete globalThis.crypto;
1314
});
1415

1516
class FakeElement {
@@ -76,6 +77,11 @@ class FakeElement {
7677

7778
function installOptionsPage({ feeds, lifecycle } = {}) {
7879
const elements = new Map();
80+
const store = {
81+
feeds: structuredClone(feeds || {}),
82+
lifecycle: structuredClone(lifecycle || {}),
83+
settings: {}
84+
};
7985
for (const id of [
8086
"updateOnStartup",
8187
"globalInterval",
@@ -119,19 +125,21 @@ function installOptionsPage({ feeds, lifecycle } = {}) {
119125
i18n: {
120126
getMessage(key, substitutions = []) {
121127
if (key === "optionsExported") return `exported:${substitutions[0]}`;
128+
if (key === "optionsOPMLImported") return `imported:${substitutions[0]}`;
122129
if (key === "popupError") return `error:${substitutions[0]}`;
123130
return key;
124131
}
125132
},
126133
storage: {
127134
local: {
128135
async get(keys) {
129-
return Object.fromEntries(keys.map((key) => [
130-
key,
131-
key === "feeds" ? feeds : key === "lifecycle" ? lifecycle : undefined
132-
]));
136+
return Object.fromEntries(keys.map((key) => [key, structuredClone(store[key])]));
133137
},
134-
async set() {}
138+
async set(patch) {
139+
for (const [key, value] of Object.entries(patch)) {
140+
store[key] = structuredClone(value);
141+
}
142+
}
135143
}
136144
},
137145
bookmarks: {
@@ -171,7 +179,16 @@ function installOptionsPage({ feeds, lifecycle } = {}) {
171179
}
172180
};
173181

174-
return { elements, writes };
182+
Object.defineProperty(globalThis, "crypto", {
183+
configurable: true,
184+
value: {
185+
randomUUID() {
186+
return `feed-${Object.keys(store.feeds).length + 1}`;
187+
}
188+
}
189+
});
190+
191+
return { elements, writes, store };
175192
}
176193

177194
test("options page exposes folder export button", () => {
@@ -205,6 +222,30 @@ test("options folder export button writes bookmark .url files and shows count",
205222
assert.equal(elements.get("feedStatus").textContent, "exported:1");
206223
});
207224

225+
test("OPML import ignores duplicate feed URLs within the same file", async () => {
226+
const { elements, store } = installOptionsPage({ feeds: {} });
227+
const opmlFile = {
228+
async text() {
229+
return `<?xml version="1.0" encoding="UTF-8"?>
230+
<opml version="2.0">
231+
<body>
232+
<outline text="Alpha" xmlUrl="https://example.test/feed.xml" />
233+
<outline text="Alpha Duplicate" xmlUrl="https://example.test/feed.xml" />
234+
</body>
235+
</opml>`;
236+
}
237+
};
238+
239+
await import(`../ui/options.js?options-opml=${Date.now()}`);
240+
241+
const input = elements.get("opmlFileInput");
242+
await input.listeners.change({ target: { files: [opmlFile], value: "picked.opml" } });
243+
244+
assert.equal(Object.keys(store.feeds).length, 1);
245+
assert.equal(store.feeds["feed-1"]?.url, "https://example.test/feed.xml");
246+
assert.equal(elements.get("feedStatus").textContent, "imported:1");
247+
});
248+
208249
test("options page renders lifecycle diagnostics from storage", async () => {
209250
const { elements } = installOptionsPage({
210251
feeds: {},

ui/options.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ document.getElementById("opmlFileInput").addEventListener("change", async (e) =>
122122
lastError: "",
123123
seen: {}
124124
});
125+
existingUrls.add(item.url);
125126
imported++;
126127
}
127128

0 commit comments

Comments
 (0)