'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.', `
`); 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 ? `
${clash.map(c => `
${esc(c.file.name)}
`).join('')}
` : ''}
`); 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 : ''), '

Loading…

'); 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 ? `
${list.map((t, i) => `
${icon(t.is_dir ? 'files' : 'file')} ${esc(t.obj_name)}${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' }))} ${libPerm[repo] === 'r' ? '' : ``}
`).join('')}

Showing the 100 most recent. Seafile empties its recycle bin after a while.

` : '

Nothing here. Deleted files and folders from this library show up here so you can get them back.

'; 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] || '', '

Searching…

'); 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 ? `
${mine.map((r, i) => ``).join('')}
` : '

Nothing found.

'}
`; 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] || ''), '

Loading…

'); 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 = `
${links.length ? `
${links.map((l, i) => `
${l.expire_date ? (l.is_expired ? 'Expired ' : 'Expires ') + esc(new Date(l.expire_date).toLocaleDateString([], { dateStyle: 'medium' })) : 'Never expires'}${l.is_expired ? '' : ''}
`).join('')}
` : ''}

${links.length ? 'New link' : 'Create a link'}

`; 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, '', '

Loading…

'); el.querySelector('.sheet').classList.add('wide'); const dl = `${icon('dl')}Download`; 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 = `

This file is ${esc(fmtSize(len))}, too big to show here.

${dl}
`; 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 = `
${esc(text)}
${ro || len > 256 * 1024 ? '' : ''}${dl}
`; el.querySelector('#sx').onclick = closeSheet; const ed = el.querySelector('#ted'); if (ed) ed.onclick = edit; }; const edit = () => { el.querySelector('#tpb').outerHTML = `
`; 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; } }