feat(sources): upload tarball/zipfile archives as sources — unpack, scan, and replace in place
Phase 49 (owner request, chat 2026-08-28: "The git sources page should remove local directory and should instead accept a tarball or zipfile upload which it will unpack and scan … reuploading the same tarball should not create a new folder, but should unpack and overwrite the previously unpacked content" — design confirmed in the same conversation): * POST /api/git-sources/upload (admin-only, require_admin): accepts .tar/.tar.gz/.tgz/.zip, streams it with the BOR_UPLOAD_MAX_MB cap (bounds BOTH the compressed upload and the total extracted bytes — zip-bomb guard), safely unpacks (absolute/traversal/symlink/hardlink escape and device/FIFO members rejected), and atomically swaps the content in over BOR_UPLOAD_DIR/<name>/ (name = filename minus the archive suffix — no missing window, a failed upload never touches the existing folder/row/KB). The git_sources row is upserted by path (kind='local', no duplicates, added_at preserved), the models are checked fail-fast (503 sanitized when down — the folder/row stay committed and the next sync/re-upload retries idempotently), and the source is scanned synchronously in the request (single-source import_sources prune=True + change-gated KB overview), answering 200 with the sync-style counts. One upload at a time (409); the request session is released before the scan so a concurrent TRUNCATE cannot deadlock against it. * app/rag/archive_upload.py: ArchiveUploadError, ARCHIVE_SUFFIXES, archive_source_name (safe-name derivation), unpack_archive (guarded zip/tar extraction with the extracted-byte cap, no partial state), swap_in (atomic replace with restore-on-failure) — fully unit-tested. * app/config.py + .env.example: BOR_UPLOAD_DIR (default ~/bor-sources/uploads, deliberately separate from the git checkouts) and BOR_UPLOAD_MAX_MB (default 512; a validator fails loud at startup on <= 0). * python-multipart added to the dependencies — FastAPI's required multipart parser (an A2 implementation detail, phase locked decision). * The Sources page: the phase-38 "Add a local directory" form is removed; #archive-upload-form takes its place (labeled file input, "Upload & scan" button, the §7.4 never-stale lifecycle, inline role=alert error, role=status count line); hint + table caption updated. The POST /api/git-sources kind=local API contract is UNCHANGED — a plain directory is still registrable via the API, and existing Local rows list/remove/sync exactly as before. * The phase-38 story E2E (test_local_directory_sources.py) is rewritten API-driven — the form it drove is gone; its acceptance stands. * The story E2E (test_archive_upload_sources.py): the swap, upload→scan→list (the deterministic "Uploading…" in-flight state, the Local row, /api/docs + the RAG catalog), same-filename re-upload (in-place replace, prune, no duplicate row, v2-only folder), the 422 inline error + recovery (the form is not wedged), and the anonymous gate + 403. * README: the archive-upload section (formats, naming rule, in-place replace, both new settings), the local-directory form removal noted, config reference rows for BOR_UPLOAD_DIR / BOR_UPLOAD_MAX_MB. Gates: unit+integration green, app/ coverage 99%, the story E2E green in isolation, the regression suites (git sources admin, local directory sources, sync button, import documents, nav rename, smoke, shared header) green in isolation, ruff + pyright clean. Note: per this phase's file-level staging, frontend/assets/styles.css also carries the small same-day in-flight owner rework already in the working tree (the .sign-in-mobile companion rule for the phase-48 mobile sign-in copy); the phase-49 change is the upload form's block.
This commit is contained in:
+136
-42
@@ -1,10 +1,12 @@
|
||||
/* Brain of Reese — Git sources admin page (phase 35, task 04;
|
||||
* local directories, phase 38 task 04).
|
||||
* archive uploads, phase 49 task 03).
|
||||
*
|
||||
* The page module for /git-sources.html: the admin-only manager for the
|
||||
* stored source list (git-sources table, phase 35 tasks 01/02) — git
|
||||
* repo URLs (kind "git") and existing local directories (kind
|
||||
* "local", phase 38).
|
||||
* repo URLs (kind "git") and uploaded archives unpacked under
|
||||
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
|
||||
* local-directory form is gone — the kind=local API POST is
|
||||
* unchanged, the page just no longer offers it).
|
||||
* This module is the single owner of the page's behaviour:
|
||||
*
|
||||
* • boot — initSharedHeader() (one cached whoami, shared with the
|
||||
@@ -22,17 +24,30 @@
|
||||
* credentials; phase 32's masking discipline). Non-2xx or a
|
||||
* network failure renders the role="alert" load error with a
|
||||
* retry button — never a stuck page.
|
||||
* • add — #git-source-form submit → POST /api/git-sources {url};
|
||||
* #local-source-form submit → POST /api/git-sources
|
||||
* {kind: "local", path} (phase 38). ONE §7.4 never-stale
|
||||
* lifecycle for both (wireAddForm): the button disables +
|
||||
* relabels "Adding…" while the request is out, re-enables on
|
||||
* success AND failure. 201 clears the input, reloads the list,
|
||||
* and focuses the new row's Remove button (a11y); a failure (409
|
||||
* duplicate, 422 validation) shows the server detail inline under
|
||||
* the form (role="alert", 422 shape-aware like the tuning forms)
|
||||
* and keeps the input — the instruction survives. Local 422/409
|
||||
* details name the path (paths are not secrets, unlike URLs).
|
||||
* • add — #git-source-form submit → POST /api/git-sources {url}.
|
||||
* The §7.4 never-stale lifecycle (wireAddForm): the button
|
||||
* disables + relabels "Adding…" while the request is out,
|
||||
* re-enables on success AND failure. 201 clears the input,
|
||||
* reloads the list, and focuses the new row's Remove button
|
||||
* (a11y); a failure (409 duplicate, 422 validation) shows the
|
||||
* server detail inline under the form (role="alert", 422
|
||||
* shape-aware like the tuning forms) and keeps the input — the
|
||||
* instruction survives. 409/422 details are fixed generic strings
|
||||
* (credential safety — the URL is never echoed).
|
||||
* • upload — #archive-upload-form submit (phase 49) → POST
|
||||
* /api/git-sources/upload with a FormData file (NO manual
|
||||
* Content-Type — the browser sets the multipart boundary). The
|
||||
* SAME §7.4 never-stale lifecycle: the button disables +
|
||||
* relabels "Uploading…" while the request is out and is restored
|
||||
* on success AND failure. 200 clears the file input, shows the
|
||||
* sync-style count line ("2 added · 1 pruned" — fmtUploadResult,
|
||||
* sources.js's fmtSyncResult convention) in the role=status
|
||||
* result line, announces "Archive uploaded: …" and reloads the
|
||||
* list (the new/updated row lands with the Local badge; a
|
||||
* re-upload simply refreshes the row — no duplicate). Non-2xx
|
||||
* inlines the server detail (422 format/name/traversal, 413
|
||||
* size, 409 busy — the messages are already user-safe) and KEEPS
|
||||
* the file selection — the fix is one re-pick, not a re-type.
|
||||
* • remove — a row's Remove button asks window.confirm first
|
||||
* (removal prunes the documents only on the NEXT sync — the
|
||||
* confirm says so). Cancel → nothing; ok → the row button
|
||||
@@ -45,9 +60,12 @@
|
||||
* aria-live=polite): the screen-reader confirmation for loads,
|
||||
* adds, and removals.
|
||||
*
|
||||
* Scope boundary (phase locked decisions): adding or removing a repo
|
||||
* does NOT clone, import, or prune — the sync service (server-side)
|
||||
* performs that; the page's hint box says so.
|
||||
* Scope boundary (phase locked decisions): adding a git repo or
|
||||
* removing a source does NOT clone, import, or prune — the sync
|
||||
* service (server-side) performs that; the page's hint box says so.
|
||||
* The phase-49 upload is the exception: it unpacks and scans the
|
||||
* single source in place, and its response counts render as the
|
||||
* result line.
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
@@ -64,12 +82,14 @@ const formEl = document.querySelector("#git-source-form");
|
||||
const urlInput = document.querySelector("#git-source-url");
|
||||
const addBtn = document.querySelector("#git-source-add");
|
||||
const addError = document.querySelector("#git-source-error");
|
||||
/* Phase 38: the second add form — "Local directory" (same element
|
||||
contract as the git form, own ids). */
|
||||
const localFormEl = document.querySelector("#local-source-form");
|
||||
const pathInput = document.querySelector("#local-source-path");
|
||||
const localAddBtn = document.querySelector("#local-source-add");
|
||||
const localAddError = document.querySelector("#local-source-error");
|
||||
/* Phase 49: the archive upload form (replaces the phase-38 local
|
||||
directory form — same card, a file input instead of a path input).
|
||||
The response counts render in the role=status result line. */
|
||||
const uploadFormEl = document.querySelector("#archive-upload-form");
|
||||
const uploadFileInput = document.querySelector("#archive-upload-file");
|
||||
const uploadBtn = document.querySelector("#archive-upload-btn");
|
||||
const uploadError = document.querySelector("#archive-upload-error");
|
||||
const uploadResult = document.querySelector("#archive-upload-result");
|
||||
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
||||
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
||||
const retryBtn = document.querySelector("#git-sources-retry");
|
||||
@@ -255,18 +275,16 @@ async function removeSource(s, btn, rowError, kindLabel) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- add (POST /api/git-sources) — both forms, one lifecycle
|
||||
* (the local form is phase 38) ----------
|
||||
* The git form posts {url}; the local form posts {kind:"local",path}.
|
||||
* wireAddForm gives both the §7.4 never-stale lifecycle: while the
|
||||
* request is out the button disables + relabels "Adding…" and
|
||||
/* ---------- add (POST /api/git-sources) — the git form ----------
|
||||
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
|
||||
* the request is out the button disables + relabels "Adding…" and
|
||||
* re-enables (idle label restored) on success AND failure. 201 clears
|
||||
* the input, reloads the list, and focuses the new row's Remove button
|
||||
* (a11y); a failure (409 duplicate, 422 validation) shows the server
|
||||
* detail inline under the form (role="alert", 422 shape-aware via
|
||||
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
|
||||
* Git 409/422 details are fixed generic strings (credential safety);
|
||||
* local details name the path (not a secret). */
|
||||
* 409/422 details are fixed generic strings (credential safety — the
|
||||
* URL is never echoed). */
|
||||
function wireAddForm(opts) {
|
||||
const { form, input, btn, error } = opts;
|
||||
if (!form || !input || !btn) return;
|
||||
@@ -336,18 +354,94 @@ wireAddForm({
|
||||
idleLabel: "Add source",
|
||||
});
|
||||
|
||||
wireAddForm({
|
||||
form: localFormEl,
|
||||
input: pathInput,
|
||||
btn: localAddBtn,
|
||||
error: localAddError,
|
||||
body: (path) => ({ kind: "local", path }),
|
||||
emptyMessage: "Enter a directory path to add.",
|
||||
failMessage: "Could not add the local directory — try again.",
|
||||
networkMessage: "Could not add the local directory — is the app reachable?",
|
||||
addedMessage: "Local source added.",
|
||||
idleLabel: "Add directory",
|
||||
});
|
||||
/* ---------- upload (POST /api/git-sources/upload) — phase 49 -------
|
||||
* The archive upload form: the file input's selection is posted as
|
||||
* FormData (the browser sets the multipart boundary — no manual
|
||||
* Content-Type). §7.4 never-stale: "Uploading…" while in flight,
|
||||
* restored in the finally block on success AND failure. 200 → the
|
||||
* input clears, the sync-style counts land in the role=status result
|
||||
* line, the announcer confirms, and loadSources() re-renders the row
|
||||
* (Local badge; a re-upload refreshes the existing row — no
|
||||
* duplicate). Non-2xx → the server detail inline (role=alert; 422
|
||||
* format/name/traversal, 413 size, 409 busy — user-safe as-is) with
|
||||
* the file selection KEPT; network failure → the fixed line. */
|
||||
|
||||
/* The success line's text — the sync-result shape (sources.js's
|
||||
fmtSyncResult convention): "N added" always leads, then updated /
|
||||
unchanged / pruned — zero parts omitted (unchanged is shown
|
||||
when nothing was added or updated). */
|
||||
function fmtUploadResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
const updated = d.updated || 0;
|
||||
const parts = [`${added} added`];
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||
parts.push(`${d.unchanged || 0} unchanged`);
|
||||
}
|
||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||
uploadFormEl.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
// Client-side no-file check (the input is `required` too — the
|
||||
// browser's native prompt is the first line, this one the second).
|
||||
const file = uploadFileInput.files && uploadFileInput.files[0];
|
||||
if (!file) {
|
||||
if (uploadError) {
|
||||
uploadError.textContent = "Choose an archive file to upload.";
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (uploadError) uploadError.hidden = true;
|
||||
if (uploadResult) uploadResult.hidden = true; // a new attempt starts clean
|
||||
uploadBtn.disabled = true; // §7.4: one upload per click
|
||||
uploadBtn.textContent = "Uploading…";
|
||||
try {
|
||||
// Multipart from the form itself (the file input's name is
|
||||
// "file") — the browser sets the boundary; NO manual
|
||||
// Content-Type header.
|
||||
const r = await fetch("/api/git-sources/upload", {
|
||||
method: "POST",
|
||||
body: new FormData(uploadFormEl),
|
||||
});
|
||||
if (r.ok) {
|
||||
let data = {};
|
||||
try {
|
||||
data = await r.json();
|
||||
} catch {
|
||||
/* the body is advisory — the counts line degrades gracefully */
|
||||
}
|
||||
uploadFileInput.value = ""; // 200: the archive is unpacked + scanned
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(data);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
announce(`Archive uploaded: ${data.source || file.name}.`);
|
||||
await loadSources(); // the new/updated row lands (Local badge)
|
||||
return;
|
||||
}
|
||||
// 422 (format/name/traversal), 413 (size), 409 (busy): the server
|
||||
// detail inline, the file selection KEPT — the fix is one
|
||||
// re-pick, not a re-type.
|
||||
if (uploadError) {
|
||||
uploadError.textContent = await apiDetail(r, "Could not upload the archive — try again.");
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
if (uploadError) {
|
||||
uploadError.textContent = "Could not upload the archive — is the app reachable?";
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
uploadBtn.disabled = false; // never stale — success OR failure
|
||||
uploadBtn.textContent = "Upload & scan";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* After a successful add, focus the new row's Remove button so the
|
||||
keyboard/screen-reader user lands where the new data is. The 201
|
||||
|
||||
+108
-32
@@ -335,9 +335,10 @@ html::after {
|
||||
.auth-link:disabled { opacity: 0.6; cursor: wait; }
|
||||
.auth-link svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* Mobile-only sign-out copy: hidden on desktop, revealed inside
|
||||
the hamburger dropdown on mobile (phase 46 UX revision). */
|
||||
.sign-out-mobile {
|
||||
/* Mobile-only sign-out / sign-in copies: hidden on desktop,
|
||||
revealed inside the hamburger dropdown on mobile (phase 46 UX revision). */
|
||||
.sign-out-mobile,
|
||||
.sign-in-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1384,14 +1385,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
|
||||
/* Add forms — the tuning form's surface as a single row: visible
|
||||
label + mono location input (git URLs may embed credentials, local
|
||||
paths may contain anything, so both inputs are mono) + the brand
|
||||
button; wraps to a column at narrow widths (the <=640px block
|
||||
below). Phase 38: the "Local directory" form (#local-source-form)
|
||||
reuses the git form's rules VERBATIM — one form language for both
|
||||
kinds. */
|
||||
label + mono location input (git URLs may embed credentials, so the
|
||||
input is mono) + the brand button; wraps to a column at narrow
|
||||
widths (the <=640px block below). Phase 49: the archive upload form
|
||||
(#archive-upload-form, replacing the phase-38 local-directory form)
|
||||
reuses the git form's card + button rules VERBATIM — one form
|
||||
language for both; its file input carries its own rules below. */
|
||||
#git-source-form,
|
||||
#local-source-form {
|
||||
#archive-upload-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -1403,11 +1404,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
#git-source-form:focus-within,
|
||||
#local-source-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#archive-upload-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#git-source-form > label,
|
||||
#local-source-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||
#git-source-url,
|
||||
#local-source-path {
|
||||
#archive-upload-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||
#git-source-url {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
min-height: 44px;
|
||||
@@ -1419,12 +1419,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
#git-source-url::placeholder,
|
||||
#local-source-path::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible,
|
||||
#local-source-path:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-url::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-add,
|
||||
#local-source-add {
|
||||
#archive-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1439,9 +1437,58 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
cursor: pointer;
|
||||
}
|
||||
#git-source-add:hover:not(:disabled),
|
||||
#local-source-add:hover:not(:disabled) { background: #7d88f5; }
|
||||
#archive-upload-btn:hover:not(:disabled) { background: #7d88f5; }
|
||||
#git-source-add:disabled,
|
||||
#local-source-add:disabled { opacity: 0.6; cursor: wait; }
|
||||
#archive-upload-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* Phase 49: the upload form's file control — mono-ish, on-surface, a
|
||||
>=44px touch target, :focus-visible via the global 3px outline rule
|
||||
(offset zeroed + brand border, exactly like the git URL input). The
|
||||
chosen filename renders in mono; the selector button keeps a plain
|
||||
surface chip. */
|
||||
#archive-upload-file {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
min-height: 44px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
#archive-upload-file:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#archive-upload-file::file-selector-button {
|
||||
min-height: 34px;
|
||||
margin-right: 0.6rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink); /* 13.8:1 on surface */
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
#archive-upload-file::file-selector-button:hover { border-color: var(--brand); color: var(--brand-ink); }
|
||||
|
||||
/* The upload's success line (role=status): the sync-result shape
|
||||
("2 added · 1 pruned") — ink-soft on surface (>=4.5:1), the dashed
|
||||
hint-box border marks it as a result, not an error; it drops onto
|
||||
its own row under the input like the error line. */
|
||||
#archive-upload-result {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The add form's inline error (role=alert): the err pair (9.1:1);
|
||||
flex-basis 100% drops it onto its own row under the input. */
|
||||
@@ -1829,7 +1876,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-ink); /* #a5b4fc on --surface ≈8.7:1 */
|
||||
color: var(--brand-ink); /* #fca5a5 on --surface ≈9.0:1 */
|
||||
}
|
||||
.doc-summary-text {
|
||||
margin: 0;
|
||||
@@ -2187,10 +2234,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.auth-link { padding: 0.4rem 0.3rem; }
|
||||
.auth-label { display: none; }
|
||||
.auth-link svg { display: block; }
|
||||
/* Phase 46 UX revision: sign-out moves into the hamburger dropdown
|
||||
on mobile — hide the bar copy, show the dropdown copy. */
|
||||
/* Phase 46 UX revision: sign-out & sign-in move into the hamburger
|
||||
dropdown on mobile — hide the bar copies, show the dropdown copies. */
|
||||
.sign-out-mobile { display: inline-flex; }
|
||||
#sign-out-btn { display: none !important; }
|
||||
.sign-in-mobile { display: inline-flex; }
|
||||
#sign-in-link { display: none !important; }
|
||||
.nav-toggle { margin-left: auto; }
|
||||
/* Dropdown-style sign-out: full-width row, error palette, label visible. */
|
||||
#app-nav .sign-out-btn {
|
||||
@@ -2217,6 +2266,33 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#app-nav .sign-out-btn .auth-label { display: inline; }
|
||||
/* Dropdown-style sign-in: matches sign-out row styling. */
|
||||
#app-nav .sign-in-mobile {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--err-ink);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
min-height: 48px;
|
||||
}
|
||||
#app-nav .sign-in-mobile:hover {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
}
|
||||
#app-nav .sign-in-mobile svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#app-nav .sign-in-mobile .auth-label { display: inline; }
|
||||
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
|
||||
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
|
||||
.tune-btn { min-height: 44px; }
|
||||
@@ -2248,18 +2324,18 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-modal-meta { padding-inline: 0.9rem; }
|
||||
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
||||
.composer { padding: 0.5rem; }
|
||||
/* Phase 35 (phase 38: + the local directory form): the add forms
|
||||
stack like the other cards — label, full-width mono input,
|
||||
full-width button; the table wrapper's horizontal scroll already
|
||||
covers long URLs/paths. */
|
||||
/* Phase 35 (phase 49: + the archive upload form): the add forms
|
||||
stack like the other cards — label, full-width input, full-width
|
||||
button; the table wrapper's horizontal scroll already covers long
|
||||
URLs/paths. */
|
||||
#git-source-form,
|
||||
#local-source-form { flex-direction: column; align-items: stretch; }
|
||||
#archive-upload-form { flex-direction: column; align-items: stretch; }
|
||||
#git-source-form > label,
|
||||
#local-source-form > label { white-space: normal; }
|
||||
#archive-upload-form > label { white-space: normal; }
|
||||
#git-source-url,
|
||||
#local-source-path { min-width: 0; }
|
||||
#archive-upload-file { min-width: 0; }
|
||||
#git-source-add,
|
||||
#local-source-add { width: 100%; }
|
||||
#archive-upload-btn { width: 100%; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
/* Sync button goes icon-only on mobile; the label hides, aria-label
|
||||
|
||||
+28
-30
@@ -174,33 +174,29 @@
|
||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
|
||||
<!-- Phase 38: the second add form — "Local directory": an
|
||||
existing directory on the server (NOT a git repo), walked
|
||||
directly by Sync / import_docs. The SAME never-stale-button
|
||||
+ inline-error pattern as the git form (PLAN §7.4): the
|
||||
button disables + relabels "Adding…" while the POST is out
|
||||
and recovers on success AND failure; on success the input
|
||||
clears and the list re-fetches (the new row lands with the
|
||||
Local badge). A missing/relative path 422s with the path
|
||||
named inline (paths are not secrets, unlike git URLs). -->
|
||||
<form id="local-source-form">
|
||||
<label for="local-source-path">Add a local directory</label>
|
||||
<input
|
||||
id="local-source-path"
|
||||
name="path"
|
||||
type="text"
|
||||
maxlength="2000"
|
||||
autocomplete="off"
|
||||
placeholder="~/Notes"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="local-source-add">Add directory</button>
|
||||
<p class="git-source-error" id="local-source-error" role="alert" hidden></p>
|
||||
<!-- Phase 49 (owner permission 2026-08-28): the archive upload form
|
||||
replaces the phase-38 local-directory form — an uploaded
|
||||
.tar/.tar.gz/.tgz/.zip is unpacked under BOR_UPLOAD_DIR and
|
||||
scanned immediately; the same filename replaces the source in
|
||||
place (no new folder, no duplicate row). The file control is
|
||||
labeled (visible <label for=…> — WCAG input-label rule); the
|
||||
button runs the §7.4 never-stale lifecycle ("Uploading…"
|
||||
while the POST is out, restored on success AND failure);
|
||||
non-2xx shows the server detail inline (role=alert), 200
|
||||
shows the sync-style counts (role=status). -->
|
||||
<form id="archive-upload-form">
|
||||
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
|
||||
<input id="archive-upload-file" name="file" type="file"
|
||||
accept=".tar,.tar.gz,.tgz,.zip" required>
|
||||
<button type="submit" id="archive-upload-btn">Upload & scan</button>
|
||||
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
|
||||
<p class="git-source-result" id="archive-upload-result" role="status"
|
||||
aria-live="polite" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
||||
<table class="git-sources-table" id="git-sources-table">
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones and local directories it walks</caption>
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
@@ -219,14 +215,16 @@
|
||||
|
||||
<!-- Scope boundary (phase locked decision): adding/removing a
|
||||
source does NOT clone or prune — the Sync button performs
|
||||
that. The hint says so (phase 38: git + local together,
|
||||
files removed from a source pruned). -->
|
||||
that; the phase-49 upload is the exception (it unpacks and
|
||||
scans in place, and a same-name re-upload replaces the
|
||||
source in place). -->
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Sync clones/pulls the git repos and imports the local
|
||||
directories together (files removed from a source are
|
||||
pruned). Run the sync to clone/pull the git repos and import
|
||||
the local directories — removing a source
|
||||
prunes its documents from the index on the next sync.
|
||||
Uploads unpack and scan immediately — re-uploading the same
|
||||
filename replaces that source in place (no new folder, no
|
||||
duplicate row). The Sync button still imports the git
|
||||
checkouts and local directories together (files removed from
|
||||
a source are pruned) — removing a source prunes its documents
|
||||
from the index on the next sync.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Polite live region: the screen-reader confirmation for list
|
||||
|
||||
Reference in New Issue
Block a user