PageSourceSearch

https://jp343.com/wp-content/themes/generatepress-child/js/features/podcast-opml.js?v=1789725227

js jp343.com collected 2026-09-24 14:46:25 UTC 15,367 bytes, 332 lines download raw bytes

1// Podcast OPML import/export
2(function () {
3'use strict';
4
5window.JP343Podcast = window.JP343Podcast || {};
6var _pc = window.JP343Podcast;
7
8// utils load first (page-podcast.php order)
9var u = _pc._util;
10
11var IMPORT_CAP = 200;
12var WARM_DELAY_MS = 2500;
13
14// before router clears the hash
15var pendingListSlug = (location.hash.match(/^#list\/([a-f0-9]{16})$/) || [])[1] || null;
16
17// case-insensitive attr match
18function attr(el, name) {
19    var lname = name.toLowerCase();
20    var attrs = el.attributes;
21    for (var i = 0; i < attrs.length; i++) {
22        if (attrs[i].name.toLowerCase() === lname) return attrs[i].value;
23    }
24    return '';
25}
26
27var Opml = {
28
29    openSheet: function () {
30        var Views = _pc.Views;
31        var ov = document.createElement('div');
32        ov.className = 'pc-sheet-overlay';
33        u.setHtml(ov, '<div class="pc-sheet" role="dialog" aria-modal="true" aria-label="OPML">' +
34            '<div class="pc-sheet__handle"></div>' +
35            '<h3 class="pc-sheet__title">OPML</h3>' +
36            '<p class="pc-opml-hint">Import subscriptions from another app, or export yours.</p>' +
37            '<div class="pc-opml-actions">' +
38            '<button type="button" class="pc-btn pc-btn--outline pc-opml-import-btn">' + u.ICON.upload + ' Import OPML file</button>' +
39            '<button type="button" class="pc-btn pc-btn--outline pc-opml-export-btn">' + u.ICON.download + ' Export subscriptions</button>' +
40            '<input type="file" class="pc-opml-file-input" accept=".opml,.xml,text/xml,application/xml,text/x-opml" hidden>' +
41            '</div>' +
42            '<div class="pc-opml-status" aria-live="polite"></div>' +
43            '<div class="pc-sheet__btns"><button type="button" class="pc-btn pc-btn--outline pc-sheet__cancel">Close</button></div>' +
44            '</div>');
45        document.body.appendChild(ov);
46
47        var statusEl = ov.querySelector('.pc-opml-status');
48        var fileInput = ov.querySelector('.pc-opml-file-input');
49        var importBtn = ov.querySelector('.pc-opml-import-btn');
50        var exportBtn = ov.querySelector('.pc-opml-export-btn');
51        var pendingList = null;
52        var pendingFolders = null;
53
54        function setStatus(html) { u.setHtml(statusEl, html); }
55
56        function setBusy(busy) {
57            importBtn.disabled = busy;
58            exportBtn.disabled = busy;
59            var confirmBtn = ov.querySelector('.pc-opml-confirm-btn');
60            if (confirmBtn) confirmBtn.disabled = busy;
61        }
62
63        function showDiff(list, newCount, alreadyCount, capped, folders) {
64            pendingList = list;
65            pendingFolders = folders || [];
66            var fCount = pendingFolders.length;
67            var msg = newCount + ' new · ' + alreadyCount + ' already subscribed' + (capped ? ' (first ' + IMPORT_CAP + ')' : '') + (fCount ? ' · ' + fCount + ' folder' + (fCount === 1 ? '' : 's') : '');
68            var html = u.esc(msg);
69            if (list.length || fCount) {
70                var label = list.length ? ('Import ' + list.length + ' podcast' + (list.length === 1 ? '' : 's')) : 'Apply folders';
71                html += ' <button type="button" class="pc-btn pc-btn--accent pc-btn--sm pc-opml-confirm-btn">' + u.esc(label) + '</button>';
72            }
73            setStatus(html);
74        }
75
76        function runImport() {
77            var hasFeeds = pendingList && pendingList.length;
78            var hasFolders = pendingFolders && pendingFolders.length;
79            if (!hasFeeds && !hasFolders) return;
80            var list = pendingList || [];
81            var folders = pendingFolders || [];
82            pendingList = null;
83            pendingFolders = null;
84            setBusy(true);
85            setStatus(u.esc('Importing...'));
86            u.ajax('jp343_podcast_import_subscriptions', { feeds: JSON.stringify(list), folders: JSON.stringify(folders) }).then(function (data) {
87                var msg = data.imported + ' imported · ' + data.skipped + ' skipped';
88                if (data.failed && data.failed.length) msg += ' · ' + data.failed.length + ' failed';
89                if (data.folders_applied) msg += ' · ' + data.folders_applied + ' filed';
90                setStatus(u.esc(msg));
91                setBusy(false);
92                var failedUrls = {};
93                (data.failed || []).forEach(function (f) { failedUrls[f.url] = true; });
94                var warmed = list.filter(function (f) { return !failedUrls[f.feed_url]; })
95                    .map(function (f) { return f.feed_url; });
96                if (_pc.Folders) _pc.Folders.invalidate();
97                _pc.Discovery.loadSubscriptions().then(function () {
98                    return _pc.Folders ? _pc.Folders.loadAsync() : null;
99                }).then(function () {
100                    if (Views.state === 'library') Views.renderLibrary();
101                }).catch(function () {
102                    Views.showToast('Import saved. Could not refresh Library.', 'error');
103                });
104                if (data.imported > 0) {
105                    Views.showToast(data.imported + ' podcast' + (data.imported === 1 ? '' : 's') + ' imported');
106                } else if (data.folders_applied) {
107                    Views.showToast('Folders applied');
108                }
109                Opml._warmCovers(warmed);
110            }).catch(function () {
111                setStatus(u.esc('Import failed — please try again.'));
112                setBusy(false);
113            });
114        }
115
116        function handleFile(file) {
117            setStatus(u.esc('Reading file...'));
118            file.text().then(function (text) {
119                var parsed = Opml.parseOpml(text);
120                if (!parsed) { setStatus(u.esc('Could not read this file — not a valid OPML export.')); return; }
121                var folders = Opml.parseFolders(text);
122                if (!parsed.length && !folders.length) { setStatus(u.esc('No podcast feeds found in this file.')); return; }
123                var existing = {};
124                (_pc.Discovery.subscriptions || []).forEach(function (s) { existing[s.feed_url] = true; });
125                var newFeeds = parsed.filter(function (f) { return !existing[f.feed_url]; });
126                var alreadyCount = parsed.length - newFeeds.length;
127                var capped = newFeeds.length > IMPORT_CAP;
128                var list = capped ? newFeeds.slice(0, IMPORT_CAP) : newFeeds;
129                showDiff(list, newFeeds.length, alreadyCount, capped, folders);
130            }).catch(function () {
131                setStatus(u.esc('Could not read this file — not a valid OPML export.'));
132            });
133        }
134
135        ov.addEventListener('click', function (ev) {
136            if (ev.target === ov || ev.target.closest('.pc-sheet__cancel')) { ov.remove(); return; }
137            if (ev.target.closest('.pc-opml-import-btn')) { fileInput.click(); return; }
138            if (ev.target.closest('.pc-opml-export-btn')) { Opml.exportOpml(); return; }
139            if (ev.target.closest('.pc-opml-confirm-btn')) { runImport(); return; }
140        });
141
142        fileInput.addEventListener('change', function (ev) {
143            var file = ev.target.files && ev.target.files[0];
144            fileInput.value = '';
145            if (file) handleFile(file);
146        });
147    },
148
149    parseOpml: function (text) {
150        var doc;
151        try {
152            doc = new DOMParser().parseFromString(text, 'text/xml');
153        } catch (e) {
154            return null;
155        }
156        if (!doc || doc.getElementsByTagName('parsererror').length) return null;
157
158        var outlines = doc.getElementsByTagName('outline');
159        var seen = {};
160        var feeds = [];
161        for (var i = 0; i < outlines.length; i++) {
162            var url = attr(outlines[i], 'xmlurl').trim();
163            if (!/^https?:\/\//i.test(url)) continue;
164            var key = url.toLowerCase();
165            if (seen[key]) continue;
166            seen[key] = true;
167            var title = attr(outlines[i], 'text') || attr(outlines[i], 'title') || '';
168            feeds.push({ feed_url: url, title: title });
169        }
170        return feeds;
171    },
172
173    parseFolders: function (text) {
174        var doc;
175        try { doc = new DOMParser().parseFromString(text, 'text/xml'); } catch (e) { return []; }
176        if (!doc || doc.getElementsByTagName('parsererror').length) return [];
177        var body = doc.getElementsByTagName('body')[0];
178        if (!body) return [];
179        var out = [];
180        var nodes = body.children;
181        for (var i = 0; i < nodes.length; i++) {
182            var node = nodes[i];
183            if (!node.tagName || node.tagName.toLowerCase() !== 'outline') continue;
184            if (attr(node, 'xmlurl').trim()) continue; // a feed, not a folder
185            var name = attr(node, 'text') || attr(node, 'title') || '';
186            if (!name) continue;
187            var feeds = [];
188            var kids = node.getElementsByTagName('outline');
189            for (var j = 0; j < kids.length; j++) {
190                var kurl = attr(kids[j], 'xmlurl').trim();
191                if (/^https?:\/\//i.test(kurl)) feeds.push(kurl);
192            }
193            out.push({ name: name, feeds: feeds });
194        }
195        return out;
196    },
197
198    exportOpml: function () {
199        var subs = _pc.Discovery.subscriptions || [];
200        var folders = _pc.Folders ? _pc.Folders.getAll() : [];
201        if (!subs.length && !folders.length) { _pc.Views.showToast('No subscriptions to export', 'error'); return; }
202
203        var doc = document.implementation.createDocument(null, 'opml', null);
204        doc.documentElement.setAttribute('version', '2.0');
205
206        var head = doc.createElement('head');
207        var title = doc.createElement('title');
208        title.textContent = 'JP343 Podcast Subscriptions';
209        head.appendChild(title);
210        doc.documentElement.appendChild(head);
211
212        var body = doc.createElement('body');
213
214        function subOutline(s) {
215            var o = doc.createElement('outline');
216            o.setAttribute('type', 'rss');
217            o.setAttribute('text', s.title || '');
218            o.setAttribute('title', s.title || '');
219            o.setAttribute('xmlUrl', s.feed_url || '');
220            return o;
221        }
222
223        var byFolder = {};
224        var unfiled = [];
225        subs.forEach(function (s) {
226            if (s.folder_id) { (byFolder[s.folder_id] = byFolder[s.folder_id] || []).push(s); }
227            else unfiled.push(s);
228        });
229
230        folders.forEach(function (f) {
231            var container = doc.createElement('outline');
232            container.setAttribute('text', f.name || '');
233            container.setAttribute('title', f.name || '');
234            (byFolder[f.id] || []).forEach(function (s) { container.appendChild(subOutline(s)); });
235            body.appendChild(container);
236        });
237        unfiled.forEach(function (s) { body.appendChild(subOutline(s)); });
238
239        doc.documentElement.appendChild(body);
240
241        var xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(doc);
242        var blob = new Blob([xml], { type: 'application/xml' });
243        var url = URL.createObjectURL(blob);
244        var a = document.createElement('a');
245        a.href = url;
246        a.download = 'jp343-podcasts.opml';
247        document.body.appendChild(a);
248        a.click();
249        a.remove();
250        URL.revokeObjectURL(url);
251    },
252
253    // warm covers for feeds without one, rate-limit safe
254    _warmTried: {},
255    _warmCovers: function (urls) {
256        var tried = Opml._warmTried;
257        var list = (urls || []).filter(function (x) { return !tried[x]; });
258        if (!list.length) return;
259        list.forEach(function (x) { tried[x] = true; });
260
261        function applyRender() {
262            if (_pc.Views.state !== 'library') return;
263            _pc.Views.renderLibrary();
264        }
265        function refresh(reload) {
266            if (!reload) { applyRender(); return; }
267            _pc.Discovery.loadSubscriptions().then(applyRender).catch(function () { applyRender(); });
268        }
269
270        // 2.5s spacing keeps 24/min, under the 30/min feed limit
271        function next(i) {
272            if (i >= list.length) { refresh(true); return; }
273            _pc.Discovery.fetchFeed(list[i]).catch(function () {}).then(function () {
274                if ((i + 1) % 10 === 0) refresh(false);
275                setTimeout(function () { next(i + 1); }, WARM_DELAY_MS);
276            });
277        }
278        next(0);
279    },
280
281    // shared-list link landed via #list/<slug>
282    maybeOpenSharedList: function () {
283        if (!pendingListSlug) return;
284        var slug = pendingListSlug;
285        pendingListSlug = null;
286        var Views = _pc.Views;
287        u.ajax('jp343_podcast_get_shared_list', { slug: slug }).then(function (data) {
288            var shows = data.shows || [];
289            var ov = document.createElement('div');
290            ov.className = 'pc-sheet-overlay';
291            var descHtml = data.description ? '<p class="pc-share-list-desc">' + u.esc(data.description) + '</p>' : '';
292            var metaLine = (data.kind === 'shows' || !data.item_count)
293                ? data.show_count + ' shows'
294                : data.show_count + ' shows · ' + data.item_count + ' episodes';
295            u.setHtml(ov, '<div class="pc-sheet" role="dialog" aria-modal="true" aria-label="' + u.esc(data.title) + '">' +
296                '<div class="pc-sheet__handle"></div><h3 class="pc-sheet__title">' + u.esc(data.title) + '</h3>' +
297                '<p class="pc-share-meta">' + metaLine + '</p>' +
298                descHtml +
299                '<div class="pc-sheet__btns"><button type="button" class="pc-btn pc-btn--outline pc-sheet__cancel">Close</button>' +
300                '<button type="button" class="pc-btn pc-btn--accent pc-share-list-add">Add ' + data.show_count + ' shows</button></div></div>');
301            document.body.appendChild(ov);
302            ov.addEventListener('click', function (ev) {
303                if (ev.target === ov || ev.target.closest('.pc-sheet__cancel')) { ov.remove(); return; }
304                if (!ev.target.closest('.pc-share-list-add')) return;
305                if (u.CONFIG.isGuest) { u.showGuestGate(); return; }
306                var feeds = shows.map(function (s) { return { feed_url: s.feed_url, title: s.title }; });
307                u.ajax('jp343_podcast_import_subscriptions', { feeds: JSON.stringify(feeds) }).then(function (res) {
308                    ov.remove();
309                    if (_pc.Folders) _pc.Folders.invalidate();
310                    _pc.Discovery.loadSubscriptions().then(function () {
311                        if (Views.state === 'library') Views.renderLibrary();
312                    }).catch(function () {
313                        Views.showToast('Import saved. Could not refresh Library.', 'error');
314                    });
315                    if (res.imported > 0) {
316                        Views.showToast(res.imported + ' podcast' + (res.imported === 1 ? '' : 's') + ' imported');
317                    } else {
318                        Views.showToast('All already subscribed');
319                    }
320                }).catch(function () {
321                    Views.showToast('Could not import', 'error');
322                });
323            });
324        }).catch(function () {
325            _pc.Views.showToast('List not found', 'error');
326        });
327    }
328};
329
330_pc.Opml = Opml;
331
332})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.