316 lines
29 KiB
JavaScript
316 lines
29 KiB
JavaScript
'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 = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
|
|
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 = `<div class="wrap"><h1>Tasks</h1>
|
|
<div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
|
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
|
<div id="tl"><p class="empty">Loading…</p></div>
|
|
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
|
$('#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 = `<div class="wrap"><h1>Tasks</h1><p class="err">${esc(e.message)}</p></div>`; }
|
|
}
|
|
|
|
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 `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
|
|
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
|
|
const showDone = store.get('task_show_done', false);
|
|
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
|
|
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : ''}` : '');
|
|
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 = `<p class="err">${esc(e.message)}</p>`; }
|
|
}
|
|
|
|
function taskSheet(t, cur, list) {
|
|
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
|
|
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
|
|
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
|
|
${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>` : ''}
|
|
<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>
|
|
<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" 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>`);
|
|
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', `<div class="row"><span class="grow"><b>${esc(title)}</b></span></div>`); 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 ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
|
|
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
|
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
|
|
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 = `<h2>Tasks</h2><a class="row card2" href="#/tasks">${icon('check')}<span class="grow"><b>${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention</b><small>${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}</small></span>${icon('chev', 'chev')}</a>`;
|
|
} 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 = `<div class="abbar"><span class="grow"><b>Mail</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
|
|
<a class="ibtn" href="#/mail" aria-label="Mail shortcuts (Gmail, Outlook and others)" title="Mail shortcuts (Gmail, Outlook and others)">${icon('mail')}</a>
|
|
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
|
|
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
|
<a class="ibtn" href="${esc(cfg.webmail)}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
|
|
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 = `<div class="wrap"><h1>Mail</h1>
|
|
<p class="sub">Each account opens in its own webmail tab. Nothing is stored here.</p>
|
|
${cfg.webmail ? `<div class="bar" style="margin-bottom:0"><a class="btn" href="#/mail/webmail">${icon('mail')}Open webmail (all accounts in one place)</a></div>` : ''}
|
|
${shown.length ? '' : '<p class="empty">No mail apps chosen. Pick the ones you use in <a href="#/settings" style="color:var(--accent)">Settings</a>.</p>'}<div class="mailgrid">${shown.map(m => `<div class="mcard" style="--mc:${m.color}">
|
|
<div class="mhd"><span class="mbadge">${icon('mail')}</span><span class="grow"><b>${esc(m.name)}</b><small>${esc(hints[m.id] || (m.id === 'icloud' ? 'Opens iCloud Mail' : 'No address saved'))}</small></span></div>
|
|
<div class="mact"><a class="btn" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">${icon('mail')}Inbox</a>
|
|
<a class="btn ghost" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">${icon('edit')}Compose</a></div></div>`).join('')}</div>
|
|
${anyHint || !shown.length ? '' : '<p class="sub" style="margin-top:14px">Tip: save your address for each account in <a href="#/settings" style="color:var(--accent)">Settings</a> so the right mailbox opens.</p>'}</div>`;
|
|
}
|
|
|
|
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 = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
|
|
<div class="grid">
|
|
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
|
|
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
|
|
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
|
|
</div>
|
|
<p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
|
<div id="odocs"></div>
|
|
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
|
|
$('#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 = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
|
|
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
|
|
return;
|
|
}
|
|
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
|
|
$('#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 ? `<h2>Documents</h2><div class="list">${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 `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
|
|
: '<p class="empty">No documents here yet. Create one above.</p>';
|
|
$('#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 = `<span class="err">${esc(e.message)}</span>`; }
|
|
})();
|
|
}
|
|
|
|
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, '', '<div id="cf"></div>');
|
|
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 = '<p class="empty">Loading…</p>';
|
|
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 = `<p class="sub">Choose a library</p><div class="list" style="background:var(--panel2);border:0;max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No libraries. Create one in Files.</p>'}</div>
|
|
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button></div>`;
|
|
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 = `<p class="sub" style="word-break:break-all">${esc(locLabel({ repoName, dir }))}</p><div class="list" style="background:var(--panel2);border:0;max-height:45dvh;overflow:auto">
|
|
<button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
|
|
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
|
|
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button>${opts.newFolder ? `<button class="btn ghost" id="cn" aria-label="New folder">${icon('newdir')}</button>` : ''}<button class="btn" id="cs" style="flex:1;justify-content:center" ${blocked ? 'disabled' : ''}>${esc(opts.okLabel || 'Save here')}</button></div>`;
|
|
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 = '<input type="text" id="cmn" placeholder="Folder name" autocomplete="off" autocapitalize="off" required><div class="err" id="cme" style="margin-top:6px"></div>';
|
|
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 = `<p class="err">${esc(e.message)}</p>`; }
|
|
};
|
|
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, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
|
|
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
|
|
<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">Create</button></div><div class="err" id="ne"></div></form>`);
|
|
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; }
|
|
};
|
|
}
|