Reading goals for e-books and the family; subtasks on iCloud lists; watch stats in Movies & TV
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
435f442761
commit
275ef2009f
12 files changed
+74
-11
No files matched your search
@@ -116,7 +116,8 @@ async function handle(req, res) {
|
|||||||
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||||
const st = state(), cur = st[id] || {};
|
const st = state(), cur = st[id] || {};
|
||||||
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
|
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
|
||||||
saveState(st); return json(res, 200, st[id]);
|
saveState(st); if (st[id] && st[id].pct >= 0.98) { try { require('./progress').markFinished('ebook:' + id, 'ebook'); } catch { /* the goal count is a nicety */ } }
|
||||||
|
return json(res, 200, st[id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part === 'notes') {
|
if (part === 'notes') {
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ function todoLines(f, { completed = true } = {}) {
|
|||||||
const L = ['SUMMARY:' + esc(String(f.title).trim())];
|
const L = ['SUMMARY:' + esc(String(f.title).trim())];
|
||||||
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
|
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
|
||||||
if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, ''));
|
if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, ''));
|
||||||
|
if (f.parent) L.push('RELATED-TO;RELTYPE=PARENT:' + esc(String(f.parent))); // a subtask points at its main task
|
||||||
if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); }
|
if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); }
|
||||||
else L.push('STATUS:NEEDS-ACTION');
|
else L.push('STATUS:NEEDS-ACTION');
|
||||||
return L;
|
return L;
|
||||||
@@ -199,6 +200,7 @@ function patchTodo(text, f) {
|
|||||||
const name = l.split(/[;:]/)[0].toUpperCase();
|
const name = l.split(/[;:]/)[0].toUpperCase();
|
||||||
if (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time
|
if (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time
|
||||||
if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(name)) return;
|
if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(name)) return;
|
||||||
|
if (name === 'RELATED-TO' && f.parent !== undefined && !/RELTYPE=(CHILD|SIBLING)/i.test(l)) return; // (replaced below when a parent is given; kept when none is mentioned)
|
||||||
if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; }
|
if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; }
|
||||||
if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; }
|
if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; }
|
||||||
out.push(l);
|
out.push(l);
|
||||||
|
|||||||
+13
-2
@@ -13,7 +13,7 @@ const stats = () => {
|
|||||||
const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60;
|
const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60;
|
||||||
let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++;
|
let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++;
|
||||||
const year = String(now.getFullYear());
|
const year = String(now.getFullYear());
|
||||||
return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
|
return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => day(f.t).startsWith(year)).length, audiobooksYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, ebooksYear: Object.values(o.finished).filter(f => f.kind === 'ebook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
|
||||||
};
|
};
|
||||||
module.exports = (name, kind) => {
|
module.exports = (name, kind) => {
|
||||||
const file = () => path.join(users.dir(), name);
|
const file = () => path.join(users.dir(), name);
|
||||||
@@ -31,4 +31,15 @@ module.exports = (name, kind) => {
|
|||||||
};
|
};
|
||||||
return { load, merge };
|
return { load, merge };
|
||||||
};
|
};
|
||||||
module.exports.stats = stats;
|
/** A book (or e-book) finished: counts toward the yearly goals. Once only. */
|
||||||
|
function markFinished(key, kind, t = Date.now()) { const log = loadLog(); if (log.finished[key]) return; log.finished[key] = { t, kind }; const lf = logFile(), tmp = lf + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(tmp, lf); }
|
||||||
|
// The family challenge: one yearly goal for everyone, counted across everybody's finished books.
|
||||||
|
const goalFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'readgoal.json');
|
||||||
|
function familyGoal() {
|
||||||
|
const year = new Date().getFullYear(); let goal = 0; try { const g = JSON.parse(fs.readFileSync(goalFile(), 'utf8')); if (g.year === year) goal = +g.goal || 0; } catch { /* none set */ }
|
||||||
|
let finished = 0; const people = [];
|
||||||
|
for (const u of users.all()) { if (u.disabled) continue; let log = { finished: {} }; try { log = JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'listening.json'), 'utf8')); } catch { /* nothing yet */ } const n = Object.values(log.finished || {}).filter(f => day(f.t).startsWith(String(year))).length; finished += n; if (n) people.push({ name: u.name, n }); }
|
||||||
|
return { year, goal, finished, people: people.sort((a, b) => b.n - a.n) };
|
||||||
|
}
|
||||||
|
function setFamilyGoal(goal) { const g = Math.max(0, Math.min(1000, Math.round(+goal) || 0)); fs.mkdirSync(path.dirname(goalFile()), { recursive: true }); fs.writeFileSync(goalFile(), JSON.stringify({ year: new Date().getFullYear(), goal: g })); return familyGoal(); }
|
||||||
|
module.exports.stats = stats; module.exports.markFinished = markFinished; module.exports.familyGoal = familyGoal; module.exports.setFamilyGoal = setFamilyGoal;
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v117';
|
const CACHE = 'hub-v119';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
|
|||||||
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||||
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
|
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
|
||||||
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
|
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
|
||||||
${list.local && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
|
${(list.local || list.icloud) && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
|
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
|
||||||
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
|
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
|
||||||
|
|||||||
+7
-3
@@ -34,11 +34,15 @@ const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' mi
|
|||||||
async function homeListening(after) {
|
async function homeListening(after) {
|
||||||
if (!after || !(allowed('podcast') || allowed('audio'))) return;
|
if (!after || !(allowed('podcast') || allowed('audio'))) return;
|
||||||
let s; try { s = await jfetch('/api/listening/stats'); } catch { return; }
|
let s; try { s = await jfetch('/api/listening/stats'); } catch { return; }
|
||||||
const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected || (!s.week && !goal && !s.finishedYear)) return;
|
const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected) return;
|
||||||
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
|
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
|
||||||
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>audiobooks finished this year</small></span></div>
|
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>books finished this year</small></span></div>
|
||||||
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
|
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
|
||||||
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
|
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}
|
||||||
|
<div id="lsfam" class="lsfam"></div><div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
|
||||||
after.insertAdjacentElement('afterend', card);
|
after.insertAdjacentElement('afterend', card);
|
||||||
|
jfetch('/api/listening/family').then(f => { const box = card.querySelector('#lsfam'); if (!box) return; if (!f.goal && !f.finished) { box.innerHTML = `<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">Set a family reading challenge</button>`; } else box.innerHTML = `<div class="lsrow" style="margin-top:12px"><span><b>${f.finished}${f.goal ? ' of ' + f.goal : ''}</b><small>family books finished in ${f.year}${f.people.length ? ' · ' + esc(f.people.map(p => p.name + ' ' + p.n).join(', ')) : ''}</small></span></div>${f.goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(f.finished / f.goal * 100))}%"></i></div>` : ''}<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">${f.goal ? 'Change the family goal' : 'Set a family goal'}</button>`;
|
||||||
|
const b = box.querySelector('#lsfg'); if (b) b.onclick = () => { const el = openSheet('Family reading challenge', 'One goal for everyone: how many books the family finishes this year (audiobooks and e-books together).', `<form id="lsff"><label for="lsfn">Books</label><input type="number" id="lsfn" min="0" max="1000" value="${f.goal || ''}" placeholder="40" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsff').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/listening/family', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ goal: +el.querySelector('#lsfn').value || 0 }) }); closeSheet(); card.remove(); homeListening(after); } catch (er) { toast(er.message); } }; };
|
||||||
|
}).catch(() => {});
|
||||||
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; };
|
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; };
|
||||||
}
|
}
|
||||||
+23
-1
@@ -73,6 +73,27 @@ async function vdRemove(m, quiet) {
|
|||||||
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
|
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
|
||||||
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
|
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
|
||||||
}
|
}
|
||||||
|
// ---- Watch stats: what you watched lately, counted from the "last watched" dates Plex keeps for you
|
||||||
|
/** items: Plex movies or episodes with lastViewedAt (seconds) and duration (ms). Counts and minutes for the last 7, 30 and 365 days, a 14-day strip, and the top genres. */
|
||||||
|
function vdAgg(items, now = Date.now()) {
|
||||||
|
const day = 864e5, out = { week: { n: 0, min: 0 }, month: { n: 0, min: 0 }, year: { n: 0, min: 0 }, days: Array(14).fill(0), genres: [] }, g = {};
|
||||||
|
for (const m of items) {
|
||||||
|
const t = (m.lastViewedAt || 0) * 1000; if (!t || t > now + day) continue; const age = now - t, mins = Math.round((m.duration || 0) / 60000), add = b => { b.n++; b.min += mins; };
|
||||||
|
if (age < 7 * day) add(out.week); if (age < 30 * day) add(out.month); if (age < 365 * day) { add(out.year); for (const x of m.Genre || []) g[x.tag] = (g[x.tag] || 0) + 1; }
|
||||||
|
const di = Math.floor(age / day); if (di >= 0 && di < 14) out.days[13 - di]++;
|
||||||
|
}
|
||||||
|
out.genres = Object.entries(g).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5); return out;
|
||||||
|
}
|
||||||
|
async function vdStatsSheet(cur) {
|
||||||
|
const isMovie = cur.type === 'movie', el = openSheet('Your watching', cur.title, '<p class="empty">Counting…</p>');
|
||||||
|
try {
|
||||||
|
const r = await px.get(`/library/sections/${cur.key}/all?type=${isMovie ? 1 : 4}&sort=lastViewedAt:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=600`), a = vdAgg((r.Metadata || []).filter(m => m.lastViewedAt)), hrs = m => m < 60 ? m + ' min' : (m / 60).toFixed(m < 600 ? 1 : 0) + ' h', max = Math.max(1, ...a.days), what = isMovie ? 'movies' : 'episodes';
|
||||||
|
el.querySelector('.empty').outerHTML = `<div class="lsrow">${[['week', 'this week'], ['month', 'last 30 days'], ['year', 'this year']].map(([k, l]) => `<span><b>${a[k].n}</b><small>${what} ${l}${a[k].min ? ' · ' + hrs(a[k].min) : ''}</small></span>`).join('')}</div>
|
||||||
|
<div class="lsbars" role="img" aria-label="${what} watched each of the last 14 days">${a.days.map((n, i) => `<i style="height:${Math.max(3, Math.round(n / max * 100))}%" title="${n} ${what}"></i>`).join('')}</div>
|
||||||
|
${a.genres.length ? `<h3 class="sub" style="margin:12px 0 4px">Favourite genres this year</h3><div class="seg" style="flex-wrap:wrap">${a.genres.map(([t, n]) => `<a href="#" style="pointer-events:none">${esc(t)} · ${n}</a>`).join('')}</div>` : ''}
|
||||||
|
${a.year.n ? '' : '<p class="empty">Nothing watched in this library yet.</p>'}<p class="sub" style="margin-top:12px">Counts each title once, on the day you last watched it.</p>`;
|
||||||
|
} catch (e) { el.querySelector('.empty').textContent = e.message; }
|
||||||
|
}
|
||||||
// ---- Favorites (yours) and Movie night (the family's list to vote on)
|
// ---- Favorites (yours) and Movie night (the family's list to vote on)
|
||||||
const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||||
const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined });
|
const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined });
|
||||||
@@ -114,7 +135,8 @@ async function videoHome(st, libKey) {
|
|||||||
const type = cur.type === 'movie' ? 1 : 2, size = 120;
|
const type = cur.type === 'movie' ? 1 : 2, size = 120;
|
||||||
// Mixes: a handful of ways to pick something to watch, from what you have not seen yet
|
// Mixes: a handful of ways to pick something to watch, from what you have not seen yet
|
||||||
{ const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]];
|
{ const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]];
|
||||||
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('');
|
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('') + '<a href="#" id="vstats">Your stats</a>';
|
||||||
|
$('#vstats').onclick = e => { e.preventDefault(); vdStatsSheet(cur); };
|
||||||
$('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '<p class="empty">Looking…</p>');
|
$('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '<p class="empty">Looking…</p>');
|
||||||
try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24);
|
try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24);
|
||||||
el.querySelector('.empty').outerHTML = items.length ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
|
el.querySelector('.empty').outerHTML = items.length ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
|
||||||
|
|||||||
@@ -664,6 +664,7 @@ async function handleRequest(req, res) {
|
|||||||
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
||||||
|
if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); }
|
||||||
if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); }
|
if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); }
|
||||||
if (pathname === '/api/export') return backup.handle(req, res, user.name);
|
if (pathname === '/api/export') return backup.handle(req, res, user.name);
|
||||||
if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ function parseTodos(text) {
|
|||||||
else if (l.name === 'STATUS') cur.status = l.value.toUpperCase();
|
else if (l.name === 'STATUS') cur.status = l.value.toUpperCase();
|
||||||
else if (l.name === 'PERCENT-COMPLETE') cur.percent = parseInt(l.value, 10);
|
else if (l.name === 'PERCENT-COMPLETE') cur.percent = parseInt(l.value, 10);
|
||||||
else if (l.name === 'COMPLETED') cur.completed = true;
|
else if (l.name === 'COMPLETED') cur.completed = true;
|
||||||
|
else if (l.name === 'RELATED-TO' && !/^(CHILD|SIBLING)$/i.test((l.params && l.params.RELTYPE) || '')) cur.parent = cal.unesc(l.value);
|
||||||
else if (l.name === 'DUE') { const d = cal.parseDate(l.value, l.params); if (d) cur.due = `${d.y}-${String(d.mo).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; }
|
else if (l.name === 'DUE') { const d = cal.parseDate(l.value, l.params); if (d) cur.due = `${d.y}-${String(d.mo).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; }
|
||||||
}
|
}
|
||||||
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed }));
|
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed, parent: t.parent || '' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it.
|
// ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it.
|
||||||
@@ -139,7 +140,12 @@ async function handle(req, res) {
|
|||||||
if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive
|
if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive
|
||||||
const cur = await caldav.getResource(b.list, b.href);
|
const cur = await caldav.getResource(b.list, b.href);
|
||||||
await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, f), { href: b.href, etag: cur.etag });
|
await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, f), { href: b.href, etag: cur.etag });
|
||||||
} else { const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }])); }
|
} else {
|
||||||
|
if (f.parent) { // a subtask goes under a main task of the same list (and a subtask cannot have subtasks)
|
||||||
|
const mine = []; for (const r of await caldav.listTodoRaw(b.list)) mine.push(...parseTodos(r.ics)); const par = mine.find(x => x.id === f.parent);
|
||||||
|
if (!par || par.parent) throw new Error('A subtask can only go under a main task in the same list'); }
|
||||||
|
const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }]));
|
||||||
|
}
|
||||||
return json(res, 200, { ok: true });
|
return json(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
if (p === '/api/tasks/toggle' && req.method === 'POST') {
|
if (p === '/api/tasks/toggle' && req.method === 'POST') {
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ for (const f of listed) {
|
|||||||
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
||||||
// the pieces the app calls by name across files are there
|
// the pieces the app calls by name across files are there
|
||||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||||
|
// the watch-stats counting (a pure function)
|
||||||
|
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
|
||||||
|
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
|
||||||
|
assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); }
|
||||||
console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);
|
console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);
|
||||||
@@ -11,5 +11,9 @@ users.runAs(me, () => {
|
|||||||
let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1);
|
let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1);
|
||||||
pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day');
|
pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day');
|
||||||
const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing');
|
const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing');
|
||||||
|
// e-books count too, and the family challenge adds everyone up
|
||||||
|
progress.markFinished('ebook:abc', 'ebook'); progress.markFinished('ebook:abc', 'ebook'); s = progress.stats(); assert.equal(s.ebooksYear, 1, 'an e-book counts once'); assert.equal(s.audiobooksYear, 1); assert.equal(s.finishedYear, 2, 'audiobooks and e-books together');
|
||||||
|
assert.equal(progress.familyGoal().finished, 2); assert.equal(progress.familyGoal().goal, 0); assert.equal(progress.setFamilyGoal(40).goal, 40); assert.equal(progress.setFamilyGoal(-5).goal, 0, 'no negative goals');
|
||||||
|
const bo = users.create({ name: 'Bo', role: 'user', handle: 'ZP' }); users.runAs(bo, () => progress.markFinished('ebook:zzz', 'ebook')); progress.setFamilyGoal(10); const fg = progress.familyGoal(); assert.equal(fg.finished, 3, 'everybody\'s books are counted'); assert.deepEqual(fg.people.map(p => p.name + p.n), ['Ann2', 'Bo1']);
|
||||||
console.log('listening test ok');
|
console.log('listening test ok');
|
||||||
});
|
});
|
||||||
@@ -48,6 +48,14 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
|
|||||||
// a due time rides along with the due date (and only with one)
|
// a due time rides along with the due date (and only with one)
|
||||||
await call('/api/tasks', { list: L, fields: { title: 'At noon', notes: '', due: plus(1), dueTime: '12:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Time but no date', notes: '', due: '', dueTime: '09:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Bad time', notes: '', due: plus(1), dueTime: '25:99', done: false } });
|
await call('/api/tasks', { list: L, fields: { title: 'At noon', notes: '', due: plus(1), dueTime: '12:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Time but no date', notes: '', due: '', dueTime: '09:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Bad time', notes: '', due: plus(1), dueTime: '25:99', done: false } });
|
||||||
ts = await list(); ok('a due time is kept with its date', ts.find(t => t.title === 'At noon').dueTime === '12:00', ts.find(t => t.title === 'At noon')); ok('a time with no date is dropped', ts.find(t => t.title === 'Time but no date').dueTime === ''); ok('and a time that is not a time', ts.find(t => t.title === 'Bad time').dueTime === '');
|
ts = await list(); ok('a due time is kept with its date', ts.find(t => t.title === 'At noon').dueTime === '12:00', ts.find(t => t.title === 'At noon')); ok('a time with no date is dropped', ts.find(t => t.title === 'Time but no date').dueTime === ''); ok('and a time that is not a time', ts.find(t => t.title === 'Bad time').dueTime === '');
|
||||||
|
// subtasks on iCloud lists: a VTODO that points at its parent (RELATED-TO), kept through edits and ticking
|
||||||
|
{ const ics = require('../ics'), { parseTodos } = require('../tasks');
|
||||||
|
const main = ics.buildTodoCalendar([{ uid: 'p1@x', fields: { title: 'Trip', notes: '', due: '', done: false } }, { uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]);
|
||||||
|
let ts = parseTodos(main); ok('a subtask is written with its parent and read back with it', ts.length === 2 && ts[0].parent === '' && ts[1].parent === 'p1@x', ts);
|
||||||
|
const edited = ics.patchTodo(ics.buildTodoCalendar([{ uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]), { title: 'Book flights now', notes: '', due: '', done: true });
|
||||||
|
ok('ticking or editing it keeps its parent', parseTodos(edited)[0].parent === 'p1@x' && parseTodos(edited)[0].done === true, parseTodos(edited));
|
||||||
|
const moved = ics.patchTodo(edited, { title: 'x', notes: '', due: '', done: false, parent: '' }); ok('and an empty parent makes it a main task again', parseTodos(moved)[0].parent === '', parseTodos(moved));
|
||||||
|
const foreign = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:k@x\r\nSUMMARY:Kid\r\nRELATED-TO;RELTYPE=CHILD:z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; ok('a link that points down (CHILD) is not a parent', parseTodos(foreign)[0].parent === ''); }
|
||||||
ok('the server is still running', child.exitCode === null);
|
ok('the server is still running', child.exitCode === null);
|
||||||
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
|
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
|
||||||
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);
|
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);
|
||||||
|
|||||||
Reference in new issue
Block a user