1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Compare commits

..

No commits in common. "d83faab8e6371f7fb1925de7306ba9e6c568e7a1" and "d3a78da766f39cb22c7528c40cbda58c74da9195" have entirely different histories.

14 changed files with 53 additions and 437 deletions

View file

@ -1323,128 +1323,6 @@ pub fn cascade_cached_bytes_after_delete(conn: &Connection, entry_id: i64) -> Re
Ok(())
}
/// Recalculates `cached_bytes` for every entry that shares a blob with any member of
/// `subtree_ids` and was archived after that member, treating the whole subtree as absent.
///
/// Unlike `cascade_cached_bytes_after_delete` (single-entry), this excludes **all** subtree IDs
/// from the EXISTS check in one SQL pass, so sibling entries don't falsely count each other as
/// "still there" during the recalculation.
///
/// Must be called before any subtree rows are deleted so the `entry_artifacts` JOIN resolves.
fn cascade_cached_bytes_after_subtree_delete(
conn: &Connection,
subtree_ids: &[i64],
) -> Result<()> {
if subtree_ids.is_empty() {
return Ok(());
}
// Build ?1,?2,…,?N — positional params can be re-referenced multiple times in one statement.
let ph: String = (1..=subtree_ids.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"UPDATE archived_entries
SET cached_bytes = (
SELECT COALESCE(SUM(b.byte_size), 0)
FROM entry_artifacts ea
JOIN blobs b ON b.id = ea.blob_id
WHERE ea.entry_id = archived_entries.id
AND ea.blob_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM entry_artifacts ea3
JOIN archived_entries e3 ON e3.id = ea3.entry_id
WHERE ea3.blob_id = ea.blob_id
AND e3.id NOT IN ({ph})
AND (e3.archived_at < archived_entries.archived_at
OR (e3.archived_at = archived_entries.archived_at
AND e3.id < archived_entries.id))
)
)
WHERE id NOT IN ({ph})
AND id IN (
SELECT DISTINCT ea2.entry_id
FROM entry_artifacts ea_sub
JOIN entry_artifacts ea2 ON ea2.blob_id = ea_sub.blob_id
JOIN archived_entries e_sub ON e_sub.id = ea_sub.entry_id
JOIN archived_entries e2 ON e2.id = ea2.entry_id
WHERE ea_sub.entry_id IN ({ph})
AND ea2.entry_id NOT IN ({ph})
AND (e2.archived_at > e_sub.archived_at
OR (e2.archived_at = e_sub.archived_at
AND e2.id > e_sub.id))
)"
);
conn.execute(&sql, rusqlite::params_from_iter(subtree_ids.iter()))?;
Ok(())
}
/// Deletes an entry and every descendant in its tree (identified by `root_entry_id = entry_id`).
///
/// Call order matters:
/// 1. Collect all subtree IDs while the rows still exist.
/// 2. Run `cascade_cached_bytes_after_subtree_delete` in one SQL pass that excludes the entire
/// subtree — necessary so sibling blobs don't falsely satisfy the EXISTS check for each other.
/// 3. NULL `archive_run_items.produced_entry_id` for every subtree entry (FK has no ON DELETE
/// action; would otherwise block with `PRAGMA foreign_keys = ON`).
/// 4. Delete children before root (self-referential `parent_entry_id` FK has no cascade).
/// 5. Delete the root entry; CASCADE handles `entry_artifacts`, `entry_tag_assignments`,
/// and `collection_entries` automatically.
///
/// Returns `Ok(false)` if no entry with `entry_uid` was found; `Ok(true)` otherwise.
/// Wrap in a transaction at the call site for atomicity.
pub fn delete_entry(conn: &Connection, entry_uid: &str) -> Result<bool> {
let entry_id: Option<i64> = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[entry_uid],
|row| row.get(0),
)
.optional()?;
let entry_id = match entry_id {
Some(id) => id,
None => return Ok(false),
};
// Collect the full subtree while rows still exist.
let subtree_ids: Vec<i64> = {
let mut stmt = conn.prepare(
"SELECT id FROM archived_entries WHERE root_entry_id = ?1",
)?;
stmt.query_map([entry_id], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?
};
// One-pass set-aware cascade: recalculate cached_bytes for all external entries that
// shared blobs with any subtree member, excluding every subtree ID simultaneously.
cascade_cached_bytes_after_subtree_delete(conn, &subtree_ids)?;
// Null the FK that has no ON DELETE action (covers root and all descendants).
conn.execute(
"UPDATE archive_run_items SET produced_entry_id = NULL
WHERE produced_entry_id IN (
SELECT id FROM archived_entries WHERE root_entry_id = ?1
)",
[entry_id],
)?;
// Children first — self-referential parent_entry_id FK has no cascade.
conn.execute(
"DELETE FROM archived_entries WHERE root_entry_id = ?1 AND id != ?1",
[entry_id],
)?;
// Root entry: CASCADE handles entry_artifacts, entry_tag_assignments, collection_entries.
conn.execute(
"DELETE FROM archived_entries WHERE id = ?1",
[entry_id],
)?;
Ok(true)
}
pub fn upsert_blob(conn: &Connection, blob: &BlobRecord) -> Result<i64> {
conn.execute(
"INSERT OR IGNORE INTO blobs (
@ -2827,108 +2705,4 @@ mod tests {
assert_eq!(updated.full_path, "/Natural-Science");
}
// ── delete_entry tests ────────────────────────────────────────────────────
/// Helper: attach a shared blob to an entry and return the blob id.
fn attach_blob(conn: &Connection, entry_id: i64, sha256: &str, byte_size: i64) -> i64 {
let blob = BlobRecord {
sha256: sha256.to_string(),
byte_size,
mime_type: None,
extension: None,
raw_relpath: format!("raw/{sha256}"),
};
let blob_id = upsert_blob(conn, &blob).unwrap();
add_entry_artifact(conn, &NewArtifact {
entry_id,
artifact_role: "main".to_string(),
storage_area: "raw".to_string(),
relpath: format!("raw/{sha256}"),
blob_id: Some(blob_id),
logical_path: None,
metadata_json: None,
}).unwrap();
blob_id
}
#[test]
fn delete_entry_returns_false_for_unknown_uid() {
let conn = conn();
assert!(!delete_entry(&conn, "entry_doesnotexist").unwrap());
}
#[test]
fn delete_entry_removes_root_and_child_rows() {
let conn = conn();
let root = create_entry_fixture(&conn, "private", None, None);
let child = create_entry_fixture(&conn, "private", Some(root.id), Some(root.id));
delete_entry(&conn, &root.entry_uid).unwrap();
let root_gone: Option<i64> = conn
.query_row("SELECT id FROM archived_entries WHERE id = ?1", [root.id], |r| r.get(0))
.optional().unwrap();
let child_gone: Option<i64> = conn
.query_row("SELECT id FROM archived_entries WHERE id = ?1", [child.id], |r| r.get(0))
.optional().unwrap();
assert!(root_gone.is_none(), "root should be gone");
assert!(child_gone.is_none(), "child should be gone");
}
#[test]
fn delete_entry_nulls_run_item_produced_entry_id() {
let conn = conn();
let user_id = ensure_default_user(&conn).unwrap();
let root = create_entry_fixture(&conn, "private", None, None);
let run = create_archive_run(&conn, user_id, 1).unwrap();
let item = create_archive_run_item(
&conn, run.id, None, 0, "https://example.com", None, "web", "page",
).unwrap();
complete_archive_run_item(&conn, item.id, root.id).unwrap();
delete_entry(&conn, &root.entry_uid).unwrap();
let produced: Option<i64> = conn
.query_row(
"SELECT produced_entry_id FROM archive_run_items WHERE id = ?1",
[item.id],
|r| r.get(0),
)
.unwrap();
assert!(produced.is_none(), "produced_entry_id should be NULL after delete");
}
#[test]
fn delete_entry_recalculates_cached_bytes_for_external_entries() {
// Scenario: root (id=N) and child (id=N+1) both own blob X (100 bytes).
// External (id=N+2, higher → newer by tiebreaker) also uses blob X.
// Before delete: external.cached_bytes = 100 (blob owned by root).
// After delete_entry(root): external.cached_bytes = 0 (no older entry remains).
let conn = conn();
let root = create_entry_fixture(&conn, "private", None, None);
let child = create_entry_fixture(&conn, "private", Some(root.id), Some(root.id));
let external = create_entry_fixture(&conn, "private", None, None);
// Attach the same blob to all three.
attach_blob(&conn, root.id, "blobx", 100);
attach_blob(&conn, child.id, "blobx", 100);
attach_blob(&conn, external.id, "blobx", 100);
// Compute external.cached_bytes before delete — root and child are older by id.
refresh_entry_cached_bytes(&conn, external.id).unwrap();
let before: i64 = conn
.query_row("SELECT cached_bytes FROM archived_entries WHERE id = ?1", [external.id], |r| r.get(0))
.unwrap();
assert_eq!(before, 100, "external should see blob as cached before delete");
delete_entry(&conn, &root.entry_uid).unwrap();
// external must still exist but with cached_bytes = 0.
let after: i64 = conn
.query_row("SELECT cached_bytes FROM archived_entries WHERE id = ?1", [external.id], |r| r.get(0))
.unwrap();
assert_eq!(after, 0, "cached_bytes must be 0 after whole subtree is deleted");
}
}

View file

@ -215,7 +215,7 @@ pub fn app_with_state(state: AppState) -> Router {
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid",
get(entry_detail).patch(patch_entry_handler).delete(delete_entry_handler),
get(entry_detail).patch(patch_entry_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
@ -615,25 +615,6 @@ async fn patch_entry_handler(
}
}
async fn delete_entry_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let mut conn = database::open_or_initialize(&mounted.archive_path)?;
// Transaction: if any step fails (cascade update, FK null, or delete), nothing is committed.
let tx = conn.transaction()?;
let found = database::delete_entry(&tx, &entry_uid)?;
tx.commit()?;
if found {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found("entry not found"))
}
}
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
@ -2977,72 +2958,4 @@ mod tests {
);
}
#[tokio::test]
async fn delete_entry_returns_204_and_entry_is_gone() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let entry = make_test_entry(&archive_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// Confirm the row is actually gone from the DB.
let conn = database::open_or_initialize(&archive_path).unwrap();
let exists: Option<i64> = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[&entry.entry_uid],
|r| r.get(0),
)
.optional()
.unwrap();
assert!(exists.is_none(), "entry row should be deleted");
}
#[tokio::test]
async fn delete_entry_returns_404_for_missing_entry() {
let dir = tempfile::tempdir().unwrap();
let (registry, _archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/archives/test/entries/entry_doesnotexist")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_entry_requires_auth() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let entry = make_test_entry(&archive_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
<script type="module" crossorigin src="/assets/index-DuR832Rs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oE_dvyrb.css">
</head>
<body>
<div id="root"></div>

View file

@ -16,7 +16,7 @@ import { displayPath } from './utils'
export const AuthContext = createContext(null);
const VIEWS = ['archive','tags','collections','runs','admin','settings']
const VIEWS = ['archive','runs','admin','tags','collections','settings']
const SETTINGS_TABS = ['profile','tokens','instance']
function parseLocation() {
@ -212,12 +212,6 @@ export default function App() {
)
}, [])
const handleEntryDeleted = useCallback((entryUid) => {
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
setSelectedEntryUid(prev => prev === entryUid ? null : prev)
}, [])
const handleCaptureClick = useCallback(() => {
setCaptureDialogOpen(true)
}, [])
@ -323,7 +317,6 @@ export default function App() {
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
onEntryTitleChange={handleEntryTitleChange}
onEntryDeleted={handleEntryDeleted}
humanizeTags={humanizeTags}
/>
</main>

View file

@ -58,14 +58,6 @@ export async function removeTag(archiveId, entryUid, tagUid) {
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
}
export async function deleteEntry(archiveId, entryUid) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}`,
{ method: 'DELETE' }
);
if (!resp.ok) throw new Error(`Delete failed (${resp.status})`);
}
export async function renameTag(archiveId, tagUid, name) {
const res = await fetch(
`/api/archives/${archiveId}/tags/${tagUid}`,

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry } from '../api'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
@ -11,7 +11,7 @@ const ExternalIcon = () => (
</svg>
)
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags }) {
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, humanizeTags }) {
const [detail, setDetail] = useState(null)
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
@ -86,17 +86,6 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
}
}
async function handleDeleteEntry() {
if (!selectedEntry || !archiveId) return
if (!window.confirm('Delete this entry? This cannot be undone.')) return
try {
await deleteEntry(archiveId, selectedEntry.entry_uid)
onEntryDeleted?.(selectedEntry.entry_uid)
} catch {
// silently ignore entry stays selected if delete failed
}
}
const metaRows = detail ? [
['Added', formatTimestamp(detail.summary.archived_at)],
['Source', detail.summary.source_kind],
@ -243,12 +232,6 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
))}
</div>
)}
<div className="rail-delete-zone">
<button className="rail-delete-btn" onClick={handleDeleteEntry}>
Delete entry
</button>
</div>
</>
)}
</aside>

View file

@ -13,10 +13,9 @@ function fmtDate(iso) {
function StatusBadge({ status }) {
const cls = status === 'completed' ? 'run-status--completed'
: status === 'failed' ? 'run-status--failed'
: status === 'in_progress' ? 'run-status--in-progress'
: status === 'running' ? 'run-status--running'
: '';
const label = status ? status.replace(/_/g, ' ') : '—';
return <span className={`run-status ${cls}`}>{label}</span>;
return <span className={`run-status ${cls}`}>{status || '—'}</span>;
}
export default function RunsView({ runs }) {

View file

@ -23,7 +23,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
</select>
</div>
<nav className="nav" aria-label="Primary">
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
onClick={() => onViewChange(name)}>
{name.charAt(0).toUpperCase() + name.slice(1)}

View file

@ -332,9 +332,7 @@ select {
.source-icon { display: flex; align-items: center; justify-content: center; width: 1.05em; height: 1.05em; flex-shrink: 0; }
.source-icon > * { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
.source-icon svg { width: 100%; height: 100%; }
.url-cell { color: #555b55; white-space: nowrap; text-overflow: ellipsis; word-break: break-all; }
#entries-body .url-cell:hover,
#entries-body .is-selected .url-cell { overflow: visible; white-space: normal; }
.url-cell { color: #555b55; word-break: break-all; }
.type-pill { display: inline-block; padding: 2px 6px; background: #d8e3df; color: #275a5f; border: 1px solid #bfd0ca; border-radius: var(--r); }
/* ── Runs view (table) ───────────────────────────────────────────────────── */
@ -527,31 +525,6 @@ select {
background: var(--accent-2);
}
/* ── Rail delete zone ───────────────────────────────────────────────────── */
.rail-delete-zone {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--line-soft);
}
.rail-delete-btn {
width: 100%;
padding: 8px 14px;
background: none;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
border-radius: var(--r);
cursor: pointer;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
transition: background 0.15s ease, border-color 0.15s ease;
}
.rail-delete-btn:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-color: var(--accent);
}
/* ── Capture dialog ──────────────────────────────────────────────────────── */
.capture-dialog {
border: 1px solid var(--line);
@ -1309,7 +1282,7 @@ select {
}
.run-status--completed { background: #d8eddf; color: #235c35; border: 1px solid #b4d9be; }
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
.run-status--in-progress { background: #fdefd8; color: #8a4f10; border: 1px solid #f0cfa0; }
.run-status--running { background: #dde8f5; color: #245f72; border: 1px solid #b8cde0; }
/* ── Token created banner ────────────────────────────────────────────────── */
.token-banner {

View file

@ -10,19 +10,8 @@ export function formatBytes(bytes) {
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export function decodeHtmlEntities(str) {
if (!str) return str;
return str
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
export function valueText(value) {
return decodeHtmlEntities(value) ?? "";
return value ?? "";
}
export function formatTimestamp(value) {