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

@ -468,6 +468,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
)?;
// Add display_name column to users if not present (idempotent migration)
let _ = conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT", []);
// Add humanize_slugs column to users if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE users ADD COLUMN humanize_slugs INTEGER NOT NULL DEFAULT 0",
[],
);
Ok(())
}
@ -752,6 +757,14 @@ pub fn update_user_display_name(conn: &Connection, user_id: i64, display_name: O
Ok(())
}
pub fn update_user_humanize_slugs(conn: &Connection, user_id: i64, value: bool) -> Result<()> {
conn.execute(
"UPDATE users SET humanize_slugs = ?1 WHERE id = ?2",
params![value as i64, user_id],
)?;
Ok(())
}
/// Updates the user-visible title of an archived entry.
/// Returns `Ok(true)` if a row was updated, `Ok(false)` if the entry_uid was not found.
pub fn update_entry_title(conn: &Connection, entry_uid: &str, title: Option<&str>) -> Result<bool> {
@ -1639,6 +1652,113 @@ pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i6
Ok(count)
}
pub fn rename_tag(
conn: &Connection,
tag_uid: &str,
new_segment: &str,
) -> Result<Option<TagRecord>> {
// Slugify: spaces→hyphens, keep alphanumeric and hyphens (case preserved), collapse runs, strip edges.
let trimmed = new_segment.trim();
let hyphenated: String = trimmed.chars().map(|c| if c == ' ' { '-' } else { c }).collect();
let filtered: String = hyphenated
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect();
let mut new_slug = String::new();
let mut prev_hyphen = false;
for c in filtered.chars() {
if c == '-' {
if !prev_hyphen {
new_slug.push(c);
}
prev_hyphen = true;
} else {
new_slug.push(c);
prev_hyphen = false;
}
}
let new_slug = new_slug.trim_matches('-').to_string();
if new_slug.is_empty() {
bail!("new segment slugifies to empty string");
}
// Fetch existing tag.
let tag = match get_tag_by_uid(conn, tag_uid)? {
Some(t) => t,
None => return Ok(None),
};
// Build new full_path by replacing the last segment.
let old_prefix = tag.full_path.clone();
let parent_prefix = match old_prefix.rfind('/') {
Some(idx) => &old_prefix[..idx],
None => "",
};
let new_full_path = format!("{}/{}", parent_prefix, new_slug);
// Collision check: bail if another tag already owns this path.
if let Some(existing) = get_tag_by_path(conn, &new_full_path)? {
if existing.tag_uid != tag_uid {
bail!("tag path already exists: {new_full_path}");
}
}
let new_name = humanize_slug(&new_slug);
// Transaction: update the tag row, then cascade path change to descendants.
let result = (|| -> Result<()> {
conn.execute_batch("BEGIN")?;
conn.execute(
"UPDATE tags SET name=?1, slug=?2, full_path=?3 WHERE tag_uid=?4",
params![new_name, new_slug, new_full_path, tag_uid],
)?;
let old_prefix_slash = format!("{}/", old_prefix);
let new_prefix_slash = format!("{}/", new_full_path);
// Use hierarchy (recursive CTE over parent_tag_id) instead of LIKE to avoid
// treating '_'/'%' in historical slugs as wildcards.
conn.execute(
"WITH RECURSIVE descendants(id) AS (\
SELECT id FROM tags WHERE parent_tag_id = ?1 \
UNION ALL \
SELECT t.id FROM tags t JOIN descendants d ON t.parent_tag_id = d.id \
) \
UPDATE tags SET full_path = REPLACE(full_path, ?2, ?3) \
WHERE id IN (SELECT id FROM descendants)",
params![tag.id, old_prefix_slash, new_prefix_slash],
)?;
conn.execute_batch("COMMIT")?;
Ok(())
})();
if let Err(e) = result {
let _ = conn.execute_batch("ROLLBACK");
return Err(e);
}
// Re-fetch the updated record.
get_tag_by_uid(conn, tag_uid)
}
/// Deletes a tag and its entire descendant subtree.
///
/// `entry_tag_assignments` rows are removed automatically via `ON DELETE CASCADE`.
/// `parent_tag_id` has no cascade so a recursive CTE is used to collect the subtree
/// before issuing a single DELETE.
///
/// Returns `Ok(true)` if anything was deleted, `Ok(false)` if `tag_uid` was not found.
pub fn delete_tag(conn: &Connection, tag_uid: &str) -> Result<bool> {
let deleted = conn.execute(
"WITH RECURSIVE subtree(id) AS (
SELECT id FROM tags WHERE tag_uid = ?1
UNION ALL
SELECT t.id FROM tags t JOIN subtree s ON t.parent_tag_id = s.id
)
DELETE FROM tags WHERE id IN (SELECT id FROM subtree)",
[tag_uid],
)?;
Ok(deleted > 0)
}
fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> {
conn.execute(
"UPDATE archive_runs
@ -2441,4 +2561,148 @@ mod tests {
assert_eq!(r2.bit_position, 5);
assert_eq!(r2.level, 2);
}
// ── rename_tag / delete_tag ────────────────────────────────────────────
#[test]
fn rename_tag_unknown_uid_returns_none() {
let conn = conn();
let result = rename_tag(&conn, "tag_doesnotexist", "anything").unwrap();
assert!(result.is_none());
}
#[test]
fn rename_tag_updates_own_path_and_cascades_to_children() {
let conn = conn();
// Create /science → /science/cs → /science/cs/algorithms
let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap();
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap();
let algo = get_tag_by_path(&conn, "/science/cs/algorithms").unwrap().unwrap();
// Rename "science" → "natural-science"
let updated = rename_tag(&conn, &science.tag_uid, "natural-science")
.unwrap()
.expect("should return updated tag");
assert_eq!(updated.slug, "natural-science");
assert_eq!(updated.name, "Natural Science");
assert_eq!(updated.full_path, "/natural-science");
// /science must no longer exist
assert!(get_tag_by_path(&conn, "/science").unwrap().is_none());
// /science/cs must have moved
assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none());
let cs_new = get_tag_by_uid(&conn, &cs.tag_uid).unwrap().unwrap();
assert_eq!(cs_new.full_path, "/natural-science/cs");
// /science/cs/algorithms must have moved
assert!(get_tag_by_path(&conn, "/science/cs/algorithms").unwrap().is_none());
let algo_new = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap();
assert_eq!(algo_new.full_path, "/natural-science/cs/algorithms");
}
#[test]
fn rename_tag_sibling_collision_returns_err() {
let conn = conn();
// Create /science and /natural-science as siblings
let _ = create_tag_path(&conn, "science").unwrap();
let _ = create_tag_path(&conn, "natural-science").unwrap();
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
// Renaming /science → natural-science should collide
let result = rename_tag(&conn, &science.tag_uid, "natural-science");
assert!(result.is_err(), "expected collision error, got {:?}", result);
}
#[test]
fn rename_tag_to_same_name_is_noop() {
let conn = conn();
let _ = create_tag_path(&conn, "science").unwrap();
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
// "Science" humanizes to the same slug; rename should succeed (no collision since same uid)
let updated = rename_tag(&conn, &science.tag_uid, "science")
.unwrap()
.expect("should return tag");
assert_eq!(updated.full_path, "/science");
}
#[test]
fn delete_tag_unknown_uid_returns_false() {
let conn = conn();
assert!(!delete_tag(&conn, "tag_doesnotexist").unwrap());
}
#[test]
fn delete_tag_removes_subtree_and_cascades_assignments() {
let conn = conn();
// Build /science/cs and /science/math
let cs_id = create_tag_path(&conn, "science/cs").unwrap();
let math_id = create_tag_path(&conn, "science/math").unwrap();
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
// Create an entry and assign it to /science/cs
let entry = create_entry_fixture(&conn, "private", None, None);
assign_entry_to_tag(&conn, entry.id, cs_id).unwrap();
// Verify assignment exists
let assigned_before: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry_tag_assignments WHERE entry_id = ?1",
[entry.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(assigned_before, 1);
// Delete the /science subtree
assert!(delete_tag(&conn, &science.tag_uid).unwrap());
// All three tag rows must be gone
let tag_count: i64 = conn
.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))
.unwrap();
assert_eq!(tag_count, 0, "all tags in subtree should be deleted");
// Assignment must have been cascade-deleted
let assigned_after: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry_tag_assignments WHERE entry_id = ?1",
[entry.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(assigned_after, 0, "assignment should be removed by cascade");
// Verify by uid too (subtree ids: science, cs, math)
assert!(get_tag_by_uid(&conn, &science.tag_uid).unwrap().is_none());
let cs_tag = conn
.query_row("SELECT tag_uid FROM tags WHERE id = ?1", [cs_id], |r| r.get::<_, String>(0))
.optional()
.unwrap();
assert!(cs_tag.is_none(), "/science/cs should be deleted");
let math_tag = conn
.query_row("SELECT tag_uid FROM tags WHERE id = ?1", [math_id], |r| r.get::<_, String>(0))
.optional()
.unwrap();
assert!(math_tag.is_none(), "/science/math should be deleted");
}
#[test]
fn rename_tag_slug_with_special_chars_is_stripped() {
let conn = conn();
let _ = create_tag_path(&conn, "science").unwrap();
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
// Input with spaces and underscores — underscores stripped, spaces become hyphens, case preserved
let updated = rename_tag(&conn, &science.tag_uid, "Natural Science")
.unwrap()
.expect("should rename");
assert_eq!(updated.slug, "Natural-Science");
assert_eq!(updated.full_path, "/Natural-Science");
}
}

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>

View file

@ -12,6 +12,7 @@ import TagsView from './components/TagsView'
import CollectionsView from './components/CollectionsView'
import SettingsView from './components/SettingsView'
import ContextRail from './components/ContextRail'
import { displayPath } from './utils'
export const AuthContext = createContext(null);
@ -81,6 +82,8 @@ export default function App() {
return saved === 'true'
})
const humanizeTags = currentUser?.humanize_slugs ?? false;
// Persist captureDialogOpen to sessionStorage
useEffect(() => {
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
@ -186,6 +189,20 @@ export default function App() {
if (archiveId) fetchTags(archiveId).then(setTagNodes)
}, [archiveId])
const handleTagRenamed = useCallback((oldFullPath, newFullPath) => {
if (tagFilter === oldFullPath) {
setTagFilter(newFullPath);
} else if (tagFilter?.startsWith(oldFullPath + '/')) {
setTagFilter(newFullPath + tagFilter.slice(oldFullPath.length));
}
}, [tagFilter]);
const handleTagDeleted = useCallback((deletedFullPath) => {
if (tagFilter === deletedFullPath || tagFilter?.startsWith(deletedFullPath + '/')) {
setTagFilter(null);
}
}, [tagFilter]);
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
setEntries(prev => prev.map(e =>
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
@ -251,7 +268,7 @@ export default function App() {
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter}
× {humanizeTags ? displayPath(tagFilter) : tagFilter}
</button>
)}
</span>
@ -275,10 +292,15 @@ export default function App() {
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
archiveId={archiveId}
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
onTagRenamed={handleTagRenamed}
onTagDeleted={handleTagDeleted}
onTagsRefresh={handleTagsRefresh}
humanizeTags={humanizeTags}
/>
)}
{view === 'collections' && (
@ -295,6 +317,7 @@ export default function App() {
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
onEntryTitleChange={handleEntryTitleChange}
humanizeTags={humanizeTags}
/>
</main>
<CaptureDialog

View file

@ -58,6 +58,27 @@ export async function removeTag(archiveId, entryUid, tagUid) {
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
}
export async function renameTag(archiveId, tagUid, name) {
const res = await fetch(
`/api/archives/${archiveId}/tags/${tagUid}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}
);
if (!res.ok) throw new Error(await res.text());
return res.json(); // returns the updated Tag: { tag_uid, name, slug, full_path }
}
export async function deleteTag(archiveId, tagUid) {
const res = await fetch(
`/api/archives/${archiveId}/tags/${tagUid}`,
{ method: 'DELETE' }
);
if (!res.ok) throw new Error(await res.text());
}
export async function fetchRuns(archiveId) {
return getJson(`/api/archives/${archiveId}/runs`);
}
@ -132,6 +153,15 @@ export async function updateProfile(displayName) {
if (!res.ok) throw new Error(await res.text());
}
export async function patchMe(patch) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
}
export async function changePassword(currentPassword, newPassword) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',

View file

@ -1,16 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
const ExternalIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7M9 7h8v8"/>
</svg>
)
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange }) {
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, humanizeTags }) {
const [detail, setDetail] = useState(null)
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
@ -190,7 +191,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
<div className="tags-wrap">
{tags.map(tag => (
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
{tag.name}
{humanizeTags ? displayPath(tag.full_path) : tag.full_path}
<button
className="remove"
title={`Remove tag ${tag.full_path}`}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { AuthContext } from '../App.jsx'
import {
updateProfile, changePassword,
updateProfile, changePassword, patchMe,
listTokens, createToken, deleteToken,
getInstanceSettings, updateInstanceSettings,
} from '../api.js'
@ -95,6 +95,29 @@ function ProfileTab({ currentUser, setCurrentUser }) {
</form>
</div>
<div className="form-section">
<h2>Display Preferences</h2>
<label className="checkbox-row">
<input
type="checkbox"
checked={currentUser?.humanize_slugs ?? false}
onChange={async e => {
const checked = e.target.checked;
try {
await patchMe({ humanize_slugs: checked });
setCurrentUser(prev => ({ ...prev, humanize_slugs: checked }));
} catch {
// silently revert
}
}}
/>
<span className="form-label" style={{ margin: 0 }}>Humanize tag display</span>
</label>
<p className="muted" style={{ fontSize: 13, margin: '4px 0 0' }}>
When on, tag paths show as "X / Articles" instead of "x/articles".
</p>
</div>
<div className="form-section">
<h2>Change Password</h2>
<form onSubmit={handleChangePassword}>

View file

@ -1,33 +1,125 @@
function TagNode({ node, tagFilter, onTagFilterSet, onViewChange }) {
const isActive = tagFilter === node.tag.full_path
function handleClick() {
const next = isActive ? null : node.tag.full_path
onTagFilterSet(next)
onViewChange('archive')
import { useState, useRef } from 'react';
import { renameTag, deleteTag } from '../api';
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
const isActive = tagFilter === node.tag.full_path;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const cancelRef = useRef(false);
function handleFilterClick() {
if (editing) return;
const next = isActive ? null : node.tag.full_path;
onTagFilterSet(next);
onViewChange('archive');
}
function startEdit(e) {
e.stopPropagation();
setDraft(node.tag.slug);
setEditing(true);
}
async function handleRenameSave() {
const value = draft.trim();
if (!value || value === node.tag.slug) {
setEditing(false);
return;
}
try {
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
onTagRenamed(node.tag.full_path, updated.full_path);
onTagsRefresh();
} catch {
// silently revert
} finally {
setEditing(false);
}
}
async function handleDelete(e) {
e.stopPropagation();
const msg = node.children?.length > 0
? `Delete tag "${node.tag.full_path}" and all its child tags? This cannot be undone.`
: `Delete tag "${node.tag.full_path}"? This cannot be undone.`;
if (!window.confirm(msg)) return;
try {
await deleteTag(archiveId, node.tag.tag_uid);
onTagDeleted(node.tag.full_path);
onTagsRefresh();
} catch {
// silently ignore
}
}
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
return (
<li>
<button
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
title={node.tag.full_path}
onClick={handleClick}>
{node.tag.name}
</button>
<div className="tag-node-row">
{editing ? (
<input
className="tag-rename-input"
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
}}
onBlur={() => {
if (cancelRef.current) {
cancelRef.current = false;
setEditing(false);
} else {
handleRenameSave();
}
}}
/>
) : (
<button
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
title={node.tag.full_path}
onClick={handleFilterClick}
onDoubleClick={startEdit}
>
{humanizeTags ? node.tag.name : node.tag.slug}
<svg
className="edit-icon"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
onClick={e => { e.stopPropagation(); startEdit(e); }}
>
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
</svg>
</button>
)}
<button
className="remove tag-node-delete"
title={`Delete tag ${node.tag.full_path}`}
onClick={handleDelete}
aria-label={`Delete tag ${node.tag.full_path}`}
>×</button>
</div>
{node.children?.length > 0 && (
<div className="tag-children">
<ul className="tag-tree-list">
{node.children.map(child => (
<TagNode key={child.tag.tag_uid} node={child}
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
))}
</ul>
</div>
)}
</li>
)
);
}
export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewChange }) {
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
return (
<section id="tags-view" className="view is-active">
<div className="tag-tree">
@ -43,11 +135,19 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
<ul className="tag-tree-list">
{tagNodes.map(node => (
<TagNode key={node.tag.tag_uid} node={node}
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
archiveId={archiveId}
tagFilter={tagFilter}
onTagFilterSet={onTagFilterSet}
onViewChange={onViewChange}
onTagRenamed={onTagRenamed}
onTagDeleted={onTagDeleted}
onTagsRefresh={onTagsRefresh}
humanizeTags={humanizeTags}
/>
))}
</ul>
)}
</div>
</section>
)
);
}

View file

@ -673,6 +673,56 @@ select {
.tag-node-btn:hover { text-decoration: underline; }
.tag-node-btn.is-active { font-weight: 600; color: var(--ink); }
/* tag-node-row: flex row containing the name button + delete × */
.tag-node-row {
display: flex;
align-items: center;
gap: 4px;
}
.tag-node-row .tag-node-btn {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
width: auto;
}
/* constrain the edit pencil so it doesn't inherit unconstrained SVG dimensions */
.tag-node-btn .edit-icon {
width: 0.75em;
height: 0.75em;
flex-shrink: 0;
vertical-align: -0.1em;
opacity: 0.35;
}
.tag-node-btn:hover .edit-icon { opacity: 0.65; }
/* delete × button next to tag name — small, unobtrusive */
.tag-node-delete {
flex-shrink: 0;
border: 0;
background: transparent;
color: var(--muted-2);
cursor: pointer;
padding: 0 3px;
font-size: 14px;
line-height: 1;
opacity: 0;
}
.tag-node-row:hover .tag-node-delete { opacity: 1; }
.tag-node-delete:hover { color: var(--accent); }
/* rename input inside the tag list */
.tag-rename-input {
font-size: 13px;
flex: 1 1 0;
min-width: 0;
padding: 2px 5px;
border: 1px solid var(--accent);
border-radius: var(--r);
background: var(--field);
color: var(--ink);
outline: none;
}
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
.collections-view {
padding: 24px;

View file

@ -38,3 +38,19 @@ export const SOURCE_ICONS = {
export function sourceIconSvg(kind) {
return SOURCE_ICONS[kind] ?? SOURCE_ICONS.other;
}
// Humanize each slash-segment of a stored full_path for display.
// Matches the backend humanize_slug convention: capitalize first char of each
// hyphen-part, replace hyphens with spaces.
// e.g. "/x/natural-science" → "/X/Natural Science"
// Used only for rendered label text; never mutate state/API values with this.
export function displayPath(fullPath) {
return fullPath
.split('/')
.map(seg =>
seg
? seg.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
: ''
)
.join('/');
}