mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
5 commits
d3a78da766
...
d83faab8e6
| Author | SHA1 | Date | |
|---|---|---|---|
| d83faab8e6 | |||
| ed1f883ff1 | |||
| cad3ae9885 | |||
| f71be25a8e | |||
| 98238cd1f3 |
14 changed files with 437 additions and 53 deletions
|
|
@ -1323,6 +1323,128 @@ pub fn cascade_cached_bytes_after_delete(conn: &Connection, entry_id: i64) -> Re
|
||||||
Ok(())
|
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> {
|
pub fn upsert_blob(conn: &Connection, blob: &BlobRecord) -> Result<i64> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO blobs (
|
"INSERT OR IGNORE INTO blobs (
|
||||||
|
|
@ -2705,4 +2827,108 @@ mod tests {
|
||||||
assert_eq!(updated.full_path, "/Natural-Science");
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/search", get(search_entries_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid",
|
"/api/archives/:archive_id/entries/:entry_uid",
|
||||||
get(entry_detail).patch(patch_entry_handler),
|
get(entry_detail).patch(patch_entry_handler).delete(delete_entry_handler),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||||
|
|
@ -615,6 +615,25 @@ 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)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct CaptureBody {
|
struct CaptureBody {
|
||||||
locator: String,
|
locator: String,
|
||||||
|
|
@ -2958,4 +2977,72 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
crates/archivr-server/static/assets/index-Dhs0pFsV.css
Normal file
1
crates/archivr-server/static/assets/index-Dhs0pFsV.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-j6_-GyNh.js
Normal file
40
crates/archivr-server/static/assets/index-j6_-GyNh.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DuR832Rs.js"></script>
|
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-oE_dvyrb.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { displayPath } from './utils'
|
||||||
|
|
||||||
export const AuthContext = createContext(null);
|
export const AuthContext = createContext(null);
|
||||||
|
|
||||||
const VIEWS = ['archive','runs','admin','tags','collections','settings']
|
const VIEWS = ['archive','tags','collections','runs','admin','settings']
|
||||||
const SETTINGS_TABS = ['profile','tokens','instance']
|
const SETTINGS_TABS = ['profile','tokens','instance']
|
||||||
|
|
||||||
function parseLocation() {
|
function parseLocation() {
|
||||||
|
|
@ -212,6 +212,12 @@ 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(() => {
|
const handleCaptureClick = useCallback(() => {
|
||||||
setCaptureDialogOpen(true)
|
setCaptureDialogOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -317,6 +323,7 @@ export default function App() {
|
||||||
tagNodes={tagNodes}
|
tagNodes={tagNodes}
|
||||||
onTagsRefresh={handleTagsRefresh}
|
onTagsRefresh={handleTagsRefresh}
|
||||||
onEntryTitleChange={handleEntryTitleChange}
|
onEntryTitleChange={handleEntryTitleChange}
|
||||||
|
onEntryDeleted={handleEntryDeleted}
|
||||||
humanizeTags={humanizeTags}
|
humanizeTags={humanizeTags}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@ export async function removeTag(archiveId, entryUid, tagUid) {
|
||||||
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
|
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) {
|
export async function renameTag(archiveId, tagUid, name) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/archives/${archiveId}/tags/${tagUid}`,
|
`/api/archives/${archiveId}/tags/${tagUid}`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
|
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry } from '../api'
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||||
|
|
||||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||||
|
|
@ -11,7 +11,7 @@ const ExternalIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, humanizeTags }) {
|
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags }) {
|
||||||
const [detail, setDetail] = useState(null)
|
const [detail, setDetail] = useState(null)
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [assignInput, setAssignInput] = useState('')
|
const [assignInput, setAssignInput] = useState('')
|
||||||
|
|
@ -86,6 +86,17 @@ 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 ? [
|
const metaRows = detail ? [
|
||||||
['Added', formatTimestamp(detail.summary.archived_at)],
|
['Added', formatTimestamp(detail.summary.archived_at)],
|
||||||
['Source', detail.summary.source_kind],
|
['Source', detail.summary.source_kind],
|
||||||
|
|
@ -232,6 +243,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="rail-delete-zone">
|
||||||
|
<button className="rail-delete-btn" onClick={handleDeleteEntry}>
|
||||||
|
Delete entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ function fmtDate(iso) {
|
||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }) {
|
||||||
const cls = status === 'completed' ? 'run-status--completed'
|
const cls = status === 'completed' ? 'run-status--completed'
|
||||||
: status === 'failed' ? 'run-status--failed'
|
: status === 'failed' ? 'run-status--failed'
|
||||||
: status === 'running' ? 'run-status--running'
|
: status === 'in_progress' ? 'run-status--in-progress'
|
||||||
: '';
|
: '';
|
||||||
return <span className={`run-status ${cls}`}>{status || '—'}</span>;
|
const label = status ? status.replace(/_/g, ' ') : '—';
|
||||||
|
return <span className={`run-status ${cls}`}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RunsView({ runs }) {
|
export default function RunsView({ runs }) {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<nav className="nav" aria-label="Primary">
|
<nav className="nav" aria-label="Primary">
|
||||||
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
|
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
|
||||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||||
onClick={() => onViewChange(name)}>
|
onClick={() => onViewChange(name)}>
|
||||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,9 @@ select {
|
||||||
.source-icon { display: flex; align-items: center; justify-content: center; width: 1.05em; height: 1.05em; flex-shrink: 0; }
|
.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 > * { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||||
.source-icon svg { width: 100%; height: 100%; }
|
.source-icon svg { width: 100%; height: 100%; }
|
||||||
.url-cell { color: #555b55; word-break: break-all; }
|
.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; }
|
||||||
.type-pill { display: inline-block; padding: 2px 6px; background: #d8e3df; color: #275a5f; border: 1px solid #bfd0ca; border-radius: var(--r); }
|
.type-pill { display: inline-block; padding: 2px 6px; background: #d8e3df; color: #275a5f; border: 1px solid #bfd0ca; border-radius: var(--r); }
|
||||||
|
|
||||||
/* ── Runs view (table) ───────────────────────────────────────────────────── */
|
/* ── Runs view (table) ───────────────────────────────────────────────────── */
|
||||||
|
|
@ -525,6 +527,31 @@ select {
|
||||||
background: var(--accent-2);
|
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 ──────────────────────────────────────────────────────── */
|
||||||
.capture-dialog {
|
.capture-dialog {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -1282,7 +1309,7 @@ select {
|
||||||
}
|
}
|
||||||
.run-status--completed { background: #d8eddf; color: #235c35; border: 1px solid #b4d9be; }
|
.run-status--completed { background: #d8eddf; color: #235c35; border: 1px solid #b4d9be; }
|
||||||
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
|
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
|
||||||
.run-status--running { background: #dde8f5; color: #245f72; border: 1px solid #b8cde0; }
|
.run-status--in-progress { background: #fdefd8; color: #8a4f10; border: 1px solid #f0cfa0; }
|
||||||
|
|
||||||
/* ── Token created banner ────────────────────────────────────────────────── */
|
/* ── Token created banner ────────────────────────────────────────────────── */
|
||||||
.token-banner {
|
.token-banner {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,19 @@ export function formatBytes(bytes) {
|
||||||
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decodeHtmlEntities(str) {
|
||||||
|
if (!str) return str;
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
export function valueText(value) {
|
export function valueText(value) {
|
||||||
return value ?? "";
|
return decodeHtmlEntities(value) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTimestamp(value) {
|
export function formatTimestamp(value) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue