mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
When a document contains multiple [[target]] wikilinks pointing to the same destination, SyncDocLinks produced duplicate (FromDocID, ToDocID, LinkType) entries. The batch INSERT ... ON CONFLICT DO UPDATE in CreateLinks then failed because PostgreSQL rejects a single statement updating the same row twice. Deduplicate by (FromDocID, ToDocID, LinkType) in SyncDocLinks before calling CreateLinks, concatenating contexts with " | " separator so no surrounding text is lost. Fixes #1473 (cherry picked from commit 549c81fd2406875be34cec3d7274125238e7d4a0)
142 lines
4.3 KiB
Go
142 lines
4.3 KiB
Go
package vault
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// wikilinkRe matches [[target]] and [[target|display text]].
|
|
var wikilinkRe = regexp.MustCompile(`\[\[([^\]|]+)(?:\|[^\]]+)?\]\]`)
|
|
|
|
// WikilinkMatch is a single parsed wikilink.
|
|
type WikilinkMatch struct {
|
|
Target string // resolved target path (no display text)
|
|
Context string // ~50 chars surrounding the link
|
|
Offset int // byte offset in content
|
|
}
|
|
|
|
// ExtractWikilinks parses all [[wikilinks]] from markdown content.
|
|
func ExtractWikilinks(content string) []WikilinkMatch {
|
|
matches := wikilinkRe.FindAllStringSubmatchIndex(content, -1)
|
|
var result []WikilinkMatch
|
|
for _, m := range matches {
|
|
if len(m) < 4 {
|
|
continue
|
|
}
|
|
target := strings.TrimSpace(content[m[2]:m[3]])
|
|
if target == "" {
|
|
continue
|
|
}
|
|
|
|
// Build context: ~25 bytes before and after the link.
|
|
// Sanitize with ToValidUTF8 so the byte-offset window does not
|
|
// split a multi-byte rune (Vietnamese, CJK, emoji), which would
|
|
// produce invalid UTF-8 that PostgreSQL rejects with
|
|
// "invalid byte sequence for encoding UTF8" (SQLSTATE 22021).
|
|
start := max(m[0]-25, 0)
|
|
end := min(m[1]+25, len(content))
|
|
ctx := strings.ToValidUTF8(content[start:end], "")
|
|
|
|
result = append(result, WikilinkMatch{
|
|
Target: target,
|
|
Context: ctx,
|
|
Offset: m[0],
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ResolveWikilinkTarget finds a vault_document matching the wikilink target.
|
|
// Strategy: exact path -> path+.md -> basename DB query -> nil.
|
|
func ResolveWikilinkTarget(ctx context.Context, vs store.VaultStore, target, tenantID, agentID string) (*store.VaultDocument, error) {
|
|
// 1. Exact path match
|
|
doc, err := vs.GetDocument(ctx, tenantID, agentID, target)
|
|
if err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
|
|
// 2. With .md suffix
|
|
if !strings.HasSuffix(target, ".md") {
|
|
doc, err = vs.GetDocument(ctx, tenantID, agentID, target+".md")
|
|
if err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
}
|
|
|
|
// 3. Basename search via targeted DB query (replaces ListDocuments N+1).
|
|
targetBase := filepath.Base(target)
|
|
doc, err = vs.GetDocumentByBasename(ctx, tenantID, agentID, targetBase)
|
|
if err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
// Try with .md suffix.
|
|
if !strings.HasSuffix(targetBase, ".md") {
|
|
doc, err = vs.GetDocumentByBasename(ctx, tenantID, agentID, targetBase+".md")
|
|
if err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil // unresolved — not an error
|
|
}
|
|
|
|
// SyncDocLinks extracts wikilinks from content, resolves targets,
|
|
// and replaces all vault_links for the source document.
|
|
func SyncDocLinks(ctx context.Context, vs store.VaultStore, doc *store.VaultDocument, content, tenantID, agentID string) error {
|
|
matches := ExtractWikilinks(content)
|
|
if len(matches) == 0 {
|
|
// No wikilinks — delete existing outbound wikilink-type links only.
|
|
return vs.DeleteDocLinksByType(ctx, tenantID, doc.ID, "wikilink")
|
|
}
|
|
|
|
// Delete old outbound wikilink-type links first (replace strategy).
|
|
// Preserves semantic links created by auto-linking.
|
|
if err := vs.DeleteDocLinksByType(ctx, tenantID, doc.ID, "wikilink"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Resolve all wikilinks, then batch-create links in a single call.
|
|
// Deduplicate by (FromDocID, ToDocID, LinkType) to prevent PostgreSQL
|
|
// "ON CONFLICT DO UPDATE command cannot affect row a second time" (SQLSTATE 21000)
|
|
// when the same target appears multiple times in the document.
|
|
type linkKey struct {
|
|
fromDocID string
|
|
toDocID string
|
|
linkType string
|
|
}
|
|
seen := make(map[linkKey]int) // key → index in links slice
|
|
var links []store.VaultLink
|
|
for _, m := range matches {
|
|
target, err := ResolveWikilinkTarget(ctx, vs, m.Target, tenantID, agentID)
|
|
if err != nil {
|
|
slog.Debug("vault.link_resolve_error", "target", m.Target, "err", err)
|
|
continue
|
|
}
|
|
if target == nil {
|
|
slog.Debug("vault.link_unresolved", "target", m.Target)
|
|
continue
|
|
}
|
|
k := linkKey{fromDocID: doc.ID, toDocID: target.ID, linkType: "wikilink"}
|
|
if idx, ok := seen[k]; ok {
|
|
links[idx].Context += " | " + m.Context
|
|
continue
|
|
}
|
|
seen[k] = len(links)
|
|
links = append(links, store.VaultLink{
|
|
FromDocID: doc.ID,
|
|
ToDocID: target.ID,
|
|
LinkType: "wikilink",
|
|
Context: m.Context,
|
|
})
|
|
}
|
|
if len(links) == 0 {
|
|
return nil
|
|
}
|
|
return vs.CreateLinks(ctx, links)
|
|
}
|