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.