mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.
|
* top-3 RRF hit but doesn't dominate it.
|
||||||
*/
|
*/
|
||||||
private double relationBoostLambda = 0.05;
|
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));
|
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) ====================
|
// ==================== 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")
|
@Select("SELECT content FROM mate_wiki_page WHERE id = #{id} AND deleted = 0")
|
||||||
String selectContentById(@Param("id") Long id);
|
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 ====================
|
// ==================== RFC-032: Two-phase keyword search ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -180,4 +180,147 @@ public class WikiLinkService {
|
|||||||
.map(s -> s.toLowerCase(Locale.ROOT))
|
.map(s -> s.toLowerCase(Locale.ROOT))
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
.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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.model.WikiRelationEntity;
|
||||||
import vip.mate.wiki.repository.WikiPageMapper;
|
import vip.mate.wiki.repository.WikiPageMapper;
|
||||||
|
import vip.mate.wiki.repository.WikiRelationMapper;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -34,6 +38,16 @@ public class WikiPageService {
|
|||||||
private final WikiPageMapper pageMapper;
|
private final WikiPageMapper pageMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final WikiLinkService linkService;
|
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("\\[\\[([^\\]]+)]]");
|
private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]");
|
||||||
|
|
||||||
@ -527,11 +541,279 @@ public class WikiPageService {
|
|||||||
kbId, slug, existing.getPageType(), existing.getLocked());
|
kbId, slug, existing.getPageType(), existing.getLocked());
|
||||||
return;
|
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(
|
pageMapper.delete(
|
||||||
new LambdaQueryWrapper<WikiPageEntity>()
|
new LambdaQueryWrapper<WikiPageEntity>()
|
||||||
.eq(WikiPageEntity::getKbId, kbId)
|
.eq(WikiPageEntity::getKbId, kbId)
|
||||||
.eq(WikiPageEntity::getSlug, slug));
|
.eq(WikiPageEntity::getSlug, slug));
|
||||||
evictSummaryCache(kbId);
|
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',
|
selectPage: 'Select a page from the sidebar',
|
||||||
pageKicker: 'Knowledge Page',
|
pageKicker: 'Knowledge Page',
|
||||||
confirmDelete: 'Delete page "{title}"? This cannot be undone.',
|
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.',
|
confirmBatchDelete: 'Delete {count} pages? This cannot be undone.',
|
||||||
batchSelect: 'Batch select',
|
batchSelect: 'Batch select',
|
||||||
selectAll: 'Select all',
|
selectAll: 'Select all',
|
||||||
|
|||||||
@ -2002,6 +2002,7 @@ export default {
|
|||||||
selectPage: '从左侧选择一个页面查看',
|
selectPage: '从左侧选择一个页面查看',
|
||||||
pageKicker: '知识页面',
|
pageKicker: '知识页面',
|
||||||
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',
|
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',
|
||||||
|
confirmDeleteRefs: '该页面被 {count} 个页面引用,删除后这些引用将被自动改写为纯文本。',
|
||||||
confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。',
|
confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。',
|
||||||
batchSelect: '批量选择',
|
batchSelect: '批量选择',
|
||||||
selectAll: '全选',
|
selectAll: '全选',
|
||||||
|
|||||||
@ -205,13 +205,30 @@ async function saveEdit() {
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!store.currentKB || !store.currentPage) return
|
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
|
if (!confirmed) return
|
||||||
try {
|
try {
|
||||||
await wikiApi.deletePage(store.currentKB.id, store.currentPage.slug)
|
await wikiApi.deletePage(store.currentKB.id, store.currentPage.slug)
|
||||||
store.currentPage = null
|
store.currentPage = null
|
||||||
// Keep the active raw-material filter so the list doesn't jump to all pages.
|
// 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) {
|
} catch (e: any) {
|
||||||
alert(e?.message || 'Delete failed')
|
alert(e?.message || 'Delete failed')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user