Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
build-container / build (push) Successful in 7m21s
build-container / build (push) Successful in 7m21s
- Store: Open Library search, books fetched from the Internet Archive (free editions only) - Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment - Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks - Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0617664d97
commit
176b1b8169
28 files changed
+799
-46
No files matched your search
+36
-6
@@ -48,36 +48,66 @@ async function viewPodcast(args = []) {
|
||||
async function pdHome() {
|
||||
const list = await pdApi('/podcasts?sort=lastepisode&order=desc'), shows = Array.isArray(list) ? list : (list.podcasts || []);
|
||||
if (!location.hash.startsWith('#/podcast')) return;
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a><button class="btn ghost" id="pdtr">${icon('search')}Search what's said</button><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
|
||||
${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}">
|
||||
<span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span>
|
||||
<b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>`
|
||||
: '<p class="empty">No podcasts on your list yet. Search for one, or paste its feed address.</p>'}</div>`;
|
||||
$('#pdtr').onclick = pdTranscriptSearch;
|
||||
$('#pdadd').onclick = () => {
|
||||
const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
|
||||
<div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
|
||||
<p class="sub" style="margin:8px 0 0">Searching uses Apple's public podcast directory, so the words you type are sent to Apple.</p>`);
|
||||
<div id="pdtopics" class="bar" style="flex-wrap:wrap;gap:6px;margin:8px 0 0"></div><div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
|
||||
<p class="sub" style="margin:8px 0 0">Searching uses public podcast directories (Apple's, and Podcast Index when the server has a key), so the words you type are sent to them.</p>`);
|
||||
const q = el.querySelector('#pdq'), res = el.querySelector('#pdres'), err = el.querySelector('#pde'); let timer, seq = 0, found = [];
|
||||
const add = async (url, btn) => {
|
||||
err.textContent = ''; if (btn) btn.disabled = true;
|
||||
try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); }
|
||||
catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; }
|
||||
};
|
||||
const show = list => {
|
||||
found = list; res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
|
||||
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
|
||||
};
|
||||
pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => `<button class="btn ghost" data-t="${esc(t)}" style="min-height:32px;padding:0 12px">${esc(t)}</button>`).join('');
|
||||
box.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const my = ++seq; q.value = ''; err.textContent = ''; res.innerHTML = '<p class="empty">Looking…</p>'; try { const l = await pdApi('/discover?topic=' + encodeURIComponent(b.dataset.t)); if (my === seq) show(l); } catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; } }); }).catch(() => {});
|
||||
const run = async () => {
|
||||
const term = q.value.trim(), my = ++seq; err.textContent = '';
|
||||
if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = `<button class="row" id="pdfeed"><span class="grow"><b>Add this feed</b><small style="white-space:normal">${esc(term)}</small></span></button>`; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; }
|
||||
if (term.length < 2) { res.innerHTML = ''; return; }
|
||||
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||
try {
|
||||
found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return;
|
||||
res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
|
||||
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
|
||||
const l = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; show(l);
|
||||
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
};
|
||||
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
|
||||
};
|
||||
}
|
||||
|
||||
// Search what has been said in every transcript you have: tap a result to play that episode from that moment.
|
||||
const pdClock = ms => { const s = Math.floor(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
|
||||
function pdTranscriptSearch() {
|
||||
const el = openSheet("Search what's said", 'Finds a word or phrase in every transcript you have', `<input type="search" id="trq" placeholder="A word or phrase" autocomplete="off"><div class="err" id="tre"></div><div id="trres" class="list" style="margin-top:8px;max-height:60vh;overflow:auto"></div>`);
|
||||
const q = el.querySelector('#trq'), res = el.querySelector('#trres'); let timer, seq = 0, found = [];
|
||||
const hl = (text, w) => { const re = new RegExp('(' + w.split(/\s+/).filter(x => x.length > 1).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi'); return esc(text).replace(re, '<mark>$1</mark>'); };
|
||||
const play = (r, h) => {
|
||||
closeSheet(); const key = r.key, track = { key, title: r.title, artist: r.artist, album: '', thumb: r.thumb, src: r.src, nocache: true };
|
||||
if (pdPlaying() === key.slice(4) && String(cur().key) === key) { pl.a.currentTime = h.t / 1000; pl.a.play().catch(() => {}); return; }
|
||||
playQueue([track], 0); pl.a.addEventListener('loadedmetadata', () => { const c = cur(); if (c && c.key === key) pl.a.currentTime = h.t / 1000; }, { once: true });
|
||||
};
|
||||
const run = async () => {
|
||||
const term = q.value.trim(), my = ++seq; if (term.length < 2) { res.innerHTML = ''; return; }
|
||||
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||
try {
|
||||
const out = (await jfetch('/api/transcript/search?q=' + encodeURIComponent(term))).results; if (my !== seq) return; found = out;
|
||||
res.innerHTML = out.length ? out.map((r, i) => `<div class="pdhit"><div class="row"><span class="grow"><b>${esc(r.title)}</b><small>${esc(r.artist)}${r.n > r.hits.length ? ' · ' + r.n + ' places' : ''}</small></span></div>
|
||||
${r.hits.map((h, j) => `<button class="row" data-i="${i}" data-j="${j}"><span class="grow"><small style="white-space:normal">${hl(h.text, term)}</small></span><small>${pdClock(h.t)}</small></button>`).join('')}</div>`).join('')
|
||||
: '<p class="empty">Nothing found. Only episodes that already have a transcript can be searched.</p>';
|
||||
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => play(found[b.dataset.i], found[b.dataset.i].hits[b.dataset.j]));
|
||||
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
};
|
||||
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
|
||||
}
|
||||
|
||||
async function pdShow(id) {
|
||||
const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
|
||||
if (!location.hash.startsWith('#/podcast')) return;
|
||||
|
||||
Reference in new issue
Block a user