diff --git a/archivist/Dockerfile b/archivist/Dockerfile index d39bf80..40a28de 100644 --- a/archivist/Dockerfile +++ b/archivist/Dockerfile @@ -17,6 +17,7 @@ ARG TARGETARCH # with `go mod download` for fully reproducible builds. COPY go.mod main.go ./ COPY internal/ ./internal/ +COPY pb_public/ ./pb_public/ RUN go mod tidy RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /archivist . @@ -34,6 +35,7 @@ RUN mkdir -p /out/etc/ssl/certs /out/usr/share /out/app/pb_data /out/tmp \ FROM scratch COPY --from=build /out/ / COPY --from=build /archivist /app/archivist +COPY --from=build /src/pb_public/ /app/pb_public/ WORKDIR /app ENV TMPDIR=/tmp diff --git a/archivist/arch.md b/archivist/arch.md index e1bb5b1..c2709ed 100644 --- a/archivist/arch.md +++ b/archivist/arch.md @@ -341,6 +341,7 @@ ing, no status change), `corrected` event carrying the field diff (US-22). 12. PDF proxy — the route streams the S3 object, refused without superuser auth (US-34). 13. Dry-run — both modes return the shared response shape and persist nothing (US-35). 14. Full correction — a pending document is re-filed and validated; value errors leave everything unchanged; an unchanged name skips the S3 move and the diff drops file_key (US-36). +15. Static dashboard — the review pages are served from pb_public/ with no external resource (US-30). `POST /api/archivist/poll` (superuser only) runs one poll pass immediately. It exists so the gate and the operator trigger passes on demand instead of waiting for the cron; a fresh upload needs two calls because the poller waits for size/ETag stability across two passes (US-10). diff --git a/archivist/e2e/e2e_test.go b/archivist/e2e/e2e_test.go index baa83a2..e2f139f 100644 --- a/archivist/e2e/e2e_test.go +++ b/archivist/e2e/e2e_test.go @@ -1114,5 +1114,40 @@ ent_events/records/"+id, nil); status < 400 { t.Fatalf("file_key must be absent from the diff: %v", after) } }) + + // 15. Static dashboard (US-30): the review pages are served from + // pb_public/ by the application itself - plain HTML/CSS/JS, nothing + // loaded from a CDN. + t.Run("dashboard: static pages served with no CDN", func(t *testing.T) { + resp, err := http.Get(baseURL + "/") + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("dashboard index: status %d", resp.StatusCode) + } + html := string(data) + if !strings.Contains(html, "archivist-dashboard") { + t.Fatal("the index page is not the review dashboard") + } + for _, marker := range []string{"src=\"http", "href=\"http", "url(http"} { + if strings.Contains(html, marker) { + t.Fatalf("the dashboard loads an external resource (%s)", marker) + } + } + for _, asset := range []string{"/styles.css", "/common.js", "/app.js", "/detail.js", "/document.html"} { + r, err := http.Get(baseURL + asset) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(r.Body) + r.Body.Close() + if r.StatusCode != 200 || len(body) == 0 { + t.Fatalf("asset %s: status %d, %d bytes", asset, r.StatusCode, len(body)) + } + } + }) } diff --git a/archivist/pb_public/app.js b/archivist/pb_public/app.js new file mode 100644 index 0000000..5245bc7 --- /dev/null +++ b/archivist/pb_public/app.js @@ -0,0 +1,425 @@ +"use strict"; + +// Document list page (US-32, US-33): server-side sort, per-column typed +// filters, status tabs, pagination, column chooser persisted in the +// browser, bulk single-field edit through one standard PATCH per +// document. + +var state = { + config: null, + meta: [], + page: 1, + perPage: 50, + sort: null, + columns: [], + statusTab: "", + filters: {}, + selected: {}, + docs: [], + total: 0, + totalPages: 1, + counts: {} +}; + +function $(id) { return document.getElementById(id); } + +function esc(v) { return String(v).replace(/'/g, "\\'"); } + +function store(key, val) { + if (val === undefined) { + try { + var raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch (e) { return null; } + } + try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} +} + +function fixedMeta() { + return [ + { name: "status", type: "status", fixed: true }, + { name: "file_key", type: "text", fixed: true }, + { name: "created", type: "date", fixed: true }, + { name: "updated", type: "date", fixed: true } + ]; +} + +function fieldMeta(name) { + for (var i = 0; i < state.meta.length; i++) { + if (state.meta[i].name === name) return state.meta[i]; + } + return null; +} + +async function init() { + var r = await api("GET", "/api/archivist/config"); + if (r.status !== 200) return; + state.config = r.data; + state.meta = state.config.fields.map(function (f) { + return { name: f.name, type: f.type, mandatory: !!f.mandatory }; + }).concat(fixedMeta()); + + var defaults = state.config.fields.filter(function (f) { return f.mandatory; }) + .map(function (f) { return f.name; }).concat(["status", "file_key"]); + var savedCols = store("archivist.columns"); + state.columns = Array.isArray(savedCols) ? savedCols : defaults; + var savedSort = store("archivist.sort"); + state.sort = savedSort && savedSort.field ? savedSort : null; + + initTheme("themeBtn"); + buildChooser(); + bindBulk(); + renderTabs(); + await loadCounts(); + await loadDocs(); +} + +function buildChooser() { + var grid = $("colgrid"); + grid.innerHTML = ""; + state.meta.forEach(function (m) { + var label = document.createElement("label"); + var cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = state.columns.indexOf(m.name) >= 0; + cb.addEventListener("change", function () { + var i = state.columns.indexOf(m.name); + if (cb.checked && i < 0) state.columns.push(m.name); + if (!cb.checked && i >= 0) state.columns.splice(i, 1); + store("archivist.columns", state.columns); + state.page = 1; + loadDocs(); + }); + label.appendChild(cb); + label.appendChild(document.createTextNode(" " + m.name)); + grid.appendChild(label); + }); +} + +async function loadCounts() { + var statuses = state.config.statuses || []; + var results = await Promise.all(statuses.map(function (st) { + return api("GET", "/api/collections/documents/records?perPage=1&fields=status&filter=" + + encodeURIComponent("(status='" + esc(st) + "')")); + })); + statuses.forEach(function (st, i) { + state.counts[st] = (results[i].data && results[i].data.totalItems) || 0; + }); + renderTabs(); +} + +function renderTabs() { + var tabs = $("tabs"); + tabs.innerHTML = ""; + var all = document.createElement("button"); + all.type = "button"; + all.className = "tab" + (state.statusTab === "" ? " active" : ""); + all.textContent = "Tous"; + all.addEventListener("click", function () { + state.statusTab = ""; + state.page = 1; + loadDocs(); + }); + tabs.appendChild(all); + (state.config.statuses || []).forEach(function (st) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "tab" + (state.statusTab === st ? " active" : ""); + b.appendChild(document.createTextNode(st)); + var c = document.createElement("span"); + c.className = "count"; + c.textContent = state.counts[st] !== undefined ? String(state.counts[st]) : ""; + b.appendChild(c); + b.addEventListener("click", function () { + state.statusTab = st; + state.page = 1; + loadDocs(); + }); + tabs.appendChild(b); + }); +} + +function buildFilterQuery() { + var parts = []; + if (state.statusTab) parts.push("(status='" + esc(state.statusTab) + "')"); + state.columns.forEach(function (name) { + if (name === "status") return; // owned by the tabs + var m = fieldMeta(name); + var f = state.filters[name]; + if (!m || !f || m.type === "number") return; + if (m.type === "date") { + if (f.from) parts.push("(" + name + ">='" + esc(f.from) + "')"); + if (f.to) parts.push("(" + name + "<='" + esc(f.to) + "')"); + } else if (m.type === "boolean") { + if (f.value === "true" || f.value === "false") parts.push("(" + name + "=" + f.value + ")"); + } else if (m.type === "enum") { + if (f.value) parts.push("(" + name + "='" + esc(f.value) + "')"); + } else { + if (f.value) parts.push("(" + name + "~'" + esc(f.value) + "')"); + } + }); + if (!parts.length) return ""; + return "&filter=" + encodeURIComponent(parts.join(" && ")); +} + +async function loadDocs() { + var sort = state.sort + ? ("&sort=" + (state.sort.dir === "desc" ? "-" : "") + encodeURIComponent(state.sort.field)) + : ""; + var q = "/api/collections/documents/records?page=" + state.page + + "&perPage=" + state.perPage + sort + buildFilterQuery(); + var r = await api("GET", q); + if (r.status !== 200) return; + state.docs = r.data.items || []; + state.total = r.data.totalItems || 0; + state.totalPages = r.data.totalPages || 1; + renderHead(); + renderRows(); + renderPager(); + renderBulkbar(); +} + +function renderHead() { + var thead = $("doctable").tHead; + thead.innerHTML = ""; + var trh = document.createElement("tr"); + var thAll = document.createElement("th"); + var cbAll = document.createElement("input"); + cbAll.type = "checkbox"; + cbAll.addEventListener("change", function () { + state.docs.forEach(function (d) { + if (cbAll.checked) state.selected[d.id] = true; + else delete state.selected[d.id]; + }); + renderRows(); + renderBulkbar(); + }); + thAll.appendChild(cbAll); + trh.appendChild(thAll); + state.columns.forEach(function (name) { + var th = document.createElement("th"); + th.textContent = name + (state.sort && state.sort.field === name + ? (state.sort.dir === "desc" ? " ↓" : " ↑") : ""); + th.addEventListener("click", function () { + if (!state.sort || state.sort.field !== name) { + state.sort = { field: name, dir: "asc" }; + } else if (state.sort.dir === "asc") { + state.sort.dir = "desc"; + } else { + state.sort = null; + } + store("archivist.sort", state.sort); + state.page = 1; + loadDocs(); + }); + trh.appendChild(th); + }); + thead.appendChild(trh); + + var trf = document.createElement("tr"); + trf.appendChild(document.createElement("td")); + state.columns.forEach(function (name) { + var td = document.createElement("td"); + if (name !== "status") { + var m = fieldMeta(name); + if (m && m.type !== "number") { + td.appendChild(buildFilterControl(m)); + } + } + trf.appendChild(td); + }); + thead.appendChild(trf); +} + +function buildFilterControl(m) { + var f = state.filters[m.name] || (state.filters[m.name] = {}); + var wrap = document.createElement("span"); + if (m.type === "date") { + var from = document.createElement("input"); + from.type = "date"; + from.value = f.from || ""; + var to = document.createElement("input"); + to.type = "date"; + to.value = f.to || ""; + from.addEventListener("change", function () { f.from = from.value; state.page = 1; loadDocs(); }); + to.addEventListener("change", function () { f.to = to.value; state.page = 1; loadDocs(); }); + wrap.appendChild(from); + wrap.appendChild(to); + } else if (m.type === "boolean") { + var sel = document.createElement("select"); + addOption(sel, "", "—"); + addOption(sel, "true", "oui"); + addOption(sel, "false", "non"); + sel.value = f.value || ""; + sel.addEventListener("change", function () { f.value = sel.value; state.page = 1; loadDocs(); }); + wrap.appendChild(sel); + } else if (m.type === "enum") { + var es = document.createElement("select"); + addOption(es, "", "—"); + ((state.config.enums || {})[m.name] || []).forEach(function (v) { addOption(es, v, v); }); + es.value = f.value || ""; + es.addEventListener("change", function () { f.value = es.value; state.page = 1; loadDocs(); }); + wrap.appendChild(es); + } else { + var inp = document.createElement("input"); + inp.type = "text"; + inp.placeholder = "contient…"; + inp.value = f.value || ""; + var timer = null; + inp.addEventListener("input", function () { + if (timer) clearTimeout(timer); + timer = setTimeout(function () { f.value = inp.value; state.page = 1; loadDocs(); }, 300); + }); + wrap.appendChild(inp); + } + return wrap; +} + +function renderRows() { + var tbody = $("doctable").tBodies[0]; + tbody.innerHTML = ""; + state.docs.forEach(function (doc) { + var tr = document.createElement("tr"); + tr.className = "clickable"; + var tdcb = document.createElement("td"); + var cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!state.selected[doc.id]; + cb.addEventListener("click", function (ev) { ev.stopPropagation(); }); + cb.addEventListener("change", function () { + if (cb.checked) state.selected[doc.id] = true; + else delete state.selected[doc.id]; + renderBulkbar(); + }); + tdcb.appendChild(cb); + tr.appendChild(tdcb); + state.columns.forEach(function (name) { + var m = fieldMeta(name); + var td = document.createElement("td"); + if (name === "status") { + var b = document.createElement("span"); + b.className = "badge st-" + doc.status; + b.textContent = doc.status; + td.appendChild(b); + } else if (m && m.type === "date") { + td.textContent = fmtDate(doc[name]); + } else { + td.textContent = fmtVal(doc[name]); + } + tr.appendChild(td); + }); + tr.addEventListener("click", function (ev) { + if (ev.target.tagName === "INPUT") return; + window.location.href = "/document.html?id=" + doc.id; + }); + tbody.appendChild(tr); + }); +} + +function renderPager() { + var pager = $("pager"); + pager.innerHTML = ""; + var prev = document.createElement("button"); + prev.type = "button"; + prev.className = "btn secondary"; + prev.textContent = "‹ Précédent"; + prev.disabled = state.page <= 1; + prev.addEventListener("click", function () { state.page--; loadDocs(); }); + var info = document.createElement("span"); + info.textContent = state.total + " document(s) — page " + state.page + " / " + Math.max(1, state.totalPages); + var next = document.createElement("button"); + next.type = "button"; + next.className = "btn secondary"; + next.textContent = "Suivant ›"; + next.disabled = state.page >= state.totalPages; + next.addEventListener("click", function () { state.page++; loadDocs(); }); + pager.appendChild(prev); + pager.appendChild(info); + pager.appendChild(next); +} + +function bindBulk() { + var sel = $("bulkfield"); + sel.innerHTML = ""; + state.config.fields.forEach(function (f) { addOption(sel, f.name, f.name); }); + sel.addEventListener("change", rebuildBulkValue); + $("bulkapply").addEventListener("click", applyBulk); + $("bulkclear").addEventListener("click", function () { + state.selected = {}; + renderRows(); + renderBulkbar(); + }); + $("bulkall").addEventListener("click", selectAllFiltered); + rebuildBulkValue(); +} + +function bulkField() { + var name = $("bulkfield").value; + for (var i = 0; i < state.config.fields.length; i++) { + if (state.config.fields[i].name === name) return state.config.fields[i]; + } + return null; +} + +function rebuildBulkValue() { + var holder = $("bulkvalue"); + holder.innerHTML = ""; + var f = bulkField(); + if (!f) return; + holder.appendChild(buildValueControl(f, (state.config.enums || {})[f.name] || [], null)); +} + +async function selectAllFiltered() { + var page = 1; + for (;;) { + var q = "/api/collections/documents/records?page=" + page + "&perPage=200&fields=id" + buildFilterQuery(); + var r = await api("GET", q); + if (r.status !== 200) break; + (r.data.items || []).forEach(function (d) { state.selected[d.id] = true; }); + if (page >= (r.data.totalPages || 1)) break; + page++; + } + renderRows(); + renderBulkbar(); +} + +async function applyBulk() { + var f = bulkField(); + if (!f) { alert("Choisissez un champ."); return; } + var el = $("bulkvalue").firstChild; + var value; + try { + var r = readValueControl(f, el); + if (r.clear) { + if (f.mandatory) { alert("Ce champ est obligatoire : une valeur est requise."); return; } + value = null; + } else { + value = r.value; + } + } catch (e) { alert(e.message); return; } + var ids = Object.keys(state.selected); + if (!ids.length) { alert("Aucun document sélectionné."); return; } + $("bulkresult").textContent = "…"; + var ok = 0; + var failed = []; + for (var i = 0; i < ids.length; i++) { + var body = {}; + body[f.name] = value; + var res = await api("PATCH", "/api/collections/documents/records/" + ids[i], body); + if (res.status === 200) ok++; + else failed.push(ids[i] + " : " + errMsg(res)); + } + $("bulkresult").textContent = ok + " document(s) modifié(s)" + + (failed.length ? ", " + failed.length + " échec(s) — " + failed[0] : ""); + state.selected = {}; + await loadCounts(); + await loadDocs(); +} + +function renderBulkbar() { + var n = Object.keys(state.selected).length; + $("bulkbar").hidden = n === 0; + $("bulkcount").textContent = n + " document(s) sélectionné(s)"; +} + +init(); diff --git a/archivist/pb_public/common.js b/archivist/pb_public/common.js new file mode 100644 index 0000000..c815d05 --- /dev/null +++ b/archivist/pb_public/common.js @@ -0,0 +1,155 @@ +"use strict"; + +// Shared helpers of the review-dashboard pages (US-30 to US-36). +// The pages carry no data; everything goes through authenticated API +// calls, the admin session cookie riding along. + +function redirectLogin() { + window.location.href = "/_/"; +} + +function storedToken() { + try { + return localStorage.getItem("archivist.token") || ""; + } catch (e) { + return ""; + } +} + +// api performs one authenticated call. A stale stored token is dropped +// and the call retried once; an unauthenticated visitor is redirected to +// the admin UI (US-30). +async function api(method, path, body) { + var res = await rawFetch(method, path, body, storedToken()); + if (res.status === 401 && storedToken()) { + try { localStorage.removeItem("archivist.token"); } catch (e) {} + res = await rawFetch(method, path, body, ""); + } + if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) { + redirectLogin(); + } + return res; +} + +async function rawFetch(method, path, body, token) { + var opts = { method: method, headers: {} }; + if (token) { + opts.headers["Authorization"] = token; + } + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + var res = await fetch(path, opts); + var data = null; + try { data = await res.json(); } catch (e) { data = null; } + return { status: res.status, data: data }; +} + +function errMsg(res) { + if (res && res.data && res.data.message) return res.data.message; + if (res && res.data) { + var s = JSON.stringify(res.data); + if (s && s !== "{}") return s.slice(0, 300); + } + return "status " + (res ? res.status : "?"); +} + +// initTheme wires the light/dark toggle: the class on is set by +// the inline head script before first paint, the button persists the +// manual choice (arch.md 7.5). +function initTheme(btnId) { + var btn = document.getElementById(btnId); + if (!btn) return; + btn.addEventListener("click", function () { + var dark = document.documentElement.classList.toggle("dark"); + document.documentElement.classList.toggle("light", !dark); + try { + localStorage.setItem("archivist.theme", dark ? "dark" : "light"); + } catch (e) {} + }); +} + +function fmtVal(v) { + if (v === null || v === undefined || v === "") return "—"; + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "string") return v; + return String(v); +} + +function fmtDate(v) { + if (!v) return "—"; + return String(v).replace("T", " ").slice(0, 16); +} + +function pbDateInput(v) { + if (!v) return ""; + return String(v).slice(0, 10); +} + +function addOption(sel, value, label) { + var o = document.createElement("option"); + o.value = value; + o.textContent = label; + sel.appendChild(o); +} + +// buildValueControl builds the typed control of one field (US-33 bulk +// edit, US-34 form, US-32 filters). +function buildValueControl(meta, vocab, value) { + var el; + var s = (value === null || value === undefined) ? "" : String(value); + switch (meta.type) { + case "number": + el = document.createElement("input"); + el.type = "number"; + el.step = "any"; + el.value = s; + break; + case "date": + el = document.createElement("input"); + el.type = "date"; + el.value = pbDateInput(s); + break; + case "boolean": + el = document.createElement("select"); + addOption(el, "", "—"); + addOption(el, "true", "oui"); + addOption(el, "false", "non"); + el.value = value === true ? "true" : (value === false ? "false" : ""); + break; + case "enum": + el = document.createElement("select"); + addOption(el, "", "—"); + (vocab || []).forEach(function (v) { addOption(el, v, v); }); + el.value = s; + break; + default: + el = document.createElement("input"); + el.type = "text"; + el.value = s; + } + el.dataset.field = meta.name; + return el; +} + +// readValueControl reads the control back: {clear:true} when empty, +// {value:v} otherwise. Throws on a malformed value. +function readValueControl(meta, el) { + var v = el.value; + switch (meta.type) { + case "number": + if (v === "") return { clear: true }; + if (isNaN(Number(v))) throw new Error(meta.name + " : nombre invalide"); + return { value: Number(v) }; + case "date": + if (v === "") return { clear: true }; + return { value: v }; + case "boolean": + if (v === "") return { clear: true }; + return { value: v === "true" }; + default: + if (v === "") return { clear: true }; + return { value: v }; + } +} diff --git a/archivist/pb_public/detail.js b/archivist/pb_public/detail.js new file mode 100644 index 0000000..8464020 --- /dev/null +++ b/archivist/pb_public/detail.js @@ -0,0 +1,294 @@ +"use strict"; + +// Document detail page (US-34 to US-36): PDF preview, typed form, +// pipeline proposals, live rendered-name dry-run, event history, and +// the three distinct exits - draft save, full correction, validation. + +var docId = new URLSearchParams(window.location.search).get("id"); + +var cfg = null; +var record = null; +var controls = {}; // field name -> control element +var originals = {}; // field name -> control value at load +var dirty = {}; // field name -> boolean +var dryErrors = []; +var dryTimer = null; + +function $(id) { return document.getElementById(id); } + +async function init() { + if (!docId) { window.location.href = "/"; return; } + var rc = await api("GET", "/api/archivist/config"); + if (rc.status !== 200) return; + var rr = await api("GET", "/api/collections/documents/records/" + docId); + if (rr.status !== 200) { alert("Document introuvable."); window.location.href = "/"; return; } + cfg = rc.data; + record = rr.data; + + initTheme("themeBtn"); + renderHead(); + var evs = await loadEvents(); + renderImportErrors(evs); + renderHistory(evs); + renderForm(); + bindActions(); + if (record.status === "processed") $("validateBtn").hidden = false; + $("targetname").textContent = "Fichier actuel : " + (record.file_key || "—"); + loadProposals(); +} + +async function loadEvents() { + var q = "/api/collections/document_events/records?perPage=200&sort=timestamp&filter=" + + encodeURIComponent("(document='" + docId + "')"); + var r = await api("GET", q); + return (r.status === 200 && r.data.items) ? r.data.items : []; +} + +function renderHead() { + document.title = "Archivist — " + (record.file_key || docId); + $("docname").textContent = record.file_key || ("Document " + docId); + var st = $("docstatus"); + st.textContent = record.status || ""; + st.className = "badge st-" + record.status; + $("pdf").src = "/api/archivist/documents/" + docId + "/pdf"; +} + +function payloadOf(ev) { + var p = ev.payload; + if (typeof p === "string") { + try { p = JSON.parse(p); } catch (e) { p = {}; } + } + return p || {}; +} + +function renderImportErrors(evs) { + var box = $("importerrors"); + for (var i = 0; i < evs.length; i++) { + var p = payloadOf(evs[i]); + if (evs[i].event_type === "imported" && p.errors && p.errors.length) { + box.hidden = false; + box.innerHTML = ""; + var head = document.createElement("div"); + head.textContent = "Échec de la classification à l'import :"; + box.appendChild(head); + var ul = document.createElement("ul"); + p.errors.forEach(function (e) { + var li = document.createElement("li"); + li.textContent = e; + ul.appendChild(li); + }); + box.appendChild(ul); + return; + } + } + box.hidden = true; +} + +function renderForm() { + var form = $("docform"); + form.innerHTML = ""; + cfg.fields.forEach(function (f) { + var row = document.createElement("div"); + row.className = "fieldrow"; + var label = document.createElement("label"); + label.textContent = f.name + (f.mandatory ? " *" : ""); + var ctl = buildValueControl(f, (cfg.enums || {})[f.name] || [], record[f.name]); + controls[f.name] = ctl; + originals[f.name] = ctl.value; + dirty[f.name] = false; + ctl.addEventListener("input", function () { fieldChanged(f); }); + ctl.addEventListener("change", function () { fieldChanged(f); }); + var prop = document.createElement("div"); + prop.className = "proposal"; + prop.hidden = true; + prop.id = "prop-" + f.name; + row.appendChild(label); + row.appendChild(ctl); + row.appendChild(prop); + form.appendChild(row); + }); +} + +function fieldChanged(f) { + dirty[f.name] = controls[f.name].value !== originals[f.name]; + if (dryTimer) clearTimeout(dryTimer); + dryTimer = setTimeout(runDryRun, 400); +} + +// dirtyBody collects the modified fields for a dry-run, a draft save or +// a full correction; empty or null clears the field. +function dirtyBody() { + var body = {}; + var any = false; + for (var i = 0; i < cfg.fields.length; i++) { + var f = cfg.fields[i]; + if (!dirty[f.name]) continue; + var r; + try { + r = readValueControl(f, controls[f.name]); + } catch (e) { + return { error: e.message }; + } + body[f.name] = r.clear ? null : r.value; + any = true; + } + return { body: body, any: any }; +} + +async function runDryRun() { + var d = dirtyBody(); + if (d.error) { renderDryErrors([d.error]); return; } + var res = await api("POST", "/api/archivist/documents/" + docId + "/dry-run", d.body); + if (res.status !== 200) { renderDryErrors(["dry-run : " + errMsg(res)]); return; } + dryErrors = res.data.errors || []; + renderDryErrors(dryErrors); + if (dryErrors.length) { + $("targetname").textContent = ""; + } else { + $("targetname").textContent = "Nom cible : files/" + (res.data.rendered || ""); + } +} + +function renderDryErrors(errs) { + var box = $("dryerrors"); + if (!errs || !errs.length) { + box.hidden = true; + box.innerHTML = ""; + $("correctBtn").disabled = false; + return; + } + box.hidden = false; + box.innerHTML = ""; + errs.forEach(function (e) { + var div = document.createElement("div"); + div.textContent = e; + box.appendChild(div); + }); + $("correctBtn").disabled = true; // US-35: no full correction while errors remain +} + +// loadProposals runs the classification dry-run (no body) and offers, +// under each field whose proposed value differs from the current one, +// the pipeline value with one-click adoption (US-35). +async function loadProposals() { + var res = await api("POST", "/api/archivist/documents/" + docId + "/dry-run"); + if (res.status !== 200) return; + var props = res.data.fields || {}; + cfg.fields.forEach(function (f) { + var p = props[f.name]; + if (p === null || p === undefined) return; + var raw; + switch (f.type) { + case "date": raw = pbDateInput(p); break; + case "boolean": raw = p ? "true" : "false"; break; + default: raw = String(p); + } + if (raw === originals[f.name]) return; + var slot = document.getElementById("prop-" + f.name); + slot.hidden = false; + slot.innerHTML = ""; + var span = document.createElement("span"); + span.textContent = "Pipeline : " + fmtVal(p); + slot.appendChild(span); + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn secondary"; + btn.textContent = "Adopter"; + btn.addEventListener("click", function () { + controls[f.name].value = raw; + slot.hidden = true; + fieldChanged(f); + }); + slot.appendChild(btn); + }); +} + +function bindActions() { + $("draftBtn").addEventListener("click", draftSave); + $("correctBtn").addEventListener("click", fullCorrect); + $("validateBtn").addEventListener("click", validate); +} + +// Draft save: a standard PATCH, no re-filing, no status change (US-22). +async function draftSave() { + var d = dirtyBody(); + if (d.error) { renderDryErrors([d.error]); return; } + if (!d.any) return; + var res = await api("PATCH", "/api/collections/documents/records/" + docId, d.body); + if (res.status !== 200) { renderDryErrors([errMsg(res)]); return; } + window.location.reload(); +} + +// Full correction: re-filing and validation in one step (US-36). +async function fullCorrect() { + if (dryErrors.length) return; + var d = dirtyBody(); + if (d.error) { renderDryErrors([d.error]); return; } + var res = await api("POST", "/api/archivist/documents/" + docId + "/correct", d.body); + if (res.status === 200) { window.location.reload(); return; } + renderDryErrors((res.data && res.data.errors && res.data.errors.length) + ? res.data.errors : [errMsg(res)]); +} + +// Simple validation of a processed document (US-23). +async function validate() { + var res = await api("PATCH", "/api/collections/documents/records/" + docId, { status: "validated" }); + if (res.status === 200) { window.location.reload(); return; } + renderDryErrors([errMsg(res)]); +} + +function renderHistory(evs) { + var hist = $("history"); + hist.innerHTML = ""; + evs.forEach(function (ev) { + var p = payloadOf(ev); + var item = document.createElement("div"); + item.className = "hist"; + var head = document.createElement("div"); + head.className = "head"; + var b = document.createElement("span"); + b.className = "badge"; + b.textContent = ev.event_type; + head.appendChild(b); + var time = document.createElement("time"); + time.textContent = fmtDate(ev.timestamp); + head.appendChild(time); + item.appendChild(head); + if (p.errors && p.errors.length) { + var ul = document.createElement("ul"); + p.errors.forEach(function (e) { + var li = document.createElement("li"); + li.textContent = e; + ul.appendChild(li); + }); + item.appendChild(ul); + } + var before = p.before || {}; + var after = p.after || {}; + var keys = {}; + Object.keys(before).forEach(function (k) { keys[k] = true; }); + Object.keys(after).forEach(function (k) { keys[k] = true; }); + Object.keys(keys).forEach(function (k) { + var line = document.createElement("div"); + line.className = "diff"; + var ks = document.createElement("span"); + ks.className = "k"; + ks.textContent = k + " : "; + var bv = document.createElement("span"); + bv.textContent = fmtVal(before[k]); + var ar = document.createElement("span"); + ar.className = "arrow"; + ar.textContent = " → "; + var av = document.createElement("span"); + av.textContent = fmtVal(after[k]); + line.appendChild(ks); + line.appendChild(bv); + line.appendChild(ar); + line.appendChild(av); + item.appendChild(line); + }); + hist.appendChild(item); + }); +} + +init(); diff --git a/archivist/pb_public/document.html b/archivist/pb_public/document.html new file mode 100644 index 0000000..6c2c171 --- /dev/null +++ b/archivist/pb_public/document.html @@ -0,0 +1,51 @@ + + +
+ + +