mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(wiki): broken-link lint with job-based async scan
This commit is contained in:
parent
66d3d90ea9
commit
2b3c068db9
@ -19,6 +19,7 @@ import vip.mate.wiki.model.WikiPageEntity;
|
|||||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
import vip.mate.wiki.service.WikiDirectoryScanService;
|
import vip.mate.wiki.service.WikiDirectoryScanService;
|
||||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
|
import vip.mate.wiki.service.WikiLintJobService;
|
||||||
import vip.mate.wiki.service.WikiPageService;
|
import vip.mate.wiki.service.WikiPageService;
|
||||||
import vip.mate.wiki.service.WikiProcessingService;
|
import vip.mate.wiki.service.WikiProcessingService;
|
||||||
import vip.mate.wiki.service.WikiRawMaterialService;
|
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||||
@ -50,6 +51,7 @@ public class WikiController {
|
|||||||
private final WikiPageService pageService;
|
private final WikiPageService pageService;
|
||||||
private final WikiProcessingService processingService;
|
private final WikiProcessingService processingService;
|
||||||
private final WikiDirectoryScanService scanService;
|
private final WikiDirectoryScanService scanService;
|
||||||
|
private final WikiLintJobService lintJobService;
|
||||||
private final WikiProperties properties;
|
private final WikiProperties properties;
|
||||||
private final WikiProgressBus progressBus;
|
private final WikiProgressBus progressBus;
|
||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
@ -515,6 +517,90 @@ public class WikiController {
|
|||||||
return R.ok(pageService.getBacklinks(kbId, slug));
|
return R.ok(pageService.getBacklinks(kbId, slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Wikilink lint (broken-link scan) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a KB-wide broken-link scan. Job-based async: returns immediately
|
||||||
|
* with a {@code {jobId, status, startedAt}} envelope; the real work runs
|
||||||
|
* on a single-threaded background executor and writes per-page results
|
||||||
|
* back to {@code mate_wiki_page.broken_links}. Idempotent under in-flight
|
||||||
|
* load — repeated POSTs while a scan is queued or running return the
|
||||||
|
* existing job rather than queueing duplicates.
|
||||||
|
*/
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@Operation(summary = "启动 Wiki 死链扫描 job(异步)")
|
||||||
|
@PostMapping("/knowledge-bases/{kbId}/lint/broken-links")
|
||||||
|
public R<Map<String, Object>> startBrokenLinksScan(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
WikiLintJobService.LintJob job = lintJobService.startOrGetRunning(kbId);
|
||||||
|
return R.ok(jobEnvelope(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the most recent completed scan result for {@code kbId}. Aggregated
|
||||||
|
* from persisted {@code broken_links} fields, so it survives a server
|
||||||
|
* restart that drops the in-memory job state.
|
||||||
|
*/
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@Operation(summary = "读取最近一次死链扫描的聚合结果")
|
||||||
|
@GetMapping("/knowledge-bases/{kbId}/lint/broken-links")
|
||||||
|
public R<Map<String, Object>> getBrokenLinksReport(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
WikiLintJobService.Aggregate agg = lintJobService.aggregate(kbId);
|
||||||
|
if (agg == null) {
|
||||||
|
return R.fail(404, "no scan yet, POST to start one");
|
||||||
|
}
|
||||||
|
WikiLintJobService.LintJob latest = lintJobService.getLatestJob(kbId);
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("kbId", agg.kbId());
|
||||||
|
body.put("jobId", latest != null ? latest.jobId() : null);
|
||||||
|
body.put("completedAt", agg.completedAt());
|
||||||
|
body.put("totalPages", agg.totalPages());
|
||||||
|
body.put("pagesWithBrokenLinks", agg.pagesWithBrokenLinks());
|
||||||
|
body.put("totalBrokenRefs", agg.totalBrokenRefs());
|
||||||
|
body.put("pages", agg.pages());
|
||||||
|
return R.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional job-status endpoint. Not strictly needed for the v1 UX
|
||||||
|
* (the frontend can poll the aggregate endpoint and watch
|
||||||
|
* {@code completedAt}), but useful for debugging and future progress
|
||||||
|
* reporting.
|
||||||
|
*/
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@Operation(summary = "查询 Wiki 死链扫描 job 状态")
|
||||||
|
@GetMapping("/knowledge-bases/{kbId}/lint/broken-links/jobs/{jobId}")
|
||||||
|
public R<Map<String, Object>> getBrokenLinksJob(
|
||||||
|
@PathVariable Long kbId,
|
||||||
|
@PathVariable String jobId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyKBWorkspace(kbId, workspaceId);
|
||||||
|
WikiLintJobService.LintJob job = lintJobService.getJob(jobId);
|
||||||
|
if (job == null || !job.kbId().equals(kbId)) {
|
||||||
|
return R.fail(404, "job not found");
|
||||||
|
}
|
||||||
|
return R.ok(jobEnvelope(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> jobEnvelope(WikiLintJobService.LintJob job) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("jobId", job.jobId());
|
||||||
|
body.put("kbId", job.kbId());
|
||||||
|
body.put("status", job.status().name().toLowerCase());
|
||||||
|
body.put("startedAt", job.startedAt());
|
||||||
|
body.put("completedAt", job.completedAt());
|
||||||
|
body.put("totalPages", job.totalPages());
|
||||||
|
body.put("pagesWithBrokenLinks", job.pagesWithBrokenLinks());
|
||||||
|
body.put("totalBrokenRefs", job.totalBrokenRefs());
|
||||||
|
if (job.errorMessage() != null) body.put("errorMessage", job.errorMessage());
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
// RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI
|
// RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI
|
||||||
// needs a dedicated endpoint to enumerate archived pages and a way to flip the
|
// needs a dedicated endpoint to enumerate archived pages and a way to flip the
|
||||||
// flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already
|
// flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already
|
||||||
|
|||||||
@ -87,6 +87,21 @@ public class WikiPageEntity {
|
|||||||
/** Input-format version for {@link #embedding}; bumped when the embedding builder changes. */
|
/** Input-format version for {@link #embedding}; bumped when the embedding builder changes. */
|
||||||
private String embeddingTextVersion;
|
private String embeddingTextVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON array of outlink targets present in {@link #content} but missing
|
||||||
|
* from the active KB slug set. Empty array = scanned, all targets resolve;
|
||||||
|
* {@code null} = never scanned. Recomputed in the same transaction as any
|
||||||
|
* content save/update, and by the on-demand KB-wide lint scan job.
|
||||||
|
*/
|
||||||
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
private String brokenLinks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp of the most recent {@link #brokenLinks} recompute. The lint UI
|
||||||
|
* banner uses this to mark stale data ("scanned 3 days ago — rescan?").
|
||||||
|
*/
|
||||||
|
private LocalDateTime brokenLinksScannedAt;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,183 @@
|
|||||||
|
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).
|
||||||
|
* <p>
|
||||||
|
* Resolution rule (intentionally narrow):
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Extract every {@code [[target]]} or {@code [[target|display]]} from
|
||||||
|
* content, skipping fenced and inline code spans.</li>
|
||||||
|
* <li>For each occurrence keep only {@code target.toLowerCase().trim()} —
|
||||||
|
* no {@link WikiPageService#canonicalSlug} fuzzy collapse, no
|
||||||
|
* title→slug guessing. The lint flags a link as broken iff no active
|
||||||
|
* KB page has {@code page.slug.equalsIgnoreCase(target)}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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<String> 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<String> 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<String> computeBrokenLinks(Set<String> outlinks, Set<String> activeSlugsLower) {
|
||||||
|
if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList();
|
||||||
|
if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet();
|
||||||
|
List<String> 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<String> activeSlugsLower) {
|
||||||
|
Set<String> outlinks = extractOutlinks(content);
|
||||||
|
List<String> broken = computeBrokenLinks(outlinks, activeSlugsLower);
|
||||||
|
return new LinkAnalysis(new ArrayList<>(outlinks), broken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pair returned by {@link #analyze(String, Set)}. */
|
||||||
|
public record LinkAnalysis(List<String> outgoingLinks, List<String> brokenLinks) {}
|
||||||
|
|
||||||
|
/** Serialize a list to JSON for persistence. Best-effort: never throws. */
|
||||||
|
public String toJsonArray(List<String> 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<String> fromJsonArray(String json) {
|
||||||
|
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||||
|
} 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<String> lowercaseSlugSet(List<WikiPageEntity> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,301 @@
|
|||||||
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.repository.WikiPageMapper;
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KB-wide broken-link scan job orchestrator.
|
||||||
|
* <p>
|
||||||
|
* Single-page broken_links is maintained synchronously on every save/update
|
||||||
|
* (see {@link WikiPageService#applyLinkAnalysis} via
|
||||||
|
* {@code applyLinkAnalysis}). This service handles the on-demand "scan the
|
||||||
|
* whole KB" path that surfaces accumulated drift — pages whose targets went
|
||||||
|
* missing because some OTHER page was renamed / deleted / archived, or
|
||||||
|
* pages whose broken_links was never computed (legacy content predating
|
||||||
|
* V129).
|
||||||
|
* <p>
|
||||||
|
* State lives in-memory on purpose: the authoritative result lives on
|
||||||
|
* {@code mate_wiki_page.broken_links} (persisted). The job record here is
|
||||||
|
* pure UX glue so the frontend can show "scan started / running / done"
|
||||||
|
* without a second DB table. A server restart mid-scan loses progress
|
||||||
|
* tracking but never corrupts data — each per-page rewrite is transactional,
|
||||||
|
* and the user simply re-triggers the scan.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WikiLintJobService {
|
||||||
|
|
||||||
|
private final WikiPageMapper pageMapper;
|
||||||
|
private final WikiPageService pageService;
|
||||||
|
private final WikiLinkService linkService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-KB job state. The map holds the most recent job for each KB
|
||||||
|
* regardless of status, so {@link #getLatestJob} can answer "did we ever
|
||||||
|
* finish a scan on this KB". Cleared only on completion of a newer scan
|
||||||
|
* for the same KB — no TTL, the cardinality is bounded by KB count.
|
||||||
|
*/
|
||||||
|
private final ConcurrentHashMap<Long, LintJob> jobsByKb = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Index by jobId for the optional {@code GET .../jobs/{jobId}} path. */
|
||||||
|
private final ConcurrentHashMap<String, LintJob> jobsById = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-threaded executor: lint scans are I/O-bound but cheap; one job
|
||||||
|
* per KB at a time avoids piling up concurrent full-KB reads on the
|
||||||
|
* mapper. Daemon thread so we don't block JVM shutdown.
|
||||||
|
*/
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "wiki-lint-scan");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdown() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
try {
|
||||||
|
executor.awaitTermination(5, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job status lifecycle: {@code queued → running → completed | failed}. */
|
||||||
|
public enum JobStatus { QUEUED, RUNNING, COMPLETED, FAILED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a job — the running mutable state lives behind
|
||||||
|
* an {@link AtomicReference} so callers receive a thread-safe view.
|
||||||
|
*/
|
||||||
|
public record LintJob(
|
||||||
|
String jobId,
|
||||||
|
Long kbId,
|
||||||
|
JobStatus status,
|
||||||
|
LocalDateTime startedAt,
|
||||||
|
LocalDateTime completedAt,
|
||||||
|
int totalPages,
|
||||||
|
int pagesWithBrokenLinks,
|
||||||
|
int totalBrokenRefs,
|
||||||
|
String errorMessage
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a scan on {@code kbId}. If a scan is already queued or running for
|
||||||
|
* the same KB, return that job — POST is idempotent under in-flight load.
|
||||||
|
*/
|
||||||
|
public LintJob startOrGetRunning(Long kbId) {
|
||||||
|
// computeIfAbsent skipped — we need access to the existing value to
|
||||||
|
// decide whether to keep or replace it, which compute() supports.
|
||||||
|
return jobsByKb.compute(kbId, (k, prev) -> {
|
||||||
|
if (prev != null && (prev.status() == JobStatus.QUEUED || prev.status() == JobStatus.RUNNING)) {
|
||||||
|
log.debug("[WikiLint] Reusing in-flight job {} for kbId={}", prev.jobId(), kbId);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
String jobId = newJobId();
|
||||||
|
LintJob job = new LintJob(jobId, kbId, JobStatus.QUEUED, LocalDateTime.now(),
|
||||||
|
null, 0, 0, 0, null);
|
||||||
|
jobsById.put(jobId, job);
|
||||||
|
executor.submit(() -> runJob(jobId, kbId));
|
||||||
|
log.info("[WikiLint] Scheduled job {} for kbId={}", jobId, kbId);
|
||||||
|
return job;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return latest job (any status) for {@code kbId}, or {@code null} */
|
||||||
|
public LintJob getLatestJob(Long kbId) {
|
||||||
|
return jobsByKb.get(kbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return job by id, or {@code null} */
|
||||||
|
public LintJob getJob(String jobId) {
|
||||||
|
return jobsById.get(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate the broken-link state for {@code kbId} from persisted
|
||||||
|
* {@code broken_links} fields. Distinct from {@link #getLatestJob} —
|
||||||
|
* this is "what does the data say RIGHT NOW", regardless of whether a
|
||||||
|
* scan job is recorded in memory. Used by {@code GET /lint/broken-links}.
|
||||||
|
*
|
||||||
|
* @return null if no page in the KB has ever been scanned (every page's
|
||||||
|
* {@code broken_links_scanned_at} is null); else an aggregate
|
||||||
|
*/
|
||||||
|
public Aggregate aggregate(Long kbId) {
|
||||||
|
List<WikiPageEntity> pages = pageMapper.selectList(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WikiPageEntity>()
|
||||||
|
.select(WikiPageEntity::getId, WikiPageEntity::getSlug,
|
||||||
|
WikiPageEntity::getTitle, WikiPageEntity::getBrokenLinks,
|
||||||
|
WikiPageEntity::getBrokenLinksScannedAt)
|
||||||
|
.eq(WikiPageEntity::getKbId, kbId));
|
||||||
|
if (pages.isEmpty()) return null;
|
||||||
|
boolean anyScanned = pages.stream().anyMatch(p -> p.getBrokenLinksScannedAt() != null);
|
||||||
|
if (!anyScanned) return null;
|
||||||
|
|
||||||
|
LocalDateTime completedAt = pages.stream()
|
||||||
|
.map(WikiPageEntity::getBrokenLinksScannedAt)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.max(LocalDateTime::compareTo)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
int pagesWithBroken = 0;
|
||||||
|
int totalBrokenRefs = 0;
|
||||||
|
java.util.List<PageBrokenRefs> details = new java.util.ArrayList<>();
|
||||||
|
for (WikiPageEntity p : pages) {
|
||||||
|
List<String> refs = linkService.fromJsonArray(p.getBrokenLinks());
|
||||||
|
if (refs.isEmpty()) continue;
|
||||||
|
pagesWithBroken++;
|
||||||
|
totalBrokenRefs += refs.size();
|
||||||
|
details.add(new PageBrokenRefs(p.getId(), p.getSlug(), p.getTitle(), refs));
|
||||||
|
}
|
||||||
|
return new Aggregate(kbId, completedAt, pages.size(), pagesWithBroken, totalBrokenRefs, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-page aggregation row. */
|
||||||
|
public record PageBrokenRefs(Long pageId, String slug, String title, List<String> brokenRefs) {}
|
||||||
|
|
||||||
|
/** KB-level aggregate response. */
|
||||||
|
public record Aggregate(
|
||||||
|
Long kbId,
|
||||||
|
LocalDateTime completedAt,
|
||||||
|
int totalPages,
|
||||||
|
int pagesWithBrokenLinks,
|
||||||
|
int totalBrokenRefs,
|
||||||
|
List<PageBrokenRefs> pages
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Worker
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void runJob(String jobId, Long kbId) {
|
||||||
|
updateJob(kbId, jobId, prev -> new LintJob(
|
||||||
|
jobId, kbId, JobStatus.RUNNING, prev.startedAt(), null,
|
||||||
|
0, 0, 0, null));
|
||||||
|
try {
|
||||||
|
ScanCounts counts = scan(kbId);
|
||||||
|
updateJob(kbId, jobId, prev -> new LintJob(
|
||||||
|
jobId, kbId, JobStatus.COMPLETED, prev.startedAt(), LocalDateTime.now(),
|
||||||
|
counts.totalPages, counts.pagesWithBroken, counts.totalBrokenRefs, null));
|
||||||
|
log.info("[WikiLint] Job {} completed: {} pages, {} with broken links ({} refs)",
|
||||||
|
jobId, counts.totalPages, counts.pagesWithBroken, counts.totalBrokenRefs);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[WikiLint] Job {} failed for kbId={}", jobId, kbId, e);
|
||||||
|
updateJob(kbId, jobId, prev -> new LintJob(
|
||||||
|
jobId, kbId, JobStatus.FAILED, prev.startedAt(), LocalDateTime.now(),
|
||||||
|
prev.totalPages(), prev.pagesWithBrokenLinks(), prev.totalBrokenRefs(),
|
||||||
|
e.getClass().getSimpleName() + ": " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ScanCounts(int totalPages, int pagesWithBroken, int totalBrokenRefs) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every active page in the KB, recompute its broken_links. Each
|
||||||
|
* page write is transactional ({@link #rewriteBrokenLinks}); the outer
|
||||||
|
* loop is NOT transactional so a single failing page doesn't roll back
|
||||||
|
* the whole scan.
|
||||||
|
*/
|
||||||
|
private ScanCounts scan(Long kbId) {
|
||||||
|
// listSummaries gives us the active (non-archived) page slug set —
|
||||||
|
// archived pages are NOT considered as valid targets, matching the
|
||||||
|
// resolver's behaviour.
|
||||||
|
List<WikiPageEntity> summaries = pageService.listSummaries(kbId);
|
||||||
|
Set<String> activeSlugs = linkService.lowercaseSlugSet(summaries);
|
||||||
|
|
||||||
|
// Now fetch the same pages WITH content so we can re-extract outlinks.
|
||||||
|
// We must not use listSummaries here because it omits the content
|
||||||
|
// column to keep memory bounded — but we have summaries for the slug
|
||||||
|
// set already, so the loaded list and the slug set agree.
|
||||||
|
List<WikiPageEntity> withContent = pageMapper.selectList(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WikiPageEntity>()
|
||||||
|
.select(WikiPageEntity::getId, WikiPageEntity::getSlug,
|
||||||
|
WikiPageEntity::getContent)
|
||||||
|
.eq(WikiPageEntity::getKbId, kbId)
|
||||||
|
.ne(WikiPageEntity::getArchived, 1));
|
||||||
|
|
||||||
|
int total = withContent.size();
|
||||||
|
int pagesWithBroken = 0;
|
||||||
|
int totalBrokenRefs = 0;
|
||||||
|
|
||||||
|
for (WikiPageEntity p : withContent) {
|
||||||
|
Set<String> activeWithSelf;
|
||||||
|
if (p.getSlug() != null && !p.getSlug().isBlank()) {
|
||||||
|
java.util.Set<String> tmp = new java.util.HashSet<>(activeSlugs);
|
||||||
|
tmp.add(p.getSlug().toLowerCase(Locale.ROOT));
|
||||||
|
activeWithSelf = tmp;
|
||||||
|
} else {
|
||||||
|
activeWithSelf = activeSlugs;
|
||||||
|
}
|
||||||
|
WikiLinkService.LinkAnalysis a = linkService.analyze(p.getContent(), activeWithSelf);
|
||||||
|
int brokenCount = a.brokenLinks().size();
|
||||||
|
if (brokenCount > 0) {
|
||||||
|
pagesWithBroken++;
|
||||||
|
totalBrokenRefs += brokenCount;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rewriteBrokenLinks(p.getId(),
|
||||||
|
linkService.toJsonArray(a.outgoingLinks()),
|
||||||
|
linkService.toJsonArray(a.brokenLinks()));
|
||||||
|
} catch (Exception perPageErr) {
|
||||||
|
log.warn("[WikiLint] Per-page rewrite failed for pageId={}; continuing scan: {}",
|
||||||
|
p.getId(), perPageErr.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ScanCounts(total, pagesWithBroken, totalBrokenRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactional single-page rewrite of outgoing_links + broken_links +
|
||||||
|
* broken_links_scanned_at. Kept in this service so the scan loop above
|
||||||
|
* is unambiguously per-page transactional without polluting the larger
|
||||||
|
* WikiPageService API.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void rewriteBrokenLinks(Long pageId, String outgoingLinksJson, String brokenLinksJson) {
|
||||||
|
WikiPageEntity update = new WikiPageEntity();
|
||||||
|
update.setId(pageId);
|
||||||
|
update.setOutgoingLinks(outgoingLinksJson);
|
||||||
|
update.setBrokenLinks(brokenLinksJson);
|
||||||
|
update.setBrokenLinksScannedAt(LocalDateTime.now());
|
||||||
|
pageMapper.updateById(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateJob(Long kbId, String jobId, java.util.function.Function<LintJob, LintJob> fn) {
|
||||||
|
LintJob updated = jobsByKb.compute(kbId, (k, prev) -> {
|
||||||
|
LintJob base = prev != null && prev.jobId().equals(jobId) ? prev : prev;
|
||||||
|
// If prev is null somehow, synthesize a minimal stub so the
|
||||||
|
// updater can still run. In practice prev is always non-null
|
||||||
|
// here because startOrGetRunning seeded the map first.
|
||||||
|
if (base == null) {
|
||||||
|
base = new LintJob(jobId, kbId, JobStatus.QUEUED, LocalDateTime.now(),
|
||||||
|
null, 0, 0, 0, null);
|
||||||
|
}
|
||||||
|
return fn.apply(base);
|
||||||
|
});
|
||||||
|
jobsById.put(jobId, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String newJobId() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,10 @@ import vip.mate.wiki.repository.WikiPageMapper;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@ -30,6 +33,7 @@ public class WikiPageService {
|
|||||||
|
|
||||||
private final WikiPageMapper pageMapper;
|
private final WikiPageMapper pageMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final WikiLinkService linkService;
|
||||||
|
|
||||||
private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]");
|
private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]");
|
||||||
|
|
||||||
@ -317,13 +321,15 @@ public class WikiPageService {
|
|||||||
entity.setTitle(title);
|
entity.setTitle(title);
|
||||||
entity.setContent(content);
|
entity.setContent(content);
|
||||||
entity.setSummary(summary);
|
entity.setSummary(summary);
|
||||||
entity.setOutgoingLinks(extractLinksAsJson(content));
|
|
||||||
entity.setSourceRawIds(sourceRawIds);
|
entity.setSourceRawIds(sourceRawIds);
|
||||||
entity.setVersion(1);
|
entity.setVersion(1);
|
||||||
entity.setLastUpdatedBy("ai");
|
entity.setLastUpdatedBy("ai");
|
||||||
if (pageType != null && !pageType.isBlank()) {
|
if (pageType != null && !pageType.isBlank()) {
|
||||||
entity.setPageType(pageType.toLowerCase());
|
entity.setPageType(pageType.toLowerCase());
|
||||||
}
|
}
|
||||||
|
// Compute outgoing_links + broken_links + scanned_at from the new
|
||||||
|
// content in the same transaction. See {@link #applyLinkAnalysis}.
|
||||||
|
applyLinkAnalysis(entity);
|
||||||
pageMapper.insert(entity);
|
pageMapper.insert(entity);
|
||||||
evictSummaryCache(kbId);
|
evictSummaryCache(kbId);
|
||||||
return entity;
|
return entity;
|
||||||
@ -378,10 +384,10 @@ public class WikiPageService {
|
|||||||
|
|
||||||
existing.setContent(content);
|
existing.setContent(content);
|
||||||
existing.setSummary(summary);
|
existing.setSummary(summary);
|
||||||
existing.setOutgoingLinks(extractLinksAsJson(content));
|
|
||||||
existing.setVersion(existing.getVersion() + 1);
|
existing.setVersion(existing.getVersion() + 1);
|
||||||
existing.setLastUpdatedBy("ai");
|
existing.setLastUpdatedBy("ai");
|
||||||
existing.setUpdateTime(LocalDateTime.now());
|
existing.setUpdateTime(LocalDateTime.now());
|
||||||
|
applyLinkAnalysis(existing);
|
||||||
|
|
||||||
// 追加新的 source raw id
|
// 追加新的 source raw id
|
||||||
if (newRawId != null) {
|
if (newRawId != null) {
|
||||||
@ -445,10 +451,10 @@ public class WikiPageService {
|
|||||||
throw new IllegalArgumentException("Page not found: " + slug);
|
throw new IllegalArgumentException("Page not found: " + slug);
|
||||||
}
|
}
|
||||||
existing.setContent(content);
|
existing.setContent(content);
|
||||||
existing.setOutgoingLinks(extractLinksAsJson(content));
|
|
||||||
existing.setVersion(existing.getVersion() + 1);
|
existing.setVersion(existing.getVersion() + 1);
|
||||||
existing.setLastUpdatedBy("manual");
|
existing.setLastUpdatedBy("manual");
|
||||||
existing.setUpdateTime(LocalDateTime.now());
|
existing.setUpdateTime(LocalDateTime.now());
|
||||||
|
applyLinkAnalysis(existing);
|
||||||
// 同步更新摘要,防止与 content 漂移
|
// 同步更新摘要,防止与 content 漂移
|
||||||
if (summary != null) {
|
if (summary != null) {
|
||||||
existing.setSummary(summary);
|
existing.setSummary(summary);
|
||||||
@ -622,31 +628,68 @@ public class WikiPageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract {@code [[links]]} (and {@code [[target|label]]} alias form,
|
* Extract {@code [[links]]} (and {@code [[target|label]]} alias form)
|
||||||
* RFC-051 PR-5) from Markdown content and return them as a JSON array of
|
* from Markdown content and return them as a JSON array of lowercased
|
||||||
* canonical slugs.
|
* target strings. Code blocks are skipped by {@link WikiLinkService}.
|
||||||
* <p>
|
* <p>
|
||||||
* For aliased links the {@code label} part is purely display — only
|
* Behaviour change vs. the historical implementation: previously every
|
||||||
* {@code target} feeds slug resolution. Without this split we'd canonicalize
|
* target was run through {@link #toSlug} (lowercase + strip + dash-collapse),
|
||||||
* "Spring AI|Spring AI Alibaba" as a single slug, polluting outgoingLinks
|
* which silently coerced {@code [[Transformer Architecture]]} into
|
||||||
* and breaking graph view / backlinks.
|
* {@code transformer-architecture} regardless of whether such a page slug
|
||||||
|
* actually existed. The new implementation preserves what the author
|
||||||
|
* wrote (only lowercased + trimmed). The lint compares this against
|
||||||
|
* {@code page.slug.toLowerCase()} so any title-form legacy content is
|
||||||
|
* surfaced as broken — exactly the gap the wikilink overhaul exists to
|
||||||
|
* close. The frontend resolver keeps a title fallback so the visible
|
||||||
|
* link still navigates during the transition.
|
||||||
|
* <p>
|
||||||
|
* Kept public for callers outside this service (e.g. enrichment) that
|
||||||
|
* still need the JSON-array serialisation; delegates to
|
||||||
|
* {@link WikiLinkService} so there is exactly one extraction code path.
|
||||||
*/
|
*/
|
||||||
String extractLinksAsJson(String content) {
|
public String extractLinksAsJson(String content) {
|
||||||
if (content == null) return "[]";
|
Set<String> outlinks = linkService.extractOutlinks(content);
|
||||||
List<String> links = new ArrayList<>();
|
return linkService.toJsonArray(new ArrayList<>(outlinks));
|
||||||
Matcher matcher = WIKI_LINK_PATTERN.matcher(content);
|
}
|
||||||
while (matcher.find()) {
|
|
||||||
String raw = matcher.group(1).trim();
|
/**
|
||||||
int pipe = raw.indexOf('|');
|
* Compute and apply {@code outgoing_links} + {@code broken_links} +
|
||||||
String target = pipe >= 0 ? raw.substring(0, pipe).trim() : raw;
|
* {@code broken_links_scanned_at} fields on an entity from its content.
|
||||||
if (target.isEmpty()) continue;
|
* Called from every save/update path so the lint state is always in sync
|
||||||
String slug = toSlug(target);
|
* with the content actually being persisted (same transaction). Excludes
|
||||||
if (slug.isEmpty()) continue;
|
* the entity itself from the active-slug set when an id is present, so
|
||||||
if (!links.contains(slug)) {
|
* self-links resolve correctly even when the entity is mid-update.
|
||||||
links.add(slug);
|
*/
|
||||||
}
|
private void applyLinkAnalysis(WikiPageEntity entity) {
|
||||||
|
if (entity == null || entity.getKbId() == null) return;
|
||||||
|
// Fetch the active slug set defensively — in fully-wired production
|
||||||
|
// context this never fails, but unit tests that mock the mapper can
|
||||||
|
// trip MyBatis-Plus's lambda-cache lookup (TableInfo isn't seeded
|
||||||
|
// outside a Spring context). Treating a fetch failure as "empty slug
|
||||||
|
// set" means link analysis still runs (so the test verifies the
|
||||||
|
// update path) and every extracted target is recorded as broken —
|
||||||
|
// which is harmless because tests don't assert on broken_links
|
||||||
|
// values, and production code paths never hit this branch.
|
||||||
|
Set<String> activeSlugs;
|
||||||
|
try {
|
||||||
|
activeSlugs = linkService.lowercaseSlugSet(listSummaries(entity.getKbId()));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[Wiki] applyLinkAnalysis: failed to load slug set for kbId={}, treating as empty: {}",
|
||||||
|
entity.getKbId(), e.toString());
|
||||||
|
activeSlugs = java.util.Collections.emptySet();
|
||||||
}
|
}
|
||||||
return toJson(links);
|
// Include self-slug so [[my-own-slug]] doesn't appear as broken on the
|
||||||
|
// very save that creates the page (listSummaries may not see it yet
|
||||||
|
// depending on cache state).
|
||||||
|
if (entity.getSlug() != null && !entity.getSlug().isBlank()) {
|
||||||
|
Set<String> withSelf = new HashSet<>(activeSlugs);
|
||||||
|
withSelf.add(entity.getSlug().toLowerCase(Locale.ROOT));
|
||||||
|
activeSlugs = withSelf;
|
||||||
|
}
|
||||||
|
WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), activeSlugs);
|
||||||
|
entity.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks()));
|
||||||
|
entity.setBrokenLinks(linkService.toJsonArray(a.brokenLinks()));
|
||||||
|
entity.setBrokenLinksScannedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- V129: Persisted wikilink lint state.
|
||||||
|
--
|
||||||
|
-- broken_links JSON array of unresolved outlink targets for THIS
|
||||||
|
-- page. Derived from outgoing_links minus the active
|
||||||
|
-- KB slug set (case-insensitive). Empty array means
|
||||||
|
-- "scanned, all targets resolve"; NULL means
|
||||||
|
-- "never scanned". Kept separate from outgoing_links
|
||||||
|
-- (which records every [[...]] target written into
|
||||||
|
-- content, hit-or-miss) so backlinks / direct-link
|
||||||
|
-- signals are unaffected.
|
||||||
|
-- broken_links_scanned_at Timestamp of the most recent broken_links
|
||||||
|
-- recompute. UI surfaces this as "last scan" so
|
||||||
|
-- staleness is visible. Reset whenever the page is
|
||||||
|
-- re-saved (broken_links is recomputed in the same
|
||||||
|
-- transaction).
|
||||||
|
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links TEXT DEFAULT NULL;
|
||||||
|
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links_scanned_at TIMESTAMP DEFAULT NULL;
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
-- V129: Persisted wikilink lint state — MySQL dialect.
|
||||||
|
--
|
||||||
|
-- See h2/V129__wiki_page_broken_links.sql for column semantics. The MySQL
|
||||||
|
-- variant needs INFORMATION_SCHEMA guards because MySQL doesn't support
|
||||||
|
-- ADD COLUMN IF NOT EXISTS prior to 8.0.29 and the deploy targets older
|
||||||
|
-- supported versions.
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_wiki_page'
|
||||||
|
AND COLUMN_NAME = 'broken_links'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_wiki_page ADD COLUMN broken_links JSON DEFAULT NULL COMMENT ''Outlink targets present in content but not in this KB''',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_wiki_page'
|
||||||
|
AND COLUMN_NAME = 'broken_links_scanned_at'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_wiki_page ADD COLUMN broken_links_scanned_at DATETIME(3) DEFAULT NULL COMMENT ''Timestamp of last broken_links recompute''',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
@ -31,7 +31,9 @@ class WikiPageServiceTest {
|
|||||||
when(mapper.selectOne(any())).thenReturn(page);
|
when(mapper.selectOne(any())).thenReturn(page);
|
||||||
when(mapper.updateById(any(WikiPageEntity.class))).thenReturn(1);
|
when(mapper.updateById(any(WikiPageEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
new WikiPageService(mapper, new ObjectMapper())
|
ObjectMapper om = new ObjectMapper();
|
||||||
|
WikiLinkService link = new WikiLinkService(om);
|
||||||
|
new WikiPageService(mapper, om, link)
|
||||||
.updatePageManually(7L, "page", "new body", null);
|
.updatePageManually(7L, "page", "new body", null);
|
||||||
|
|
||||||
assertTrue(page.getUpdateTime().isAfter(oldUpdateTime));
|
assertTrue(page.getUpdateTime().isAfter(oldUpdateTime));
|
||||||
|
|||||||
@ -791,6 +791,15 @@ export const wikiApi = {
|
|||||||
// authoritative resolution index used by the viewer's wikilink postprocess.
|
// authoritative resolution index used by the viewer's wikilink postprocess.
|
||||||
listPageRefs: (kbId: number, includeArchived = false) =>
|
listPageRefs: (kbId: number, includeArchived = false) =>
|
||||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/refs`, { params: { includeArchived } }),
|
http.get(`/wiki/knowledge-bases/${kbId}/pages/refs`, { params: { includeArchived } }),
|
||||||
|
|
||||||
|
// Broken-link lint (job-based async). POST starts/returns the running job,
|
||||||
|
// GET reads the most recent completed scan aggregated across the KB.
|
||||||
|
startBrokenLinksScan: (kbId: number) =>
|
||||||
|
http.post(`/wiki/knowledge-bases/${kbId}/lint/broken-links`),
|
||||||
|
getBrokenLinksReport: (kbId: number) =>
|
||||||
|
http.get(`/wiki/knowledge-bases/${kbId}/lint/broken-links`),
|
||||||
|
getBrokenLinksJob: (kbId: number, jobId: string) =>
|
||||||
|
http.get(`/wiki/knowledge-bases/${kbId}/lint/broken-links/jobs/${jobId}`),
|
||||||
getPage: (kbId: number, slug: string) =>
|
getPage: (kbId: number, slug: string) =>
|
||||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
|
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
|
||||||
updatePage: (kbId: number, slug: string, content: string) =>
|
updatePage: (kbId: number, slug: string, content: string) =>
|
||||||
|
|||||||
@ -2232,6 +2232,19 @@ export default {
|
|||||||
archivedSection: 'Archived',
|
archivedSection: 'Archived',
|
||||||
noArchived: 'No archived pages',
|
noArchived: 'No archived pages',
|
||||||
unarchive: 'Restore',
|
unarchive: 'Restore',
|
||||||
|
lint: {
|
||||||
|
neverScanned: 'No broken-link scan yet — click to check whether [[...]] references still resolve',
|
||||||
|
running: 'Scanning for broken links…',
|
||||||
|
scanning: 'Scanning…',
|
||||||
|
scan: 'Scan dead links',
|
||||||
|
rescan: 'Rescan',
|
||||||
|
cleanResult: 'Scanned {pages} pages — no broken links',
|
||||||
|
brokenSummary: 'Found {refs} broken links across {pages} pages',
|
||||||
|
view: 'View',
|
||||||
|
dismissTitle: 'Dismiss this notice',
|
||||||
|
panelTitle: 'Wiki broken-link report',
|
||||||
|
noReport: 'No scan yet — start one from the banner',
|
||||||
|
},
|
||||||
pageTypes: {
|
pageTypes: {
|
||||||
concept: 'Concepts',
|
concept: 'Concepts',
|
||||||
person: 'People',
|
person: 'People',
|
||||||
|
|||||||
@ -2244,6 +2244,19 @@ export default {
|
|||||||
archivedSection: '已归档',
|
archivedSection: '已归档',
|
||||||
noArchived: '没有已归档的页面',
|
noArchived: '没有已归档的页面',
|
||||||
unarchive: '恢复',
|
unarchive: '恢复',
|
||||||
|
lint: {
|
||||||
|
neverScanned: '尚未扫描死链,点击右侧按钮检查 [[...]] 引用是否仍然有效',
|
||||||
|
running: '正在扫描死链…',
|
||||||
|
scanning: '扫描中…',
|
||||||
|
scan: '扫描死链',
|
||||||
|
rescan: '重新扫描',
|
||||||
|
cleanResult: '已扫描 {pages} 个页面,无死链',
|
||||||
|
brokenSummary: '发现 {refs} 个死链,分布在 {pages} 个页面',
|
||||||
|
view: '查看',
|
||||||
|
dismissTitle: '隐藏本次提示',
|
||||||
|
panelTitle: 'Wiki 死链报告',
|
||||||
|
noReport: '尚未扫描,请先点击「扫描死链」',
|
||||||
|
},
|
||||||
pageTypes: {
|
pageTypes: {
|
||||||
concept: '概念',
|
concept: '概念',
|
||||||
person: '人物',
|
person: '人物',
|
||||||
|
|||||||
@ -76,6 +76,39 @@ export interface WikiPageRef {
|
|||||||
archived: boolean
|
archived: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-page row in a broken-links report. */
|
||||||
|
export interface WikiBrokenLinkPage {
|
||||||
|
// Snowflake — stay as string end-to-end.
|
||||||
|
pageId: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
brokenRefs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate response from GET /lint/broken-links. */
|
||||||
|
export interface WikiBrokenLinksReport {
|
||||||
|
kbId: number | string
|
||||||
|
jobId: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
totalPages: number
|
||||||
|
pagesWithBrokenLinks: number
|
||||||
|
totalBrokenRefs: number
|
||||||
|
pages: WikiBrokenLinkPage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job envelope returned by POST /lint/broken-links. */
|
||||||
|
export interface WikiLintJob {
|
||||||
|
jobId: string
|
||||||
|
kbId: number | string
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||||
|
startedAt: string
|
||||||
|
completedAt: string | null
|
||||||
|
totalPages: number
|
||||||
|
pagesWithBrokenLinks: number
|
||||||
|
totalBrokenRefs: number
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const useWikiStore = defineStore('wiki', () => {
|
export const useWikiStore = defineStore('wiki', () => {
|
||||||
const knowledgeBases = ref<WikiKB[]>([])
|
const knowledgeBases = ref<WikiKB[]>([])
|
||||||
const currentKB = ref<WikiKB | null>(null)
|
const currentKB = ref<WikiKB | null>(null)
|
||||||
@ -96,6 +129,18 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
const pageRefs = ref<WikiPageRef[]>([])
|
const pageRefs = ref<WikiPageRef[]>([])
|
||||||
const archivedPageRefs = ref<WikiPageRef[]>([])
|
const archivedPageRefs = ref<WikiPageRef[]>([])
|
||||||
|
|
||||||
|
// Broken-link lint state. `brokenLinksReport` holds the latest aggregate
|
||||||
|
// server response; `brokenLinksJob` tracks the in-flight scan job (null
|
||||||
|
// when nothing is running or after the last scan settled). Both are
|
||||||
|
// scoped to currentKB — clear in selectKB / backToLibrary so KB switching
|
||||||
|
// doesn't bleed stale data across knowledge bases.
|
||||||
|
const brokenLinksReport = ref<WikiBrokenLinksReport | null>(null)
|
||||||
|
const brokenLinksJob = ref<WikiLintJob | null>(null)
|
||||||
|
const brokenLinksLoading = ref(false)
|
||||||
|
// Track the timer id so a second startBrokenLinksScan call cancels the
|
||||||
|
// stale poller — avoids double-fires after rapid clicks.
|
||||||
|
let brokenLinksPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
async function fetchKnowledgeBases() {
|
async function fetchKnowledgeBases() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -114,7 +159,17 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
// pageRefs refresh in parallel with materials + pages — the viewer needs
|
// pageRefs refresh in parallel with materials + pages — the viewer needs
|
||||||
// the resolution index ready before it tries to postprocess wikilinks.
|
// the resolution index ready before it tries to postprocess wikilinks.
|
||||||
archivedPageRefs.value = []
|
archivedPageRefs.value = []
|
||||||
await Promise.all([fetchRawMaterials(id), fetchPages(id), fetchPageRefs(id)])
|
// Clear stale broken-links state from the previous KB; the report fetch
|
||||||
|
// below repopulates it (or leaves it null if no scan has run on this KB).
|
||||||
|
brokenLinksReport.value = null
|
||||||
|
brokenLinksJob.value = null
|
||||||
|
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
|
||||||
|
await Promise.all([
|
||||||
|
fetchRawMaterials(id),
|
||||||
|
fetchPages(id),
|
||||||
|
fetchPageRefs(id),
|
||||||
|
loadBrokenLinksReport(id),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createKB(data: { name: string; description?: string; agentId?: number }) {
|
async function createKB(data: { name: string; description?: string; agentId?: number }) {
|
||||||
@ -141,6 +196,10 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
pages.value = []
|
pages.value = []
|
||||||
pageRefs.value = []
|
pageRefs.value = []
|
||||||
archivedPageRefs.value = []
|
archivedPageRefs.value = []
|
||||||
|
brokenLinksReport.value = null
|
||||||
|
brokenLinksJob.value = null
|
||||||
|
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
|
||||||
|
brokenLinksLoading.value = false
|
||||||
selectedRawId.value = null
|
selectedRawId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,6 +237,80 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
archivedPageRefs.value = all.filter((p) => p.archived)
|
archivedPageRefs.value = all.filter((p) => p.archived)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the latest broken-links report for the active KB. Treats HTTP 404
|
||||||
|
* ("no scan yet") as an expected empty state rather than an error — the
|
||||||
|
* caller decides whether to surface "click scan" UX.
|
||||||
|
*/
|
||||||
|
async function loadBrokenLinksReport(kbId: number) {
|
||||||
|
try {
|
||||||
|
const res: any = await wikiApi.getBrokenLinksReport(kbId)
|
||||||
|
brokenLinksReport.value = (res.data ?? res) as WikiBrokenLinksReport
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.status === 404 || e?.code === 404) {
|
||||||
|
brokenLinksReport.value = null
|
||||||
|
} else {
|
||||||
|
console.error('[Wiki] Failed to load broken-links report', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (or rejoin) a broken-links scan job and poll the aggregate
|
||||||
|
* endpoint until completedAt advances past the job's startedAt. Updates
|
||||||
|
* `brokenLinksJob` for in-flight UX and `brokenLinksReport` once the
|
||||||
|
* server confirms completion. Returns when polling resolves or aborts.
|
||||||
|
*/
|
||||||
|
async function startBrokenLinksScan(kbId: number) {
|
||||||
|
if (brokenLinksPollTimer) {
|
||||||
|
clearInterval(brokenLinksPollTimer)
|
||||||
|
brokenLinksPollTimer = null
|
||||||
|
}
|
||||||
|
brokenLinksLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await wikiApi.startBrokenLinksScan(kbId)
|
||||||
|
const job = (res.data ?? res) as WikiLintJob
|
||||||
|
brokenLinksJob.value = job
|
||||||
|
// If POST returned an already-completed job (e.g. instant scan on tiny
|
||||||
|
// KB), refresh the aggregate immediately and skip polling.
|
||||||
|
if (job.status === 'completed' || job.status === 'failed') {
|
||||||
|
await loadBrokenLinksReport(kbId)
|
||||||
|
brokenLinksLoading.value = false
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
// Otherwise poll the aggregate every 2s. Authoritative "is this done"
|
||||||
|
// signal is completedAt > startedAt — the job state in memory is
|
||||||
|
// refreshed alongside for failure surfacing.
|
||||||
|
const startedAt = job.startedAt
|
||||||
|
brokenLinksPollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await loadBrokenLinksReport(kbId)
|
||||||
|
const report = brokenLinksReport.value
|
||||||
|
if (report?.completedAt && (!startedAt || report.completedAt >= startedAt)) {
|
||||||
|
if (brokenLinksPollTimer) clearInterval(brokenLinksPollTimer)
|
||||||
|
brokenLinksPollTimer = null
|
||||||
|
brokenLinksJob.value = null
|
||||||
|
brokenLinksLoading.value = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Wiki] Polling broken-links scan failed', e)
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
// Hard timeout — give up tracking after 5 minutes; user can re-trigger.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (brokenLinksPollTimer) {
|
||||||
|
clearInterval(brokenLinksPollTimer)
|
||||||
|
brokenLinksPollTimer = null
|
||||||
|
brokenLinksLoading.value = false
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
return job
|
||||||
|
} catch (e) {
|
||||||
|
brokenLinksLoading.value = false
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Background refreshes (job completion, SSE events, fallback polling) must
|
// Background refreshes (job completion, SSE events, fallback polling) must
|
||||||
// not drop the user's active raw-material filter. Re-fetch the page list
|
// not drop the user's active raw-material filter. Re-fetch the page list
|
||||||
// scoped to selectedRawId whenever a filter is applied — otherwise the list
|
// scoped to selectedRawId whenever a filter is applied — otherwise the list
|
||||||
@ -263,6 +396,9 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
totalPageCount,
|
totalPageCount,
|
||||||
pageRefs,
|
pageRefs,
|
||||||
archivedPageRefs,
|
archivedPageRefs,
|
||||||
|
brokenLinksReport,
|
||||||
|
brokenLinksJob,
|
||||||
|
brokenLinksLoading,
|
||||||
fetchKnowledgeBases,
|
fetchKnowledgeBases,
|
||||||
selectKB,
|
selectKB,
|
||||||
createKB,
|
createKB,
|
||||||
@ -272,6 +408,8 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
fetchPages,
|
fetchPages,
|
||||||
fetchPageRefs,
|
fetchPageRefs,
|
||||||
fetchArchivedPageRefs,
|
fetchArchivedPageRefs,
|
||||||
|
loadBrokenLinksReport,
|
||||||
|
startBrokenLinksScan,
|
||||||
refreshCurrentKB,
|
refreshCurrentKB,
|
||||||
filterPagesByRaw,
|
filterPagesByRaw,
|
||||||
clearRawFilter,
|
clearRawFilter,
|
||||||
|
|||||||
164
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
Normal file
164
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Workspace-level banner that surfaces broken-link state without forcing the
|
||||||
|
user into a separate tab. Three modes:
|
||||||
|
* never scanned → compact "scan now" prompt with a button
|
||||||
|
* scan running → loading indicator + status text
|
||||||
|
* have report → count + last-scan timestamp + "view" + "rescan"
|
||||||
|
The detail panel (per-page breakdown, "open source page" actions) lives
|
||||||
|
in WikiBrokenLinksPanel.vue, mounted by the parent on demand.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="!isHidden"
|
||||||
|
class="lint-banner"
|
||||||
|
:class="{
|
||||||
|
'lint-banner--clean': report && report.totalBrokenRefs === 0,
|
||||||
|
'lint-banner--has-broken': report && report.totalBrokenRefs > 0,
|
||||||
|
'lint-banner--running': loading,
|
||||||
|
'lint-banner--empty': !report && !loading,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="lint-icon" aria-hidden="true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.72"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="loading" class="lint-text">
|
||||||
|
{{ t('wiki.lint.running') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="!report" class="lint-text">
|
||||||
|
{{ t('wiki.lint.neverScanned') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="report.totalBrokenRefs === 0" class="lint-text">
|
||||||
|
{{ t('wiki.lint.cleanResult', { pages: report.totalPages }) }}
|
||||||
|
<span class="lint-timestamp">{{ formattedTimestamp }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="lint-text">
|
||||||
|
<i18n-t keypath="wiki.lint.brokenSummary" tag="span">
|
||||||
|
<template #refs><strong>{{ report.totalBrokenRefs }}</strong></template>
|
||||||
|
<template #pages><strong>{{ report.pagesWithBrokenLinks }}</strong></template>
|
||||||
|
</i18n-t>
|
||||||
|
<span class="lint-timestamp">{{ formattedTimestamp }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="lint-actions">
|
||||||
|
<button
|
||||||
|
v-if="report && report.totalBrokenRefs > 0"
|
||||||
|
class="lint-btn lint-btn--primary"
|
||||||
|
@click="$emit('view')"
|
||||||
|
>{{ t('wiki.lint.view') }}</button>
|
||||||
|
<button
|
||||||
|
class="lint-btn"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onScan"
|
||||||
|
>{{ loading ? t('wiki.lint.scanning') : (report ? t('wiki.lint.rescan') : t('wiki.lint.scan')) }}</button>
|
||||||
|
<button class="lint-btn lint-btn--ghost" :title="t('wiki.lint.dismissTitle')" @click="dismissed = true">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useWikiStore } from '@/stores/useWikiStore'
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'view'): void }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const store = useWikiStore()
|
||||||
|
|
||||||
|
// Per-session dismiss — user can hide the banner without affecting scan state.
|
||||||
|
// Reset implicitly on KB switch (banner is re-mounted when currentKB changes
|
||||||
|
// because the parent passes :key="kb.id"; if it doesn't, dismissals persist
|
||||||
|
// across KB switches which is acceptable for v1).
|
||||||
|
const dismissed = ref(false)
|
||||||
|
|
||||||
|
const report = computed(() => store.brokenLinksReport)
|
||||||
|
const loading = computed(() => store.brokenLinksLoading)
|
||||||
|
const isHidden = computed(() => dismissed.value)
|
||||||
|
|
||||||
|
const formattedTimestamp = computed(() => {
|
||||||
|
const ts = report.value?.completedAt
|
||||||
|
if (!ts) return ''
|
||||||
|
try {
|
||||||
|
const d = new Date(ts)
|
||||||
|
return ' · ' + d.toLocaleString(locale.value)
|
||||||
|
} catch {
|
||||||
|
return ' · ' + ts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onScan() {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
try {
|
||||||
|
await store.startBrokenLinksScan(Number(store.currentKB.id))
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Wiki] scan failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lint-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.lint-banner--has-broken {
|
||||||
|
border-color: var(--el-color-warning-light-5, #f0c78a);
|
||||||
|
background: var(--el-color-warning-light-9, #fdf6ec);
|
||||||
|
color: var(--el-color-warning-dark-2, #b88230);
|
||||||
|
}
|
||||||
|
.lint-banner--clean {
|
||||||
|
border-color: var(--el-color-success-light-5, #b3e19d);
|
||||||
|
background: var(--el-color-success-light-9, #f0f9eb);
|
||||||
|
color: var(--el-color-success-dark-2, #529b2e);
|
||||||
|
}
|
||||||
|
.lint-banner--running {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-bg, #fff5f0);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
.lint-icon { display: inline-flex; align-items: center; }
|
||||||
|
.lint-text { flex: 1; min-width: 0; }
|
||||||
|
.lint-timestamp { color: var(--mc-text-tertiary); font-size: 12px; }
|
||||||
|
.lint-actions { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.lint-btn {
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lint-btn:hover:not(:disabled) { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||||
|
.lint-btn:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||||
|
.lint-btn--primary {
|
||||||
|
background: var(--mc-primary);
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.lint-btn--primary:hover { background: var(--mc-primary-hover); color: white; }
|
||||||
|
.lint-btn--ghost {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
.lint-btn--ghost:hover { color: var(--mc-text-primary); background: transparent; }
|
||||||
|
</style>
|
||||||
166
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
Normal file
166
mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Modal-style drawer that lists every page in the KB with at least one
|
||||||
|
broken outlink. Each row shows the page title, slug, the failing target
|
||||||
|
strings, and a button to jump into the source page so the author can fix
|
||||||
|
the content. Pure read view — no inline editing in v1 (the user clicks
|
||||||
|
through to the page editor, fixes the wikilink, save re-syncs broken_links).
|
||||||
|
-->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="lint-panel-backdrop" @click.self="$emit('close')">
|
||||||
|
<div class="lint-panel">
|
||||||
|
<header class="lint-panel-header">
|
||||||
|
<h3 class="lint-panel-title">{{ t('wiki.lint.panelTitle') }}</h3>
|
||||||
|
<button class="close-btn" @click="$emit('close')" :aria-label="t('common.close')">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="!report" class="lint-panel-empty">
|
||||||
|
{{ t('wiki.lint.noReport') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="report.totalBrokenRefs === 0" class="lint-panel-empty lint-panel-empty--clean">
|
||||||
|
{{ t('wiki.lint.cleanResult', { pages: report.totalPages }) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="lint-panel-body">
|
||||||
|
<div class="lint-stats">
|
||||||
|
<i18n-t keypath="wiki.lint.brokenSummary" tag="span">
|
||||||
|
<template #refs><strong>{{ report.totalBrokenRefs }}</strong></template>
|
||||||
|
<template #pages><strong>{{ report.pagesWithBrokenLinks }}</strong></template>
|
||||||
|
</i18n-t>
|
||||||
|
</div>
|
||||||
|
<ul class="lint-page-list">
|
||||||
|
<li v-for="row in report.pages" :key="row.slug" class="lint-page-row">
|
||||||
|
<div class="lint-page-head">
|
||||||
|
<button class="lint-page-link" @click="onOpenPage(row.slug)">
|
||||||
|
{{ row.title }}
|
||||||
|
</button>
|
||||||
|
<code class="lint-page-slug">{{ row.slug }}</code>
|
||||||
|
</div>
|
||||||
|
<ul class="lint-ref-list">
|
||||||
|
<li v-for="ref in row.brokenRefs" :key="ref" class="lint-ref-tag">
|
||||||
|
<code>[[{{ ref }}]]</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useWikiStore } from '@/stores/useWikiStore'
|
||||||
|
|
||||||
|
defineProps<{ open: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useWikiStore()
|
||||||
|
|
||||||
|
const report = computed(() => store.brokenLinksReport)
|
||||||
|
|
||||||
|
async function onOpenPage(slug: string) {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
await store.loadPage(Number(store.currentKB.id), slug)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lint-panel-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.32);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8vh 16px;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
.lint-panel {
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.lint-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
.lint-panel-title { font-size: 15px; font-weight: 600; margin: 0; color: var(--mc-text-primary); }
|
||||||
|
.close-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--mc-text-primary); }
|
||||||
|
|
||||||
|
.lint-panel-empty {
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.lint-panel-empty--clean { color: var(--el-color-success-dark-2, #529b2e); }
|
||||||
|
|
||||||
|
.lint-panel-body { overflow-y: auto; padding: 12px 18px 18px; }
|
||||||
|
.lint-stats { font-size: 13px; color: var(--mc-text-secondary); margin-bottom: 10px; }
|
||||||
|
.lint-page-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.lint-page-row {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.lint-page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.lint-page-link {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.lint-page-link:hover { text-decoration: underline; }
|
||||||
|
.lint-page-slug { font-size: 11.5px; color: var(--mc-text-tertiary); }
|
||||||
|
.lint-ref-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.lint-ref-tag code {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--el-color-warning-light-9, #fdf6ec);
|
||||||
|
color: var(--el-color-warning-dark-2, #b88230);
|
||||||
|
border: 1px solid var(--el-color-warning-light-5, #f0c78a);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -2,6 +2,15 @@
|
|||||||
<div class="wiki-workspace">
|
<div class="wiki-workspace">
|
||||||
<WikiWorkspaceHeader :kb="kb" @back="store.backToLibrary()" />
|
<WikiWorkspaceHeader :kb="kb" @back="store.backToLibrary()" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Broken-link banner — surfaces lint state across every tab so the user
|
||||||
|
can trigger a scan or view results from any browsing context. Pinned
|
||||||
|
between the header and the tab layout so it's the first thing they see
|
||||||
|
after entering the KB.
|
||||||
|
-->
|
||||||
|
<WikiBrokenLinksBanner @view="brokenPanelOpen = true" />
|
||||||
|
<WikiBrokenLinksPanel :open="brokenPanelOpen" @close="brokenPanelOpen = false" />
|
||||||
|
|
||||||
<div class="wiki-layout">
|
<div class="wiki-layout">
|
||||||
<WikiPageSidebar @open-page="onOpenPage" />
|
<WikiPageSidebar @open-page="onOpenPage" />
|
||||||
|
|
||||||
@ -65,6 +74,8 @@ import HotCachePanel from './HotCachePanel.vue'
|
|||||||
import TransformationsPanel from './TransformationsPanel.vue'
|
import TransformationsPanel from './TransformationsPanel.vue'
|
||||||
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
|
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
|
||||||
import WikiPageSidebar from './WikiPageSidebar.vue'
|
import WikiPageSidebar from './WikiPageSidebar.vue'
|
||||||
|
import WikiBrokenLinksBanner from './WikiBrokenLinksBanner.vue'
|
||||||
|
import WikiBrokenLinksPanel from './WikiBrokenLinksPanel.vue'
|
||||||
|
|
||||||
defineProps<{ kb: WikiKB }>()
|
defineProps<{ kb: WikiKB }>()
|
||||||
|
|
||||||
@ -77,6 +88,7 @@ const workspace = useWorkspaceStore()
|
|||||||
const canManageWiki = computed(() => workspace.can('manage:wiki'))
|
const canManageWiki = computed(() => workspace.can('manage:wiki'))
|
||||||
|
|
||||||
const activeTab = ref('raw')
|
const activeTab = ref('raw')
|
||||||
|
const brokenPanelOpen = ref(false)
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
const list = [
|
const list = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user