feat(classifier): server copy picks any accessible project's archive

Replaces the raw archive-URL prompt with a project picker: copyToServer fetches
the caller's projects from /.profile/access and lets them choose one; files are
PUT under <project>/archive/<party>/<received|issued>/<transmittal>/. Write
permission is enforced server-side, so a 403 surfaces per file (and resume
retries). Same resumable engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-11 08:53:37 -05:00
parent 144baeec61
commit 61c1b4f90d
2 changed files with 60 additions and 20 deletions

View file

@ -582,6 +582,11 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.copy-choice h3 { margin: 0 0 0.5rem; font-size: 1.15rem; }
.copy-choice p { margin: 0 0 1.1rem; color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; }
.copy-choice code { font-size: 0.82em; }
.copy-choice__select {
width: 100%; margin: 0 0 1rem; padding: 0.45rem 0.55rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem;
}
.copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
/* ── By-tracking merged-cell table ──────────────────────────────────────── */

View file

@ -197,37 +197,72 @@
return s;
}
// Copy straight into the server's archive over HTTP (PUT per file, mkdir as
// needed). Uses the zddc-source HTTP handle, so the SAME copy engine writes
// <party>/<received|issued>/<transmittal>/<name> under the chosen archive URL.
// Copy straight into a project's archive on the server over HTTP (PUT per
// file, mkdir as needed). Uses the zddc-source HTTP handle, so the SAME copy
// engine writes <party>/<received|issued>/<transmittal>/<name> under
// <project>/archive/. The user picks any project they can access.
async function copyToServer(todo) {
var src = window.zddc && window.zddc.source;
if (!src || location.protocol === 'file:') {
toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error');
return;
}
var url = serverArchiveUrl || guessArchiveUrl();
url = prompt('Server archive URL to file into (canonical copies go under <party>/<received|issued>/<transmittal>/):', url);
if (!url) return;
if (url.charAt(url.length - 1) !== '/') url += '/';
serverArchiveUrl = url;
var out;
var projects = await fetchAccessProjects();
if (projects == null) { toast('Could not load your projects from the server.', 'error'); return; }
if (!projects.length) { toast('No projects you can access on this server.', 'warning'); return; }
var proj = await chooseProject(projects);
if (!proj) return;
var archive;
try {
var u = new URL(url, location.origin);
out = new src.HttpDirectoryHandle(u.href, 'archive');
} catch (e) { toast('Bad archive URL — ' + (e.message || e), 'error'); return; }
var rel = proj.url || ('/' + proj.name + '/');
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
archive = new URL(rel + 'archive/', location.origin).href;
} catch (e) { toast('Bad project URL — ' + (e.message || e), 'error'); return; }
var out = new src.HttpDirectoryHandle(archive, 'archive');
var s = await copyTo(out, todo);
summary(s, 'server archive');
summary(s, (proj.title || proj.name) + ' / archive');
return s;
}
// Best-guess archive root from the current page: the path up to and including
// the first "archive/" segment, else the served directory.
function guessArchiveUrl() {
var p = location.pathname;
var m = /^(.*?\/archive\/)/.exec(p);
return location.origin + (m ? m[1] : p.replace(/[^/]*$/, ''));
// The caller's accessible projects (read view from /.profile/access). Write
// permission is enforced server-side on PUT, so a 403 surfaces per file.
async function fetchAccessProjects() {
try {
var resp = await fetch('/.profile/access', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', cache: 'no-cache' });
if (!resp.ok) return null;
if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null;
var data = await resp.json();
return Array.isArray(data.projects) ? data.projects : [];
} catch (e) { return null; }
}
function chooseProject(projects) {
return new Promise(function (resolve) {
var done = false;
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
function onKey(e) { if (e.key === 'Escape') finish(null); }
var back = document.createElement('div'); back.className = 'copy-choice__backdrop';
var box = document.createElement('div'); box.className = 'copy-choice';
var h = document.createElement('h3'); h.textContent = 'Copy to a project archive';
var p = document.createElement('p');
p.innerHTML = 'Files go to <code>&lt;project&gt;/archive/&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/</code>. Pick a project you can access.';
var sel = document.createElement('select'); sel.className = 'copy-choice__select';
projects.forEach(function (pr, i) {
var o = document.createElement('option'); o.value = String(i);
o.textContent = pr.name + (pr.title ? ' — ' + pr.title : '');
sel.appendChild(o);
});
var row = document.createElement('div'); row.className = 'copy-choice__btns';
var go = document.createElement('button'); go.className = 'btn btn-primary'; go.textContent = 'Copy here';
go.addEventListener('click', function () { finish(projects[Number(sel.value)] || null); });
var cancel = document.createElement('button'); cancel.className = 'btn btn-secondary'; cancel.textContent = 'Cancel';
cancel.addEventListener('click', function () { finish(null); });
row.appendChild(go); row.appendChild(cancel);
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});
}
var serverArchiveUrl = null;
// Tiny modal: choose server archive vs local folder. Resolves 'server' |
// 'local' | null. The server option is offered only over http(s).