1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat: reorder child entries with a configurable role permission

- Persist sibling order for child entries (archived_entries.position):
  backfilled to the previous archived_at order, new children appended
  (initial playlist order kept; sync appends after user order).
- PUT /api/archives/:id/entries/:uid/children/order takes the full child
  UID list in one IMMEDIATE transaction (401 guest, 403 role not allowed,
  404 unknown or invisible parent, 400 unless exactly the current children).
- Reorder permission is a role mask in the auth instance settings
  (reorder_children_role_bits, default Admin|Owner). Only the Owner can
  change it, built-in or custom roles, never Guest. /api/auth/me and login
  expose can_reorder_children. Settings PATCH is now one IMMEDIATE txn.
- Main page: drag handle for mouse/trackpad on viewports >640px, up/down
  arrows otherwise (pure CSS, arrows are the fallback), Alt+Up/Down
  everywhere; optimistic with revert. Settings > Instance > Permissions
  for the Owner (read-only for admins).
- Fix: renaming a child entry now updates its row immediately.
- Remove Storybook (deps, config, stories, docs).
This commit is contained in:
TheGeneralist 2026-10-04 18:54:04 +02:00
parent 8fc6754e28
commit 699eb7f62d
27 changed files with 1300 additions and 2477 deletions

2
.gitignore vendored
View file

@ -42,5 +42,3 @@ frontend/result/**
frontend/dist/
frontend/dist/**
.DS_Store
frontend/storybook-static/
frontend/storybook-static/**

View file

@ -16,7 +16,7 @@ Three crates with a strict ownership split — **core owns truth; CLI and server
Capture flow: locator → `determine_source()` (`crates/archivr-core/src/capture.rs`) routes by platform/shorthand (`yt:`, `x:`, `tweet:` …) → platform downloader (`downloader/ytdlp.rs`, `tweets.rs`, `singlefile.rs`, `http.rs`, `local.rs`) stages into `temp/` → SHA3-256 dedup (`hash.rs`, `downloader/store.rs`) moves blobs to `raw/A/B/HASH.EXT` → rows written to `archivr.sqlite` (runs, entries, artifacts, blobs) → served via `/api/archives/:id/...`. `CaptureConfig` carries per-request toggles (uBlock, reader mode, Freedium mirror, etc.); when `via_freedium` is set, the fetch URL is rewritten through `freedium-mirror.cfd` while the canonical DB URL stays the original locator.
YouTube playlists and channels produce a **parent container entry** with each video captured as a child entry. `downloader/ytdlp.rs` handles the playlist probe (fetching per-video quality metadata before archiving), the multi-video download loop, and sync mode (skipping already-archived videos when re-archiving a playlist or channel).
YouTube playlists and channels produce a **parent container entry** with each video captured as a child entry. `downloader/ytdlp.rs` handles the flat-playlist probe (fetching per-video quality metadata before archiving); the multi-video download loop and sync mode (skipping already-archived videos when re-archiving a playlist or channel) live in `capture.rs`. Child order is persisted in `archived_entries.position` (append-only at insert in `database::create_archived_entry`; rewritten only by `database::reorder_child_entries` behind `PUT …/entries/:entry_uid/children/order` (allowed roles = `InstanceSettings::reorder_children_role_bits`, default ADMIN|OWNER, Owner-editable)).
Pasted text takes a much shorter path: `perform_text_capture()` (`capture.rs`) skips source detection
and every downloader shell-out — `downloader/text.rs` stages the body under `temp/`, hashes it, and the
@ -43,7 +43,7 @@ remains synchronous: the server puts provider work in its blocking boundary rath
Entry free-text search includes summary text (and generated JSON tags inside it) from the latest completed summary only.
Pending and failed rows do not match, and a newer pending or failed request does not hide an older completed summary.
Per-archive layout (created by `archivr init`): `.archivr/` (name, store_path, `archivr.sqlite`) + sibling `store/` (`raw/`, `raw_tweets/`, `structured/`, `temp/`). Server-level auth lives in a **separate** `archivr-auth.sqlite` (users, sessions, API tokens, role bits GUEST=1/USER=2/ADMIN=4/OWNER=8).
Per-archive layout (created by `archivr init`): `.archivr/` (name, store_path, `archivr.sqlite`) + sibling `store/` (`raw/`, `raw_tweets/`, `structured/`, `temp/`). Server-level auth lives in a **separate** `archivr-auth.sqlite` (users, sessions, API tokens, role bits GUEST=1/USER=2/ADMIN=4/OWNER=8; per-action role masks (e.g. `reorder_children_role_bits`) live on its `instance_settings` row).
The server mounts multiple archives from a TOML registry (`crates/archivr-server/src/registry.rs`); routes are parameterized by `:archive_id`.
@ -75,7 +75,6 @@ cargo build --release -p archivr-server
bun install
bun run dev # Vite dev server
bun run build # → ../crates/archivr-server/static (served by the server)
bun run storybook # Storybook on :6006
# Nix
nix develop # devshell: yt-dlp, nushell, uv, twitter-api-client
@ -144,5 +143,5 @@ No CI is configured; no rustfmt.toml/clippy.toml — default `cargo fmt`/`clippy
## Testing & QA
- **Rust**: unit tests only, in `#[cfg(test)]` modules inside source files (e.g. `capture.rs`, `database.rs`, `registry.rs`, `routes.rs`, `hash.rs`, and newer: `summarizer.rs` — 23 tests over provider construction, CLI/env resolution, output extraction, HTML/text/JSON input reduction and tweet-thread joining; `downloader/ytdlp.rs` — 13 tests, several covering resolver priority and version tie-breaking). No `tests/` integration dir. Patterns: `tempfile` for scratch archives, config round-trip assertions, regex/parser validation. Run `cargo test` or `cargo test -p <crate>`.
- **Frontend**: no test framework. Storybook (`bun run storybook`) is the component QA surface — stories are colocated `*.stories.jsx` files; add one when adding a nontrivial component.
- **Frontend**: component render tests are colocated `*.test.jsx` files on `bun:test` (`bun test` from `frontend/`); there is no Storybook.
- Manual smoke test for server changes: build frontend, `cargo run -p archivr-server -- <config.toml>`, exercise `/api/*`.

View file

@ -72,8 +72,11 @@ Rules:
- Maximum nesting depth is 2 (root → child). Children cannot have children.
- The UI shows child entries collapsed under their parent, expandable with a chevron.
- Container entries are created by playlist/channel captures. Single-video and all other source types produce a standalone root entry with no children.
- Children have a persisted sibling order: `archived_entries.position` (0-based per parent, `NULL` for roots). `list_child_entries` orders by it (tie-break `archived_at, id`).
- New children are always appended (`MAX(position)+1` in `create_archived_entry`): an initial playlist/channel capture keeps playlist enumeration order; sync appends newly found videos after the existing (possibly user-reordered) children.
- Users whose roles are allowed by the instance setting `reorder_children_role_bits` (auth DB `instance_settings`; default ADMIN|OWNER = 12; editable only by the Owner in Settings → Instance → Permissions) reorder an expanded parent's children on the main page. Exactly one control is shown, selected purely in CSS: the ↑/↓ buttons are the default, and a single `(hover: hover) and (pointer: fine) and (min-width: 641px)` query swaps in the drag handle (HTML5 drag-and-drop). Touch, no-pointer, phone-width (≤ 640px) viewports, and browsers that can't evaluate the query keep the arrows, since HTML5 DnD is unreliable there and no feature test detects it. Alt+↑/↓ on a focused child row works on all inputs (advertised via `aria-keyshortcuts`). The UI sends the full child UID list to `PUT /api/archives/:archive_id/entries/:entry_uid/children/order` (401 guest, 403 role not in mask, 404 unknown parent or a parent the caller can't see under the `list_child_entries` rule (`database::caller_sees_all_children`), 400 unless the list is exactly the current children). `/api/auth/me` and login return `can_reorder_children`; the UI hides reorder controls when it is false.
If a feature touches how entries are parented or how the UI groups them, start in `archivr-core` (`database.rs` for schema, `archive.rs` for listing, `capture.rs` for creation).
If a feature touches how entries are parented or how the UI groups them, start in `archivr-core` (`database.rs` for schema, `archive.rs` for listing, `capture.rs` for creation). Child-order UI lives in `routes.rs` (`reorder_entry_children_handler`) and `frontend/src/components/EntryRow.jsx`.
## How To Run It
@ -287,7 +290,7 @@ If a browser feature needs new data, the usual order is:
The server both reads and writes archive data. Capture jobs are asynchronous: `POST /api/archives/:id/captures` inserts a job row, spawns a blocking task, and returns immediately; the frontend polls until the job completes or fails. Heavy work stays synchronous inside `archivr-core`.
**Auth model.** A separate `archivr-auth.sqlite` (path derived from the server config directory) holds users, sessions, and API tokens. Role bits are `u32` flags (`GUEST`, `USER`, `ADMIN`, `OWNER`) so a single bitmask value covers assignment, checks, and visibility. The middleware stack is `setup_guard` → `login_rate_limit` → `security_headers`; route families are classified `READ / ADMIN / WRITE / STATIC` in `routes.rs`.
**Auth model.** A separate `archivr-auth.sqlite` (path derived from the server config directory) holds users, sessions, and API tokens. Role bits are `u32` flags (`GUEST`, `USER`, `ADMIN`, `OWNER`) so a single bitmask value covers assignment, checks, and visibility. The middleware stack is `setup_guard` → `login_rate_limit` → `security_headers`; route families are classified `READ / ADMIN / WRITE / STATIC` in `routes.rs`. Configurable per-action permissions are `u32` role masks on the auth `instance_settings` singleton (currently `reorder_children_role_bits`). Handlers read them per request and allow when `caller_bits & mask != 0`, so changes take effect without re-login. Only the Owner may change them, and masks may contain only existing non-guest role bits (`database::grantable_role_bits`).
**Search** is server-side free-text filtering over entry fields and the latest completed summary. The summary JSON is
searched as text, so generated `tags` participate. Older completed summaries stay searchable while a newer request is

View file

@ -510,9 +510,11 @@ pub fn list_entries_for_collection(
Ok(entries)
}
/// Returns the direct children of the entry identified by `parent_entry_uid`,
/// ordered ascending by `archived_at, id` (preserves playlist ordinal feel).
/// Returns an empty vec if the parent has no children or does not exist.
/// Returns the direct children of the entry identified by `parent_entry_uid`
/// in persisted sibling order (`position`, set at insert time as append and
/// rewritten by `database::reorder_child_entries`), tie-broken by
/// `archived_at, id`. Returns an empty vec if the parent has no children or
/// does not exist.
pub fn list_child_entries(
conn: &rusqlite::Connection,
parent_entry_uid: &str,
@ -535,7 +537,7 @@ pub fn list_child_entries(
)\
) \
GROUP BY e.id \
ORDER BY e.archived_at ASC, e.id ASC",
ORDER BY e.position ASC, e.archived_at ASC, e.id ASC",
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
);
let mut stmt = conn.prepare(&sql)?;
@ -2268,4 +2270,37 @@ mod tests {
assert!(guest_children.is_empty(), "guest must not see children of a USER-only collection");
}
#[test]
fn list_child_entries_orders_by_position_not_archived_at() {
let (conn, user_id, run_id) = make_tag_test_db();
let container = make_entry_in_db(&conn, user_id, run_id, None, None,
"Playlist", "https://example.com/pl");
let mk = |title: &str, url: &str| make_entry_in_db(&conn, user_id, run_id,
Some(container.id), Some(container.id), title, url);
let v1 = mk("V1", "https://example.com/pl/v1");
let v2 = mk("V2", "https://example.com/pl/v2");
let v3 = mk("V3", "https://example.com/pl/v3");
conn.execute(
"UPDATE archived_entries SET archived_at = '2000-01-01T00:00:00Z' WHERE id = ?1",
[v3.id],
).unwrap();
let uids = || -> Vec<String> {
list_child_entries(&conn, &container.entry_uid, 12).unwrap()
.into_iter().map(|e| e.entry_uid).collect()
};
assert_eq!(uids(), vec![v1.entry_uid.clone(), v2.entry_uid.clone(), v3.entry_uid.clone()]);
let order = vec![v3.entry_uid.clone(), v1.entry_uid.clone(), v2.entry_uid.clone()];
assert_eq!(
database::reorder_child_entries(&conn, &container.entry_uid, &order).unwrap(),
database::ReorderChildrenOutcome::Reordered
);
assert_eq!(uids(), order);
let v4 = mk("V4", "https://example.com/pl/v4");
let mut expected = order.clone();
expected.push(v4.entry_uid.clone());
assert_eq!(uids(), expected);
}
}

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result, bail};
use chrono::Utc;
use rusqlite::{Connection, OptionalExtension, params};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use uuid::Uuid;
@ -155,6 +156,9 @@ pub struct RoleRecord {
pub is_builtin: bool,
}
/// Default reorder permission: ADMIN (bit 2) | OWNER (bit 3). Keep in sync with the SQL DEFAULT 12 in `initialize_auth_schema`.
pub const DEFAULT_REORDER_CHILDREN_ROLE_BITS: u32 = 0b1100;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InstanceSettings {
pub public_index_enabled: bool,
@ -168,6 +172,16 @@ pub struct InstanceSettings {
pub cookie_ext_enabled: bool,
/// Global default for modal-closer browser-script behavior during WebPage captures.
pub modal_closer_enabled: bool,
/// Role bits allowed to reorder child entries (`PUT …/children/order`).
/// A caller may reorder iff `role_bits & reorder_children_role_bits != 0`.
/// Only the Owner may change it. Never contains the Guest bit.
pub reorder_children_role_bits: u32,
}
impl InstanceSettings {
pub fn can_reorder_children(&self, role_bits: u32) -> bool {
role_bits & self.reorder_children_role_bits != 0
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -296,7 +310,8 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
representation_kind TEXT NOT NULL,
source_metadata_json TEXT NOT NULL DEFAULT '{}',
display_metadata_json TEXT,
cached_bytes INTEGER NOT NULL DEFAULT 0
cached_bytes INTEGER NOT NULL DEFAULT 0,
position INTEGER
);
CREATE TABLE IF NOT EXISTS blobs (
@ -493,6 +508,51 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
[],
);
// Migration: persisted sibling order for child entries (`position`).
// NULL for roots; 0-based per parent for children. The backfill reproduces
// the previous implicit child order (archived_at ASC, id ASC) so existing
// archives render unchanged. New DBs get the column from the DDL above and
// skip this (they have no rows to backfill).
let has_position = |conn: &Connection| -> Result<bool> {
Ok(conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('archived_entries') WHERE name = 'position'",
[],
|row| row.get::<_, i64>(0),
)? > 0)
};
if !has_position(conn)? {
// IMMEDIATE + re-check under the write lock: two connections opening
// the same pre-migration DB at once must not both run the ALTER.
conn.execute_batch("BEGIN IMMEDIATE")?;
let migrated = (|| -> Result<()> {
if !has_position(conn)? {
conn.execute_batch(
"ALTER TABLE archived_entries ADD COLUMN position INTEGER;
UPDATE archived_entries
SET position = (
SELECT COUNT(*) FROM archived_entries s
WHERE s.parent_entry_id = archived_entries.parent_entry_id
AND (s.archived_at < archived_entries.archived_at
OR (s.archived_at = archived_entries.archived_at
AND s.id < archived_entries.id))
)
WHERE parent_entry_id IS NOT NULL;",
)?;
}
Ok(())
})();
conn.execute_batch(if migrated.is_ok() { "COMMIT" } else { "ROLLBACK" })?;
migrated?;
}
// Sibling-order lookups: list_child_entries ORDER BY and the MAX(position)
// append in create_archived_entry. Created after the migration because the
// column may not exist yet when the main DDL batch runs.
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_entries_parent_position
ON archived_entries(parent_entry_id, position)",
[],
)?;
// Summary attempts used to be unique by cache key, which meant forced
// regeneration erased the last completed result. Rebuild that small table
// without the cache-key constraint while retaining all existing rows.
@ -605,7 +665,8 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
default_entry_visibility INTEGER NOT NULL DEFAULT 2,
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1)),
cookie_ext_enabled INTEGER NOT NULL DEFAULT 1 CHECK (cookie_ext_enabled IN (0, 1)),
modal_closer_enabled INTEGER NOT NULL DEFAULT 1 CHECK (modal_closer_enabled IN (0, 1))
modal_closer_enabled INTEGER NOT NULL DEFAULT 1 CHECK (modal_closer_enabled IN (0, 1)),
reorder_children_role_bits INTEGER NOT NULL DEFAULT 12
);
INSERT OR IGNORE INTO instance_settings
@ -660,6 +721,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
"ALTER TABLE instance_settings ADD COLUMN modal_closer_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
// Add reorder_children_role_bits (ADMIN|OWNER = 12 by default) if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN reorder_children_role_bits INTEGER NOT NULL DEFAULT 12",
[],
);
Ok(())
}
@ -885,7 +951,8 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
public_archive_submission_enabled, default_entry_visibility,
COALESCE(ublock_enabled, 1),
COALESCE(cookie_ext_enabled, 1),
COALESCE(modal_closer_enabled, 1)
COALESCE(modal_closer_enabled, 1),
COALESCE(reorder_children_role_bits, 12)
FROM instance_settings WHERE id = 1",
[],
|row| {
@ -897,6 +964,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
ublock_enabled: row.get::<_, i64>(4)? != 0,
cookie_ext_enabled: row.get::<_, i64>(5)? != 0,
modal_closer_enabled: row.get::<_, i64>(6)? != 0,
reorder_children_role_bits: row.get::<_, i64>(7)? as u32,
})
},
)
@ -912,7 +980,8 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
default_entry_visibility = ?4,
ublock_enabled = ?5,
cookie_ext_enabled = ?6,
modal_closer_enabled = ?7
modal_closer_enabled = ?7,
reorder_children_role_bits = ?8
WHERE id = 1",
params![
settings.public_index_enabled as i64,
@ -922,6 +991,7 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
settings.ublock_enabled as i64,
settings.cookie_ext_enabled as i64,
settings.modal_closer_enabled as i64,
settings.reorder_children_role_bits as i64,
],
)?;
Ok(())
@ -1051,6 +1121,78 @@ pub fn update_entry_title(conn: &Connection, entry_uid: &str, title: Option<&str
Ok(n > 0)
}
/// Outcome of [`reorder_child_entries`]; the server maps it to 204/404/400.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReorderChildrenOutcome {
Reordered,
ParentNotFound,
/// `ordered_child_uids` was not exactly the parent's current direct
/// children (missing, extra, foreign or duplicate UID).
ChildSetMismatch,
}
/// Replaces the sibling order of the direct children of `parent_entry_uid`.
/// `ordered_child_uids` must be a permutation of the current children; child
/// at index `i` gets `position = i`. Nothing is written on mismatch.
/// Wrap in a transaction at the call site so the set check and the writes
/// are atomic against concurrent child inserts (sync capture).
pub fn reorder_child_entries(
conn: &Connection,
parent_entry_uid: &str,
ordered_child_uids: &[String],
) -> Result<ReorderChildrenOutcome> {
let Some(parent_id) = entry_id_for_uid(conn, parent_entry_uid)? else {
return Ok(ReorderChildrenOutcome::ParentNotFound);
};
let current: HashSet<String> = {
let mut stmt =
conn.prepare("SELECT entry_uid FROM archived_entries WHERE parent_entry_id = ?1")?;
stmt.query_map([parent_id], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?
};
let requested: HashSet<&str> = ordered_child_uids.iter().map(String::as_str).collect();
if requested.len() != ordered_child_uids.len()
|| requested.len() != current.len()
|| !requested.iter().all(|uid| current.contains(*uid))
{
return Ok(ReorderChildrenOutcome::ChildSetMismatch);
}
let mut update = conn.prepare(
"UPDATE archived_entries SET position = ?1
WHERE parent_entry_id = ?2 AND entry_uid = ?3",
)?;
for (index, uid) in ordered_child_uids.iter().enumerate() {
update.execute(params![index as i64, parent_id, uid])?;
}
Ok(ReorderChildrenOutcome::Reordered)
}
/// True when `caller_bits` can see every direct child of `parent_entry_uid`
/// under the rule `archive::list_child_entries` applies: ADMIN/OWNER (bits 12)
/// see everything, otherwise the parent must be in a collection whose
/// `visibility_bits` overlap the caller's bits (children inherit it). A caller
/// who sees only individually-shared children cannot submit a full order, so
/// that case is false too. False for an unknown parent.
pub fn caller_sees_all_children(
conn: &Connection,
parent_entry_uid: &str,
caller_bits: u32,
) -> Result<bool> {
if caller_bits & 12 != 0 {
return Ok(entry_id_for_uid(conn, parent_entry_uid)?.is_some());
}
let visible: bool = conn.query_row(
"SELECT EXISTS (
SELECT 1 FROM collection_entries ce
JOIN archived_entries p ON p.id = ce.entry_id
WHERE p.entry_uid = ?1 AND ce.visibility_bits & ?2 != 0
)",
params![parent_entry_uid, caller_bits as i64],
|row| row.get(0),
)?;
Ok(visible)
}
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
conn.query_row(
"SELECT display_name FROM users WHERE id = ?1",
@ -1294,6 +1436,17 @@ pub fn list_roles(conn: &Connection) -> Result<Vec<RoleRecord>> {
.map_err(Into::into)
}
/// OR of `1 << bit_position` over every role except Guest (bit 0): the bits a
/// role-permission mask may contain. Guest is excluded because every signed-in
/// account carries it, so granting it would mean "everyone".
pub fn grantable_role_bits(conn: &Connection) -> Result<u32> {
let mut stmt = conn.prepare("SELECT bit_position FROM roles WHERE bit_position > 0")?;
let bits = stmt
.query_map([], |row| row.get::<_, i64>(0))?
.try_fold(0u32, |acc, b| b.map(|b| acc | (1u32 << b)))?;
Ok(bits)
}
/// Creates a new custom role (level=2, bit_position = max existing + 1, min 4).
/// Returns the created RoleRecord.
pub fn create_custom_role(conn: &Connection, slug: &str, name: &str) -> Result<RoleRecord> {
@ -2201,10 +2354,14 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
entry_uid, source_identity_id, archive_run_id, parent_entry_id, root_entry_id,
created_by_user_id, owned_by_user_id, source_kind, entity_kind, title, visibility,
archived_at, original_published_at, structured_root_relpath, representation_kind,
source_metadata_json, display_metadata_json
source_metadata_json, display_metadata_json, position
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
?12, NULL, ?13, ?14, ?15, ?16
?12, NULL, ?13, ?14, ?15, ?16,
CASE WHEN ?4 IS NULL THEN NULL ELSE (
SELECT COALESCE(MAX(position), -1) + 1
FROM archived_entries WHERE parent_entry_id = ?4
) END
)",
params![
entry_uid,
@ -3798,6 +3955,60 @@ mod tests {
assert_eq!(r2.bit_position, 5);
assert_eq!(r2.level, 2);
}
#[test]
fn instance_settings_reorder_mask_defaults_to_admin_owner() {
let conn = make_auth_conn_for_mgmt();
let s = get_instance_settings(&conn).unwrap();
assert_eq!(s.reorder_children_role_bits, 12);
assert_eq!(s.reorder_children_role_bits, DEFAULT_REORDER_CHILDREN_ROLE_BITS);
}
#[test]
fn instance_settings_reorder_mask_round_trips() {
let conn = make_auth_conn_for_mgmt();
let mut s = get_instance_settings(&conn).unwrap();
s.reorder_children_role_bits = 2 | 16;
update_instance_settings(&conn, &s).unwrap();
let s = get_instance_settings(&conn).unwrap();
assert_eq!(s.reorder_children_role_bits, 18);
assert!(s.ublock_enabled);
}
#[test]
fn instance_settings_reorder_mask_migrates_legacy_table() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE instance_settings (id INTEGER PRIMARY KEY CHECK (id = 1), public_index_enabled INTEGER NOT NULL DEFAULT 0, public_entry_content_enabled INTEGER NOT NULL DEFAULT 0, public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0, default_entry_visibility INTEGER NOT NULL DEFAULT 2);
INSERT INTO instance_settings (id) VALUES (1);",
)
.unwrap();
initialize_auth_schema(&conn).unwrap();
initialize_auth_schema(&conn).unwrap();
let s = get_instance_settings(&conn).unwrap();
assert_eq!(s.reorder_children_role_bits, 12);
assert!(s.modal_closer_enabled);
}
#[test]
fn can_reorder_children_intersects_mask() {
let conn = make_auth_conn_for_mgmt();
let mut s = get_instance_settings(&conn).unwrap();
assert!(s.can_reorder_children(15));
assert!(s.can_reorder_children(7));
assert!(!s.can_reorder_children(3));
assert!(!s.can_reorder_children(1));
s.reorder_children_role_bits = 0;
assert!(!s.can_reorder_children(15));
}
#[test]
fn grantable_role_bits_excludes_guest_and_tracks_custom_roles() {
let conn = make_auth_conn_for_mgmt();
assert_eq!(grantable_role_bits(&conn).unwrap(), 0b1110);
create_custom_role(&conn, "editor", "Editor").unwrap();
assert_eq!(grantable_role_bits(&conn).unwrap(), 30);
}
// ── rename_tag / delete_tag ────────────────────────────────────────────
#[test]
@ -4940,4 +5151,128 @@ mod tests {
);
assert_eq!(entry_id_for_uid(&c, "ent_nope").unwrap(), None);
}
fn position_of(c: &Connection, id: i64) -> Option<i64> {
c.query_row(
"SELECT position FROM archived_entries WHERE id = ?1",
[id],
|r| r.get(0),
)
.unwrap()
}
fn child(c: &Connection, parent: &ArchivedEntry) -> ArchivedEntry {
create_entry_fixture(c, "private", Some(parent.id), Some(parent.id))
}
#[test]
fn create_archived_entry_assigns_child_positions_append_only() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
assert_eq!(position_of(&c, r.id), None);
let a = child(&c, &r);
let b = child(&c, &r);
let c3 = child(&c, &r);
assert_eq!(position_of(&c, a.id), Some(0));
assert_eq!(position_of(&c, b.id), Some(1));
assert_eq!(position_of(&c, c3.id), Some(2));
let r2 = create_entry_fixture(&c, "private", None, None);
let x = child(&c, &r2);
assert_eq!(position_of(&c, x.id), Some(0));
assert!(delete_entry(&c, &b.entry_uid).unwrap());
let d = child(&c, &r);
assert_eq!(position_of(&c, d.id), Some(3));
}
#[test]
fn reorder_child_entries_rewrites_positions() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
let a = child(&c, &r);
let b = child(&c, &r);
let c3 = child(&c, &r);
let order = vec![c3.entry_uid.clone(), a.entry_uid.clone(), b.entry_uid.clone()];
assert_eq!(
reorder_child_entries(&c, &r.entry_uid, &order).unwrap(),
ReorderChildrenOutcome::Reordered
);
assert_eq!(position_of(&c, c3.id), Some(0));
assert_eq!(position_of(&c, a.id), Some(1));
assert_eq!(position_of(&c, b.id), Some(2));
let d = child(&c, &r);
assert_eq!(position_of(&c, d.id), Some(3));
}
#[test]
fn reorder_child_entries_rejects_mismatched_sets() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
let a = child(&c, &r);
let b = child(&c, &r);
let r2 = create_entry_fixture(&c, "private", None, None);
let x = child(&c, &r2);
let (a_u, b_u) = (a.entry_uid.clone(), b.entry_uid.clone());
let cases: Vec<Vec<String>> = vec![
vec![a_u.clone()],
vec![a_u.clone(), b_u.clone(), x.entry_uid.clone()],
vec![a_u.clone(), b_u.clone(), r2.entry_uid.clone()],
vec![a_u.clone(), a_u.clone()],
vec![a_u.clone(), b_u.clone(), a_u.clone()],
vec![],
];
for case in cases {
assert_eq!(
reorder_child_entries(&c, &r.entry_uid, &case).unwrap(),
ReorderChildrenOutcome::ChildSetMismatch,
"{case:?}"
);
assert_eq!(position_of(&c, a.id), Some(0));
assert_eq!(position_of(&c, b.id), Some(1));
}
}
#[test]
fn reorder_child_entries_unknown_parent() {
let c = conn();
assert_eq!(
reorder_child_entries(&c, "entry_nope", &[]).unwrap(),
ReorderChildrenOutcome::ParentNotFound
);
}
#[test]
fn initialize_schema_backfills_child_positions_once() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
let a = child(&c, &r);
let b = child(&c, &r);
let c3 = child(&c, &r);
c.execute(
"UPDATE archived_entries SET archived_at = '2026-01-01T00:00:00Z' WHERE id = ?1",
[b.id],
)
.unwrap();
c.execute(
"UPDATE archived_entries SET archived_at = '2026-01-02T00:00:00Z' WHERE id IN (?1, ?2)",
[a.id, c3.id],
)
.unwrap();
c.execute_batch(
"DROP INDEX idx_archived_entries_parent_position;
ALTER TABLE archived_entries DROP COLUMN position;",
)
.unwrap();
initialize_schema(&c).unwrap();
assert_eq!(position_of(&c, b.id), Some(0));
assert_eq!(position_of(&c, a.id), Some(1));
assert_eq!(position_of(&c, c3.id), Some(2));
assert_eq!(position_of(&c, r.id), None);
let order = vec![a.entry_uid.clone(), b.entry_uid.clone(), c3.entry_uid.clone()];
reorder_child_entries(&c, &r.entry_uid, &order).unwrap();
initialize_schema(&c).unwrap();
assert_eq!(position_of(&c, a.id), Some(0));
assert_eq!(position_of(&c, b.id), Some(1));
assert_eq!(position_of(&c, c3.id), Some(2));
}
}

View file

@ -11,13 +11,17 @@
// POST /api/archives/:id/tags
// POST/DELETE /api/archives/:id/entries/:uid/tags
// PATCH /api/archives/:id/entries/:uid
// ADMIN — requires ROLE_ADMIN: (future)
// OWNER — requires ROLE_OWNER: (future)
// PERMISSION — authenticated + caller role_bits ∩ instance-settings mask (else 403):
// PUT /api/archives/:id/entries/:uid/children/order
// (mask = reorder_children_role_bits, default ADMIN|OWNER)
// ADMIN — requires ROLE_ADMIN: /api/admin/* (users, roles, cookie-rules)
// OWNER — requires ROLE_OWNER:
// changing reorder_children_role_bits via PATCH /api/admin/instance-settings
// AUTH_SELF — own resources, require_auth() only:
// GET/POST/DELETE /api/auth/tokens
// POST /api/auth/logout, GET/PATCH /api/auth/me
// SETTINGS — instance settings, require ROLE_ADMIN:
// GET/PATCH /api/admin/instance-settings
// SETTINGS — instance settings:
// GET/PATCH /api/admin/instance-settings (ROLE_ADMIN; the reorder mask field is OWNER-only)
// ────────────────────────────────────────────────────────────────────────────
use parking_lot::Mutex;
@ -36,7 +40,7 @@ use axum::{
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
routing::{delete, get, patch, post},
routing::{delete, get, patch, post, put},
};
use tower::ServiceExt;
use tower_http::services::{ServeDir, ServeFile};
@ -260,6 +264,10 @@ pub fn app_with_state(state: AppState) -> Router {
"/api/archives/:archive_id/entries/:entry_uid/children",
get(list_entry_children),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/children/order",
put(reorder_entry_children_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
get(serve_artifact),
@ -1114,6 +1122,44 @@ async fn delete_entry_handler(
}
}
async fn reorder_entry_children_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, entry_uid)): Path<(String, String)>,
Json(body): Json<ReorderChildrenBody>,
) -> Result<StatusCode, ApiError> {
let (_, role_bits) = auth_user.require_auth()?; // guests → 401
let settings = database::get_instance_settings(&database::open_auth_db(&state.auth_db_path)?)?;
if !settings.can_reorder_children(role_bits) {
return Err(ApiError::forbidden(
"your role is not allowed to reorder child entries",
));
}
let mounted = mounted_archive(&state, &archive_id)?;
let mut conn = database::open_or_initialize(&mounted.archive_path)?;
// IMMEDIATE: take the write lock before the set check so a concurrent
// sync capture cannot add a sibling between validation and the writes.
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
// A role granted reorder must still be able to see the parent's children
// (same rule as the children GET); hidden parents are indistinguishable
// from missing ones.
if !database::caller_sees_all_children(&tx, &entry_uid, role_bits)? {
return Err(ApiError::not_found("entry not found"));
}
match database::reorder_child_entries(&tx, &entry_uid, &body.child_uids)? {
database::ReorderChildrenOutcome::Reordered => {
tx.commit()?;
Ok(StatusCode::NO_CONTENT)
}
database::ReorderChildrenOutcome::ParentNotFound => {
Err(ApiError::not_found("entry not found"))
}
database::ReorderChildrenOutcome::ChildSetMismatch => Err(ApiError::bad_request(
"child_uids must list every current child of the entry exactly once",
)),
}
}
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
@ -1174,6 +1220,12 @@ struct PatchEntryBody {
title: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ReorderChildrenBody {
/// Every current direct child UID of the parent, in the desired order.
child_uids: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
struct PatchTagBody {
name: String,
@ -1922,6 +1974,8 @@ async fn auth_login(
return Err(ApiError::unauthorized("invalid_credentials"));
}
let role_bits = database::compute_role_bits(&conn, user.id)?;
let can_reorder_children =
database::get_instance_settings(&conn)?.can_reorder_children(role_bits);
let user_agent = headers.get("user-agent").and_then(|v| v.to_str().ok());
let session_uid = database::create_session(&conn, user.id, role_bits, user_agent)?;
@ -1950,6 +2004,7 @@ async fn auth_login(
"user_uid": user.user_uid,
"username": user.username,
"role_bits": role_bits,
"can_reorder_children": can_reorder_children,
})),
))
}
@ -1986,11 +2041,13 @@ async fn auth_me(
)
.map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?;
let humanize_slugs = humanize_slugs_int != 0;
let settings = database::get_instance_settings(&conn)?;
Ok(Json(serde_json::json!({
"role_bits": role_bits,
"username": username,
"display_name": display_name,
"humanize_slugs": humanize_slugs,
"can_reorder_children": settings.can_reorder_children(role_bits),
})))
}
@ -2069,8 +2126,12 @@ async fn update_instance_settings_handler(
Json(body): Json<UpdateInstanceSettingsBody>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
let mut settings = database::get_instance_settings(&conn)?;
let mut conn = database::open_auth_db(&state.auth_db_path)?;
// IMMEDIATE: the read-merge-write below rewrites every column, so it must
// not interleave with another PATCH (an admin save could otherwise write a
// stale reorder mask over the owner's change).
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let mut settings = database::get_instance_settings(&tx)?;
if let Some(v) = body.public_index_enabled {
settings.public_index_enabled = v;
}
@ -2092,7 +2153,24 @@ async fn update_instance_settings_handler(
if let Some(v) = body.modal_closer_enabled {
settings.modal_closer_enabled = v;
}
database::update_instance_settings(&conn, &settings)?;
if let Some(mask) = body.reorder_children_role_bits {
if mask != settings.reorder_children_role_bits {
if !auth_user.has_role(ROLE_OWNER) {
return Err(ApiError::forbidden(
"only the owner can change who may reorder child entries",
));
}
let grantable = database::grantable_role_bits(&tx)?;
if mask & !grantable != 0 {
return Err(ApiError::bad_request(
"reorder_children_role_bits may only contain bits of existing non-guest roles",
));
}
settings.reorder_children_role_bits = mask;
}
}
database::update_instance_settings(&tx, &settings)?;
tx.commit()?;
Ok(StatusCode::NO_CONTENT)
}
@ -2440,6 +2518,7 @@ struct UpdateInstanceSettingsBody {
ublock_enabled: Option<bool>,
cookie_ext_enabled: Option<bool>,
modal_closer_enabled: Option<bool>,
reorder_children_role_bits: Option<u32>,
}
async fn admin_list_users(
@ -3031,6 +3110,78 @@ mod tests {
format!("session={}", sess_uid)
}
/// Creates an active user holding `roles` (assign_role adds the cumulative ones:
/// user for any non-guest, admin for owner) and returns a session cookie.
fn make_role_session(auth_path: &std::path::Path, username: &str, roles: &[&str]) -> String {
let conn = archivr_core::database::open_auth_db(auth_path).unwrap();
let owner_id: i64 = conn
.query_row(
"SELECT id FROM users WHERE username = 'testowner'",
[],
|r| r.get(0),
)
.unwrap();
let uid = database::create_user(&conn, username, None, "dummy", owner_id).unwrap();
let user_id = database::get_user_id_by_uid(&conn, &uid).unwrap().unwrap();
for role in roles {
database::assign_role(&conn, user_id, role, owner_id).unwrap();
}
// assign_role deletes sessions, so create the session afterwards.
let bits = database::compute_role_bits(&conn, user_id).unwrap();
format!(
"session={}",
database::create_session(&conn, user_id, bits, None).unwrap()
)
}
fn patch_settings_request(body: serde_json::Value, cookie: &str) -> Request<Body> {
Request::builder()
.method("PATCH")
.uri("/api/admin/instance-settings")
.header("content-type", "application/json")
.header("cookie", cookie)
.body(Body::from(body.to_string()))
.unwrap()
}
async fn get_settings_json(
registry: ServerRegistry,
auth_path: std::path::PathBuf,
cookie: &str,
) -> serde_json::Value {
let resp = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/admin/instance-settings")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp).await
}
async fn me_json(
registry: ServerRegistry,
auth_path: std::path::PathBuf,
cookie: &str,
) -> serde_json::Value {
let resp = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp).await
}
fn make_test_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry {
let conn = database::open_or_initialize(archive_path).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
@ -3065,6 +3216,38 @@ mod tests {
.unwrap()
}
fn make_test_child(
archive_path: &std::path::Path,
parent_id: i64,
title: &str,
url: &str,
) -> archivr_core::database::ArchivedEntry {
let conn = database::open_or_initialize(archive_path).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
let si = database::upsert_source_identity(&conn, "web", "page", None, Some(url), url)
.unwrap();
database::create_archived_entry(
&conn,
&database::NewEntry {
source_identity_id: si,
archive_run_id: run.id,
parent_entry_id: Some(parent_id),
root_entry_id: Some(parent_id),
created_by_user_id: user_id,
owned_by_user_id: user_id,
source_kind: "web".to_string(),
entity_kind: "page".to_string(),
title: Some(title.to_string()),
visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
)
.unwrap()
}
fn add_summary_test_artifact(
archive_path: &std::path::Path,
entry_id: i64,
@ -4220,6 +4403,35 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_response_includes_can_reorder_children() {
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.sqlite");
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
let hash = crate::auth::hash_password("pw").unwrap();
archivr_core::database::create_owner(&conn, "owner", &hash).unwrap();
}
let registry = ServerRegistry {
archives: vec![],
bind: None,
auth_db_path: None,
};
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(r#"{"username":"owner","password":"pw"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_json(response).await["can_reorder_children"], true);
}
#[tokio::test]
async fn create_token_requires_auth() {
let (test_app, _dir) = make_test_app();
@ -4841,6 +5053,7 @@ mod tests {
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["public_index_enabled"], false);
assert_eq!(json["open_registration_enabled"], false);
assert_eq!(json["reorder_children_role_bits"], 12);
}
#[tokio::test]
@ -6166,6 +6379,328 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
fn reorder_request(parent_uid: &str, uids: &[&str], cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.method("PUT")
.uri(format!("/api/archives/test/entries/{parent_uid}/children/order"))
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder
.body(Body::from(serde_json::json!({ "child_uids": uids }).to_string()))
.unwrap()
}
async fn child_uids_via_api(
registry: ServerRegistry,
auth_path: std::path::PathBuf,
cookie: &str,
parent_uid: &str,
) -> Vec<String> {
let resp = app(registry, auth_path)
.oneshot(
Request::builder()
.uri(format!("/api/archives/test/entries/{parent_uid}/children"))
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp)
.await
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_uid"].as_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn reorder_entry_children_requires_auth() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &[&a.entry_uid], None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// owner is allowed by the default mask
#[tokio::test]
async fn reorder_entry_children_persists_order() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let cookie = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let c = make_test_child(&archive_path, parent.id, "C", "https://example.com/c");
let order = [c.entry_uid.as_str(), a.entry_uid.as_str(), b.entry_uid.as_str()];
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&cookie)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
child_uids_via_api(registry, auth_path, &cookie, &parent.entry_uid).await,
order
);
}
#[tokio::test]
async fn reorder_entry_children_rejects_mismatched_set() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let cookie = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let other = make_test_entry(&archive_path);
let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str());
let bad: [&[&str]; 3] = [&[a_u], &[a_u, b_u, other.entry_uid.as_str()], &[a_u, a_u]];
for uids in bad {
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, uids, Some(&cookie)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{uids:?}");
}
assert_eq!(
child_uids_via_api(registry, auth_path, &cookie, &parent.entry_uid).await,
[a_u, b_u]
);
}
#[tokio::test]
async fn reorder_entry_children_returns_404_for_unknown_parent() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let cookie = make_test_session(&auth_path);
let resp = app(registry, auth_path)
.oneshot(reorder_request("no-such-uid", &[], Some(&cookie)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn reorder_entry_children_denies_plain_user_by_default() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let plain = make_role_session(&auth_path, "plain", &[]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str());
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &[b_u, a_u], Some(&plain)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert_eq!(
child_uids_via_api(registry, auth_path, &owner, &parent.entry_uid).await,
[a_u, b_u]
);
}
#[tokio::test]
async fn reorder_entry_children_allows_admin_by_default() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let admin = make_role_session(&auth_path, "adm", &["admin"]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str());
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &[b_u, a_u], Some(&admin)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
child_uids_via_api(registry, auth_path, &admin, &parent.entry_uid).await,
[b_u, a_u]
);
}
#[tokio::test]
async fn reorder_entry_children_allows_custom_role_after_owner_grants_it() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
let role = database::create_custom_role(&conn, "editor", "Editor").unwrap();
assert_eq!(role.bit_position, 4);
}
let editor = make_role_session(&auth_path, "ed", &["editor"]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let order = [b.entry_uid.as_str(), a.entry_uid.as_str()];
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&editor)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 4 | 8 | 16 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&editor)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn reorder_entry_children_hides_parent_invisible_to_granted_role() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let user = make_role_session(&auth_path, "plain", &["user"]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
// Admin/owner-only membership: the USER role cannot see the parent or children.
database::open_or_initialize(&archive_path)
.unwrap()
.execute("UPDATE collection_entries SET visibility_bits = 12", [])
.unwrap();
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 2 | 4 | 8 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let order = [b.entry_uid.as_str(), a.entry_uid.as_str()];
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&user)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "granted but cannot see the parent");
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&owner)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT, "owner sees every entry");
}
#[tokio::test]
async fn reorder_entry_children_empty_mask_denies_everyone() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 0 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &[&a.entry_uid], Some(&owner)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn instance_settings_reorder_mask_requires_owner_to_change() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let admin = make_role_session(&auth_path, "adm", &["admin"]);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 2 }),
&admin,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let json = get_settings_json(registry.clone(), auth_path.clone(), &admin).await;
assert_eq!(json["reorder_children_role_bits"], 12);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 12, "open_registration_enabled": true }),
&admin,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let json = get_settings_json(registry.clone(), auth_path.clone(), &admin).await;
assert_eq!(json["open_registration_enabled"], true);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 14 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let json = get_settings_json(registry, auth_path, &owner).await;
assert_eq!(json["reorder_children_role_bits"], 14);
}
#[tokio::test]
async fn instance_settings_reorder_mask_rejects_invalid_bits() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
for mask in [16u32, 13, 1u32 << 31] {
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": mask }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{mask}");
}
let json = get_settings_json(registry, auth_path, &owner).await;
assert_eq!(json["reorder_children_role_bits"], 12);
}
#[tokio::test]
async fn auth_me_reports_can_reorder_children() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let plain = make_role_session(&auth_path, "plain", &[]);
let me = me_json(registry.clone(), auth_path.clone(), &owner).await;
assert_eq!(me["can_reorder_children"], true);
let me = me_json(registry.clone(), auth_path.clone(), &plain).await;
assert_eq!(me["can_reorder_children"], false);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 14 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let me = me_json(registry, auth_path, &plain).await;
assert_eq!(me["can_reorder_children"], true);
}
#[tokio::test]
async fn auth_me_returns_humanize_slugs_false_by_default() {
let dir = tempfile::tempdir().unwrap();
@ -6950,24 +7485,7 @@ mod tests {
// Create parent entry.
let parent = make_test_entry(&archive_path);
// Create child entry referencing parent.
let child = {
let conn = database::open_or_initialize(&archive_path).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
let si = database::upsert_source_identity(
&conn, "web", "page", None,
Some("https://example.com/child"), "https://example.com/child",
).unwrap();
database::create_archived_entry(&conn, &database::NewEntry {
source_identity_id: si, archive_run_id: run.id,
parent_entry_id: Some(parent.id), root_entry_id: Some(parent.id),
created_by_user_id: user_id, owned_by_user_id: user_id,
source_kind: "web".to_string(), entity_kind: "page".to_string(),
title: Some("Child Entry".to_string()), visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(), display_metadata_json: None,
}).unwrap()
};
let child = make_test_child(&archive_path, parent.id, "Child Entry", "https://example.com/child");
// Put parent in a public collection with guest visibility.
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session, "PubParent", "pub-parent", 3, false,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@
<title>Archivr</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<script type="module" crossorigin src="/assets/index-BxBKvOHp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1h0SqIvL.css">
<script type="module" crossorigin src="/assets/index-CKGxin5o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKWq7vB.css">
</head>
<body>
<div id="root"></div>

View file

@ -46,7 +46,7 @@ Archivr is a self-hosted tool for capturing and preserving digital content — Y
- **Deduplication** — SHA3-256 content-addressed blob store shared across all captures; identical files are stored once
- **Tags and search** — hierarchical tag tree, full-text search (including the latest completed summary and its generated JSON tags), filterable entry list
- **Multiple archives** — the server mounts any number of separate archives from a single TOML config
- **Role-based auth** — Guest / User / Admin / Owner roles; session cookies and API tokens; Argon2 passwords
- **Role-based auth** — Guest / User / Admin / Owner roles; session cookies and API tokens; Argon2 passwords; the Owner can choose which roles (including custom ones) may reorder child entries
- **Quality selection** — choose video quality or audio-only per capture; a live metadata probe populates the selector before download
- **LLM summaries** — regenerable per-entry summary via the Anthropic HTTP API, an OpenAI-compatible HTTP API, a local `claude` CLI, or a local `codex` CLI; triggered manually from the entry rail, never automatically on capture; text-only by default, with an explicit `Include attached images` option
- **Text notes** — capture a plain-text or Markdown note with a title and no URL; the byte-preserving note is stored as a normal deduplicated blob and opens in the usual entry-rail preview
@ -146,6 +146,8 @@ Capturing a playlist or channel creates a **container entry** with each video ar
**Sync mode:** when re-archiving a playlist or channel, enable sync mode in the capture dialog to skip videos that are already in the archive. Only new videos are downloaded; the existing container is reused.
**Reordering:** if your role is allowed (by default Admin and Owner), expand a container on the main page and drag a video by its handle to change its position. On touch screens and phone-sized windows, where dragging isn't available, ↑/↓ buttons appear instead. Alt+↑/↓ on a selected row works everywhere. The order is saved to the archive. Videos added later by sync mode appear at the end. The Owner chooses which roles, including custom roles, may reorder under **Settings → Instance → Permissions**.
### Video quality and audio-only
When capturing a yt-dlp-backed source through the web UI, a metadata probe runs first and populates the quality selector with heights actually available in that video:
@ -429,7 +431,6 @@ cargo run -p archivr-server -- ./archivr-server.toml
bun install
bun run dev # Vite dev server
bun run build # → crates/archivr-server/static/
bun run storybook # Component QA on :6006
# Nix
nix develop # dev shell

View file

@ -1,13 +0,0 @@
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../src/**/*.stories.{js,jsx,ts,tsx}'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;

View file

@ -1,5 +0,0 @@
import '../src/styles.css';
export const parameters = {
layout: 'fullscreen',
};

File diff suppressed because it is too large Load diff

View file

@ -6,24 +6,14 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.6.20",
"@storybook/addon-interactions": "^7.6.20",
"@storybook/blocks": "^7.6.20",
"@storybook/preview-api": "^7.6.20",
"@storybook/react": "^7.6.20",
"@storybook/react-vite": "^7.6.20",
"@storybook/test": "^7.6.20",
"@vitejs/plugin-react": "^4.3.4",
"storybook": "^7.6.20",
"vite": "^5.4.11"
}
}

View file

@ -123,6 +123,9 @@ export default function App() {
const [collections, setCollections] = useState([])
const [entries, setEntries] = useState([])
const [deletedUids, setDeletedUids] = useState(() => new Set())
// Renames made in this session, keyed by entry_uid. Child rows live in each
// EntryRow's private state (not `entries`), so they read titles through this.
const [renamedTitles, setRenamedTitles] = useState(() => new Map())
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
const [selectedEntry, setSelectedEntry] = useState(null)
const [selectedUids, setSelectedUids] = useState(() => {
@ -427,6 +430,9 @@ export default function App() {
}, [tagFilter]);
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
setRenamedTitles(prev => new Map(prev).set(entryUid, newTitle))
const cached = entryCacheRef.current.get(entryUid)
if (cached) entryCacheRef.current.set(entryUid, { ...cached, title: newTitle })
setEntries(prev => prev.map(e =>
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
))
@ -637,6 +643,10 @@ export default function App() {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const handleChildReorderError = useCallback((message) => {
handleToast(message, null, 'error', 'Reorder failed')
}, [handleToast])
const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', 'true')
setUblockWarningIgnored(true)
@ -752,7 +762,10 @@ export default function App() {
archiveId={archiveId}
pendingCaptures={pendingCaptures}
deletedUids={deletedUids}
renamedTitles={renamedTitles}
isPublicSession={!currentUser}
canReorder={!!currentUser?.can_reorder_children}
onChildReorderError={handleChildReorderError}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}

View file

@ -77,6 +77,26 @@ export async function fetchEntryChildren(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
}
// Persists a new sibling order for a parent's direct children. `childUids`
// must be exactly the parent's current children; the server answers 400
// when the set is stale (e.g. a sync added a video) — err.status carries it.
export async function reorderEntryChildren(archiveId, parentEntryUid, childUids) {
const res = await fetch(
`/api/archives/${archiveId}/entries/${parentEntryUid}/children/order`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ child_uids: childUids }),
}
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
}
// Fetch multiple artifact JSON payloads for an entry in parallel.
// Returns a Promise<Array> preserving index order.
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
@ -359,7 +379,7 @@ export async function updateInstanceSettings(patch) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
// ── Admin helpers ─────────────────────────────────────────────────────────────

View file

@ -1,41 +0,0 @@
import { useState } from 'react';
import CaptureDialog from './CaptureDialog';
export default {
component: CaptureDialog,
tags: ['autodocs'],
};
function CaptureDialogWrapper(args) {
const [open, setOpen] = useState(args.open);
return (
<div>
<button onClick={() => setOpen(true)} style={{ padding: '8px 16px', marginBottom: '16px' }}>
Open Capture Dialog
</button>
<CaptureDialog
{...args}
open={open}
onClose={() => setOpen(false)}
/>
</div>
);
}
export const Default = {
render: (args) => <CaptureDialogWrapper {...args} />,
args: {
open: false,
archiveId: 'archive_1',
onCaptured: () => {},
},
};
export const Open = {
args: {
open: true,
archiveId: 'archive_1',
onCaptured: () => {},
},
};

View file

@ -2,7 +2,7 @@ import SkeletonEntryRow from './SkeletonEntryRow';
import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids, isPublicSession }) {
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids, renamedTitles, isPublicSession, canReorder = false, onChildReorderError }) {
return (
<section id="archive-view" className="view is-active">
<div className="entry-table">
@ -29,7 +29,10 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
onRowClick={onRowClick}
selectedUids={selectedUids}
deletedUids={deletedUids}
renamedTitles={renamedTitles}
isPublicSession={isPublicSession}
canReorder={canReorder}
onReorderError={onChildReorderError}
/>
))}
</div>

View file

@ -1,15 +1,23 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
import { fetchEntryChildren } from '../api';
import { fetchEntryChildren, reorderEntryChildren } from '../api';
function ChildRow({ entry, index, onRowClick, selectedUids }) {
function ChildRow({
entry, index, onRowClick, selectedUids,
isFirst, isLast, reorderDisabled, onMove, canReorder,
isDragging, dropEdge, onHandleDragStart, onHandleDragEnd, onRowDragOver, onRowDrop,
}) {
const isSelected = (selectedUids?.size === 1) && selectedUids.has(entry.entry_uid);
const isMultiSelected = (selectedUids?.size >= 2) && selectedUids.has(entry.entry_uid);
const label = valueText(entry.title) || valueText(entry.entry_uid);
const cls = ['child-entry-row',
index % 2 === 0 ? 'child-entry-row--light' : 'child-entry-row--dark',
isSelected && 'is-selected',
isMultiSelected && 'is-multi-selected',
isDragging && 'is-dragging',
dropEdge === 'before' && 'is-drop-before',
dropEdge === 'after' && 'is-drop-after',
].filter(Boolean).join(' ');
return (
@ -19,15 +27,54 @@ function ChildRow({ entry, index, onRowClick, selectedUids }) {
data-entry-uid={entry.entry_uid}
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
onClick={e => onRowClick(entry, e)}
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
onDragOver={canReorder ? onRowDragOver : undefined}
onDrop={canReorder ? onRowDrop : undefined}
aria-keyshortcuts={canReorder ? 'Alt+ArrowUp Alt+ArrowDown' : undefined}
onKeyDown={e => {
if (canReorder && e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
if (!reorderDisabled) onMove(e.key === 'ArrowUp' ? -1 : 1);
return;
}
if (e.key === 'Enter') onRowClick(entry, e);
}}
>
<div className="col-check" aria-hidden="true" />
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
<div className="col-title">
{canReorder && (
<span className="child-reorder-controls">
<span
className="child-drag-handle"
draggable={!reorderDisabled}
title="Drag to reorder (or Alt+↑/↓)"
aria-hidden="true"
onClick={e => e.stopPropagation()}
onDragStart={onHandleDragStart}
onDragEnd={onHandleDragEnd}
>⋮⋮</span>
<button
type="button"
className="child-move-btn"
aria-label={`Move ${label} up`}
disabled={reorderDisabled || isFirst}
onClick={e => { e.stopPropagation(); onMove(-1); }}
onKeyDown={e => e.stopPropagation()}
>↑</button>
<button
type="button"
className="child-move-btn"
aria-label={`Move ${label} down`}
disabled={reorderDisabled || isLast}
onClick={e => { e.stopPropagation(); onMove(1); }}
onKeyDown={e => e.stopPropagation()}
>↓</button>
</span>
)}
<span className="source-icon">
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
</span>
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
<span className="entry-title">{label}</span>
</div>
<div className="col-type">
<span className="type-pill">{valueText(entry.entity_kind)}</span>
@ -40,11 +87,16 @@ function ChildRow({ entry, index, onRowClick, selectedUids }) {
);
}
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, isPublicSession }) {
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, renamedTitles, isPublicSession, canReorder = false, onReorderError }) {
const [favFailed, setFavFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState(null);
const [childrenLoading, setChildrenLoading] = useState(false);
const [reorderSaving, setReorderSaving] = useState(false);
const [dragUid, setDragUid] = useState(null);
const [dropTarget, setDropTarget] = useState(null); // { uid, after: bool }
const childListRef = useRef(null);
const focusUidRef = useRef(null);
const showFavicon =
entry.source_kind === 'web' &&
@ -94,6 +146,83 @@ export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMul
}
}
// Deleted children are hidden locally and already gone server-side, so the
// filtered list is exactly the set the server expects. Renames are applied on
// top of the fetched list, since that list is only fetched once per expansion.
const visibleChildren = (children ?? [])
.filter(c => !deletedUids?.has(c.entry_uid))
.map(c => (renamedTitles?.has(c.entry_uid) ? { ...c, title: renamedTitles.get(c.entry_uid) } : c));
async function commitChildOrder(next) {
const prev = children;
setChildren(next); // optimistic
setReorderSaving(true);
try {
await reorderEntryChildren(archiveId, entry.entry_uid, next.map(c => c.entry_uid));
} catch (err) {
setChildren(prev); // revert
onReorderError?.(err.message);
if (err.status === 400) { // stale set: resync with the server
try { setChildren(await fetchEntryChildren(archiveId, entry.entry_uid)); } catch (_) { /* keep reverted list */ }
}
} finally {
setReorderSaving(false);
}
}
function moveChild(fromIdx, toIdx) {
if (!canReorder || reorderSaving || fromIdx === toIdx || toIdx < 0 || toIdx >= visibleChildren.length) return;
const next = visibleChildren.slice();
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
commitChildOrder(next);
}
function handleChildMove(idx, delta) {
focusUidRef.current = visibleChildren[idx]?.entry_uid ?? null;
moveChild(idx, idx + delta);
}
// Keep keyboard focus on the moved row (React re-inserts keyed nodes,
// which can drop focus in some browsers).
useEffect(() => {
const uid = focusUidRef.current;
if (!uid || !childListRef.current) return;
focusUidRef.current = null;
const row = childListRef.current.querySelector(`[data-entry-uid="${CSS.escape(uid)}"]`);
if (row && !row.contains(document.activeElement)) row.focus();
}, [children]);
function handleDragStart(uid, e) {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', uid); // Firefox requires data to start a drag
const rowEl = e.currentTarget.closest('.child-entry-row');
if (rowEl) e.dataTransfer.setDragImage(rowEl, 16, rowEl.offsetHeight / 2);
setDragUid(uid);
}
function handleDragEnd() { setDragUid(null); setDropTarget(null); }
function handleRowDragOver(uid, e) {
if (!dragUid) return; // foreign drags (files, other parents) are not droppable
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
if (dropTarget?.uid !== uid || dropTarget?.after !== after) setDropTarget({ uid, after });
}
function handleRowDrop(uid, e) {
if (!dragUid) return;
e.preventDefault();
const fromIdx = visibleChildren.findIndex(c => c.entry_uid === dragUid);
const targetIdx = visibleChildren.findIndex(c => c.entry_uid === uid);
const after = dropTarget?.uid === uid ? dropTarget.after : false;
handleDragEnd();
if (fromIdx === -1 || targetIdx === -1) return;
let toIdx = targetIdx + (after ? 1 : 0);
if (fromIdx < toIdx) toIdx -= 1;
moveChild(fromIdx, toIdx);
}
const outerClass = [
'entry-row-outer',
rowIndex % 2 === 0 ? 'entry-row-outer--light' : 'entry-row-outer--dark',
@ -154,18 +283,32 @@ export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMul
{expanded && (
<>
{childrenLoading && <div className="child-entries-loading">Loading…</div>}
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
{children && children
.filter(c => !deletedUids?.has(c.entry_uid))
.map((child, idx) => (
<ChildRow
key={child.entry_uid}
entry={child}
index={idx}
onRowClick={onRowClick}
selectedUids={selectedUids}
/>
))}
<div
className="child-entries"
ref={childListRef}
aria-busy={reorderSaving}
aria-label={`${entry.child_count} child entries`}
>
{visibleChildren.map((child, idx) => (
<ChildRow
key={child.entry_uid}
entry={child}
index={idx}
onRowClick={onRowClick}
selectedUids={selectedUids}
isFirst={idx === 0}
isLast={idx === visibleChildren.length - 1}
reorderDisabled={reorderSaving}
canReorder={canReorder}
onMove={delta => handleChildMove(idx, delta)}
isDragging={dragUid === child.entry_uid}
dropEdge={dropTarget?.uid === child.entry_uid && dragUid !== child.entry_uid ? (dropTarget.after ? 'after' : 'before') : null}
onHandleDragStart={e => handleDragStart(child.entry_uid, e)}
onHandleDragEnd={handleDragEnd}
onRowDragOver={e => handleRowDragOver(child.entry_uid, e)}
onRowDrop={e => handleRowDrop(child.entry_uid, e)}
/>
))}
</div>
</>
)}

View file

@ -1,66 +0,0 @@
import EntryRow from './EntryRow';
export default {
component: EntryRow,
tags: ['autodocs'],
};
const sampleEntry = {
entry_uid: 'entry_123',
title: 'A Great Article About Web Design',
archived_at: '2026-06-27T10:30:00Z',
entity_kind: 'page',
source_kind: 'web',
total_artifact_bytes: 2048576,
original_url: 'https://example.com/article',
has_favicon: false,
};
const videoEntry = {
entry_uid: 'entry_456',
title: 'React Performance Tips',
archived_at: '2026-06-26T14:15:00Z',
entity_kind: 'video',
source_kind: 'youtube',
total_artifact_bytes: 104857600,
original_url: 'https://youtube.com/watch?v=xyz',
has_favicon: false,
};
export const Default = {
args: {
entry: sampleEntry,
archiveId: 'archive_1',
isSelected: false,
onSelect: () => {},
},
decorators: [
(Story) => (
<div style={{
display: 'grid',
gridTemplateColumns: '178px 38% 130px 110px 34%',
gap: '10px',
padding: '10px',
background: 'var(--paper-3)',
}}>
<Story />
</div>
),
],
};
export const Selected = {
args: {
...Default.args,
isSelected: true,
},
decorators: Default.decorators,
};
export const Video = {
args: {
...Default.args,
entry: videoEntry,
},
decorators: Default.decorators,
};

View file

@ -6,13 +6,16 @@ import {
getInstanceSettings, updateInstanceSettings,
scanOrphanBlobs, deleteOrphanBlobs,
listCookieRules, createCookieRule, updateCookieRule, deleteCookieRule,
listRoles, fetchMe,
} from '../api.js'
const ROLE_ADMIN = 4
const ROLE_OWNER = 8
export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const isOwner = !!currentUser && (currentUser.role_bits & ROLE_OWNER) !== 0
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'extensions', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', extensions: 'Extensions', storage: 'Storage' }
@ -32,7 +35,7 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'instance' && isAdmin && <InstanceTab isOwner={isOwner} setCurrentUser={setCurrentUser} />}
{tab === 'cookies' && isAdmin && <CookiesTab />}
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
@ -239,26 +242,56 @@ function TokensTab() {
)
}
function InstanceTab() {
function InstanceTab({ isOwner, setCurrentUser }) {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState(null)
const [roles, setRoles] = useState([])
const [reorderBits, setReorderBits] = useState(12)
const [permSaving, setPermSaving] = useState(false)
const [permMsg, setPermMsg] = useState(null)
useEffect(() => {
(async () => {
try { setSettings(await getInstanceSettings()) }
try {
const [s, r] = await Promise.all([getInstanceSettings(), listRoles()])
setSettings(s)
setRoles(r.filter(role => role.bit_position > 0))
setReorderBits(s.reorder_children_role_bits ?? 12)
}
catch (e) { setError(e.message) }
finally { setLoading(false) }
})()
}, [])
function toggleRole(bit, checked) {
setReorderBits(b => checked ? (b | bit) >>> 0 : (b & ~bit) >>> 0)
}
async function handleSavePermissions(e) {
e.preventDefault()
setPermSaving(true); setPermMsg(null)
try {
await updateInstanceSettings({ reorder_children_role_bits: reorderBits })
setSettings(s => ({ ...s, reorder_children_role_bits: reorderBits }))
const me = await fetchMe()
if (me) setCurrentUser?.(me) // owner's own can_reorder_children may change
setPermMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setPermMsg({ ok: false, text: err.message })
} finally {
setPermSaving(false)
}
}
async function handleSave(e) {
e.preventDefault()
setSaving(true); setSaveMsg(null)
try {
await updateInstanceSettings(settings)
const { reorder_children_role_bits: _mask, ...rest } = settings
await updateInstanceSettings(rest)
setSaveMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setSaveMsg({ ok: false, text: err.message })
@ -302,6 +335,31 @@ function InstanceTab() {
</button>
</form>
</div>
<div className="form-section">
<h2>Permissions</h2>
<form onSubmit={handleSavePermissions}>
<label className="form-label">Reorder child entries</label>
{roles.map(role => {
const bit = (1 << role.bit_position) >>> 0
return (
<label key={role.role_uid} className="checkbox-row">
<input type="checkbox" disabled={!isOwner || permSaving}
checked={(reorderBits & bit) !== 0}
onChange={e => toggleRole(bit, e.target.checked)} />
{role.name}{!role.is_builtin && <span className="muted"> (custom)</span>}
</label>
)
})}
<p className="form-hint">Roles are cumulative: every signed-in account also has User, and owners also have Admin. Checking User lets every signed-in account reorder.</p>
{!isOwner && <p className="form-hint">Only the owner can change this.</p>}
{permMsg && <div className={`form-msg form-msg--${permMsg.ok ? 'ok' : 'err'}`}>{permMsg.text}</div>}
{isOwner && (
<button className="btn-primary" type="submit" disabled={permSaving}>
{permSaving ? 'Saving\u2026' : 'Save Permissions'}
</button>
)}
</form>
</div>
</div>
)
}

View file

@ -1,31 +0,0 @@
import SkeletonEntryRow from './SkeletonEntryRow';
export default {
component: SkeletonEntryRow,
tags: ['autodocs'],
parameters: { layout: 'padded' },
};
function PendingRows({ locators }) {
return (
<div className="entry-table">
<div id="entries-body">
{locators.map(locator => (
<SkeletonEntryRow key={locator} locator={locator} />
))}
</div>
</div>
);
}
export const Examples = {
render: () => (
<PendingRows
locators={[
'https://example.com/articles/a-small-and-useful-page',
'https://www.youtube.com/watch?v=abc123&list=PL1234567890',
'tweet:1891234567890123456',
]}
/>
),
};

View file

@ -1,167 +0,0 @@
import { useState, useEffect } from 'react';
import TagsView from './TagsView';
// Installs a per-story fetch stub that intercepts /api/ tag mutations and
// returns realistic Tag shapes so renameTag/moveTag/createTag don't throw.
// Installed in useEffect so it never leaks into other stories and won't
// double-wrap on re-renders.
function ApiStub({ children }) {
useEffect(() => {
const realFetch = window.fetch.bind(window);
window.fetch = (url, opts) => {
if (typeof url === 'string' && url.startsWith('/api/')) {
const method = (opts?.method ?? 'GET').toUpperCase();
if (method === 'DELETE') {
return Promise.resolve(new Response(null, { status: 204 }));
}
// Parse request body to construct a realistic Tag shape.
// renameTag sends { name }, moveTag/createTag send path info.
// full_path must be present or callers throw on updated.full_path.
let body = {};
try { body = JSON.parse(opts?.body ?? '{}'); } catch { /* ignore */ }
const slug = (body.name ?? body.path ?? 'stub')
.trim().replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+|-+$/g, '') || 'stub';
const stubTag = {
tag_uid: 'stub-uid',
name: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
slug,
full_path: `/${slug}`,
};
return Promise.resolve(
new Response(JSON.stringify(stubTag), {
status: method === 'POST' ? 201 : 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
return realFetch(url, opts);
};
return () => { window.fetch = realFetch; };
}, []);
return children;
}
function withApiStub(Story) {
return <ApiStub><Story /></ApiStub>;
}
export default {
component: TagsView,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [withApiStub],
};
// ── Shared fixtures ───────────────────────────────────────────────────────
const noop = () => {};
function tag(tag_uid, name, slug, full_path, entry_count = 0, children = [], subtree_count = null) {
return { tag: { tag_uid, name, slug, full_path }, entry_count, subtree_count: subtree_count ?? entry_count, children };
}
const sampleTree = [
tag('t1', 'Science', 'science', '/science', 12, [
tag('t2', 'Computer Science', 'computer-science', '/science/computer-science', 7, [
tag('t3', 'Algorithms', 'algorithms', '/science/computer-science/algorithms', 3),
tag('t4', 'Compilers', 'compilers', '/science/computer-science/compilers', 1),
], 11),
tag('t5', 'Physics', 'physics', '/science/physics', 4),
], 23),
tag('t6', 'History', 'history', '/history', 5, [
tag('t7', 'Ancient', 'ancient', '/history/ancient', 2),
], 7),
tag('t8', 'Reading List', 'reading-list', '/reading-list', 0),
];
// Wrapper that wires local state so onTagsRefresh/onTagRenamed callbacks
// keep the tree consistent within a story session.
function TagsViewSandbox({ initialNodes = sampleTree, tagFilter = null, humanizeTags = false }) {
const [nodes, setNodes] = useState(initialNodes);
const [filter, setFilter] = useState(tagFilter);
function handleTagRenamed(oldPath, newPath) {
if (filter === oldPath) setFilter(newPath);
else if (filter?.startsWith(oldPath + '/')) setFilter(newPath + filter.slice(oldPath.length));
}
function handleTagDeleted(deletedPath) {
if (filter === deletedPath || filter?.startsWith(deletedPath + '/')) setFilter(null);
}
// onTagsRefresh is a no-op here: in a real app it re-fetches; the stub
// tag returned from fetch won't match our fixture tree, so the tree stays
// as-is after mutations. That is acceptable for visual QA purposes.
return (
<div style={{ maxWidth: 340, fontFamily: 'Helvetica Neue, sans-serif' }}>
<TagsView
archiveId="demo"
tagNodes={nodes}
tagFilter={filter}
onTagFilterSet={setFilter}
onViewChange={noop}
onTagRenamed={handleTagRenamed}
onTagDeleted={handleTagDeleted}
onTagsRefresh={noop}
humanizeTags={humanizeTags}
/>
</div>
);
}
// ── Stories ───────────────────────────────────────────────────────────────
/** Default view with a nested tag tree. All interactions are exercisable:
* click + New / Move to open the picker modal; click a tag to filter;
* double-click or pencil to rename; × to delete.
* API mutations are stubbed — the tree won't re-fetch after saves,
* but no network errors will occur. */
export const Default = {
render: () => <TagsViewSandbox />,
};
/** Empty archive — "No tags yet." shown; + New still works. */
export const Empty = {
render: () => <TagsViewSandbox initialNodes={[]} />,
};
/** Humanize mode: slugs displayed as Title Case names. */
export const HumanizedNames = {
render: () => <TagsViewSandbox humanizeTags />,
};
/** Active tag filter — header shows the current filter path. */
export const WithActiveFilter = {
render: () => (
<TagsViewSandbox tagFilter="/science/computer-science" />
),
};
/** Flat list with no nesting. */
export const FlatList = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('a1', 'Books', 'books', '/books', 8),
tag('a2', 'Films', 'films', '/films', 3),
tag('a3', 'Music', 'music', '/music', 0),
tag('a4', 'Podcasts', 'podcasts', '/podcasts', 14),
]}
/>
),
};
/** Tags with large entry counts — verifies count badge layout. */
export const HighCounts = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('h1', 'All', 'all', '/all', 9999, [
tag('h2', 'Starred', 'starred', '/all/starred', 432),
tag('h3', 'Archive', 'archive', '/all/archive', 1204),
]),
]}
/>
),
};

View file

@ -1,36 +0,0 @@
import Topbar from './Topbar';
export default {
component: Topbar,
tags: ['autodocs'],
};
const defaultArchives = [
{ id: '1', label: 'Main Archive' },
{ id: '2', label: 'Research' },
{ id: '3', label: 'Screenshots' },
];
export const Default = {
args: {
archives: defaultArchives,
archiveId: '1',
onArchiveChange: () => {},
view: 'archive',
onViewChange: () => {},
onCaptureClick: () => {},
},
};
export const WithUser = {
args: {
...Default.args,
},
decorators: [
(Story) => (
<div style={{ background: 'var(--paper)' }}>
<Story />
</div>
),
],
};

View file

@ -1,102 +0,0 @@
export default {
title: 'UI Patterns',
tags: ['autodocs'],
};
export const Buttons = () => (
<div style={{ padding: '20px', display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<button className="capture-button">+ Capture</button>
<button className="nav-link">Nav Link</button>
<button className="nav-link is-active">Nav Link (Active)</button>
<button className="assign-tag-btn">Add Tag</button>
<button className="assign-tag-btn" style={{ pointerEvents: 'none', opacity: 0.6 }}>Disabled</button>
</div>
);
export const FormInputs = () => (
<div style={{ padding: '20px', maxWidth: '400px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Search Input</label>
<input className="search-input" type="search" placeholder="Search archive..." />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Capture Input</label>
<input className="capture-input" type="text" placeholder="tweet:1234567890 or https://..." />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Tag Input</label>
<input className="assign-tag-input" type="text" placeholder="/science/cs" />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Archive Switcher</label>
<select className="archive-switcher" style={{ width: '100%' }}>
<option>Main Archive</option>
<option>Research</option>
<option>Screenshots</option>
</select>
</div>
</div>
);
export const Pills = () => (
<div style={{ padding: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<span className="type-pill">page</span>
<span className="type-pill">video</span>
<span className="type-pill">tweet_thread</span>
<span className="type-pill">file</span>
</div>
);
export const TagPills = () => (
<div style={{ padding: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<span className="tag-pill">
science
<button className="remove-tag">×</button>
</span>
<span className="tag-pill">
computer-science
<button className="remove-tag">×</button>
</span>
<span className="tag-pill">
learning
<button className="remove-tag">×</button>
</span>
</div>
);
export const ColorPalette = () => (
<div style={{ padding: '20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div>
<div style={{ height: '80px', background: 'var(--ink)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Ink</strong> (#20251f)
</div>
<div>
<div style={{ height: '80px', background: 'var(--paper)', border: '1px solid var(--line)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Paper</strong> (#f5f0e7)
</div>
<div>
<div style={{ height: '80px', background: 'var(--accent)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Accent</strong> (#8d3f30)
</div>
<div>
<div style={{ height: '80px', background: 'var(--accent-2)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Accent 2</strong> (#b78342)
</div>
<div>
<div style={{ height: '80px', background: 'var(--link)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Link</strong> (#245f72)
</div>
<div>
<div style={{ height: '80px', background: 'var(--top)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Top</strong> (#141d18)
</div>
<div>
<div style={{ height: '80px', background: 'var(--muted)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Muted</strong> (#666a61)
</div>
<div>
<div style={{ height: '80px', background: 'var(--line)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Line</strong> (#d2c6b5)
</div>
</div>
);

View file

@ -3062,6 +3062,56 @@ body.has-audio-bar { padding-bottom: 56px; }
.child-entry-row .col-added { padding-left: 22px; }
.child-entry-row > div:last-child { padding-right: 22px; }
/* Child reorder: drag handle or move buttons inside .col-title, never both.
The arrows are the default (touch, no pointer, phone-sized ≤640px viewports,
and any browser that can't evaluate the query below). The one media query
switches a mouse/trackpad on a wider viewport to the drag handle, so the two
cases are exact complements without range syntax. Alt+↑/↓ on a focused row
works everywhere. */
.child-reorder-controls {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
transition: opacity 0.15s;
}
.child-drag-handle {
display: none;
cursor: grab;
padding: 0 3px;
font-size: 0.8em;
letter-spacing: -2px;
user-select: none;
color: var(--muted);
}
.child-drag-handle:active { cursor: grabbing; }
.child-move-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: none;
color: inherit;
font-size: 0.9em;
line-height: 1;
cursor: pointer;
}
.child-move-btn:disabled { opacity: 0.3; cursor: default; }
.child-move-btn:focus { outline: none; }
.child-move-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
@media (hover: hover) and (pointer: fine) and (min-width: 641px) {
/* Dimmed until the row is hovered/focused; there is hover to reveal it. */
.child-reorder-controls { gap: 1px; opacity: 0.3; }
.child-entry-row:hover .child-reorder-controls,
.child-entry-row:focus-within .child-reorder-controls { opacity: 1; }
.child-drag-handle { display: inline; }
.child-move-btn { display: none; }
}
.child-entry-row.is-dragging { opacity: 0.4; }
.child-entry-row.is-drop-before { box-shadow: inset 0 2px 0 var(--accent); }
.child-entry-row.is-drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
.child-entries[aria-busy="true"] .child-entry-row { cursor: progress; }
/* Expand chevron button */
.entry-expand-btn {
display: inline-flex;