package vip.mate.wiki.service; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.wiki.model.WikiPageEntity; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * Single source of truth for wikilink extraction and resolution-state * computation. The page viewer's TypeScript {@code resolveWikilink} mirrors * the matching semantics; both must stay in lockstep so users do not see a * visibly-working link that the lint marks as broken (or vice-versa). *

* Resolution rule (intentionally narrow): * *

* * The strict comparison surfaces real authoring mistakes (typo in slug, * stale ref to a renamed page) rather than silently papering over them with * canonical-form coercion. Phase 1's frontend resolver keeps a title * fallback for legacy {@code [[Page Title]]} content so the visible link * still navigates, but that fallback is intentionally absent here — title- * form authors are expected to migrate as the slug-first prompt rollout * lands in Phase 3. */ @Slf4j @Service @RequiredArgsConstructor public class WikiLinkService { /** * Matches every {@code [[...]]} occurrence. Non-greedy on the inside so * pathological inputs like {@code [[a]] [[b]]} resolve as two separate * links rather than one giant link {@code "a]] [[b"}. */ private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]"); /** * Matches a fenced code block. Anchored to {@code ^```} on a line so a * stray triple-backtick mid-paragraph does not flip the world into "in * code" mode and swallow real wikilinks for the rest of the document. * Captures the opening fence and content lazily; the matched range is * removed wholesale before wikilink extraction. */ private static final Pattern FENCED_CODE = Pattern.compile( "(?m)^```[\\s\\S]*?^```", Pattern.MULTILINE); /** * Matches inline {@code `...`} spans. Non-greedy so adjacent inline spans * are handled as separate matches. */ private static final Pattern INLINE_CODE = Pattern.compile("`[^`\\n]*?`"); /** Hard cap matching the frontend's MAX_SLUG_LEN — see wikilink.ts. */ private static final int MAX_TARGET_LEN = 256; private final ObjectMapper objectMapper; /** * Extract every wikilink target string from {@code content}, normalised * to lowercase + trimmed, with code blocks stripped first. *

* Returns an insertion-ordered set so callers that serialize to JSON get * a stable order (helps diffability of {@code broken_links} fields across * scans and makes audit logs easier to read). * * @param content full markdown body; {@code null} or blank returns empty * @return targets as written (before {@code |} alias), lowercased */ public Set extractOutlinks(String content) { if (content == null || content.isBlank()) return Collections.emptySet(); // Strip code first so inline / fenced examples that show literal // [[wikilink]] syntax stay literal. Replacement with an equal-length // run of spaces would be more correct (preserves positions for any // future error reporting) but isn't worth the complexity here — we // only need the targets. String stripped = FENCED_CODE.matcher(content).replaceAll(""); stripped = INLINE_CODE.matcher(stripped).replaceAll(""); Set targets = new LinkedHashSet<>(); Matcher m = WIKILINK.matcher(stripped); while (m.find()) { String raw = m.group(1).trim(); if (raw.isEmpty()) continue; int pipe = raw.indexOf('|'); String target = (pipe >= 0 ? raw.substring(0, pipe) : raw).trim(); if (target.isEmpty() || target.length() > MAX_TARGET_LEN) continue; // Lowercase here so {@link #computeBrokenLinks} can do exact // equality against {@code page.slug.toLowerCase()} without an // extra normalisation step per page. targets.add(target.toLowerCase(Locale.ROOT)); } return targets; } /** * Compute the broken subset of {@code outlinks} given the KB's active * page slug set. {@code activeSlugs} is expected to be already lowercased * — callers compute it once per scan and reuse across pages. * * @return targets that have no matching page slug, in the same insertion * order as {@code outlinks} */ public List computeBrokenLinks(Set outlinks, Set activeSlugsLower) { if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList(); if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet(); List broken = new ArrayList<>(); for (String t : outlinks) { if (!activeSlugsLower.contains(t)) broken.add(t); } return broken; } /** * Convenience: extract + compute in one call. Used from * {@code WikiPageService.save/update} where both fields are written in * the same transaction. */ public LinkAnalysis analyze(String content, Set activeSlugsLower) { Set outlinks = extractOutlinks(content); List broken = computeBrokenLinks(outlinks, activeSlugsLower); return new LinkAnalysis(new ArrayList<>(outlinks), broken); } /** Pair returned by {@link #analyze(String, Set)}. */ public record LinkAnalysis(List outgoingLinks, List brokenLinks) {} /** Serialize a list to JSON for persistence. Best-effort: never throws. */ public String toJsonArray(List values) { if (values == null || values.isEmpty()) return "[]"; try { return objectMapper.writeValueAsString(values); } catch (Exception e) { log.warn("[WikiLink] Failed to serialize list to JSON, falling back to empty: {}", e.getMessage()); return "[]"; } } /** Parse a JSON array back into a list. Best-effort: never throws. */ public List fromJsonArray(String json) { if (json == null || json.isBlank()) return Collections.emptyList(); try { return objectMapper.readValue(json, new TypeReference>() {}); } catch (Exception e) { log.warn("[WikiLink] Failed to parse JSON array, treating as empty: {}", e.getMessage()); return Collections.emptyList(); } } /** * Compute the lowercase slug set for a KB from a pre-loaded page list. * Centralised so both single-page save paths and the KB-wide scan use the * same definition of "active page". */ public Set lowercaseSlugSet(List pages) { if (pages == null || pages.isEmpty()) return Collections.emptySet(); return pages.stream() .map(WikiPageEntity::getSlug) .filter(s -> s != null && !s.isBlank()) .map(s -> s.toLowerCase(Locale.ROOT)) .collect(Collectors.toUnmodifiableSet()); } // ============================================================ // Cascade rewrite — used by page delete + rename to update referrers // ============================================================ /** * Strip every {@code [[deletedSlug]]} or {@code [[deletedSlug|alias]]} * occurrence in {@code content}, replacing the wikilink with plain text: * *

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

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

*/ private String rewriteWikilinks(String content, java.util.function.BiFunction rewriter) { // Split content into alternating "narrative" and "code" regions so we // can apply the rewriter only to narrative. The same fenced+inline // patterns the extractor uses, but here we preserve the matched code // text verbatim instead of stripping it. List regions = splitByCode(content); StringBuilder out = new StringBuilder(content.length() + 16); for (Region r : regions) { if (r.isCode) { out.append(r.text); continue; } Matcher m = WIKILINK.matcher(r.text); int last = 0; while (m.find()) { out.append(r.text, last, m.start()); String raw = m.group(1).trim(); String target; String alias; int pipe = raw.indexOf('|'); if (pipe >= 0) { target = raw.substring(0, pipe).trim(); alias = raw.substring(pipe + 1).trim(); } else { target = raw; alias = null; } String replacement = null; if (!target.isEmpty()) { replacement = rewriter.apply(target.toLowerCase(Locale.ROOT), alias); } if (replacement == null) { out.append(m.group()); } else { out.append(replacement); } last = m.end(); } out.append(r.text, last, r.text.length()); } return out.toString(); } /** Linear scan that splits content into alternating narrative + code regions. */ private List splitByCode(String content) { List result = new ArrayList<>(); if (content == null || content.isEmpty()) return result; // Run fenced first, then inline within each non-code piece. List afterFenced = splitOne(content, FENCED_CODE); for (Region r : afterFenced) { if (r.isCode) { result.add(r); continue; } result.addAll(splitOne(r.text, INLINE_CODE)); } return result; } private List splitOne(String text, Pattern codePattern) { List out = new ArrayList<>(); Matcher m = codePattern.matcher(text); int last = 0; while (m.find()) { if (m.start() > last) out.add(new Region(text.substring(last, m.start()), false)); out.add(new Region(m.group(), true)); last = m.end(); } if (last < text.length()) out.add(new Region(text.substring(last), false)); return out; } /** Narrative-vs-code text region used by {@link #rewriteWikilinks}. */ private record Region(String text, boolean isCode) {} }