Files
hub/public/files-extra.js

210 lines
20 KiB
JavaScript

'use strict';
// Files, the sturdier parts: signing in again in place, a saved copy of each folder (instant, and still there offline), upload name clashes,
// undo and the recycle bin, search in a library, zip downloads, share link options, and text files you can read and edit.
// Uses sf, store, ME, jfetch, openSheet, closeSheet, confirmSheet, actionSheet, toast, esc, icon, fmtSize, fmtDate, joinP, libNames, libPerm, cfg from the other scripts.
// ---- Seafile sign-in expired: ask again without leaving the page, then the original request carries on
let reauthP = null;
function sfReauth() {
if (reauthP) return reauthP;
reauthP = new Promise((ok, no) => {
let settled = false;
const finish = (good, err) => { if (settled) return; settled = true; reauthP = null; good ? ok() : (store.del('sf_token'), no(err || new Error('Session expired, sign in again'))); };
const el = openSheet('Sign in to Seafile again', 'Your Seafile sign-in expired. You will stay where you are.', `<form id="rf"><label for="ru">Email / username</label><input type="text" id="ru" autocomplete="username" autocapitalize="off" required>
<label for="rp">Password</label><input type="password" id="rp" autocomplete="current-password" required><div id="rotp" hidden><label for="ro">2FA code</label><input type="text" id="ro" inputmode="numeric" autocomplete="one-time-code"></div>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Not now</button><button class="btn" id="rb" style="flex:1;justify-content:center">Sign in</button></div><div class="err" id="re" role="alert"></div></form>`);
el.querySelector('#sx').onclick = () => { closeSheet(); finish(false); };
el.querySelector('.scrim').addEventListener('click', () => finish(false));
el.querySelector('#rf').onsubmit = async e => {
e.preventDefault(); const b = el.querySelector('#rb'); b.disabled = true; el.querySelector('#re').textContent = '';
try { await sf.login(el.querySelector('#ru').value.trim(), el.querySelector('#rp').value, el.querySelector('#ro').value.trim() || undefined); closeSheet(); finish(true); }
catch (err) { b.disabled = false; if (err.otp) el.querySelector('#rotp').hidden = false; el.querySelector('#re').textContent = err.message; }
};
setTimeout(() => el.querySelector('#ru').focus(), 60);
});
return reauthP;
}
// ---- A saved copy of every folder you open (names, sizes, dates only): shown at once while the real list loads, and when Seafile cannot be reached
const dcIndex = () => 'dc:index:' + (ME ? ME.id : '');
const dcKey = (repo, dir) => 'dc:' + (ME ? ME.id : '') + ':' + repo + ':' + dir;
function dcGet(repo, dir) { try { const o = JSON.parse(localStorage.getItem(dcKey(repo, dir))); return o && Array.isArray(o.items) ? o : null; } catch { return null; } }
function dcPut(repo, dir, items) {
const key = dcKey(repo, dir), body = JSON.stringify({ t: Date.now(), items: items.slice(0, 1500).map(x => ({ id: x.id, name: x.name, type: x.type, size: x.size, mtime: x.mtime })) });
const touch = () => { const idx = (JSON.parse(localStorage.getItem(dcIndex()) || '[]')).filter(k => k !== key); idx.unshift(key); while (idx.length > 40) localStorage.removeItem(idx.pop()); localStorage.setItem(dcIndex(), JSON.stringify(idx)); };
try { localStorage.setItem(key, body); touch(); }
catch { try { const idx = JSON.parse(localStorage.getItem(dcIndex()) || '[]'); for (const k of idx.splice(Math.ceil(idx.length / 2))) localStorage.removeItem(k); localStorage.setItem(dcIndex(), JSON.stringify(idx)); localStorage.setItem(key, body); touch(); } catch { /* storage is full: go without */ } }
}
const fScroll = new Map(); // where each folder was scrolled to
document.addEventListener('scroll', e => { if (e.target && e.target.id === 'view' && /^#\/files\/./.test(location.hash)) fScroll.set(location.hash, e.target.scrollTop); }, true);
// ---- Uploading a file whose name is already there
async function resolveUploadConflicts(repo, dir, entries) {
let existing;
try { existing = new Set((await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).map(x => x.name.toLowerCase())); } catch { return entries; }
const clash = entries.filter(e => !e.rel && existing.has(e.file.name.toLowerCase())); // (files inside dropped folders keep Seafile's own renaming)
if (!clash.length) return entries;
const choice = await new Promise(ok => {
const el = openSheet(clash.length === 1 ? 'Replace “' + clash[0].file.name + '”?' : clash.length + ' files already exist here', 'Replacing keeps the old version in the file\'s history.',
`${clash.length > 1 ? `<div class="list" style="background:var(--panel2);border:0;max-height:30dvh;overflow:auto">${clash.map(c => `<div class="row"><span class="grow"><b>${esc(c.file.name)}</b></span></div>`).join('')}</div>` : ''}
<div class="bar"><button class="btn" id="cr" style="flex:1;justify-content:center">Replace</button><button class="btn ghost" id="ck" style="flex:1;justify-content:center">Keep both</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="cs" style="flex:1;justify-content:center">Skip ${clash.length === 1 ? 'it' : 'those'}</button><button class="btn ghost" id="cc" style="flex:1;justify-content:center">Cancel upload</button></div>`);
let done = false; const pick = v => { if (done) return; done = true; closeSheet(); ok(v); };
el.querySelector('#cr').onclick = () => pick('replace'); el.querySelector('#ck').onclick = () => pick('both'); el.querySelector('#cs').onclick = () => pick('skip'); el.querySelector('#cc').onclick = () => pick('cancel');
el.querySelector('.scrim').addEventListener('click', () => pick('cancel'));
});
if (choice === 'cancel') return [];
if (choice === 'skip') return entries.filter(e => !clash.includes(e));
return entries.map(e => clash.includes(e) ? { ...e, replace: choice === 'replace' } : e);
}
const waitOnline = () => new Promise(ok => { if (navigator.onLine) return ok(); const f = () => { removeEventListener('online', f); ok(); }; addEventListener('online', f); });
// ---- Undo a delete, and the recycle bin (Seafile keeps deleted files for a while)
const trashWhen = t => typeof t === 'number' ? t * 1000 : Date.parse(t) || 0;
async function trashList(repo, path = '/', limit = 100) {
const r = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/trash/?path=${encodeURIComponent(path)}&limit=${limit}`);
return (r && (r.items || r.data)) || [];
}
async function trashRestore(repo, t) {
const parent = (t.parent_dir || '/').replace(/\/+$/, '') || '';
await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${t.is_dir ? 'dir' : 'file'}/revert/`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ p: parent + '/' + t.obj_name, commit_id: t.commit_id }) });
}
/** Right after a delete: find the just-deleted items in the recycle bin and offer Undo. (Quietly nothing if Seafile has no recycle bin API.) */
async function offerUndo(repo, dir, items, afterRestore) {
try {
const names = new Set(items.map(x => x.name)), newest = new Map();
for (const t of await trashList(repo, dir, 60)) if (names.has(t.obj_name) && Date.now() - trashWhen(t.deleted_time) < 5 * 60e3 && (!newest.has(t.obj_name) || trashWhen(t.deleted_time) > trashWhen(newest.get(t.obj_name).deleted_time))) newest.set(t.obj_name, t);
if (!newest.size) return false;
toast(items.length === 1 ? 'Deleted “' + items[0].name + '”' : items.length + ' items deleted', { label: 'Undo', run: async () => {
let bad = 0; for (const t of newest.values()) { try { await trashRestore(repo, t); } catch { bad++; } }
toast(bad ? 'Could not restore ' + bad + ' item' + (bad === 1 ? '' : 's') : 'Restored'); if (afterRestore) afterRestore();
} });
return true;
} catch { return false; /* no undo available */ }
}
async function recycleBin(repo, dir) {
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
if (!document.body.contains(el)) return;
const body = el.querySelector('.empty');
body.outerHTML = list.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${list.map((t, i) => `<div class="row"><span class="ic" style="background:${t.is_dir ? '#e0a82b' : '#6a7280'}">${icon(t.is_dir ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(t.obj_name)}</b><small style="white-space:normal">${esc((t.parent_dir || '/'))} · ${t.is_dir ? 'folder' : esc(fmtSize(t.size || 0))} · deleted ${esc(new Date(trashWhen(t.deleted_time)).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></span>
${libPerm[repo] === 'r' ? '' : `<button class="btn ghost" data-rs="${i}" style="min-height:36px">Restore</button>`}</div>`).join('')}</div><p class="sub">Showing the 100 most recent. Seafile empties its recycle bin after a while.</p>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`
: '<p class="empty">Nothing here. Deleted files and folders from this library show up here so you can get them back.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>';
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-rs]').forEach(b => b.onclick = async () => {
b.disabled = true; try { await trashRestore(repo, list[+b.dataset.rs]); b.textContent = 'Restored'; if (fv && fv.repo === repo) fv.reload(true); } catch (e) { b.disabled = false; toast(e.message); }
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}
// ---- Search a whole library (names, from the Seafile index when it has one, otherwise by walking the folders)
function libSearch(repo) {
textSheet('Search this library', 'File or folder name', 'Search', async term => {
const el = openSheet('Results for “' + term + '”', libNames[repo] || '', '<p class="empty">Searching…</p>'); el.querySelector('.sheet').classList.add('wide');
try {
const { results } = await searchFiles(term, [{ id: repo, name: libNames[repo] || '' }]);
const mine = results.filter(r => r.repo_id === repo).slice(0, 60);
if (!document.body.contains(el)) return;
el.querySelector('.empty').outerHTML = `${mine.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${mine.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${r.is_dir ? '#e0a82b' : '#6a7280'}">${icon(r.is_dir ? 'files' : 'file')}</span><span class="grow"><b>${esc(r.name)}</b><small style="white-space:normal">${esc((r.fullpath || '').replace(/\/[^/]*$/, '') || '/')}</small></span></button>`).join('')}</div>` : '<p class="empty">Nothing found.</p>'}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => {
const r = mine[+b.dataset.r], parent = (r.fullpath || '').replace(/\/[^/]*$/, '') || '/'; closeSheet();
if (r.is_dir) location.hash = '#/files/' + encodeURIComponent(repo) + (r.fullpath || '').split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join('');
else openFile(repo, r.fullpath, r.name, parent);
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
});
}
// ---- Download several items, or a whole folder, as one zip (Seafile builds it; we wait for it)
async function zipDownload(repo, dir, names) {
try {
const q = new URLSearchParams({ parent_dir: dir }); names.forEach(n => q.append('dirents', n));
const task = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/zip-task/?${q}`), token = task.zip_token;
if (!token) throw new Error('Seafile could not make a zip of that');
for (let i = 0; i < 300; i++) {
const p = await sf.req(`/api/v2.1/query-zip-progress/?token=${encodeURIComponent(token)}`);
const total = +p.total || 0, zipped = +p.zipped || 0;
if (p.failed || p.failed_reason) throw new Error('Seafile could not make the zip' + (p.failed_reason ? ': ' + p.failed_reason : ''));
if (total && zipped >= total) break;
toast('Preparing the zip… ' + (total ? Math.round(100 * zipped / total) + '%' : ''));
await sleep(i < 5 ? 500 : 1500);
if (i === 299) throw new Error('The zip is taking too long. Try fewer files.');
}
const a = document.createElement('a'); a.href = '/sf/seafhttp/zip/' + encodeURIComponent(token); a.download = ''; document.body.appendChild(a); a.click(); a.remove();
toast('Download started');
} catch (e) { toast(e.message); }
}
// ---- Share links with a password and an expiry, and the ones that already exist
async function shareSheet(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
const draw = async () => {
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
if (!document.body.contains(el)) return;
const host = el.querySelector('.empty') || el.querySelector('#shbody');
const html = `<div id="shbody">${links.length ? `<div class="list" style="background:var(--panel2);border:0">${links.map((l, i) => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:0"><input type="text" readonly value="${esc(l.link)}" onfocus="this.select()" style="margin:0"><small style="white-space:normal">${l.expire_date ? (l.is_expired ? 'Expired ' : 'Expires ') + esc(new Date(l.expire_date).toLocaleDateString([], { dateStyle: 'medium' })) : 'Never expires'}${l.is_expired ? '' : ''}</small></span>
<button class="btn ghost" data-cp="${i}" style="min-height:36px">Copy</button><button class="btn danger" data-rm="${i}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
<form id="shf"><h3 style="margin:14px 0 4px;font-size:15px">${links.length ? 'New link' : 'Create a link'}</h3>
<label for="shp">Password (optional, at least 8 characters)</label><input type="password" id="shp" autocomplete="new-password" placeholder="No password">
<label for="she">Expires</label><select id="she"><option value="">Never</option><option value="1">In 1 day</option><option value="7" selected>In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" id="shc" style="flex:1;justify-content:center">Create link</button></div><div class="err" id="she2" role="alert"></div></form></div>`;
host.outerHTML = html;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-cp]').forEach(b => b.onclick = async () => { const l = links[+b.dataset.cp]; try { await navigator.clipboard.writeText(l.link); toast('Link copied'); } catch { toast('Select the link and copy it'); } });
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete this link?', 'Anyone using it loses access.', 'Delete', async () => { try { await sf.req(`/api/v2.1/share-links/${enc(links[+b.dataset.rm].token)}/`, { method: 'DELETE' }); shareSheet(repo, path, isDir); } catch (e) { toast(e.message); } }));
el.querySelector('#shf').onsubmit = async e => {
e.preventDefault(); const pw = el.querySelector('#shp').value, days = el.querySelector('#she').value, btn = el.querySelector('#shc');
if (pw && pw.length < 8) { el.querySelector('#she2').textContent = 'A password needs at least 8 characters.'; return; }
btn.disabled = true;
try {
const body = new URLSearchParams({ repo_id: repo, path: p }); if (pw) body.set('password', pw); if (days) body.set('expire_days', days);
const made = await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORMH, body });
try { await navigator.clipboard.writeText(made.link); toast('Link created and copied'); } catch { toast('Link created'); }
shareSheet(repo, path, isDir);
} catch (err) { btn.disabled = false; el.querySelector('#she2').textContent = err.message; }
};
};
draw();
}
// ---- Text files: read them here, and edit small ones (the old version stays in the file's history)
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
async function textPreview(repo, path, name) {
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
try {
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
const len = +r.headers.get('content-length') || 0;
if (len > 1024 * 1024) { await r.body.cancel(); el.querySelector('.empty').outerHTML = `<p class="sub">This file is ${esc(fmtSize(len))}, too big to show here.</p><div class="bar">${dl}<button class="btn ghost" id="sx">Close</button></div>`; el.querySelector('#sx').onclick = closeSheet; return; }
let text = await r.text(); if (text.length > 1024 * 1024) text = text.slice(0, 1024 * 1024);
const view = () => {
if (!document.body.contains(el)) return;
const host = el.querySelector('#tpb') || el.querySelector('.empty');
host.outerHTML = `<div id="tpb"><pre class="ptxt">${esc(text)}</pre><div class="bar">${ro || len > 256 * 1024 ? '' : '<button class="btn" id="ted">Edit</button>'}${dl}<button class="btn ghost" id="sx">Close</button></div></div>`;
el.querySelector('#sx').onclick = closeSheet; const ed = el.querySelector('#ted'); if (ed) ed.onclick = edit;
};
const edit = () => {
el.querySelector('#tpb').outerHTML = `<div id="tpb"><textarea id="tta" class="ptxt-edit" spellcheck="false">${esc(text)}</textarea><div class="bar"><button class="btn" id="tsv">Save</button><button class="btn ghost" id="tcx">Cancel</button></div><div class="err" id="ter" role="alert"></div></div>`;
el.querySelector('#tcx').onclick = view;
el.querySelector('#tsv').onclick = async () => {
const b = el.querySelector('#tsv'); b.disabled = true; const next = el.querySelector('#tta').value;
try {
const link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData();
fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', new Blob([next], { type: 'text/plain' }), name);
const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd });
if (!up.ok) throw new Error('Could not save (' + up.status + ')');
text = next; toast('Saved'); view(); if (fv && fv.repo === repo) fv.reload(true);
} catch (e) { b.disabled = false; el.querySelector('#ter').textContent = e.message; }
};
};
view();
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}