'use strict'; // ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' }); return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; }; const taskStore = []; // tasks on screen, so a tap can open the right one async function viewTasks() { const v = $('#view'); v.innerHTML = '

Tasks

Loading…

'; try { const { lists, icloud } = await jfetch('/api/tasks/lists'); if (!location.hash.startsWith('#/tasks')) return; let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id; const list = lists.find(l => l.id === cur); v.innerHTML = `

Tasks

${lists.map(l => `${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}`).join('')}+ List

Loading…

`; $('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.' : icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : ''; v.querySelectorAll('.seg a[data-l]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('task_list', a.dataset.l); viewTasks(); }); $('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); }; $('#qa').onsubmit = async e => { e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return; try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); } catch (err) { toast(err.message); } }; loadTaskList(cur, list); } catch (e) { v.innerHTML = `

Tasks

${esc(e.message)}

`; } } async function loadTaskList(cur, list) { const box = $('#tl'); if (!box) return; try { const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur)); if (!$('#tl')) return; taskStore.length = 0; const ids = new Set(tasks.map(t => t.id)), byParent = {}; tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); }); const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title); const open = tasks.filter(t => top(t) && !t.done).sort(byDue); const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title)); const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' }; const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : ''; const extra = [rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · '); return `
`; }; const showDone = store.get('task_show_done', false); box.innerHTML = (open.length ? `
${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : '

Nothing to do. Add a task above.

') + (done.length ? `

${showDone ? 'Hide' : 'Show'} completed (${done.length})

${showDone ? `
${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : ''}` : ''); const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); }; box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => { const t = taskStore[+b.dataset.t]; b.disabled = true; try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); } catch (err) { b.disabled = false; toast(err.message); } }); box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list)); } catch (e) { box.innerHTML = `

${esc(e.message)}

`; } } function taskSheet(t, cur, list) { const el = openSheet('Task', list.name, `
${list.local ? `

A repeating task moves to its next date when you complete it.

` : ''} ${list.local && t.id && !t.parent ? `
${taskStore.filter(k => k.parent === t.id).map(k => `
${esc(k.title)}
`).join('')}
` : ''}
`); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; }; el.querySelector('#tf').onsubmit = async e => { e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); loadTaskList(cur, list); } catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; } }; if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => { const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return; try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) }); el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `
${esc(title)}
`); inp.value = ''; inp.focus(); loadTaskList(cur, list); } catch (err) { el.querySelector('#te').textContent = err.message; } }; if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } }; el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), 'Delete', async () => { try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); } catch (err) { toast(err.message); } }); } function newListSheet(lists, list) { const mine = lists.filter(l => l.local && l.id !== 'local:inbox'); const el = openSheet('Lists', '', `${mine.length ? `
${mine.map(l => `
${esc(l.name)}
`).join('')}
` : ''}
`); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#lf').onsubmit = async e => { e.preventDefault(); try { const r = await jfetch('/api/tasks/lists', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#ln').value }) }); store.set('task_list', r.id); closeSheet(); viewTasks(); } catch (err) { el.querySelector('#le').textContent = err.message; } }; el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); })); } // Home: a card only when something is overdue or due today async function renderTasksCard(el) { if (!el) return; try { const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today; if (!n || !document.body.contains(el)) return; el.innerHTML = `

Tasks

${icon('check')}${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}${icon('chev', 'chev')}`; } catch {} } // Mail: with WEBMAIL_URL set, your own webmail (which you can allow to be framed) opens inside the app and keeps running between tabs. // The provider shortcuts (Gmail, Outlook, ...) stay one tap away, because those sites refuse to be framed. function viewMail(args = []) { if (cfg.webmail && args[0] === 'webmail') return viewWebmail(); // the shortcuts are the default; the webmail is one tap away return viewMailCards(); } function viewWebmail() { const old = $('#audiowrap iframe[data-k="mail"]'); $('#view').innerHTML = `
Mail${old ? '' : 'Opening…'} ${icon('mail')} ${icon('ext')}
`; const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; }; const open = fresh => { setSt('Opening…'); let f = $('#audiowrap iframe[data-k="mail"]'); if (f) f.remove(); showFrame('mail', cfg.webmail + (fresh ? (cfg.webmail.includes('?') ? '&' : '?') + '_=' + Date.now() : ''), 'Mail', 'fullscreen; clipboard-write; clipboard-read'); f = $('#audiowrap iframe[data-k="mail"]'); let done = false; f.addEventListener('load', () => { done = true; setSt(''); webmailHooks(f); }); setTimeout(() => { if (!done) setSt('Still loading… if it says it refused to connect, the webmail must allow framing (see the README)'); }, 10000); }; if (!old) open(false); else { showFrame('mail', '', 'Mail', ''); setSt(''); } $('#ar').onclick = () => open(true); $('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="mail"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); }; } // The webmail page shares our address, so we can help it from outside: sign in for you, and send links that cannot be framed to the right place. const SIGNIN_HOSTS = /^(accounts\.google\.com|login\.microsoftonline\.com|login\.live.com|appleid\.apple\.com|github\.com)$/; async function webmailHooks(f) { let d; try { d = f.contentDocument; if (!d || !d.body) return; } catch { return; } if (!d.__hubHooks) { d.__hubHooks = true; d.addEventListener('click', e => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented) return; let u; try { u = new URL(a.href, d.baseURI); } catch { return; } if (!/^https?:$/.test(u.protocol) || u.origin === location.origin) return; e.preventDefault(); e.stopPropagation(); if (SIGNIN_HOSTS.test(u.hostname)) location.href = u.href; // a provider's sign-in page refuses to be framed: use the whole window else window.open(u.href, '_blank', 'noopener'); }, true); } const u = d.querySelector('input[name=username]'), p = d.querySelector('input[name=password]'), form = u && u.form; if (form && p && !d.querySelector('.error, .danger, .alert-danger') && !f.dataset.tried) { // the webmail's own login box: fill it in (once, so a wrong password is not retried) f.dataset.tried = 1; try { const c = await jfetch('/api/webmail/login'); if (c.user && c.pass) { u.value = c.user; p.value = c.pass; form.submit(); } } catch {} } } function viewMailCards() { const hints = mailHints(), on = mailOn(), shown = MAIL.filter(m => on.includes(m.id)), anyHint = shown.some(m => hints[m.id]); $('#view').innerHTML = `

Mail

Each account opens in its own webmail tab. Nothing is stored here.

${cfg.webmail ? `
${icon('mail')}Open webmail (all accounts in one place)
` : ''} ${shown.length ? '' : '

No mail apps chosen. Pick the ones you use in Settings.

'}
${shown.map(m => `
${icon('mail')}${esc(m.name)}${esc(hints[m.id] || (m.id === 'icloud' ? 'Opens iCloud Mail' : 'No address saved'))}
`).join('')}
${anyHint || !shown.length ? '' : '

Tip: save your address for each account in Settings so the right mailbox opens.

'}
`; } const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join(''); function viewOffice(args = []) { if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); $('#view').innerHTML = `

Office

Create, open and edit documents without leaving this tab. Everything is saved in Seafile.

${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}

Browse all files${sf.token() ? '' : 'Sign in'}
`; $('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l)); if (!sf.token()) return; (async () => { try { const loc = await officeLocation(false), el = $('#olib'); if (!el) return; if (!loc) { el.textContent = 'Choose where your documents are saved in Seafile.'; $('#odocs').innerHTML = '
'; $('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); return; } el.innerHTML = `Saved in ${esc(locLabel(loc))} · change`; $('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); }; const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`); const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60); if (!location.hash.startsWith('#/office') || !$('#odocs')) return; $('#odocs').innerHTML = docs.length ? `

Documents

${docs.map((f, i) => { const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b'; return ``; }).join('')}
` : '

No documents here yet. Create one above.

'; $('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => { const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name; addRecent(loc.repo, path, f.name); if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); }); } catch (e) { const el = $('#olib'); if (el) el.innerHTML = `${esc(e.message)}`; } })(); } const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / ')); // The saved Office location, if it still exists (the library may have been deleted). async function officeLocation(ask) { const loc = store.get('office_loc'); if (loc) { let repos; try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice const r = repos.find(x => x.id === loc.repo); if (r) { try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; } catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; } } store.del('office_loc'); // library or folder is really gone } if (!ask) return null; const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l; } // Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled. function chooseFolder(title, start, opts = {}) { return new Promise(resolve => { let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false; const el = openSheet(title, '', '
'); el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); const finish = v => { done = true; closeSheet(); resolve(v); }; const draw = async () => { const box = el.querySelector('#cf'); box.innerHTML = '

Loading…

'; try { if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name)); box.innerHTML = `

Choose a library

${repos.map(r => ``).join('') || '

No libraries. Create one in Files.

'}
`; box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); }); } else { const hidden = opts.hide ? opts.hide(repo, dir) : [], blocked = opts.blocked ? opts.blocked(repo, dir) : false; const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir' && !hidden.includes(x.name)).sort((a, b) => a.name.localeCompare(b.name)); box.innerHTML = `

${esc(locLabel({ repoName, dir }))}

${subs.map(x => ``).join('')}
${opts.newFolder ? `` : ''}
`; box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); }); box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); }; box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir }); const cn = box.querySelector('#cn'); if (cn) cn.onclick = () => { cn.disabled = true; const f = document.createElement('form'); f.style.marginTop = '10px'; f.innerHTML = '
'; box.insertBefore(f, box.querySelector('.bar')); f.querySelector('input').focus(); f.onsubmit = async ev => { ev.preventDefault(); const n = f.querySelector('input').value.trim(), bad = badName(n, subs); if (bad) { f.querySelector('#cme').textContent = bad; return; } try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(joinP(dir, n))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'mkdir' }) }); dir = joinP(dir, n); draw(); } catch (e) { f.querySelector('#cme').textContent = e.message; } }; }; } box.querySelector('#cx').onclick = () => finish(null); } catch (e) { box.innerHTML = `

${esc(e.message)}

`; } }; draw(); }); } // Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor). async function createFromTemplate(repo, dir, name, x) { const enc = encodeURIComponent; if (!['docx', 'xlsx', 'pptx'].includes(x)) { await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) }); return name; } const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing'); const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`); const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name); const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd }); if (!r.ok) throw new Error('Could not create ' + name); return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken } async function newOfficeDoc(x, label) { if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; } let loc; try { loc = await officeLocation(true); } catch (e) { return toast(e.message); } if (!loc) return; const el = openSheet('New ' + label, '', `
`); const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); }; showLoc(); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#nl').onclick = async () => { // Sheets don't stack, so close this one, choose, then reopen with the new location. const picked = await chooseFolder('Save in…', loc); if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label); }; setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50); el.querySelector('form').onsubmit = async e => { e.preventDefault(); const n = el.querySelector('#nn').value.trim(); if (!n) return; const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x; const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; try { const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name); addRecent(loc.repo, path, name); closeSheet(); if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); } catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; } }; }