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:
parent
144baeec61
commit
61c1b4f90d
2 changed files with 60 additions and 20 deletions
|
|
@ -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 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 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 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; }
|
.copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
|
||||||
|
|
||||||
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -197,37 +197,72 @@
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy straight into the server's archive over HTTP (PUT per file, mkdir as
|
// Copy straight into a project's archive on the server over HTTP (PUT per
|
||||||
// needed). Uses the zddc-source HTTP handle, so the SAME copy engine writes
|
// file, mkdir as needed). Uses the zddc-source HTTP handle, so the SAME copy
|
||||||
// <party>/<received|issued>/<transmittal>/<name> under the chosen archive URL.
|
// engine writes <party>/<received|issued>/<transmittal>/<name> under
|
||||||
|
// <project>/archive/. The user picks any project they can access.
|
||||||
async function copyToServer(todo) {
|
async function copyToServer(todo) {
|
||||||
var src = window.zddc && window.zddc.source;
|
var src = window.zddc && window.zddc.source;
|
||||||
if (!src || location.protocol === 'file:') {
|
if (!src || location.protocol === 'file:') {
|
||||||
toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error');
|
toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var url = serverArchiveUrl || guessArchiveUrl();
|
var projects = await fetchAccessProjects();
|
||||||
url = prompt('Server archive URL to file into (canonical copies go under <party>/<received|issued>/<transmittal>/):', url);
|
if (projects == null) { toast('Could not load your projects from the server.', 'error'); return; }
|
||||||
if (!url) return;
|
if (!projects.length) { toast('No projects you can access on this server.', 'warning'); return; }
|
||||||
if (url.charAt(url.length - 1) !== '/') url += '/';
|
var proj = await chooseProject(projects);
|
||||||
serverArchiveUrl = url;
|
if (!proj) return;
|
||||||
var out;
|
var archive;
|
||||||
try {
|
try {
|
||||||
var u = new URL(url, location.origin);
|
var rel = proj.url || ('/' + proj.name + '/');
|
||||||
out = new src.HttpDirectoryHandle(u.href, 'archive');
|
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
||||||
} catch (e) { toast('Bad archive URL — ' + (e.message || e), 'error'); return; }
|
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);
|
var s = await copyTo(out, todo);
|
||||||
summary(s, 'server archive');
|
summary(s, (proj.title || proj.name) + ' / archive');
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
// Best-guess archive root from the current page: the path up to and including
|
// The caller's accessible projects (read view from /.profile/access). Write
|
||||||
// the first "archive/" segment, else the served directory.
|
// permission is enforced server-side on PUT, so a 403 surfaces per file.
|
||||||
function guessArchiveUrl() {
|
async function fetchAccessProjects() {
|
||||||
var p = location.pathname;
|
try {
|
||||||
var m = /^(.*?\/archive\/)/.exec(p);
|
var resp = await fetch('/.profile/access', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', cache: 'no-cache' });
|
||||||
return location.origin + (m ? m[1] : p.replace(/[^/]*$/, ''));
|
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><project>/archive/<party>/<received|issued>/<transmittal>/</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' |
|
// Tiny modal: choose server archive vs local folder. Resolves 'server' |
|
||||||
// 'local' | null. The server option is offered only over http(s).
|
// 'local' | null. The server option is offered only over http(s).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue