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

feat: tag delete, rename, and per-user humanize-tags display setting (#16)

* feat: add tag delete and rename (backend + frontend)

- DELETE /api/archives/:id/tags/:tag_uid — deletes tag subtree via
  recursive CTE; entry_tag_assignments cascade automatically
- PATCH  /api/archives/:id/tags/:tag_uid { name } — renames a tag
  segment (case-preserving slug), cascades full_path to all descendants
  in one transaction using hierarchy CTE (not LIKE), returns updated Tag
- database: rename_tag + delete_tag with 7 unit tests covering subtree
  cascade, collision detection, descendant path rewrite, slug stripping
- TagsView: inline rename (pencil icon / double-click → input), × delete
  with confirmation dialog (warns about child tags)
- App.jsx: handleTagRenamed rewrites tagFilter for exact + descendant
  paths; handleTagDeleted clears filter for deleted subtree
- api.js: renameTag (PATCH, returns Tag) + deleteTag (DELETE)
- ContextRail: tag pills show displayPath(full_path) (humanized) with
  raw full_path in title tooltip; humanize-tags setting coming next

* feat: per-user humanize-tags display setting

- auth DB: idempotent migration adds users.humanize_slugs INTEGER DEFAULT 0
- GET /api/auth/me: returns humanize_slugs bool
- PATCH /api/auth/me: accepts { humanize_slugs: bool }, persisted via
  database::update_user_humanize_slugs
- displayPath() helper moved to utils.js (was local to ContextRail)
- TagsView: node label shows tag.name vs tag.slug based on humanizeTags
- ContextRail: pill label applies displayPath conditionally
- App.jsx: derives humanizeTags from currentUser.humanize_slugs, passes
  to TagsView + ContextRail; filter badge label humanized when on
- Settings > Profile: Display Preferences section with checkbox toggle,
  updates backend and currentUser immediately via setCurrentUser
- api.js: patchMe(patch) for generic PATCH /api/auth/me
- Tests: auth_me default=false, PATCH persists=true (2 route tests)
This commit is contained in:
TheGeneralist 2026-07-03 14:26:45 +02:00 committed by GitHub
parent d6b52ba06c
commit 55f85134df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 699 additions and 69 deletions

View file

@ -236,6 +236,10 @@ pub fn app_with_state(state: AppState) -> Router {
get(get_capture_job_handler),
)
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
.route(
"/api/archives/:archive_id/tags/:tag_uid",
patch(patch_tag_handler).delete(delete_tag_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags",
get(list_entry_tags).post(assign_entry_tag_handler),
@ -556,6 +560,41 @@ async fn remove_entry_tag_handler(
}
}
async fn patch_tag_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, tag_uid)): Path<(String, String)>,
Json(body): Json<PatchTagBody>,
) -> Result<Json<archive::Tag>, ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
match database::rename_tag(&conn, &tag_uid, &body.name)? {
Some(record) => Ok(Json(archive::Tag {
tag_uid: record.tag_uid,
name: record.name,
slug: record.slug,
full_path: record.full_path,
})),
None => Err(ApiError::not_found("tag not found")),
}
}
async fn delete_tag_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, tag_uid)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
if database::delete_tag(&conn, &tag_uid)? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found("tag not found"))
}
}
async fn patch_entry_handler(
State(state): State<AppState>,
auth_user: AuthUser,
@ -603,6 +642,11 @@ struct PatchEntryBody {
title: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct PatchTagBody {
name: String,
}
async fn capture_handler(
State(state): State<AppState>,
auth_user: AuthUser,
@ -776,17 +820,19 @@ async fn auth_me(
) -> Result<Json<serde_json::Value>, ApiError> {
let (user_id, role_bits) = auth_user.require_auth()?;
let conn = database::open_auth_db(&state.auth_db_path)?;
let (username, display_name): (String, Option<String>) = conn
let (username, display_name, humanize_slugs_int): (String, Option<String>, i64) = conn
.query_row(
"SELECT username, display_name FROM users WHERE id = ?1",
"SELECT username, display_name, COALESCE(humanize_slugs, 0) FROM users WHERE id = ?1",
[user_id],
|r| Ok((r.get(0)?, r.get(1)?))
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
)
.map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?;
let humanize_slugs = humanize_slugs_int != 0;
Ok(Json(serde_json::json!({
"role_bits": role_bits,
"username": username,
"display_name": display_name,
"humanize_slugs": humanize_slugs,
})))
}
@ -817,6 +863,10 @@ async fn patch_me(
database::update_user_display_name(&conn, user_id, v)?;
}
if let Some(hs) = body.humanize_slugs {
database::update_user_humanize_slugs(&conn, user_id, hs)?;
}
Ok(StatusCode::NO_CONTENT)
}
@ -905,6 +955,7 @@ struct UpdateProfileBody {
display_name: Option<String>,
current_password: Option<String>,
new_password: Option<String>,
humanize_slugs: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
@ -2835,4 +2886,76 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn auth_me_returns_humanize_slugs_false_by_default() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = body_json(response).await;
assert_eq!(
json["humanize_slugs"], false,
"humanize_slugs must default to false for new users"
);
}
#[tokio::test]
async fn patch_me_humanize_slugs_persists() {
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.sqlite");
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
}
let state = AppState {
registry: Arc::new(ServerRegistry { archives: vec![], bind: None, auth_db_path: None }),
auth_db_path: Arc::new(auth_path.clone()),
login_attempts: Arc::new(Mutex::new(HashMap::new())),
};
let session_cookie = make_test_session(&auth_path);
// PATCH humanize_slugs = true
let patch_resp = app_with_state(state.clone())
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/auth/me")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"humanize_slugs":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::NO_CONTENT);
// GET /api/auth/me — must now return humanize_slugs: true
let get_resp = app_with_state(state)
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let json = body_json(get_resp).await;
assert_eq!(
json["humanize_slugs"], true,
"humanize_slugs must be true after PATCH"
);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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