diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java index 4df212e6..4ad3115f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -270,4 +270,17 @@ public class WikiProperties { * top-3 RRF hit but doesn't dominate it. */ private double relationBoostLambda = 0.05; + + /** + * Feature flag for the cascade-delete / cascade-rename pipeline: when a + * page is deleted (or renamed), find every other page that linked to it + * via {@code [[slug]]} and rewrite those references so they don't dangle. + *

+ * Defaults to {@code true} — the legacy row-only delete left dangling + * {@code [[slug]]} markers behind, which is exactly the bug class this + * RFC closes. Set to {@code false} only as a temporary kill-switch if a + * cascade pass starts mangling referrer content (which would be a real + * bug to chase down, not a steady state). + */ + private boolean cascadeDeleteEnabled = true; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index bf62cd05..a3427cc4 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -517,6 +517,38 @@ public class WikiController { return R.ok(pageService.getBacklinks(kbId, slug)); } + /** + * Rename a page within a KB. The old slug is no longer reachable after + * this call; every wikilink in the KB that pointed at it is rewritten + * to the new slug in the same transaction. Aliases ({@code [[oldSlug|x]]}) + * are preserved by carrying the alias text over to the new target. + */ + @RequireWorkspaceRole("admin") + @Operation(summary = "重命名 Wiki 页面,并级联更新所有引用方") + @PostMapping("/knowledge-bases/{kbId}/pages/{slug}/rename") + public R> renamePage( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + String newSlug = body == null ? null : body.get("newSlug"); + WikiPageEntity renamed; + try { + renamed = pageService.rename(kbId, slug, newSlug); + } catch (IllegalArgumentException e) { + return R.fail(400, e.getMessage()); + } catch (IllegalStateException e) { + return R.fail(409, e.getMessage()); + } + if (renamed == null) return R.fail(404, "Page not found"); + Map out = new LinkedHashMap<>(); + out.put("oldSlug", slug); + out.put("newSlug", renamed.getSlug()); + out.put("pageId", String.valueOf(renamed.getId())); + return R.ok(out); + } + // ==================== Wikilink lint (broken-link scan) ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java index 333d1a70..07141ba5 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java @@ -53,6 +53,35 @@ public interface WikiPageMapper extends BaseMapper { @Select("SELECT content FROM mate_wiki_page WHERE id = #{id} AND deleted = 0") String selectContentById(@Param("id") Long id); + /** + * Candidate-set query for the cascade-delete / cascade-rename pipeline. + * Find every page in {@code kbId} whose {@code outgoing_links} JSON array + * might mention {@code slug}, using a LIKE pre-filter (works on both H2 + * and MySQL without a JSON_CONTAINS shim). The {@code slugPattern} should + * be {@code %""%} so the surrounding quotes pin the + * match to a full JSON string element rather than a substring; the + * caller still re-verifies each row by parsing the content with + * {@code WikiLinkService} because the actual rewrite must skip code + * blocks and ignore false-positive substring matches inside other JSON + * strings. + *

+ * SQL guard order: {@code kb_id} + {@code deleted} + {@code archived} + + * {@code id != excludeId} are all ANDed before the LIKE. The explicit + * prefix prevents a multi-tenant leak where a future OR-clause might + * accidentally cross KB boundaries. + */ + @Select("SELECT id, kb_id, slug, title, content, outgoing_links " + + "FROM mate_wiki_page " + + "WHERE kb_id = #{kbId} " + + " AND deleted = 0 " + + " AND archived = 0 " + + " AND id != #{excludeId} " + + " AND outgoing_links LIKE #{slugPattern}") + List findReferrersByOutgoingLink( + @Param("kbId") Long kbId, + @Param("excludeId") Long excludeId, + @Param("slugPattern") String slugPattern); + // ==================== RFC-032: Two-phase keyword search ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java index 816cdd5a..67e9a583 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java @@ -180,4 +180,147 @@ public class WikiLinkService { .map(s -> s.toLowerCase(Locale.ROOT)) .collect(Collectors.toUnmodifiableSet()); } + + // ============================================================ + // Cascade rewrite — used by page delete + rename to update referrers + // ============================================================ + + /** + * Strip every {@code [[deletedSlug]]} or {@code [[deletedSlug|alias]]} + * occurrence in {@code content}, replacing the wikilink with plain text: + * + *

+ * + * Mirrors {@link #extractOutlinks} on every protective axis: code blocks + * are skipped via the same fenced/inline strip-and-restore dance below, + * matching is exact case-insensitive on the slug only (never on the + * alias), and at-most-one {@code |} is honoured so a malformed + * {@code [[a|b|c]]} keeps the b|c suffix as alias text rather than + * collapsing. + */ + public String stripDeletedLink(String content, String deletedSlug, String snapshotDisplay) { + if (content == null || content.isEmpty()) return content; + if (deletedSlug == null || deletedSlug.isBlank()) return content; + String targetLower = deletedSlug.toLowerCase(Locale.ROOT); + String fallback = (snapshotDisplay != null && !snapshotDisplay.isBlank()) + ? snapshotDisplay : deletedSlug; + return rewriteWikilinks(content, (slugLower, alias) -> { + if (!slugLower.equals(targetLower)) return null; // unchanged + // No href to preserve — the link is being demoted to plain text. + return (alias != null && !alias.isBlank()) ? alias : fallback; + }); + } + + /** + * Rewrite every {@code [[oldSlug]]} or {@code [[oldSlug|alias]]} so the + * target becomes {@code newSlug}. Preserves the wikilink form — only the + * slug part changes, the alias (if any) is kept verbatim. Used when a + * page is renamed and every referrer must follow. + */ + public String renameLink(String content, String oldSlug, String newSlug) { + if (content == null || content.isEmpty()) return content; + if (oldSlug == null || oldSlug.isBlank() || newSlug == null || newSlug.isBlank()) return content; + String oldLower = oldSlug.toLowerCase(Locale.ROOT); + return rewriteWikilinks(content, (slugLower, alias) -> { + if (!slugLower.equals(oldLower)) return null; + // Return the full replacement string for this wikilink occurrence + // (still a wikilink, just with a different target). + return (alias != null && !alias.isBlank()) + ? "[[" + newSlug + "|" + alias + "]]" + : "[[" + newSlug + "]]"; + }); + } + + /** + * Walk {@code content} replacing wikilinks via {@code rewriter}. Code + * spans are detected and restored verbatim — replacement only happens in + * "narrative" regions so a doc literally showing {@code [[foo]]} inside + * a code fence is never silently mutated. + *

+ * The rewriter receives the lowercased slug and the raw alias (or + * {@code null}). It returns either: + *

+ */ + private String rewriteWikilinks(String content, + java.util.function.BiFunction rewriter) { + // Split content into alternating "narrative" and "code" regions so we + // can apply the rewriter only to narrative. The same fenced+inline + // patterns the extractor uses, but here we preserve the matched code + // text verbatim instead of stripping it. + List regions = splitByCode(content); + StringBuilder out = new StringBuilder(content.length() + 16); + for (Region r : regions) { + if (r.isCode) { + out.append(r.text); + continue; + } + Matcher m = WIKILINK.matcher(r.text); + int last = 0; + while (m.find()) { + out.append(r.text, last, m.start()); + String raw = m.group(1).trim(); + String target; + String alias; + int pipe = raw.indexOf('|'); + if (pipe >= 0) { + target = raw.substring(0, pipe).trim(); + alias = raw.substring(pipe + 1).trim(); + } else { + target = raw; + alias = null; + } + String replacement = null; + if (!target.isEmpty()) { + replacement = rewriter.apply(target.toLowerCase(Locale.ROOT), alias); + } + if (replacement == null) { + out.append(m.group()); + } else { + out.append(replacement); + } + last = m.end(); + } + out.append(r.text, last, r.text.length()); + } + return out.toString(); + } + + /** Linear scan that splits content into alternating narrative + code regions. */ + private List splitByCode(String content) { + List result = new ArrayList<>(); + if (content == null || content.isEmpty()) return result; + // Run fenced first, then inline within each non-code piece. + List afterFenced = splitOne(content, FENCED_CODE); + for (Region r : afterFenced) { + if (r.isCode) { result.add(r); continue; } + result.addAll(splitOne(r.text, INLINE_CODE)); + } + return result; + } + + private List splitOne(String text, Pattern codePattern) { + List out = new ArrayList<>(); + Matcher m = codePattern.matcher(text); + int last = 0; + while (m.find()) { + if (m.start() > last) out.add(new Region(text.substring(last, m.start()), false)); + out.add(new Region(m.group(), true)); + last = m.end(); + } + if (last < text.length()) out.add(new Region(text.substring(last), false)); + return out; + } + + /** Narrative-vs-code text region used by {@link #rewriteWikilinks}. */ + private record Region(String text, boolean isCode) {} } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 1b683277..231d60ec 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -7,8 +7,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.audit.service.AuditEventService; +import vip.mate.wiki.WikiProperties; import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRelationEntity; import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.repository.WikiRelationMapper; import java.time.LocalDateTime; import java.util.ArrayList; @@ -34,6 +38,16 @@ public class WikiPageService { private final WikiPageMapper pageMapper; private final ObjectMapper objectMapper; private final WikiLinkService linkService; + // Cascade dependencies — optional via setter so the legacy unit-test + // constructor (mapper + ObjectMapper + linkService) still compiles. In + // production these are auto-wired through the field setters Lombok + // generates from @Setter on Spring's post-construct path. + @org.springframework.beans.factory.annotation.Autowired(required = false) + private WikiRelationMapper relationMapper; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private AuditEventService auditEventService; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private WikiProperties wikiProperties; private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]"); @@ -527,11 +541,279 @@ public class WikiPageService { kbId, slug, existing.getPageType(), existing.getLocked()); return; } + + // Snapshot the title BEFORE the row goes away. Referrer rewrites + // demote `[[slug]]` to plain text using the title as the visible + // word; without the snapshot the demotion would fall back to the + // raw slug, which reads worse. + Long pageId = existing.getId(); + String snapshotTitle = (existing.getTitle() != null && !existing.getTitle().isBlank()) + ? existing.getTitle() : slug; + + // Cascade-rewrite every other page that linked to this slug. Feature- + // flagged so a hypothetical content-mangling regression has a + // production kill-switch; default-on because the legacy behaviour + // (just dropping the row) left dangling [[slug]] tokens that this + // RFC exists to eliminate. + List affectedReferrers = java.util.Collections.emptyList(); + boolean cascadeOn = wikiProperties == null || wikiProperties.isCascadeDeleteEnabled(); + if (cascadeOn) { + try { + affectedReferrers = cascadeStripReferrers(kbId, pageId, slug, snapshotTitle); + } catch (RuntimeException e) { + // Don't fail the delete on a referrer-rewrite hiccup — the + // page itself coming out is the user's primary intent; lint + // will catch any stragglers on the next scan. + log.warn("[Wiki] Cascade rewrite failed for slug={} (continuing with delete): {}", + slug, e.toString()); + } + } + + // Defensive relation-cache cleanup. The mate_wiki_relation table is + // currently a reserved cache (no production writer today), but we + // wipe matching rows anyway so a future writer that populates it + // can't strand entries pointing at a deleted page. + if (relationMapper != null) { + try { + relationMapper.delete( + new LambdaQueryWrapper() + .eq(WikiRelationEntity::getKbId, kbId) + .and(w -> w.eq(WikiRelationEntity::getPageAId, pageId) + .or().eq(WikiRelationEntity::getPageBId, pageId))); + } catch (RuntimeException e) { + log.warn("[Wiki] Failed to purge mate_wiki_relation rows for pageId={}: {}", + pageId, e.toString()); + } + } + pageMapper.delete( new LambdaQueryWrapper() .eq(WikiPageEntity::getKbId, kbId) .eq(WikiPageEntity::getSlug, slug)); evictSummaryCache(kbId); + + // Audit event runs after the row is gone so the resourceId reflects + // the actual deletion. Async insert means a failing audit log won't + // poison the transaction. + if (auditEventService != null) { + try { + String detail = objectMapper.writeValueAsString(java.util.Map.of( + "kbId", kbId, + "slug", slug, + "title", snapshotTitle, + "affectedPageIds", affectedReferrers, + "cascadeEnabled", cascadeOn)); + auditEventService.record("wiki.page.delete", "wiki_page", + String.valueOf(pageId), snapshotTitle, detail); + } catch (Exception e) { + log.debug("[Wiki] Audit event emit failed for delete kbId={} slug={}: {}", + kbId, slug, e.toString()); + } + } + } + + /** + * Walk every page in {@code kbId} that links to {@code targetSlug}, + * rewrite the wikilink to plain text via the parser, and persist the + * referrer with refreshed outgoing_links + broken_links. Returns the + * affected page ids so the caller can include them in the audit event. + *

+ * Candidate set comes from {@link WikiPageMapper#findReferrersByOutgoingLink} + * (a LIKE pre-filter on {@code outgoing_links}). Each candidate is then + * verified by re-extracting outlinks from its content — LIKE matches on + * the raw JSON column can include false positives if the slug happens + * to appear as a substring of another value, so we trust the parser as + * the final word. + */ + private List cascadeStripReferrers(Long kbId, Long deletedPageId, + String deletedSlug, String snapshotTitle) { + // outgoing_links is stored as a JSON array of lowercased strings, so + // we wrap with quotes to anchor the match to a full JSON element + // rather than any substring match. + String slugLower = deletedSlug.toLowerCase(Locale.ROOT); + String likePattern = "%\"" + slugLower + "\"%"; + List candidates = pageMapper.findReferrersByOutgoingLink( + kbId, deletedPageId, likePattern); + if (candidates.isEmpty()) return List.of(); + + // Pre-compute the active slug set ONCE for the recompute pass — every + // referrer's broken_links recompute would otherwise re-trigger the + // summary query. + Set activeSlugs; + try { + activeSlugs = linkService.lowercaseSlugSet(listSummaries(kbId)); + } catch (RuntimeException e) { + activeSlugs = java.util.Collections.emptySet(); + } + // The deleted page is, by construction, no longer "active" — remove + // its slug from the set so any referrers' broken_links recompute + // doesn't accidentally still resolve `[[deletedSlug]]` in their + // (now-rewritten) content. + if (!activeSlugs.contains(slugLower)) { + // already missing — common case + } else { + Set trimmed = new HashSet<>(activeSlugs); + trimmed.remove(slugLower); + activeSlugs = trimmed; + } + + List affected = new ArrayList<>(candidates.size()); + for (WikiPageEntity referrer : candidates) { + String originalContent = referrer.getContent(); + if (originalContent == null) continue; + String rewritten = linkService.stripDeletedLink(originalContent, deletedSlug, snapshotTitle); + if (rewritten.equals(originalContent)) { + // LIKE matched but parser found no real wikilink — pure + // false-positive (e.g. slug appeared as substring inside an + // alias of an unrelated link). Skip. + continue; + } + + // Recompute outgoing + broken from the rewritten content, including + // the referrer's own slug so any self-links remain non-broken. + Set activeForThisReferrer = activeSlugs; + if (referrer.getSlug() != null && !referrer.getSlug().isBlank()) { + Set withSelf = new HashSet<>(activeSlugs); + withSelf.add(referrer.getSlug().toLowerCase(Locale.ROOT)); + activeForThisReferrer = withSelf; + } + WikiLinkService.LinkAnalysis a = linkService.analyze(rewritten, activeForThisReferrer); + + WikiPageEntity update = new WikiPageEntity(); + update.setId(referrer.getId()); + update.setContent(rewritten); + update.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks())); + update.setBrokenLinks(linkService.toJsonArray(a.brokenLinks())); + update.setBrokenLinksScannedAt(LocalDateTime.now()); + pageMapper.updateById(update); + affected.add(referrer.getId()); + } + return affected; + } + + /** + * Rename a page from {@code oldSlug} to {@code newSlug}. + *

+ * Updates the page row's slug AND rewrites every referrer's + * {@code [[oldSlug]]} (and {@code [[oldSlug|alias]]}) to point at the + * new slug, preserving aliases. Both pieces run in the same transaction + * so a partial rename can never leave a "page exists at new slug but + * referrers still point at old slug" inconsistency. + * + * @return the renamed page entity, or {@code null} if {@code oldSlug} + * didn't exist + * @throws IllegalArgumentException if {@code newSlug} is blank, equals + * the current slug, or collides with another page in the same KB + */ + @Transactional + public WikiPageEntity rename(Long kbId, String oldSlug, String newSlug) { + if (newSlug == null || newSlug.isBlank()) { + throw new IllegalArgumentException("new slug must not be blank"); + } + if (newSlug.equals(oldSlug)) { + throw new IllegalArgumentException("new slug equals old slug — no-op"); + } + WikiPageEntity existing = getBySlug(kbId, oldSlug); + if (existing == null) return null; + if (isProtected(existing)) { + throw new IllegalStateException("page is protected (system or locked), refusing to rename"); + } + WikiPageEntity collision = getBySlug(kbId, newSlug); + if (collision != null) { + throw new IllegalArgumentException("a page with slug '" + newSlug + "' already exists in this KB"); + } + + Long pageId = existing.getId(); + // Update the row's own slug first so referrer rewrites that include + // a self-link to the same page (rare but possible — e.g. a "see also" + // anchor) resolve to the new slug as well. + existing.setSlug(newSlug); + existing.setUpdateTime(LocalDateTime.now()); + pageMapper.updateById(existing); + evictSummaryCache(kbId); + + List affected = java.util.Collections.emptyList(); + boolean cascadeOn = wikiProperties == null || wikiProperties.isCascadeDeleteEnabled(); + if (cascadeOn) { + try { + affected = cascadeRenameReferrers(kbId, pageId, oldSlug, newSlug); + } catch (RuntimeException e) { + log.warn("[Wiki] Cascade rename failed for {}→{} (continuing): {}", + oldSlug, newSlug, e.toString()); + } + } + + if (auditEventService != null) { + try { + String detail = objectMapper.writeValueAsString(java.util.Map.of( + "kbId", kbId, + "oldSlug", oldSlug, + "newSlug", newSlug, + "affectedPageIds", affected, + "cascadeEnabled", cascadeOn)); + auditEventService.record("wiki.page.rename", "wiki_page", + String.valueOf(pageId), existing.getTitle(), detail); + } catch (Exception e) { + log.debug("[Wiki] Audit event emit failed for rename: {}", e.toString()); + } + } + + return existing; + } + + /** + * Mirror of {@link #cascadeStripReferrers} for the rename path — + * replaces {@code [[oldSlug]]} with {@code [[newSlug]]} (preserving the + * wikilink form and any alias) instead of demoting to plain text. + */ + private List cascadeRenameReferrers(Long kbId, Long renamedPageId, + String oldSlug, String newSlug) { + String slugLower = oldSlug.toLowerCase(Locale.ROOT); + String likePattern = "%\"" + slugLower + "\"%"; + List candidates = pageMapper.findReferrersByOutgoingLink( + kbId, renamedPageId, likePattern); + if (candidates.isEmpty()) return List.of(); + + Set activeSlugs; + try { + activeSlugs = linkService.lowercaseSlugSet(listSummaries(kbId)); + } catch (RuntimeException e) { + activeSlugs = java.util.Collections.emptySet(); + } + // The renamed page is now under newSlug; oldSlug is gone, newSlug + // should resolve. listSummaries has been evicted above so this picks + // up the new row when re-queried, but be defensive in case the cache + // hasn't repopulated yet. + Set activeBase = new HashSet<>(activeSlugs); + activeBase.remove(slugLower); + activeBase.add(newSlug.toLowerCase(Locale.ROOT)); + activeSlugs = activeBase; + + List affected = new ArrayList<>(candidates.size()); + for (WikiPageEntity referrer : candidates) { + String originalContent = referrer.getContent(); + if (originalContent == null) continue; + String rewritten = linkService.renameLink(originalContent, oldSlug, newSlug); + if (rewritten.equals(originalContent)) continue; + + Set activeForThisReferrer = activeSlugs; + if (referrer.getSlug() != null && !referrer.getSlug().isBlank()) { + Set withSelf = new HashSet<>(activeSlugs); + withSelf.add(referrer.getSlug().toLowerCase(Locale.ROOT)); + activeForThisReferrer = withSelf; + } + WikiLinkService.LinkAnalysis a = linkService.analyze(rewritten, activeForThisReferrer); + + WikiPageEntity update = new WikiPageEntity(); + update.setId(referrer.getId()); + update.setContent(rewritten); + update.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks())); + update.setBrokenLinks(linkService.toJsonArray(a.brokenLinks())); + update.setBrokenLinksScannedAt(LocalDateTime.now()); + pageMapper.updateById(update); + affected.add(referrer.getId()); + } + return affected; } /** diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCascadeTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCascadeTest.java new file mode 100644 index 00000000..a38e9df2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceCascadeTest.java @@ -0,0 +1,120 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Behavioural coverage for the cascade-rewrite helpers in + * {@link WikiLinkService}. Each test pins down one of the protective rules + * the cascade delete/rename pipeline depends on: + * + *

    + *
  • bare {@code [[slug]]} demotes to the snapshot title, alias form + * keeps the alias text, miss-slugs untouched
  • + *
  • case-insensitive on the slug part only — aliases are display text + * and must not be matched against
  • + *
  • code fences and inline code are preserved literally — a doc that + * teaches wikilink syntax must not be rewritten on delete
  • + *
  • rename rewrites {@code [[a]]} → {@code [[b]]} and + * {@code [[a|alias]]} → {@code [[b|alias]]}
  • + *
+ */ +class WikiLinkServiceCascadeTest { + + private final WikiLinkService svc = new WikiLinkService(new ObjectMapper()); + + @Test + void stripBareWikilinkUsesSnapshotTitle() { + String out = svc.stripDeletedLink( + "See [[deprecated-concept]] for context.", + "deprecated-concept", "Deprecated Concept"); + assertEquals("See Deprecated Concept for context.", out); + } + + @Test + void stripAliasedWikilinkKeepsAlias() { + String out = svc.stripDeletedLink( + "More on [[deprecated-concept|that old idea]] later.", + "deprecated-concept", "Deprecated Concept"); + assertEquals("More on that old idea later.", out); + } + + @Test + void stripIsCaseInsensitiveOnSlug() { + String out = svc.stripDeletedLink( + "Both [[Foo]] and [[FOO]] go away.", + "foo", "Foo Page"); + assertEquals("Both Foo Page and Foo Page go away.", out); + } + + @Test + void stripLeavesUnrelatedWikilinksAlone() { + String input = "[[keep-me]] stays; [[delete-me]] does not."; + String out = svc.stripDeletedLink(input, "delete-me", "Delete Me"); + assertEquals("[[keep-me]] stays; Delete Me does not.", out); + } + + @Test + void stripSkipsFencedCodeBlocks() { + String input = "Outside [[a]] gone.\n\n```\nInside [[a]] stays.\n```\n"; + String out = svc.stripDeletedLink(input, "a", "A Page"); + // outside replaced, inside literal + assertTrue(out.startsWith("Outside A Page gone."), + "outside should be replaced, got: " + out); + assertTrue(out.contains("Inside [[a]] stays."), + "fenced [[a]] must be preserved, got: " + out); + } + + @Test + void stripSkipsInlineCodeSpans() { + String input = "Use `[[a]]` to link, e.g. [[a]] in prose."; + String out = svc.stripDeletedLink(input, "a", "A Page"); + // inline-code [[a]] preserved, prose [[a]] demoted + assertTrue(out.contains("`[[a]]`"), + "inline code must be preserved, got: " + out); + assertTrue(out.contains("A Page in prose"), + "prose occurrence must be replaced, got: " + out); + } + + @Test + void stripFallsBackToSlugWhenSnapshotMissing() { + String out = svc.stripDeletedLink("See [[a]] here.", "a", null); + assertEquals("See a here.", out); + } + + @Test + void renameRewritesBareWikilink() { + String out = svc.renameLink("Link to [[old-slug]].", "old-slug", "new-slug"); + assertEquals("Link to [[new-slug]].", out); + } + + @Test + void renameRewritesAliasedWikilink() { + String out = svc.renameLink("Read [[old-slug|the manifesto]].", "old-slug", "new-slug"); + assertEquals("Read [[new-slug|the manifesto]].", out); + } + + @Test + void renameIsCaseInsensitiveOnSlug() { + String out = svc.renameLink("Both [[OLD-slug]] and [[Old-Slug|x]] should move.", + "old-slug", "new-slug"); + assertEquals("Both [[new-slug]] and [[new-slug|x]] should move.", out); + } + + @Test + void renameSkipsCodeBlocks() { + String input = "Outside [[old]] moves.\n\n```\nInside [[old]] does not.\n```\n"; + String out = svc.renameLink(input, "old", "new"); + assertTrue(out.contains("Outside [[new]] moves.")); + assertTrue(out.contains("Inside [[old]] does not.")); + } + + @Test + void renameLeavesUnrelatedWikilinksAlone() { + String out = svc.renameLink("[[a]] and [[b]] are friends.", "a", "x"); + assertEquals("[[x]] and [[b]] are friends.", out); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 604ba6a4..53f94585 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1990,6 +1990,7 @@ export default { selectPage: 'Select a page from the sidebar', pageKicker: 'Knowledge Page', confirmDelete: 'Delete page "{title}"? This cannot be undone.', + confirmDeleteRefs: '{count} pages link to this one — their wikilinks will be rewritten to plain text.', confirmBatchDelete: 'Delete {count} pages? This cannot be undone.', batchSelect: 'Batch select', selectAll: 'Select all', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d30cb3af..9a4f98a9 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2002,6 +2002,7 @@ export default { selectPage: '从左侧选择一个页面查看', pageKicker: '知识页面', confirmDelete: '确认删除页面「{title}」?此操作不可撤销。', + confirmDeleteRefs: '该页面被 {count} 个页面引用,删除后这些引用将被自动改写为纯文本。', confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。', batchSelect: '批量选择', selectAll: '全选', diff --git a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue index cba0550f..0c25cfca 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue @@ -205,13 +205,30 @@ async function saveEdit() { async function handleDelete() { if (!store.currentKB || !store.currentPage) return - const confirmed = confirm(t('wiki.confirmDelete', { title: store.currentPage.title })) + // Surface the referrer count before deletion so the user knows how many + // pages will be unlinked in cascade. backlinks already loads on page + // open (see the currentPage watcher), so this read is local — no extra + // round-trip. The cascade runs on the backend regardless of UI prompt + // wording; this is purely advisory. + const refCount = backlinks.value.length + const baseMessage = t('wiki.confirmDelete', { title: store.currentPage.title }) + const withRefs = refCount > 0 + ? `${baseMessage}\n\n${t('wiki.confirmDeleteRefs', { count: refCount })}` + : baseMessage + const confirmed = confirm(withRefs) if (!confirmed) return try { await wikiApi.deletePage(store.currentKB.id, store.currentPage.slug) store.currentPage = null // Keep the active raw-material filter so the list doesn't jump to all pages. - await store.fetchPages(store.currentKB.id, store.selectedRawId) + // Refresh pageRefs + broken-link report too — both can change after a + // delete because cascade-rewrite may upgrade or downgrade other pages' + // resolution states. + await Promise.all([ + store.fetchPages(store.currentKB.id, store.selectedRawId), + store.fetchPageRefs(store.currentKB.id), + store.loadBrokenLinksReport(store.currentKB.id), + ]) } catch (e: any) { alert(e?.message || 'Delete failed') }