build-container / build (push) Successful in 20m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
210 lines
20 KiB
JavaScript
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; }
|
|
}
|