From 70d69e7441780332845c6ba8445f76107448700d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:54:06 -0400 Subject: [PATCH] File version history (list, download, restore) for Seafile files Co-Authored-By: Claude Sonnet 5.5 --- public/app.js | 1 + public/file-history.js | 35 +++++++++++++++++++++++++++++++++++ public/index.html | 1 + server.js | 4 ++-- 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 public/file-history.js diff --git a/public/app.js b/public/app.js index 5dd7d0d..a6c8ee7 100644 --- a/public/app.js +++ b/public/app.js @@ -2263,6 +2263,7 @@ function fileSheet(repo, path, name, dir) { if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') }); acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) }); acts.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, path) }); + acts.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, path, name, ro) }); if (!ro) acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) }); if (!ro) acts.push({ label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }); acts.push({ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) }); diff --git a/public/file-history.js b/public/file-history.js new file mode 100644 index 0000000..359a5e8 --- /dev/null +++ b/public/file-history.js @@ -0,0 +1,35 @@ +// Version history for a Seafile file: see earlier versions, download one, or restore it. +// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js. +async function fileHistory(repo, path, name, readOnly) { + const base = `/api2/repos/${encodeURIComponent(repo)}/file`; + const el = openSheet('Version history', name, '

Loading…

'); + el.querySelector('.sheet').classList.add('wide'); + try { + let commits = [], next = ''; + for (let page = 0; page < 3; page++) { // up to three pages of history + const r = await sf.req(`${base}/history/?p=${encodeURIComponent(path)}${next ? '&commit_id=' + encodeURIComponent(next) : ''}`); + commits = commits.concat(r.commits || []); + next = r.next_start_commit; if (!next) break; + } + if (!document.body.contains(el)) return; + if (!commits.length) { el.querySelector('.empty').textContent = 'No earlier versions are kept for this file.'; return; } + const body = el.querySelector('.empty'); + body.outerHTML = `
${commits.map((c, i) => ` +
${esc(new Date((c.ctime || 0) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${i === 0 ? ' · current' : ''} + ${esc(fmtSize(c.rev_file_size || 0))}${c.creator_name ? ' · ' + esc(c.creator_name) : ''} + ${i === 0 ? '' : `${readOnly ? '' : ``}`}
`).join('')}
+
`; + el.querySelector('#sx').onclick = closeSheet; + el.querySelectorAll('[data-dl]').forEach(b => b.onclick = async () => { + const c = commits[+b.dataset.dl]; + try { window.open(await sf.req(`${base}/revision/?p=${encodeURIComponent(path)}&commit_id=${encodeURIComponent(c.id)}`), '_blank', 'noopener'); } catch (e) { toast(e.message); } + }); + el.querySelectorAll('[data-rs]').forEach(b => b.onclick = () => { + const c = commits[+b.dataset.rs], when = new Date((c.ctime || 0) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); + confirmSheet('Restore this version?', name + ' goes back to how it was on ' + when + '. The current version stays in the history.', 'Restore', async () => { + try { await sf.req(`${base}/revert/`, { method: 'POST', headers: FORM, body: new URLSearchParams({ p: path, commit_id: c.id }) }); toast('Restored'); if (typeof route === 'function') route(); } + catch (e) { toast(e.message); } + }); + }); + } catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; } +} diff --git a/public/index.html b/public/index.html index a15b528..a54c478 100644 --- a/public/index.html +++ b/public/index.html @@ -55,6 +55,7 @@ + diff --git a/server.js b/server.js index 828facb..7c8f597 100644 --- a/server.js +++ b/server.js @@ -99,7 +99,7 @@ function serveStatic(req, res) { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views|music-extras)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views|music-extras|file-history)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -113,7 +113,7 @@ function serveStatic(req, res) { } // Changes whenever the app's files change, so an open page can tell that a newer version is available. -const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'music-extras.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); +const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'music-extras.js', 'file-history.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';