From 2d019b37b393429b77566afd6c05f95acbcf5223 Mon Sep 17 00:00:00 2001 From: mountain Date: Tue, 21 Jul 2026 11:40:17 +0800 Subject: [PATCH 1/8] feat(api): delete a knowledge base (POST /api/v1/kb/delete + openkb delete-kb) Physical, irreversible KB deletion with a type-the-name confirmation. - config.delete_kb: rmtree the KB directory + unregister it from the global registry (known_kbs / kb_aliases / default_kb, via the new unregister_kb). Guards against deleting a non-KB path; tolerates a ghost registry entry (directory already gone) by just unregistering. - POST /api/v1/kb/delete: confirm_name must equal kb, re-checked server-side. Lives in a new api_kbs_router (sibling of the config/graph/output routers) so api.py stays under the 800-line gate; delete_kb self-locks (config lock), no create_app closure needed. - openkb delete-kb NAME: type-the-name prompt (or --yes) then delete. Tests: endpoint (success + unregister, confirm mismatch, non-KB target) in test_api.py; config (physical delete + unregister, refuse non-KB, ghost tolerance) in test_config.py. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG --- openkb/api.py | 2 ++ openkb/api_kbs_router.py | 37 +++++++++++++++++++++++ openkb/api_models.py | 13 +++++++++ openkb/cli.py | 34 ++++++++++++++++++++++ openkb/config.py | 44 ++++++++++++++++++++++++++++ tests/test_api.py | 63 ++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 37 +++++++++++++++++++++++ 7 files changed, 230 insertions(+) create mode 100644 openkb/api_kbs_router.py diff --git a/openkb/api.py b/openkb/api.py index 92d26ec9..ceebc317 100644 --- a/openkb/api.py +++ b/openkb/api.py @@ -56,6 +56,7 @@ require_bearer_token, ) from openkb.api_kbs import _list_knowledge_bases +from openkb.api_kbs_router import kbs_router from openkb.api_models import ( AddResponse, ChatRequest, @@ -165,6 +166,7 @@ async def lifespan(app: FastAPI): app.include_router(graph_router) app.include_router(output_router) app.include_router(config_router) + app.include_router(kbs_router) @app.get("/api/v1/kbs", response_model=KbListResponse) async def list_kbs_endpoint( diff --git a/openkb/api_kbs_router.py b/openkb/api_kbs_router.py new file mode 100644 index 00000000..cb493db0 --- /dev/null +++ b/openkb/api_kbs_router.py @@ -0,0 +1,37 @@ +"""Knowledge-base lifecycle endpoints beyond config (POST /api/v1/kb/delete). + +An APIRouter (sibling of api_config_router.py / api_graph.py / api_output.py) so +api.py stays under the per-file line gate (tests/test_file_size.py). delete_kb +does its own filesystem removal + global-registry unregister (config.py's lock); +like /api/v1/remove it needs no create_app closure and extracts cleanly. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from starlette.concurrency import run_in_threadpool + +from openkb.api_helpers import _resolve_kb, require_bearer_token +from openkb.api_models import KbDeleteRequest, KbDeleteResponse +from openkb.config import delete_kb + +kbs_router = APIRouter() + + +@kbs_router.post("/api/v1/kb/delete", response_model=KbDeleteResponse) +async def delete_kb_endpoint( + request: KbDeleteRequest, + _: None = Depends(require_bearer_token), +) -> Any: + # Type-the-name confirmation, re-checked server-side: this physically + # removes the whole KB directory (raw docs + wiki) and is irreversible, so + # it must never fire from a client that skipped the guard. + if request.confirm_name != request.kb: + raise HTTPException(status_code=400, detail="confirm_name does not match the KB name.") + kb_dir = _resolve_kb(request.kb) # 400 if the name is not a KB + # delete_kb rmtrees the directory and unregisters it from global.yaml; + # offload the blocking filesystem work off the event loop. + await run_in_threadpool(delete_kb, kb_dir) + return KbDeleteResponse(deleted=True, kb=request.kb, path=str(kb_dir)) diff --git a/openkb/api_models.py b/openkb/api_models.py index ed9a5510..c077e97c 100644 --- a/openkb/api_models.py +++ b/openkb/api_models.py @@ -322,6 +322,19 @@ class KbListResponse(BaseModel): knowledge_bases: list[KbSummaryItem] +class KbDeleteRequest(BaseModel): + kb: str = Field(..., min_length=1) + # Must equal `kb`: a type-the-name confirmation, re-checked server-side so + # this irreversible delete never fires from a client that skipped the guard. + confirm_name: str = Field(..., min_length=1) + + +class KbDeleteResponse(BaseModel): + deleted: bool + kb: str + path: str + + class MetaResponse(BaseModel): version: str diff --git a/openkb/cli.py b/openkb/cli.py index 82ed2453..34c64595 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1819,6 +1819,40 @@ def _refresh_schema(wiki_dir: Path) -> bool: return True +@cli.command(name="delete-kb") +@click.argument("name") +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the type-the-name confirmation." +) +def delete_kb_cmd(name, yes): + """Permanently delete a knowledge base (physical removal, irreversible). + + NAME is the KB name as addressed by the web UI / registry. Removes the + entire KB directory (raw docs + wiki) and unregisters it from the global + config. There is no undo. + """ + from openkb.config import _is_kb_dir, delete_kb, resolve_kb_alias + + try: + kb_dir = resolve_kb_alias(name) + except ValueError as exc: + click.echo(f"Invalid KB name: {exc}") + return + if not _is_kb_dir(kb_dir): + click.echo(f"No knowledge base named '{name}' found.") + return + click.echo(f"About to PERMANENTLY delete knowledge base '{name}':") + click.echo(f" {kb_dir}") + click.echo("This removes the entire directory (raw docs + wiki) and cannot be undone.") + if not yes: + typed = click.prompt("Type the KB name to confirm", default="", show_default=False) + if typed.strip() != name: + click.echo("Name did not match — aborted.") + return + delete_kb(kb_dir) + click.echo(f"Deleted knowledge base '{name}'.") + + @cli.command() @click.argument("doc_name", required=False) @click.option( diff --git a/openkb/config.py b/openkb/config.py index c454cb05..e2434853 100644 --- a/openkb/config.py +++ b/openkb/config.py @@ -5,6 +5,7 @@ import math import os import re +import shutil from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterator @@ -748,3 +749,46 @@ def registered_kbs() -> list[tuple[str, Path]]: seen_paths.add(str(resolved)) result.append((name, resolved)) return result + + +def unregister_kb(kb_path: Path) -> None: + """Remove ``kb_path`` from the global registry: its ``known_kbs`` entry, any + ``kb_aliases`` pointing at it, and ``default_kb`` if it referenced it. Held + under the global-config lock; a no-op for a never-registered path. + """ + resolved = str(kb_path.resolve()) + with _with_global_config_lock(): + gc = _load_global_config_unlocked() + changed = False + known = gc.get("known_kbs") + if isinstance(known, list): + pruned = [k for k in known if k != resolved] + if len(pruned) != len(known): + gc["known_kbs"] = pruned + changed = True + aliases = gc.get("kb_aliases") + if isinstance(aliases, dict): + pruned_aliases = {a: p for a, p in aliases.items() if p != resolved} + if len(pruned_aliases) != len(aliases): + gc["kb_aliases"] = pruned_aliases + changed = True + if gc.get("default_kb") == resolved: + del gc["default_kb"] + changed = True + if changed: + _atomic_yaml_dump(GLOBAL_CONFIG_PATH, gc) + + +def delete_kb(kb_dir: Path) -> None: + """Physically delete a KB directory and unregister it (irreversible; the + CALLER confirms). Guards against deleting an arbitrary path: an existing + ``kb_dir`` MUST be a KB (``.openkb`` + ``wiki``) or :class:`ValueError` is + raised and nothing is removed. A ghost entry (directory already gone) is + tolerated — no ``rmtree``, just unregistered. + """ + kb_dir = kb_dir.resolve() + if kb_dir.exists(): + if not _is_kb_dir(kb_dir): + raise ValueError(f"Refusing to delete: not a knowledge base directory: {kb_dir}") + shutil.rmtree(kb_dir) + unregister_kb(kb_dir) diff --git a/tests/test_api.py b/tests/test_api.py index ea11849d..ebbee872 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3030,3 +3030,66 @@ def test_kbs_listing_survives_dangling_summary_symlink(monkeypatch, tmp_path): assert set(items) == {"bad-kb", "good-kb"} # The bad KB lists with no last_compile rather than throwing out of the scan. assert items["bad-kb"]["last_compile"] is None + + +# --- DELETE /api/v1/kb/delete ------------------------------------------------ + + +def _isolate_global(monkeypatch, tmp_path: Path) -> Path: + """Point the global registry at an isolated dir kept OUTSIDE any KB dir, so + physically deleting a KB never removes the test's own global.yaml.""" + gdir = tmp_path / "gconf" + gdir.mkdir() + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", gdir) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", gdir / "global.yaml") + return gdir + + +def _make_kb(root: Path) -> Path: + (root / ".openkb").mkdir(parents=True) + (root / "wiki").mkdir(parents=True) + return root + + +def test_delete_kb_removes_dir_and_unregisters(monkeypatch, tmp_path): + from openkb.config import register_kb_alias, registered_kbs + + _isolate_global(monkeypatch, tmp_path) + kb = _make_kb(tmp_path / "mykb") + register_kb_alias("gone-kb", kb) + assert any(n == "gone-kb" for n, _ in registered_kbs()) + + client = _client(monkeypatch) + name = _use_named_kb(monkeypatch, kb, name="gone-kb") + resp = client.post( + "/api/v1/kb/delete", json={"kb": name, "confirm_name": name}, headers=_auth() + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["deleted"] is True + assert body["kb"] == "gone-kb" + assert not kb.exists() # physically removed + assert all(n != "gone-kb" for n, _ in registered_kbs()) # unregistered + + +def test_delete_kb_confirm_name_mismatch_is_rejected(monkeypatch, tmp_path): + kb = _make_kb(tmp_path / "mykb") + client = _client(monkeypatch) + name = _use_named_kb(monkeypatch, kb, name="keep-kb") + resp = client.post( + "/api/v1/kb/delete", json={"kb": name, "confirm_name": "WRONG"}, headers=_auth() + ) + assert resp.status_code == 400 + assert kb.exists() # guard fires before resolve — nothing deleted + + +def test_delete_kb_rejects_non_kb_target(monkeypatch, tmp_path): + plain = tmp_path / "plain" + plain.mkdir() # a real directory, but not a KB (no .openkb/wiki) + client = _client(monkeypatch) + name = _use_named_kb(monkeypatch, plain, name="ghost-kb") + resp = client.post( + "/api/v1/kb/delete", json={"kb": name, "confirm_name": name}, headers=_auth() + ) + assert resp.status_code == 400 + assert plain.exists() diff --git a/tests/test_config.py b/tests/test_config.py index ebe69baf..768d3f34 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -705,3 +705,40 @@ def test_resolve_kb_alias_degrades_on_non_mapping_global_yaml( monkeypatch.delenv("OPENKB_KB_ROOT", raising=False) (_isolated_global / "global.yaml").write_text("- a\n- b\n", encoding="utf-8") assert resolve_kb_alias("mykb") == (_isolated_global / "kbs" / "mykb").resolve() + + +# --- delete_kb / unregister_kb ----------------------------------------------- + + +def test_delete_kb_physical_removal_and_unregister(_isolated_global, tmp_path): + from openkb.config import delete_kb, register_kb_alias, registered_kbs + + kb = _make_kb_dir(tmp_path / "doomed") + register_kb_alias("doomed", kb) + assert any(n == "doomed" for n, _ in registered_kbs()) + + delete_kb(kb) + assert not kb.exists() # directory physically removed + assert all(n != "doomed" for n, _ in registered_kbs()) # unregistered + + +def test_delete_kb_refuses_non_kb_directory(_isolated_global, tmp_path): + from openkb.config import delete_kb + + plain = tmp_path / "not-a-kb" + plain.mkdir() + (plain / "important.txt").write_text("keep me", encoding="utf-8") + with pytest.raises(ValueError, match="not a knowledge base"): + delete_kb(plain) + assert (plain / "important.txt").exists() # refused — nothing removed + + +def test_delete_kb_tolerates_ghost_registry_entry(_isolated_global, tmp_path): + from openkb.config import delete_kb, register_kb_alias, registered_kbs + + ghost = tmp_path / "ghost" # registered but its directory never existed + register_kb_alias("ghost", ghost) + assert any(n == "ghost" for n, _ in registered_kbs()) + + delete_kb(ghost) # no dir to rmtree → just unregister, no error + assert all(n != "ghost" for n, _ in registered_kbs()) From 69e1c0be47588aec9f5e4261822f07902b7ef26f Mon Sep 17 00:00:00 2001 From: mountain Date: Tue, 21 Jul 2026 11:50:06 +0800 Subject: [PATCH 2/8] feat(api): delete a wiki page (POST /api/v1/page/delete) with backlink impact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete a concept/entity page and degrade references cleanly instead of leaving them dangling: - page_ops.delete_wiki_page: dry_run reports the backlink pages (whose inbound [[wikilinks]] would be demoted) without touching anything; execute removes the page under the KB ingest lock, strips its index.md entry outright (compiler.remove_doc_from_index — the entry is a [[link]] line), and demotes the now-dangling inbound [[links]] to plain text in ONLY those backlink pages (lint.fix_broken_links restrict_to). The page ref is "section/stem", validated to concepts/ or entities/ only (path-traversal-safe). - New api_pages_router hosts POST /api/v1/page (read — relocated here from api.py to keep it under the 800-line gate) + POST /api/v1/page/delete. Tests: dry-run impact + execute (page gone, inbound link demoted to text, index entry removed, sibling kept), 404 on missing page, and rejection of unsafe / non-editable refs. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG --- openkb/api.py | 20 +------ openkb/api_models.py | 20 +++++++ openkb/api_pages_router.py | 58 +++++++++++++++++++ openkb/page_ops.py | 113 +++++++++++++++++++++++++++++++++++++ tests/test_api.py | 59 +++++++++++++++++++ 5 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 openkb/api_pages_router.py create mode 100644 openkb/page_ops.py diff --git a/openkb/api.py b/openkb/api.py index ceebc317..91fe86df 100644 --- a/openkb/api.py +++ b/openkb/api.py @@ -80,8 +80,6 @@ LintResponse, ListResponse, MetaResponse, - PageRequest, - PageResponse, QueryRequest, QueryResponse, RecompileRequest, @@ -96,6 +94,7 @@ WatchStatusResponse, ) from openkb.api_output import output_router +from openkb.api_pages_router import pages_router from openkb.cli import ( get_kb_list, get_kb_status, @@ -167,6 +166,7 @@ async def lifespan(app: FastAPI): app.include_router(output_router) app.include_router(config_router) app.include_router(kbs_router) + app.include_router(pages_router) @app.get("/api/v1/kbs", response_model=KbListResponse) async def list_kbs_endpoint( @@ -420,22 +420,6 @@ async def list_endpoint( detail=f"List failed: {exc}", ) from exc - @app.post("/api/v1/page", response_model=PageResponse) - async def page_endpoint( - request: PageRequest, - _: None = Depends(require_bearer_token), - ) -> PageResponse: - kb_dir = _resolve_kb(request.kb) - wiki_dir = (kb_dir / "wiki").resolve() - rel = request.path if request.path.endswith(".md") else f"{request.path}.md" - target = (wiki_dir / rel).resolve() - if not target.is_relative_to(wiki_dir): - raise HTTPException(status_code=400, detail="Invalid page path.") - if not target.is_file(): - raise HTTPException(status_code=404, detail=f"Page not found: {request.path}") - content = await run_in_threadpool(target.read_text, encoding="utf-8") - return PageResponse(path=request.path, content=content) - @app.post("/api/v1/status", response_model=StatusResponse) async def status_endpoint( request: KbRequest, diff --git a/openkb/api_models.py b/openkb/api_models.py index c077e97c..98568bc0 100644 --- a/openkb/api_models.py +++ b/openkb/api_models.py @@ -285,6 +285,26 @@ class PageResponse(BaseModel): content: str +class PageDeleteRequest(BaseModel): + kb: str = Field(..., min_length=1) + # A '
/' wiki-page ref, e.g. "concepts/attention". Only + # concepts/ and entities/ pages are user-deletable (validated server-side). + path: str = Field(..., min_length=1) + # dry_run reports the impacted backlink pages without deleting anything, so + # the UI can show "these N pages will have their links demoted" + confirm. + dry_run: bool = False + + +class PageDeleteResponse(BaseModel): + status: str + target: str + # Content pages whose inbound [[links]] to the target will be / were demoted + # to plain text (the deletion's blast radius). + backlinks: list[str] = [] + files_changed: int | None = None + ghosts_stripped: int | None = None + + class WatchStartRequest(BaseModel): kb: str = Field(..., min_length=1) debounce: float = Field(default=2.0, gt=0) diff --git a/openkb/api_pages_router.py b/openkb/api_pages_router.py new file mode 100644 index 00000000..e2b8b8ec --- /dev/null +++ b/openkb/api_pages_router.py @@ -0,0 +1,58 @@ +"""Wiki-page REST endpoints: read / delete a page. + +An APIRouter (sibling of api_graph.py / api_config_router.py / api_kbs_router.py) +so api.py stays under the per-file line gate (tests/test_file_size.py). All +endpoints depend only on module-level helpers (_resolve_kb, require_bearer_token) +and page_ops — no create_app closure — so they extract cleanly. Page mutations +serialize via page_ops' own KB ingest lock, not the app's per-KB asyncio lock. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from starlette.concurrency import run_in_threadpool + +from openkb.api_helpers import _resolve_kb, require_bearer_token +from openkb.api_models import ( + PageDeleteRequest, + PageDeleteResponse, + PageRequest, + PageResponse, +) +from openkb.page_ops import delete_wiki_page + +pages_router = APIRouter() + + +@pages_router.post("/api/v1/page", response_model=PageResponse) +async def page_endpoint( + request: PageRequest, + _: None = Depends(require_bearer_token), +) -> PageResponse: + kb_dir = _resolve_kb(request.kb) + wiki_dir = (kb_dir / "wiki").resolve() + rel = request.path if request.path.endswith(".md") else f"{request.path}.md" + target = (wiki_dir / rel).resolve() + if not target.is_relative_to(wiki_dir): + raise HTTPException(status_code=400, detail="Invalid page path.") + if not target.is_file(): + raise HTTPException(status_code=404, detail=f"Page not found: {request.path}") + content = await run_in_threadpool(target.read_text, encoding="utf-8") + return PageResponse(path=request.path, content=content) + + +@pages_router.post("/api/v1/page/delete", response_model=PageDeleteResponse) +async def delete_page_endpoint( + request: PageDeleteRequest, + _: None = Depends(require_bearer_token), +) -> PageDeleteResponse: + kb_dir = _resolve_kb(request.kb) + try: + result = await run_in_threadpool( + delete_wiki_page, kb_dir, request.path, dry_run=request.dry_run + ) + except ValueError as exc: # invalid/traversal-unsafe page ref + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result["status"] == "not_found": + raise HTTPException(status_code=404, detail=f"Page not found: {request.path}") + return PageDeleteResponse(**result) diff --git a/openkb/page_ops.py b/openkb/page_ops.py new file mode 100644 index 00000000..9ae97e31 --- /dev/null +++ b/openkb/page_ops.py @@ -0,0 +1,113 @@ +"""Wiki-page mutations for the Workbench: delete a concept/entity page. + +Reuses the compile/lint machinery so a deletion degrades cleanly rather than +leaving dangling references: + +- inbound ``[[wikilinks]]`` in other pages are demoted to plain text + (``lint.fix_broken_links``), not left pointing at a gone page; +- the page's ``index.md`` entry (itself a ``[[wikilink]]`` line) is removed + outright (``compiler.remove_doc_from_index``). + +All writes run under the KB ingest lock (crash-safe serial mutation, matching +``openkb remove``); the underlying rewrites use ``atomic_write_text``. +""" + +from __future__ import annotations + +from pathlib import Path + +from openkb.lint import _extract_wikilinks, _normalize_target, _read_md, fix_broken_links +from openkb.locks import kb_ingest_lock + +# Only these compiled page types are user-deletable/editable. summaries are +# per-source-document (managed by add/remove); index/log/reports are generated. +EDITABLE_SECTIONS = ("concepts", "entities") + + +def validate_page_ref(path: str) -> tuple[str, str]: + """Split a ``'
/'`` page ref into ``(section, stem)``. + + Guards against path traversal: ``section`` must be an editable section and + ``stem`` a single safe filename segment (no separators, ``..``, or leading + dot). Raises :class:`ValueError` otherwise. + """ + parts = path.strip().strip("/").split("/") + if len(parts) != 2: + raise ValueError("page ref must be '
/' (e.g. concepts/attention)") + section, stem = parts + if section not in EDITABLE_SECTIONS: + raise ValueError(f"section must be one of {EDITABLE_SECTIONS}, got {section!r}") + if not stem or stem in (".", "..") or stem.startswith(".") or "\\" in stem: + raise ValueError(f"invalid page name: {stem!r}") + return section, stem + + +def pages_linking_to(wiki: Path, target_norm: str, *, exclude: Path) -> list[str]: + """Content pages whose ``[[wikilinks]]`` resolve to ``target_norm`` (backlinks). + + Excludes ``exclude`` (the target page itself), ``index.md`` (handled by the + index-entry removal, not link demotion), and ``reports/`` + ``sources/``. + Returns relative ``'section/stem'`` refs, sorted, for a stable impact preview. + """ + hits: set[str] = set() + exclude_resolved = exclude.resolve() + for md in wiki.rglob("*.md"): + rel = md.relative_to(wiki) + if rel.parts[:1] in (("reports",), ("sources",)): + continue + if md.name == "index.md" or md.resolve() == exclude_resolved: + continue + for raw in _extract_wikilinks(_read_md(md)): + if _normalize_target(raw) == target_norm: + hits.add(str(rel.with_suffix("")).replace("\\", "/")) + break + return sorted(hits) + + +def delete_wiki_page(kb_dir: Path, path: str, *, dry_run: bool = False) -> dict: + """Delete a concept/entity page and clean up references to it. + + ``path`` is a ``'section/stem'`` ref (e.g. ``'concepts/attention'``). With + ``dry_run`` it only reports the impacted backlink pages (whose inbound links + WOULD be demoted). Returns a dict whose ``status`` is one of ``not_found``, + ``dry_run``, ``deleted``. + """ + section, stem = validate_page_ref(path) + wiki = kb_dir / "wiki" + page = wiki / section / f"{stem}.md" + target = f"{section}/{stem}" + + if not page.is_file(): + return {"status": "not_found", "target": target, "backlinks": []} + + backlinks = pages_linking_to(wiki, _normalize_target(target), exclude=page) + if dry_run: + return {"status": "dry_run", "target": target, "backlinks": backlinks} + + # Imported lazily: compiler pulls in the LLM stack, which this pure + # index-editing helper does not otherwise need at module import. + from openkb.agent.compiler import remove_doc_from_index + + with kb_ingest_lock(kb_dir / ".openkb"): + page.unlink() + # Remove the page's index.md entry (a [[link]] LINE — full removal, + # not link demotion). Empty doc_name leaves the Documents section alone. + remove_doc_from_index( + wiki, + "", + concept_slugs_deleted=[stem] if section == "concepts" else [], + entity_slugs_deleted=[stem] if section == "entities" else [], + ) + # Demote the now-dangling inbound [[links]] to plain text — surgically, + # only in the pages that referenced this one (matches `openkb remove`, + # so pre-existing dangling links elsewhere are left untouched). + restrict = [wiki / f"{ref}.md" for ref in backlinks] + files_changed, ghosts_stripped = fix_broken_links(wiki, restrict_to=restrict) + + return { + "status": "deleted", + "target": target, + "backlinks": backlinks, + "files_changed": files_changed, + "ghosts_stripped": ghosts_stripped, + } diff --git a/tests/test_api.py b/tests/test_api.py index ebbee872..a4f8ed48 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3093,3 +3093,62 @@ def test_delete_kb_rejects_non_kb_target(monkeypatch, tmp_path): ) assert resp.status_code == 400 assert plain.exists() + + +# --- POST /api/v1/page/delete ------------------------------------------------ + + +def test_delete_page_demotes_inbound_links_and_removes_index_entry(monkeypatch, kb_dir): + wiki = kb_dir / "wiki" + (wiki / "concepts" / "foo.md").write_text("# Foo\n\nAbout foo.\n", encoding="utf-8") + (wiki / "concepts" / "bar.md").write_text( + "# Bar\n\nSee [[concepts/foo]] for context.\n", encoding="utf-8" + ) + (wiki / "index.md").write_text( + "# Index\n\n## Concepts\n- [[concepts/foo]] — foo\n- [[concepts/bar]] — bar\n", + encoding="utf-8", + ) + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + # dry-run reports the impacted backlink page and deletes nothing. + r = client.post( + "/api/v1/page/delete", + json={"kb": kb, "path": "concepts/foo", "dry_run": True}, + headers=_auth(), + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "dry_run" + assert "concepts/bar" in r.json()["backlinks"] + assert (wiki / "concepts" / "foo.md").exists() + + # execute + r = client.post("/api/v1/page/delete", json={"kb": kb, "path": "concepts/foo"}, headers=_auth()) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "deleted" + assert body["files_changed"] == 1 # bar.md rewritten + assert not (wiki / "concepts" / "foo.md").exists() # page removed + bar = (wiki / "concepts" / "bar.md").read_text(encoding="utf-8") + assert "[[concepts/foo]]" not in bar # inbound link demoted... + assert "foo" in bar # ...to plain text, sentence kept + idx = (wiki / "index.md").read_text(encoding="utf-8") + assert "[[concepts/foo]]" not in idx # index entry removed outright + assert "[[concepts/bar]]" in idx # sibling entry untouched + + +def test_delete_page_not_found_returns_404(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + r = client.post( + "/api/v1/page/delete", json={"kb": kb, "path": "concepts/nope"}, headers=_auth() + ) + assert r.status_code == 404 + + +def test_delete_page_rejects_unsafe_or_non_editable_ref(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + for bad in ["summaries/x", "index", "concepts/a/b", "concepts/..", "reports/health"]: + r = client.post("/api/v1/page/delete", json={"kb": kb, "path": bad}, headers=_auth()) + assert r.status_code == 400, bad From 97c83570599311623bc09e17f501fe71a08e36d4 Mon Sep 17 00:00:00 2001 From: mountain Date: Tue, 21 Jul 2026 11:54:43 +0800 Subject: [PATCH 3/8] feat(api): edit a wiki page (PUT /api/v1/page) + link context (POST /api/v1/page/links) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page_ops.edit_wiki_page: replace a concept/entity page's BODY while preserving its code-managed OKF frontmatter (type/description/sources) verbatim; any frontmatter in the submitted content is dropped. Dead [[wikilinks]] the user typed are demoted to plain text on save (OpenKB keeps the wiki broken-link-free) and returned as ghosts_stripped so the UI can warn. Atomic write under the KB ingest lock. - page_ops.page_link_context (POST /api/v1/page/links): outbound + inbound links for the edit-impact panel. Editing the body does not break either (links are path-based), so this is context, not a blocker — the honest impact model. - PUT /api/v1/page added to api_pages_router. Tests: edit preserves frontmatter + keeps a resolvable link + demotes a dead one (reports ghosts_stripped) + replaces the body; 404 on a missing page; links reports out/backlinks. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG --- openkb/api_models.py | 29 +++++++++++++++ openkb/api_pages_router.py | 36 ++++++++++++++++++- openkb/page_ops.py | 74 ++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/openkb/api_models.py b/openkb/api_models.py index 98568bc0..0a1f5076 100644 --- a/openkb/api_models.py +++ b/openkb/api_models.py @@ -305,6 +305,35 @@ class PageDeleteResponse(BaseModel): ghosts_stripped: int | None = None +class PageLinksRequest(BaseModel): + kb: str = Field(..., min_length=1) + path: str = Field(..., min_length=1) + + +class PageLinksResponse(BaseModel): + status: str + target: str + outlinks: list[str] = [] # pages this page links to + backlinks: list[str] = [] # pages that link to this page + + +class PageEditRequest(BaseModel): + kb: str = Field(..., min_length=1) + path: str = Field(..., min_length=1) + # New page BODY. The OKF frontmatter (type/description/sources) is + # code-managed and preserved server-side; any frontmatter here is dropped. + content: str + + +class PageEditResponse(BaseModel): + status: str + target: str + # Dead [[links]] the edit introduced, demoted to plain text on save (so the + # UI can tell the user which links didn't resolve). + ghosts_stripped: list[str] = [] + content: str | None = None # the saved full page (frontmatter + body) + + class WatchStartRequest(BaseModel): kb: str = Field(..., min_length=1) debounce: float = Field(default=2.0, gt=0) diff --git a/openkb/api_pages_router.py b/openkb/api_pages_router.py index e2b8b8ec..4998e518 100644 --- a/openkb/api_pages_router.py +++ b/openkb/api_pages_router.py @@ -16,10 +16,14 @@ from openkb.api_models import ( PageDeleteRequest, PageDeleteResponse, + PageEditRequest, + PageEditResponse, + PageLinksRequest, + PageLinksResponse, PageRequest, PageResponse, ) -from openkb.page_ops import delete_wiki_page +from openkb.page_ops import delete_wiki_page, edit_wiki_page, page_link_context pages_router = APIRouter() @@ -56,3 +60,33 @@ async def delete_page_endpoint( if result["status"] == "not_found": raise HTTPException(status_code=404, detail=f"Page not found: {request.path}") return PageDeleteResponse(**result) + + +@pages_router.post("/api/v1/page/links", response_model=PageLinksResponse) +async def page_links_endpoint( + request: PageLinksRequest, + _: None = Depends(require_bearer_token), +) -> PageLinksResponse: + kb_dir = _resolve_kb(request.kb) + try: + result = await run_in_threadpool(page_link_context, kb_dir, request.path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result["status"] == "not_found": + raise HTTPException(status_code=404, detail=f"Page not found: {request.path}") + return PageLinksResponse(**result) + + +@pages_router.put("/api/v1/page", response_model=PageEditResponse) +async def edit_page_endpoint( + request: PageEditRequest, + _: None = Depends(require_bearer_token), +) -> PageEditResponse: + kb_dir = _resolve_kb(request.kb) + try: + result = await run_in_threadpool(edit_wiki_page, kb_dir, request.path, request.content) + except ValueError as exc: # invalid/traversal-unsafe page ref + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result["status"] == "not_found": + raise HTTPException(status_code=404, detail=f"Page not found: {request.path}") + return PageEditResponse(**result) diff --git a/openkb/page_ops.py b/openkb/page_ops.py index 9ae97e31..fd0398cf 100644 --- a/openkb/page_ops.py +++ b/openkb/page_ops.py @@ -16,8 +16,16 @@ from pathlib import Path -from openkb.lint import _extract_wikilinks, _normalize_target, _read_md, fix_broken_links -from openkb.locks import kb_ingest_lock +from openkb import frontmatter +from openkb.lint import ( + _extract_wikilinks, + _normalize_target, + _read_md, + fix_broken_links, + list_existing_wiki_targets, + strip_ghost_wikilinks, +) +from openkb.locks import atomic_write_text, kb_ingest_lock # Only these compiled page types are user-deletable/editable. summaries are # per-source-document (managed by add/remove); index/log/reports are generated. @@ -111,3 +119,65 @@ def delete_wiki_page(kb_dir: Path, path: str, *, dry_run: bool = False) -> dict: "files_changed": files_changed, "ghosts_stripped": ghosts_stripped, } + + +def page_link_context(kb_dir: Path, path: str) -> dict: + """Outbound + inbound links for a page — the edit-impact panel. + + ``outlinks`` are the distinct pages this page links to (resolvable + ``[[wikilinks]]``); ``backlinks`` are the pages that link to it. Editing the + BODY does not break either (links are path-based), so this is context, not a + blocker. Returns ``status`` ``not_found`` when the page is absent. + """ + section, stem = validate_page_ref(path) + wiki = kb_dir / "wiki" + page = wiki / section / f"{stem}.md" + target = f"{section}/{stem}" + if not page.is_file(): + return {"status": "not_found", "target": target, "outlinks": [], "backlinks": []} + + target_norm = _normalize_target(target) + norm_known = {_normalize_target(t): t for t in list_existing_wiki_targets(wiki)} + out: set[str] = set() + for raw in _extract_wikilinks(_read_md(page)): + canon = norm_known.get(_normalize_target(raw)) + if canon and _normalize_target(canon) != target_norm: + out.add(canon) + backlinks = pages_linking_to(wiki, target_norm, exclude=page) + return {"status": "ok", "target": target, "outlinks": sorted(out), "backlinks": backlinks} + + +def edit_wiki_page(kb_dir: Path, path: str, content: str) -> dict: + """Replace a concept/entity page's BODY, preserving its OKF frontmatter. + + The ``type:``/``description:``/``sources:`` frontmatter is code-managed, so + any frontmatter in the incoming ``content`` is discarded and the existing + block is re-attached verbatim. Dead ``[[wikilinks]]`` the user typed are + demoted to plain text on save (OpenKB keeps the wiki free of broken links); + the demoted targets are returned so the UI can warn. Write is atomic, under + the KB ingest lock. Returns ``status`` ``not_found`` when the page is absent. + """ + section, stem = validate_page_ref(path) + wiki = kb_dir / "wiki" + page = wiki / section / f"{stem}.md" + target = f"{section}/{stem}" + if not page.is_file(): + return {"status": "not_found", "target": target, "ghosts_stripped": []} + + existing = frontmatter.split(_read_md(page)) + fm_block = existing[0] if existing else "" + # Strip any frontmatter the client sent back (it is code-managed); keep only + # the edited body. + incoming = frontmatter.split(content) + body = incoming[1] if incoming else content + cleaned_body, ghosts = strip_ghost_wikilinks(body, list_existing_wiki_targets(wiki)) + + with kb_ingest_lock(kb_dir / ".openkb"): + atomic_write_text(page, fm_block + cleaned_body) + + return { + "status": "saved", + "target": target, + "ghosts_stripped": sorted(set(ghosts)), + "content": fm_block + cleaned_body, + } diff --git a/tests/test_api.py b/tests/test_api.py index a4f8ed48..1cf30d3d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3152,3 +3152,62 @@ def test_delete_page_rejects_unsafe_or_non_editable_ref(monkeypatch, kb_dir): for bad in ["summaries/x", "index", "concepts/a/b", "concepts/..", "reports/health"]: r = client.post("/api/v1/page/delete", json={"kb": kb, "path": bad}, headers=_auth()) assert r.status_code == 400, bad + + +# --- PUT /api/v1/page (edit) + POST /api/v1/page/links ----------------------- + + +def test_edit_page_preserves_frontmatter_and_demotes_dead_links(monkeypatch, kb_dir): + wiki = kb_dir / "wiki" + (wiki / "concepts" / "bar.md").write_text("# Bar\n\nbar body\n", encoding="utf-8") + (wiki / "concepts" / "foo.md").write_text( + "---\ntype: Concept\ndescription: about foo\n---\n# Foo\n\nOriginal body.\n", + encoding="utf-8", + ) + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + new_body = "# Foo\n\nEdited, links [[concepts/bar]] and [[concepts/ghost]].\n" + r = client.put( + "/api/v1/page", + json={"kb": kb, "path": "concepts/foo", "content": new_body}, + headers=_auth(), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "saved" + assert body["ghosts_stripped"] == ["concepts/ghost"] + + saved = (wiki / "concepts" / "foo.md").read_text(encoding="utf-8") + assert "type: Concept" in saved # frontmatter preserved + assert "description: about foo" in saved + assert "Edited, links [[concepts/bar]]" in saved # good link kept + assert "[[concepts/ghost]]" not in saved # dead link demoted to text + assert "Original body." not in saved # body replaced + + +def test_edit_page_not_found_returns_404(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + r = client.put( + "/api/v1/page", + json={"kb": kb, "path": "concepts/nope", "content": "x"}, + headers=_auth(), + ) + assert r.status_code == 404 + + +def test_page_links_reports_out_and_backlinks(monkeypatch, kb_dir): + wiki = kb_dir / "wiki" + (wiki / "concepts" / "hub.md").write_text( + "# Hub\n\nlinks [[concepts/leaf]]\n", encoding="utf-8" + ) + (wiki / "concepts" / "leaf.md").write_text("# Leaf\n\nleaf\n", encoding="utf-8") + (wiki / "concepts" / "ref.md").write_text("# Ref\n\nsee [[concepts/hub]]\n", encoding="utf-8") + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + r = client.post("/api/v1/page/links", json={"kb": kb, "path": "concepts/hub"}, headers=_auth()) + assert r.status_code == 200, r.text + body = r.json() + assert body["outlinks"] == ["concepts/leaf"] # hub -> leaf + assert body["backlinks"] == ["concepts/ref"] # ref -> hub From 3abcf2822f69975d86194f0b850707a79f5d6789 Mon Sep 17 00:00:00 2001 From: mountain Date: Tue, 21 Jul 2026 12:19:22 +0800 Subject: [PATCH 4/8] feat(web): delete a knowledge base from the settings sheet (type-name confirm) Adds a Danger Zone section to KbSettingsSheet: a type-the-name confirmation gates deleteKb() (POST /api/v1/kb/delete); on success KbDetail navigates back to the KB list. New deleteKb client + kbSettings danger* keys + common cancel (zh + en, identical key sets). Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG --- frontend/src/api/kb.ts | 7 ++ frontend/src/components/KbSettingsSheet.tsx | 86 ++++++++++++++++++++- frontend/src/locales/en/common.json | 3 +- frontend/src/locales/en/kbSettings.json | 9 ++- frontend/src/locales/zh/common.json | 3 +- frontend/src/locales/zh/kbSettings.json | 9 ++- frontend/src/pages/KbDetail.tsx | 7 +- 7 files changed, 118 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/kb.ts b/frontend/src/api/kb.ts index be2012e2..6309b21b 100644 --- a/frontend/src/api/kb.ts +++ b/frontend/src/api/kb.ts @@ -41,6 +41,13 @@ export function createKb(body: InitRequest): Promise { return apiFetch("/api/v1/init", { method: "POST", body }) } +/** Permanently delete a knowledge base (physical removal + unregister). + * `confirmName` must equal `kb` (re-checked server-side); the caller collects + * the type-the-name confirmation. */ +export function deleteKb(kb: string, confirmName: string): Promise<{ deleted: boolean; kb: string; path: string }> { + return apiFetch("/api/v1/kb/delete", { body: { kb, confirm_name: confirmName } }) +} + export type ConfigSource = "kb" | "global" | "default" export interface KbConfig { diff --git a/frontend/src/components/KbSettingsSheet.tsx b/frontend/src/components/KbSettingsSheet.tsx index 09b3fe33..e0865176 100644 --- a/frontend/src/components/KbSettingsSheet.tsx +++ b/frontend/src/components/KbSettingsSheet.tsx @@ -8,7 +8,7 @@ import { ShieldCheck, Clock, Radio, } from 'lucide-react' import { toast } from 'sonner' -import { getKbConfig, patchKbConfig, type KbConfig, type ConfigSource } from '@/api/kb' +import { deleteKb, getKbConfig, patchKbConfig, type KbConfig, type ConfigSource } from '@/api/kb' import { watchStart, watchStop, watchStatus, runRecompile, runLint, type WatchStatus, } from '@/api/maintenance' @@ -28,12 +28,14 @@ export default function KbSettingsSheet({ onClose, docCount, onChanged, + onDeleted, }: { kb: string open: boolean onClose: () => void docCount: number onChanged?: () => void + onDeleted?: () => void }) { const { t } = useTranslation(['kbSettings', 'common']) const reduce = useReducedMotion() @@ -114,6 +116,7 @@ export default function KbSettingsSheet({
+
@@ -732,3 +735,84 @@ function KbMaintenanceSection({ ) } + +/** Danger zone: permanently delete this KB. A type-the-name confirmation gates + * POST /api/v1/kb/delete; on success the parent (KbDetail) navigates away. */ +function KbDangerSection({ kb, onDeleted }: { kb: string; onDeleted?: () => void }) { + const { t } = useTranslation(['kbSettings', 'common']) + const [confirming, setConfirming] = useState(false) + const [typed, setTyped] = useState('') + const [busy, setBusy] = useState(false) + const matches = typed.trim() === kb + + const doDelete = useCallback(async () => { + if (!matches || busy) return + setBusy(true) + try { + await deleteKb(kb, kb) + toast.success(t('kbSettings:dangerDeletedToast', { kb })) + onDeleted?.() + } catch (e) { + toast.error(t('kbSettings:dangerDeleteError', { error: errMsg(e) })) + setBusy(false) + } + }, [kb, matches, busy, onDeleted, t]) + + return ( +
+

+ {t('kbSettings:dangerHeading')} +

+
+

{t('kbSettings:dangerDesc')}

+ {!confirming ? ( + + ) : ( +
+ + setTyped(e.target.value)} + placeholder={kb} + className="w-full h-9 rounded-md border border-input bg-transparent px-3 text-[13px] font-mono2 outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-red-400" + /> +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 88cc7561..d22624fd 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -7,7 +7,8 @@ "actions": { "newKb": "New knowledge base", "close": "Close", - "refresh": "Refresh" + "refresh": "Refresh", + "cancel": "Cancel" }, "fields": { "model": "Model", diff --git a/frontend/src/locales/en/kbSettings.json b/frontend/src/locales/en/kbSettings.json index 3945cccd..a3cecab6 100644 --- a/frontend/src/locales/en/kbSettings.json +++ b/frontend/src/locales/en/kbSettings.json @@ -38,5 +38,12 @@ "lint": "Structure check", "recompileHeading": "Recompile", "recompileSummary": "· {{total}} total · {{recompiled}} compiled · {{skipped}} skipped", - "preparing": "Preparing…" + "preparing": "Preparing…", + "dangerHeading": "Danger zone", + "dangerDesc": "Permanently delete this knowledge base — its raw documents and compiled wiki. This cannot be undone.", + "dangerDeleteButton": "Delete this knowledge base", + "dangerConfirmPrompt": "Type the KB name ({{kb}}) to confirm", + "dangerConfirmDelete": "Delete permanently", + "dangerDeletedToast": "Deleted knowledge base “{{kb}}”", + "dangerDeleteError": "Delete failed: {{error}}" } diff --git a/frontend/src/locales/zh/common.json b/frontend/src/locales/zh/common.json index 524e0e0b..e76945a7 100644 --- a/frontend/src/locales/zh/common.json +++ b/frontend/src/locales/zh/common.json @@ -7,7 +7,8 @@ "actions": { "newKb": "新建知识库", "close": "关闭", - "refresh": "刷新" + "refresh": "刷新", + "cancel": "取消" }, "fields": { "model": "模型", diff --git a/frontend/src/locales/zh/kbSettings.json b/frontend/src/locales/zh/kbSettings.json index 6f894169..85d42dcc 100644 --- a/frontend/src/locales/zh/kbSettings.json +++ b/frontend/src/locales/zh/kbSettings.json @@ -38,5 +38,12 @@ "lint": "结构检查", "recompileHeading": "重新编译", "recompileSummary": "· 共 {{total}} · 编译 {{recompiled}} · 跳过 {{skipped}}", - "preparing": "正在准备…" + "preparing": "正在准备…", + "dangerHeading": "危险操作", + "dangerDesc": "永久删除这个知识库(原始文档 + 已编译的 wiki),不可撤销。", + "dangerDeleteButton": "删除此知识库", + "dangerConfirmPrompt": "输入知识库名称({{kb}})以确认", + "dangerConfirmDelete": "永久删除", + "dangerDeletedToast": "已删除知识库「{{kb}}」", + "dangerDeleteError": "删除失败:{{error}}" } diff --git a/frontend/src/pages/KbDetail.tsx b/frontend/src/pages/KbDetail.tsx index c525aac9..62030a49 100644 --- a/frontend/src/pages/KbDetail.tsx +++ b/frontend/src/pages/KbDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' -import { useParams } from 'react-router' +import { useNavigate, useParams } from 'react-router' import { useTranslation } from 'react-i18next' import { motion, useReducedMotion } from 'motion/react' import { FileText, Loader2, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle } from 'lucide-react' @@ -103,6 +103,7 @@ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)) export default function KbDetail() { const { id = '' } = useParams() + const navigate = useNavigate() const { t } = useTranslation(['kb', 'common']) const [inv, setInv] = useState(null) @@ -453,6 +454,10 @@ export default function KbDetail() { onClose={() => setSettingsOpen(false)} docCount={docCount} onChanged={refreshInventory} + onDeleted={() => { + setSettingsOpen(false) + navigate('/kb') + }} /> ) From c4da03f819ab1acc71046cbbe079788009e40fd1 Mon Sep 17 00:00:00 2001 From: mountain Date: Tue, 21 Jul 2026 14:53:52 +0800 Subject: [PATCH 5/8] feat(web): in-reader page edit + delete with impact preview (F2/F3) For an open concept/entity page, the reader header now shows Edit and Delete: - Delete: a dry-run first (deletePage dryRun) surfaces the backlink pages whose [[links]] will demote to plain text; a red confirm card lists them, then the real delete closes the page and refreshes the inventory. - Edit: a body textarea (frontmatter is preserved server-side), Save via PUT /api/v1/page, a links panel (getPageLinks: out/backlinks), a "recompile may overwrite" note, and a toast listing any dead links demoted to text. Adds the deletePage/editPage/getPageLinks wiki clients and kb:pageOps.* locale keys (zh + en, identical key sets). Build green (i18n guard + tsc + vite). Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG --- frontend/src/api/wiki.ts | 42 +++++ frontend/src/locales/en/kb.json | 24 +++ frontend/src/locales/zh/kb.json | 24 +++ frontend/src/pages/KbDetail.tsx | 298 +++++++++++++++++++++++++++++++- 4 files changed, 381 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/wiki.ts b/frontend/src/api/wiki.ts index 7ae08efa..5b8aec73 100644 --- a/frontend/src/api/wiki.ts +++ b/frontend/src/api/wiki.ts @@ -52,3 +52,45 @@ export function getPage(kb: string, path: string): Promise<{ path: string; conte export function getKbInventory(kb: string): Promise { return apiFetch("/api/v1/list", { body: { kb } }) } + +/** Result of `/api/v1/page/delete`. `backlinks` are 'section/stem' refs whose + * inbound [[links]] will be / were demoted to plain text. */ +export interface PageDeleteResult { + status: string + target: string + backlinks: string[] + files_changed?: number | null + ghosts_stripped?: number | null +} + +/** Delete a concept/entity page. With `dryRun`, only reports the impact + * (backlinks) without changing anything — used for the confirm preview. */ +export function deletePage(kb: string, path: string, dryRun = false): Promise { + return apiFetch("/api/v1/page/delete", { body: { kb, path, dry_run: dryRun } }) +} + +/** Outbound + inbound links for a page (`/api/v1/page/links`) — the edit panel. */ +export interface PageLinks { + status: string + target: string + outlinks: string[] + backlinks: string[] +} + +export function getPageLinks(kb: string, path: string): Promise { + return apiFetch("/api/v1/page/links", { body: { kb, path } }) +} + +/** Result of editing a page (`PUT /api/v1/page`). `ghosts_stripped` are the + * dead [[links]] demoted to plain text on save. */ +export interface PageEditResult { + status: string + target: string + ghosts_stripped: string[] + content: string | null +} + +/** Save new BODY for a concept/entity page (frontmatter preserved server-side). */ +export function editPage(kb: string, path: string, content: string): Promise { + return apiFetch("/api/v1/page", { method: "PUT", body: { kb, path, content } }) +} diff --git a/frontend/src/locales/en/kb.json b/frontend/src/locales/en/kb.json index 55dd02ad..6c89a8b2 100644 --- a/frontend/src/locales/en/kb.json +++ b/frontend/src/locales/en/kb.json @@ -46,6 +46,30 @@ "multiple": "Delete failed: {{message}} (candidates: {{names}})" } }, + "pageOps": { + "edit": "Edit", + "delete": "Delete", + "editorAria": "Page source (Markdown)", + "deletePrompt": "Delete {{title}}?", + "deleteImpact_one": "{{count}} page links here — its link will become plain text", + "deleteImpact_other": "{{count}} pages link here — their links will become plain text", + "deleteNoBacklinks": "No other pages link here", + "deleteConfirm": "Delete page", + "deleteSuccess": "Deleted {{title}}", + "deleteError": "Delete failed: {{error}}", + "save": "Save", + "saveSuccess": "Page saved", + "saveError": "Save failed: {{error}}", + "ghostsDemoted": "These links didn't resolve and became plain text: {{links}}", + "editRecompileNote": "Manual edits may be overwritten the next time this KB is recompiled.", + "links": { + "heading": "Page links", + "outlinks": "This page links to", + "backlinks": "Pages linking here", + "none": "None", + "error": "Failed to load links: {{error}}" + } + }, "remote": { "heading": "Remote data sources", "note": "Cloud connectors are in development and not yet available · want one? Click a card to vote on GitHub" diff --git a/frontend/src/locales/zh/kb.json b/frontend/src/locales/zh/kb.json index 02801c18..9668ce27 100644 --- a/frontend/src/locales/zh/kb.json +++ b/frontend/src/locales/zh/kb.json @@ -46,6 +46,30 @@ "multiple": "删除失败:{{message}}(候选:{{names}})" } }, + "pageOps": { + "edit": "编辑", + "delete": "删除", + "editorAria": "页面源码(Markdown)", + "deletePrompt": "确认删除 {{title}}?", + "deleteImpact_one": "{{count}} 个页面链接到此页 — 删除后这些链接将变为纯文本", + "deleteImpact_other": "{{count}} 个页面链接到此页 — 删除后这些链接将变为纯文本", + "deleteNoBacklinks": "没有其他页面链接到此页", + "deleteConfirm": "删除页面", + "deleteSuccess": "已删除 {{title}}", + "deleteError": "删除失败:{{error}}", + "save": "保存", + "saveSuccess": "页面已保存", + "saveError": "保存失败:{{error}}", + "ghostsDemoted": "以下链接无法解析,已变为纯文本:{{links}}", + "editRecompileNote": "手动修改在下次重新编译该库时可能被覆盖。", + "links": { + "heading": "页面链接", + "outlinks": "此页链接到", + "backlinks": "链接到此页的页面", + "none": "无", + "error": "链接加载失败:{{error}}" + } + }, "remote": { "heading": "远程数据源", "note": "云端连接器开发中,尚不可用 · 想要它?点卡片去 GitHub 投票" diff --git a/frontend/src/pages/KbDetail.tsx b/frontend/src/pages/KbDetail.tsx index 62030a49..bf88a66a 100644 --- a/frontend/src/pages/KbDetail.tsx +++ b/frontend/src/pages/KbDetail.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } fro import { useNavigate, useParams } from 'react-router' import { useTranslation } from 'react-i18next' import { motion, useReducedMotion } from 'motion/react' -import { FileText, Loader2, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle } from 'lucide-react' +import { FileText, Link2, Loader2, Pencil, Upload, RefreshCw, Settings2, Trash2, Circle, CheckCircle2, CircleSlash2, XCircle } from 'lucide-react' import { toast } from 'sonner' -import { getKbInventory, getPage, type KbInventory, type WikiDocument } from '@/api/wiki' +import { deletePage, editPage, getKbInventory, getPage, getPageLinks, type KbInventory, type WikiDocument } from '@/api/wiki' import { streamUpload, removeDocument, type AddResult } from '@/api/maintenance' import { ApiError } from '@/api/client' import MarkdownView from '@/components/MarkdownView' @@ -123,6 +123,9 @@ export default function KbDetail() { // response never renders under a newly selected page. const [page, setPage] = useState<{ path: string; content: string } | null>(null) const [pageError, setPageError] = useState<{ path: string; message: string } | null>(null) + // Bumped to re-run the page-load effect for the SAME path (used after a page + // edit when the backend did not return the saved content). + const [pageReloadSeq, setPageReloadSeq] = useState(0) // Documents section: upload state. `uploadFiles` tracks per-file progress // for the current/most-recent streaming upload; it is reset at the start of @@ -179,7 +182,8 @@ export default function KbDetail() { } }, [id]) - // Fetch the selected page's Markdown from the real endpoint. + // Fetch the selected page's Markdown from the real endpoint. `pageReloadSeq` + // re-runs it for the same path after a save that returned no content. useEffect(() => { if (!selectedPath) return const path = selectedPath @@ -196,7 +200,7 @@ export default function KbDetail() { return () => { cancelled = true } - }, [id, selectedPath]) + }, [id, selectedPath, pageReloadSeq]) /** Re-fetch the inventory (after an upload / recompile that changed docs). */ const refreshInventory = useCallback(async () => { @@ -209,6 +213,31 @@ export default function KbDetail() { } }, [id]) + /** The open page was deleted (F2): close the now-gone page and refresh the + * inventory — backlink pages changed on disk too (their [[links]] were + * demoted to plain text). */ + const onPageDeleted = useCallback(() => { + setSelectedPath(null) + void refreshInventory() + }, [refreshInventory]) + + /** The open page was saved (F3): adopt the returned file content (stripping + * frontmatter exactly like the load effect) or re-fetch when the backend + * didn't return it, then refresh the inventory (edits can change the link + * graph). The functional set never clobbers a page that loaded for a + * DIFFERENT selection while the save was in flight. */ + const onPageSaved = useCallback( + (path: string, content: string | null) => { + if (content != null) { + setPage((prev) => (prev && prev.path !== path ? prev : { path, content: stripFrontmatter(content) })) + } else { + setPageReloadSeq((n) => n + 1) + } + void refreshInventory() + }, + [refreshInventory], + ) + const doUpload = useCallback( async (files: File[]) => { if (files.length === 0 || uploading) return @@ -404,6 +433,7 @@ export default function KbDetail() { > {section === 'index' ? ( ) : section === 'documents' ? ( - + )} @@ -466,6 +509,8 @@ export default function KbDetail() { /** Shared props for the page-content column, whether it renders full-width * (Index) or beside the 300px `PageList` sidebar (a type card). */ interface ReaderProps { + /** KB name — the `kb` param for the page edit/delete/links endpoints. */ + kb: string selected: SelectedPage | null page: { path: string; content: string } | null pageError: { path: string; message: string } | null @@ -474,13 +519,49 @@ interface ReaderProps { inv: KbInventory | null /** Navigate to a `[[wikilink]]` target clicked inside the rendered page. */ onWikiLink: (target: string) => void + /** The open page was deleted: close it and refresh the inventory. */ + onDeleted: () => void + /** The open page was saved: adopt `content` (the saved file, frontmatter and + * all) or re-fetch when null, then refresh the inventory. */ + onSaved: (path: string, content: string | null) => void } /** The actual page body: breadcrumb + Markdown, or an empty/loading state. * Shared verbatim by `Reader` (Browse) and `IndexReader` (Index, full width) — - * they differ only in their outer scroll container. */ -function ReaderBody({ selected, page, pageError, selectedPath, hasPages, inv, onWikiLink }: ReaderProps) { + * they differ only in their outer scroll container. Concept/entity pages also + * carry inline Edit/Delete controls (F2/F3): delete previews its backlink + * impact via a dry-run before the destructive call; edit swaps the rendered + * Markdown for a body-only textarea plus a read-only outlink/backlink panel. */ +function ReaderBody({ kb, selected, page, pageError, selectedPath, hasPages, inv, onWikiLink, onDeleted, onSaved }: ReaderProps) { const { t } = useTranslation(['kb', 'common']) + + // Edit mode (F3). `links`/`linksError` are tagged with the path they belong + // to (same discipline as `page`/`pageError` in KbDetail) so a slow response + // never renders under a different page. + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const [links, setLinks] = useState<{ path: string; outlinks: string[]; backlinks: string[] } | null>(null) + const [linksError, setLinksError] = useState<{ path: string; message: string } | null>(null) + + // Delete confirm (F2): `deleteImpact` holds the dry-run backlink preview for + // the page awaiting confirmation (path-tagged like the edit state above). + const [checkingDelete, setCheckingDelete] = useState(false) + const [deleteImpact, setDeleteImpact] = useState<{ path: string; backlinks: string[] } | null>(null) + const [deleting, setDeleting] = useState(false) + + // Switching pages must never leak edit/confirm state into the next page. + useEffect(() => { + setEditing(false) + setDraft('') + setSaving(false) + setLinks(null) + setLinksError(null) + setCheckingDelete(false) + setDeleteImpact(null) + setDeleting(false) + }, [selectedPath]) + const pageReady = page && page.path === selectedPath const pageFailed = pageError && pageError.path === selectedPath @@ -492,6 +573,75 @@ function ReaderBody({ selected, page, pageError, selectedPath, hasPages, inv, on ) } + // Only compiled concept/entity pages are user-editable; summaries, reports + // and index.md are generated artifacts the backend refuses to mutate. + const editable = selected.group === 'concepts/' || selected.group === 'entities/' + const confirmActive = deleteImpact !== null && deleteImpact.path === selected.path + const linksReady = links && links.path === selected.path + const linksFailed = linksError && linksError.path === selected.path + + const startEdit = () => { + if (!page || page.path !== selected.path) return + setDraft(page.content) + setEditing(true) + const path = selected.path + setLinks(null) + setLinksError(null) + getPageLinks(kb, path) + .then((r) => setLinks({ path, outlinks: r.outlinks, backlinks: r.backlinks })) + .catch((e) => setLinksError({ path, message: errMsg(e) })) + } + + const doSave = async () => { + if (saving) return + setSaving(true) + try { + const res = await editPage(kb, selected.path, draft) + const ghosts = res.ghosts_stripped ?? [] + if (ghosts.length > 0) { + toast.warning(t('kb:pageOps.ghostsDemoted', { links: ghosts.join(', ') })) + } else { + toast.success(t('kb:pageOps.saveSuccess')) + } + onSaved(selected.path, res.content) + setEditing(false) + setDraft('') + } catch (e) { + // Keep edit mode (and the draft) so a transient failure loses nothing. + toast.error(t('kb:pageOps.saveError', { error: errMsg(e) })) + } finally { + setSaving(false) + } + } + + const startDelete = async () => { + if (checkingDelete) return + setCheckingDelete(true) + const path = selected.path + try { + const res = await deletePage(kb, path, true) + setDeleteImpact({ path, backlinks: res.backlinks ?? [] }) + } catch (e) { + toast.error(t('kb:pageOps.deleteError', { error: errMsg(e) })) + } finally { + setCheckingDelete(false) + } + } + + const doDelete = async () => { + if (deleting) return + setDeleting(true) + try { + await deletePage(kb, selected.path) + toast.success(t('kb:pageOps.deleteSuccess', { title: selected.title })) + onDeleted() + } catch (e) { + toast.error(t('kb:pageOps.deleteError', { error: errMsg(e) })) + } finally { + setDeleting(false) + } + } + return (
@@ -499,11 +649,123 @@ function ReaderBody({ selected, page, pageError, selectedPath, hasPages, inv, on wiki/{selected.group} {selected.title} + {editable && pageReady && !editing && !confirmActive && ( +
+ + +
+ )}
+ + {/* Delete confirm (F2): dry-run impact preview + Confirm/Cancel. */} + {confirmActive && ( +
+

{t('kb:pageOps.deletePrompt', { title: selected.title })}

+

+ {deleteImpact.backlinks.length > 0 + ? t('kb:pageOps.deleteImpact', { count: deleteImpact.backlinks.length }) + : t('kb:pageOps.deleteNoBacklinks')} +

+ {deleteImpact.backlinks.length > 0 && ( +
+ {deleteImpact.backlinks.map((b) => ( + + {b} + + ))} +
+ )} +
+ + +
+
+ )} + {pageFailed ? (
{t('common:pageLoadError', { error: pageError.message })}
+ ) : editing && pageReady ? ( + /* Edit mode (F3): body-only textarea + save/cancel + links panel. */ +
+