'use strict';
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library.
async function searchFiles(term, libs) {
const enc = encodeURIComponent, lower = term.toLowerCase();
let results = [], via = 'index';
try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {}
if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; }
return { results, via, scanned: libs.filter(l => !l.encrypted).length };
}
// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly.
async function searchMusic(term) {
const enc = encodeURIComponent, lower = term.toLowerCase(), items = [];
try {
const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`);
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m);
} catch {}
if (!items.length) {
const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]);
for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' });
for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' });
}
return items.slice(0, 16);
}
// Plex movies and shows (and episodes) from the server-wide search.
async function searchVideo(term) {
const mc = await px.get(`/hubs/search?query=${encodeURIComponent(term)}&limit=12`), items = [];
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['movie', 'show', 'episode'].includes(m.type)) items.push(m);
return items.slice(0, 24);
}
// Podgrab: the podcasts you follow, matched on title and author.
async function searchPodcasts(term) {
const list = await jfetch('/api/podcast/podcasts'), lower = term.toLowerCase();
return (Array.isArray(list) ? list : []).filter(s => ((pdF(s, 'title') || '') + ' ' + (pdF(s, 'author') || '')).toLowerCase().includes(lower)).slice(0, 12);
}
// The audiobook library, matched on title, author and series.
async function searchBooks(term) {
const [data] = await Promise.all([jfetch('/api/ab/books'), abLoadProg()]), all = Array.isArray(data) ? data : (data.books || Object.values(data)), lower = term.toLowerCase();
return all.filter(b => (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(lower)).slice(0, 10);
}
function viewSearch() {
$('#view').innerHTML = `
`;
const q = $('#q'); q.focus();
let timer, seq = 0, plexSt = null;
if (window.pendingSearch) { q.value = window.pendingSearch; window.pendingSearch = ''; setTimeout(() => q.oninput && q.oninput(), 0); }
// One line that says what Search covers and what each source returned, with a link to sign in where a source needs it.
const showSources = (r = {}) => {
const el = $('#src'); if (!el) return;
const link = (href, t) => `${t} `, bad = t => `${esc(t)} `;
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}${allowed('mail') && cfg.mail ? ' · Mail ' + part(r.mail, '✓') : ''}`;
};
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
showSources();
// Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab.
const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
res.innerHTML = 'Searching…
';
const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, video, pods, books, ddg, hub, mailR, myBooks] = await Promise.allSettled([
libsP,
libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
plexReady ? searchVideo(term) : Promise.reject(new Error('not connected')),
allowed('podcast') ? searchPodcasts(term) : Promise.reject(new Error('not allowed')),
allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')),
jfetch('/api/ddg?q=' + enc(term)),
jfetch('/api/search?q=' + enc(term)),
allowed('mail') && cfg.mail ? jfetch('/api/mail/messages?folder=inbox&q=' + enc(term)) : Promise.reject(new Error('not allowed')),
allowed('read') ? jfetch('/api/read/list') : Promise.reject(new Error('not allowed')),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `${title} ${html}
` : '';
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
`${icon('files')} ${esc(r.name)} Library `).join('') : '';
const found = files.status === 'fulfilled' ? files.value.results : [];
const fileHtml = found.map(f => {
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
return f.is_dir ? `${icon('files')} ${esc(f.name)} ${esc(f.repo_name || '')} ${esc(f.fullpath)} `
: `${icon('file')} ${esc(f.name)} ${esc(f.repo_name || '')} ${esc(parent)} `;
}).join('');
let musicHtml = '';
for (const m of music.status === 'fulfilled' ? music.value : []) {
if (m.type === 'artist') musicHtml += `${esc(m.title)} Artist `;
else if (m.type === 'album') musicHtml += `${esc(m.title)} Album · ${esc(m.parentTitle || '')} `;
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `${esc(m.title)} Track · ${esc(m.grandparentTitle || '')} `; }
}
const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join('');
const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `${esc(pdF(s, 'title'))} Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''} `).join('');
const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `${esc(abTitle(b))} Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''} `).join('');
const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `${icon('mail')} ${esc(m.subject)} ${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())} `).join('');
const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `
${d.image ? `
` : ''}
From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}
${d.heading ? `
${esc(d.heading)}
` : ''}${d.answer ? `
${esc(d.answer)}
` : ''}
${d.text ? `
${esc(d.text)}
` : ''}${d.url ? `
Read more ` : ''}
` : '';
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
// the family parts: notes, chat, contacts, chores, recipes, and the books on your shelf
const row = (href, color, ic, title, sub) => `${icon(ic)} ${esc(title)} ${esc(sub)} `;
const myBookHtml = (myBooks.status === 'fulfilled' && Array.isArray(myBooks.value) ? myBooks.value : []).filter(b => ((b.title || '') + ' ' + (b.author || '')).toLowerCase().includes(lower)).slice(0, 8).map(b => row('#/read/open/' + enc(b.id), '#2b8a5e', 'book', b.title, b.author || 'Your books')).join('');
const famHtml = sec('Notes and lists', (h.notes || []).map(n => row('#/notes/' + enc(n.id), '#e5b800', n.type === 'list' ? 'check' : 'doc', n.title, n.share + (n.snippet ? ' · ' + n.snippet : ''))).join(''))
+ sec('Chat', (h.chat || []).map(c => row('#/chat/' + enc(c.conv), '#2aa6a0', 'chat', c.convName, c.msg.name + ': ' + c.msg.text)).join(''))
+ sec('Contacts', (h.contacts || []).map(c => row('#/contacts/c/' + enc(c.id), '#5b8def', 'people', c.name, [c.org, c.email, c.phone].filter(Boolean).join(' · '))).join(''))
+ sec('Chores', (h.chores || []).map(c => row('#/chores', '#2f9e6e', 'check', c.title, [c.repeat, c.who, c.points ? c.points + ' pts' : ''].filter(Boolean).join(' · '))).join(''))
+ sec('Recipes', (h.recipes || []).map(r => row('#/recipes/r/' + enc(r.slug), '#e0742b', 'chef', r.name, r.minutes ? r.minutes + ' min' : 'Recipe')).join(''))
+ sec('Your books', myBookHtml);
evStore.length = 0;
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
return `${icon('check')} ${esc(t.title)} ${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''} `; }).join('');
const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start);
return `${esc(e.title)} ${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)} ${e.editable ? icon('chev', 'chev') : ''} `; }).join('');
// what each source did, so an empty screen always has an explanation
showSources({
tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message },
events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message },
books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message },
mail: !(allowed('mail') && cfg.mail) ? undefined : mailR.status === 'fulfilled' ? { n: (mailR.value.items || []).length } : { err: mailR.reason && mailR.reason.message },
video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message },
});
const notes = [...(h.errors || [])].map(e => `${esc(e)}
`).join('');
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `${icon('link')} ${esc(l.title || l.url)} ${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''} `).join('') : '';
res.innerHTML = card + famHtml + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `Movies & TV ${videoHtml}
` : '') + notes;
if (!famHtml && !taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = 'No results. The line above shows what was searched.
' + notes;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
}
}