mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): cascade delete + rename to keep wikilinks consistent
This commit is contained in:
parent
105b075f13
commit
16eac232c4
@ -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;
|
||||
}
|
||||
|
||||
@ -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) ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -2002,6 +2002,7 @@ export default {
|
||||
selectPage: '从左侧选择一个页面查看',
|
||||
pageKicker: '知识页面',
|
||||
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',
|
||||
confirmDeleteRefs: '该页面被 {count} 个页面引用,删除后这些引用将被自动改写为纯文本。',
|
||||
confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。',
|
||||
batchSelect: '批量选择',
|
||||
selectAll: '全选',
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user