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

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));
}
}