feat(wiki): cascade delete + rename to keep wikilinks consistent

This commit is contained in:
matevip 2026-05-28 08:17:19 +08:00
parent 105b075f13
commit 16eac232c4
9 changed files with 640 additions and 2 deletions

View File

@ -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.
* <p>
* 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;
}

View File

@ -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<Map<String, Object>> renamePage(
@PathVariable Long kbId,
@PathVariable String slug,
@RequestBody Map<String, String> 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<String, Object> 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) ====================
/**

View File

@ -53,6 +53,35 @@ public interface WikiPageMapper extends BaseMapper<WikiPageEntity> {
@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 %"<slug-lowercased>"%} 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.
* <p>
* 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<WikiPageEntity> findReferrersByOutgoingLink(
@Param("kbId") Long kbId,
@Param("excludeId") Long excludeId,
@Param("slugPattern") String slugPattern);
// ==================== RFC-032: Two-phase keyword search ====================
/**

View File

@ -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:
*
* <ul>
* <li>{@code [[deletedSlug]]} {@code snapshotDisplay} (the deleted
* page's last known title, or the slug itself if title missing)</li>
* <li>{@code [[deletedSlug|alias]]} {@code alias} (the author's
* chosen display text wins)</li>
* </ul>
*
* 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.
* <p>
* The rewriter receives the lowercased slug and the raw alias (or
* {@code null}). It returns either:
* <ul>
* <li>{@code null} to leave the wikilink unchanged (caller is not
* interested in this slug), or</li>
* <li>the literal replacement string typically plain text for a
* strip, or a re-formed {@code [[newSlug]]} for a rename.</li>
* </ul>
*/
private String rewriteWikilinks(String content,
java.util.function.BiFunction<String, String, String> 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<Region> 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<Region> splitByCode(String content) {
List<Region> result = new ArrayList<>();
if (content == null || content.isEmpty()) return result;
// Run fenced first, then inline within each non-code piece.
List<Region> 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<Region> splitOne(String text, Pattern codePattern) {
List<Region> 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) {}
}

View File

@ -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<Long> 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<WikiRelationEntity>()
.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<WikiPageEntity>()
.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.
* <p>
* 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<Long> 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<WikiPageEntity> 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<String> 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<String> trimmed = new HashSet<>(activeSlugs);
trimmed.remove(slugLower);
activeSlugs = trimmed;
}
List<Long> 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<String> activeForThisReferrer = activeSlugs;
if (referrer.getSlug() != null && !referrer.getSlug().isBlank()) {
Set<String> 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}.
* <p>
* 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<Long> 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<Long> cascadeRenameReferrers(Long kbId, Long renamedPageId,
String oldSlug, String newSlug) {
String slugLower = oldSlug.toLowerCase(Locale.ROOT);
String likePattern = "%\"" + slugLower + "\"%";
List<WikiPageEntity> candidates = pageMapper.findReferrersByOutgoingLink(
kbId, renamedPageId, likePattern);
if (candidates.isEmpty()) return List.of();
Set<String> 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<String> activeBase = new HashSet<>(activeSlugs);
activeBase.remove(slugLower);
activeBase.add(newSlug.toLowerCase(Locale.ROOT));
activeSlugs = activeBase;
List<Long> 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<String> activeForThisReferrer = activeSlugs;
if (referrer.getSlug() != null && !referrer.getSlug().isBlank()) {
Set<String> 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;
}
/**

View File

@ -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:
*
* <ul>
* <li>bare {@code [[slug]]} demotes to the snapshot title, alias form
* keeps the alias text, miss-slugs untouched</li>
* <li>case-insensitive on the slug part only aliases are display text
* and must not be matched against</li>
* <li>code fences and inline code are preserved literally a doc that
* teaches wikilink syntax must not be rewritten on delete</li>
* <li>rename rewrites {@code [[a]]} {@code [[b]]} and
* {@code [[a|alias]]} {@code [[b|alias]]}</li>
* </ul>
*/
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);
}
}

View File

@ -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',

View File

@ -2002,6 +2002,7 @@ export default {
selectPage: '从左侧选择一个页面查看',
pageKicker: '知识页面',
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',
confirmDeleteRefs: '该页面被 {count} 个页面引用,删除后这些引用将被自动改写为纯文本。',
confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。',
batchSelect: '批量选择',
selectAll: '全选',

View File

@ -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')
}