Changeset 0.17.3 (#78)
This commit is contained in:
35
server/database/migrations/003_v0173_note_links.sql
Normal file
35
server/database/migrations/003_v0173_note_links.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- v0.17.3: Note Links + Wikilink Infrastructure
|
||||
--
|
||||
-- New: note_links table for [[wikilink]] graph edges
|
||||
-- New: notes.source_message_id for chat-to-note provenance
|
||||
|
||||
-- =========================================
|
||||
-- 1. Note Links
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
|
||||
WHERE target_note_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE note_links IS 'Directed links between notes, extracted from [[wikilink]] syntax on save';
|
||||
COMMENT ON COLUMN note_links.target_note_id IS 'NULL for unresolved links (target note does not exist yet)';
|
||||
COMMENT ON COLUMN note_links.target_title IS 'Raw [[title]] text — preserved for re-resolution after renames';
|
||||
COMMENT ON COLUMN note_links.is_transclusion IS 'true for ![[embed]] syntax, false for regular [[link]]';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Source Message Provenance
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id)
|
||||
WHERE source_message_id IS NOT NULL;
|
||||
26
server/database/migrations/sqlite/002_v0173_note_links.sql
Normal file
26
server/database/migrations/sqlite/002_v0173_note_links.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- v0.17.3: Note Links + Wikilink Infrastructure (SQLite)
|
||||
--
|
||||
-- New: note_links table for [[wikilink]] graph edges
|
||||
-- New: notes.source_message_id for chat-to-note provenance
|
||||
|
||||
-- =========================================
|
||||
-- 1. Note Links
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id);
|
||||
|
||||
-- =========================================
|
||||
-- 2. Source Message Provenance
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE notes ADD COLUMN source_message_id TEXT;
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notelinks"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ type createNoteRequest struct {
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
SourceChannelID string `json:"source_channel_id"`
|
||||
SourceMessageID string `json:"source_message_id"`
|
||||
}
|
||||
|
||||
type updateNoteRequest struct {
|
||||
@@ -38,6 +40,7 @@ type noteResponse struct {
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
SourceChannelID *string `json:"source_channel_id,omitempty"`
|
||||
SourceMessageID *string `json:"source_message_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -85,6 +88,7 @@ func toNoteResponse(n *models.Note) noteResponse {
|
||||
FolderPath: n.FolderPath,
|
||||
Tags: tags,
|
||||
SourceChannelID: n.SourceChannelID,
|
||||
SourceMessageID: n.SourceMessageID,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
@@ -132,6 +136,10 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
||||
if req.SourceChannelID != "" {
|
||||
sourceChannelID = &req.SourceChannelID
|
||||
}
|
||||
var sourceMessageID *string
|
||||
if req.SourceMessageID != "" {
|
||||
sourceMessageID = &req.SourceMessageID
|
||||
}
|
||||
|
||||
note := &models.Note{
|
||||
UserID: userID,
|
||||
@@ -140,6 +148,7 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
||||
FolderPath: folder,
|
||||
Tags: tags,
|
||||
SourceChannelID: sourceChannelID,
|
||||
SourceMessageID: sourceMessageID,
|
||||
}
|
||||
|
||||
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
|
||||
@@ -147,6 +156,14 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract and store wikilinks
|
||||
h.extractAndStoreLinks(c, note.ID, note.Content, userID)
|
||||
|
||||
// Resolve dangling links from other notes that reference this title
|
||||
if h.stores.NoteLinks != nil {
|
||||
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, toNoteResponse(note))
|
||||
}
|
||||
|
||||
@@ -235,6 +252,11 @@ func (h *NoteHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-extract links if content changed
|
||||
if req.Content != nil {
|
||||
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toNoteResponse(updated))
|
||||
}
|
||||
|
||||
@@ -500,3 +522,122 @@ func normalizeFolderPath(p string) string {
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ── Wikilink Endpoints ──────────────────────
|
||||
|
||||
// GET /api/v1/notes/search-titles?q=query&limit=10
|
||||
func (h *NoteHandler) SearchTitles(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
notes, err := h.stores.Notes.SearchTitles(c.Request.Context(), userID, q, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
|
||||
type titleResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
results := make([]titleResult, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
results = append(results, titleResult{ID: n.ID, Title: n.Title})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": results})
|
||||
}
|
||||
|
||||
// GET /api/v1/notes/:id/backlinks
|
||||
func (h *NoteHandler) Backlinks(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
|
||||
if err != nil || note.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.stores.NoteLinks == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.stores.NoteLinks.Backlinks(c.Request.Context(), noteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load backlinks"})
|
||||
return
|
||||
}
|
||||
if results == nil {
|
||||
results = []models.NoteLinkResult{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": results})
|
||||
}
|
||||
|
||||
// GET /api/v1/notes/graph
|
||||
func (h *NoteHandler) Graph(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
if h.stores.NoteLinks == nil {
|
||||
c.JSON(http.StatusOK, models.NoteGraph{
|
||||
Nodes: []models.NoteGraphNode{},
|
||||
Edges: []models.NoteGraphEdge{},
|
||||
Unresolved: []models.NoteGraphDangling{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
graph, err := h.stores.NoteLinks.Graph(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load graph"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, graph)
|
||||
}
|
||||
|
||||
// ── Link Extraction Helper ──────────────────
|
||||
|
||||
// extractAndStoreLinks parses [[wikilinks]] from content and stores them.
|
||||
func (h *NoteHandler) extractAndStoreLinks(c *gin.Context, noteID, content, userID string) {
|
||||
if h.stores.NoteLinks == nil {
|
||||
return
|
||||
}
|
||||
|
||||
links := notelinks.ExtractWikilinks(content)
|
||||
if len(links) == 0 {
|
||||
// Clear any existing links
|
||||
h.stores.NoteLinks.ReplaceLinks(c.Request.Context(), noteID, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve titles to note IDs
|
||||
ctx := c.Request.Context()
|
||||
for i, link := range links {
|
||||
notes, err := h.stores.Notes.SearchTitles(ctx, userID, link.TargetTitle, 1)
|
||||
if err == nil {
|
||||
for _, n := range notes {
|
||||
if strings.EqualFold(n.Title, link.TargetTitle) {
|
||||
id := n.ID
|
||||
links[i].TargetNoteID = &id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.stores.NoteLinks.ReplaceLinks(ctx, noteID, links)
|
||||
}
|
||||
|
||||
@@ -315,11 +315,14 @@ func main() {
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/search", notes.Search)
|
||||
protected.GET("/notes/search-titles", notes.SearchTitles)
|
||||
protected.GET("/notes/folders", notes.ListFolders)
|
||||
protected.GET("/notes/graph", notes.Graph)
|
||||
protected.POST("/notes/bulk-delete", notes.BulkDelete)
|
||||
protected.GET("/notes/:id", notes.Get)
|
||||
protected.PUT("/notes/:id", notes.Update)
|
||||
protected.DELETE("/notes/:id", notes.Delete)
|
||||
protected.GET("/notes/:id/backlinks", notes.Backlinks)
|
||||
|
||||
// Attachments (file upload/download)
|
||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||
|
||||
@@ -389,9 +389,58 @@ type Note struct {
|
||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
|
||||
SourceMessageID *string `json:"source_message_id,omitempty" db:"source_message_id"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
}
|
||||
|
||||
// NoteLink represents a directed link extracted from [[wikilink]] syntax.
|
||||
type NoteLink struct {
|
||||
TargetNoteID *string `json:"target_note_id,omitempty"`
|
||||
TargetTitle string `json:"target_title"`
|
||||
DisplayText string `json:"display_text,omitempty"`
|
||||
IsTransclusion bool `json:"is_transclusion"`
|
||||
}
|
||||
|
||||
// NoteLinkResult represents a backlink — a note that links to a given note.
|
||||
type NoteLinkResult struct {
|
||||
SourceNoteID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DisplayText string `json:"display_text,omitempty"`
|
||||
}
|
||||
|
||||
// NoteGraphNode is a lightweight note representation for graph display.
|
||||
type NoteGraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LinkCount int `json:"link_count"`
|
||||
}
|
||||
|
||||
// NoteGraphEdge is a resolved link between two notes.
|
||||
type NoteGraphEdge struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Title string `json:"title"`
|
||||
IsTransclusion bool `json:"is_transclusion"`
|
||||
}
|
||||
|
||||
// NoteGraphDangling is an unresolved [[link]] reference.
|
||||
type NoteGraphDangling struct {
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// NoteGraph is the full graph topology for a user's notes.
|
||||
type NoteGraph struct {
|
||||
Nodes []NoteGraphNode `json:"nodes"`
|
||||
Edges []NoteGraphEdge `json:"edges"`
|
||||
Unresolved []NoteGraphDangling `json:"unresolved"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// ATTACHMENTS
|
||||
// =========================================
|
||||
|
||||
50
server/notelinks/extract.go
Normal file
50
server/notelinks/extract.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package notelinks
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// wikiRe matches [[Title]], [[Title|Display]], ![[Title]], and ![[Title|Display]].
|
||||
// Title first char rejects [ and ] to avoid matching inside [[[triple]]].
|
||||
var wikiRe = regexp.MustCompile(`(!?)\[\[([^\[\]|][^\]|]*?)(?:\|([^\]]+?))?\]\]`)
|
||||
|
||||
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown content.
|
||||
// Returns deduplicated links preserving first occurrence.
|
||||
// Ignores matches preceded by [ (e.g. [[[triple]]]) since Go regexp lacks lookbehind.
|
||||
func ExtractWikilinks(content string) []models.NoteLink {
|
||||
indices := wikiRe.FindAllStringSubmatchIndex(content, -1)
|
||||
seen := make(map[string]bool)
|
||||
var links []models.NoteLink
|
||||
|
||||
for _, idx := range indices {
|
||||
matchStart := idx[0] // start of full match (including optional !)
|
||||
|
||||
// Skip if preceded by [ — rejects [[[triple]]] patterns
|
||||
if matchStart > 0 && content[matchStart-1] == '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract submatches by index pairs: idx[2*n], idx[2*n+1]
|
||||
title := strings.TrimSpace(content[idx[4]:idx[5]]) // group 2: title
|
||||
|
||||
key := strings.ToLower(title)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
link := models.NoteLink{
|
||||
TargetTitle: title,
|
||||
IsTransclusion: content[idx[2]:idx[3]] == "!", // group 1
|
||||
}
|
||||
// group 3: display text (optional — idx[6:7] may be -1)
|
||||
if idx[6] >= 0 && idx[7] >= 0 {
|
||||
link.DisplayText = strings.TrimSpace(content[idx[6]:idx[7]])
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
return links
|
||||
}
|
||||
122
server/notelinks/extract_test.go
Normal file
122
server/notelinks/extract_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package notelinks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractWikilinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want int
|
||||
checks func(t *testing.T, links []struct{ title, display string; transclusion bool })
|
||||
}{
|
||||
{
|
||||
name: "no links",
|
||||
content: "Just some plain text with [single brackets]",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single link",
|
||||
content: "See [[Project Notes]] for details",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Project Notes" {
|
||||
t.Errorf("title = %q, want %q", links[0].title, "Project Notes")
|
||||
}
|
||||
if links[0].transclusion {
|
||||
t.Error("should not be transclusion")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "link with display text",
|
||||
content: "Check the [[Architecture Doc|arch doc]] here",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Architecture Doc" {
|
||||
t.Errorf("title = %q", links[0].title)
|
||||
}
|
||||
if links[0].display != "arch doc" {
|
||||
t.Errorf("display = %q", links[0].display)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transclusion",
|
||||
content: "Embed this: ![[Meeting Notes]]",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Meeting Notes" {
|
||||
t.Errorf("title = %q", links[0].title)
|
||||
}
|
||||
if !links[0].transclusion {
|
||||
t.Error("should be transclusion")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple links deduplicated",
|
||||
content: "See [[Alpha]] and [[Beta]] and [[Alpha]] again",
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive dedup",
|
||||
content: "See [[Project]] and [[project]]",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Project" {
|
||||
t.Errorf("title = %q, want first occurrence", links[0].title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed links and transclusions",
|
||||
content: "Link to [[Alpha]], embed ![[Beta|b]], and [[Gamma]]",
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
content: "See [[ Spaced Title ]] here",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Spaced Title" {
|
||||
t.Errorf("title = %q, want trimmed", links[0].title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested brackets ignored",
|
||||
content: "Not a link: [[[triple]]] but [[Valid]] is",
|
||||
want: 1,
|
||||
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
|
||||
if links[0].title != "Valid" {
|
||||
t.Errorf("title = %q, want Valid", links[0].title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty brackets ignored",
|
||||
content: "Empty [[]] should not match",
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
links := ExtractWikilinks(tt.content)
|
||||
if len(links) != tt.want {
|
||||
t.Fatalf("got %d links, want %d", len(links), tt.want)
|
||||
}
|
||||
if tt.checks != nil {
|
||||
simplified := make([]struct{ title, display string; transclusion bool }, len(links))
|
||||
for i, l := range links {
|
||||
simplified[i] = struct{ title, display string; transclusion bool }{
|
||||
title: l.TargetTitle, display: l.DisplayText, transclusion: l.IsTransclusion,
|
||||
}
|
||||
}
|
||||
tt.checks(t, simplified)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type Stores struct {
|
||||
Messages MessageStore
|
||||
Audit AuditStore
|
||||
Notes NoteStore
|
||||
NoteLinks NoteLinkStore
|
||||
GlobalConfig GlobalConfigStore
|
||||
Usage UsageStore
|
||||
Pricing PricingStore
|
||||
@@ -269,6 +270,7 @@ type NoteStore interface {
|
||||
Delete(ctx context.Context, id string) error
|
||||
ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error)
|
||||
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error)
|
||||
SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error)
|
||||
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
|
||||
}
|
||||
|
||||
@@ -279,6 +281,25 @@ type NoteListOptions struct {
|
||||
TeamID string
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// NOTE LINK STORE
|
||||
// =========================================
|
||||
|
||||
// NoteLinkStore manages wikilink edges between notes.
|
||||
type NoteLinkStore interface {
|
||||
// ReplaceLinks deletes existing links for sourceNoteID and inserts new ones.
|
||||
ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error
|
||||
|
||||
// ResolveByTitle sets target_note_id on dangling links matching the title for a user.
|
||||
ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error
|
||||
|
||||
// Backlinks returns notes that link to the given note.
|
||||
Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error)
|
||||
|
||||
// Graph returns all nodes and edges for a user's note graph.
|
||||
Graph(ctx context.Context, userID string) (*models.NoteGraph, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// GLOBAL CONFIG STORE
|
||||
// =========================================
|
||||
|
||||
@@ -19,31 +19,33 @@ func NewNoteStore() *NoteStore { return &NoteStore{} }
|
||||
|
||||
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
n.UserID, n.Title, n.Content, n.FolderPath,
|
||||
pq.Array(n.Tags), ToJSON(n.Metadata),
|
||||
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
|
||||
models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID),
|
||||
models.NullString(n.TeamID),
|
||||
).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||
var metadataJSON []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, title, content, folder_path, tags, metadata,
|
||||
source_channel_id, team_id, created_at, updated_at
|
||||
source_channel_id, source_message_id, team_id, created_at, updated_at
|
||||
FROM notes WHERE id = $1`, id).Scan(
|
||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
pq.Array(&n.Tags), &metadataJSON,
|
||||
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt,
|
||||
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
||||
return &n, nil
|
||||
@@ -77,7 +79,7 @@ func (s *NoteStore) Delete(ctx context.Context, id string) error {
|
||||
|
||||
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
|
||||
b := NewSelect(
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
||||
"notes",
|
||||
).Where("user_id = ?", userID)
|
||||
|
||||
@@ -111,15 +113,16 @@ func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.N
|
||||
var result []models.Note
|
||||
for rows.Next() {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||
var metadataJSON []byte
|
||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
pq.Array(&n.Tags), &metadataJSON,
|
||||
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
||||
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
||||
result = append(result, n)
|
||||
@@ -131,7 +134,7 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
|
||||
tsQuery := strings.Join(strings.Fields(query), " & ")
|
||||
|
||||
b := NewSelect(
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
||||
"notes",
|
||||
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
|
||||
b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC")
|
||||
@@ -152,15 +155,16 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
|
||||
var result []models.Note
|
||||
for rows.Next() {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||
var metadataJSON []byte
|
||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
pq.Array(&n.Tags), &metadataJSON,
|
||||
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
||||
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
||||
result = append(result, n)
|
||||
@@ -189,3 +193,29 @@ func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string)
|
||||
n, _ := result.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, title, folder_path
|
||||
FROM notes
|
||||
WHERE user_id = $1 AND title ILIKE '%' || $2 || '%'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $3`, userID, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []models.Note
|
||||
for rows.Next() {
|
||||
var n models.Note
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, n)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
179
server/store/postgres/note_link.go
Normal file
179
server/store/postgres/note_link.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type NoteLinkStore struct{}
|
||||
|
||||
func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} }
|
||||
|
||||
func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete existing links for this source note
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"DELETE FROM note_links WHERE source_note_id = $1", sourceNoteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new links
|
||||
if len(links) > 0 {
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (source_note_id, target_title) DO NOTHING`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, l := range links {
|
||||
var targetID interface{}
|
||||
if l.TargetNoteID != nil {
|
||||
targetID = *l.TargetNoteID
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
sourceNoteID, targetID, l.TargetTitle, l.DisplayText, l.IsTransclusion,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE note_links
|
||||
SET target_note_id = $1
|
||||
WHERE target_note_id IS NULL
|
||||
AND LOWER(target_title) = LOWER($2)
|
||||
AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3)`,
|
||||
targetNoteID, title, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '')
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE nl.target_note_id = $1
|
||||
ORDER BY n.updated_at DESC`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []models.NoteLinkResult
|
||||
for rows.Next() {
|
||||
var r models.NoteLinkResult
|
||||
if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath,
|
||||
&r.UpdatedAt, &r.DisplayText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) {
|
||||
graph := &models.NoteGraph{
|
||||
Nodes: make([]models.NoteGraphNode, 0),
|
||||
Edges: make([]models.NoteGraphEdge, 0),
|
||||
Unresolved: make([]models.NoteGraphDangling, 0),
|
||||
}
|
||||
|
||||
// Nodes: all user's notes with link counts
|
||||
nodeRows, err := DB.QueryContext(ctx, `
|
||||
SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at::text,
|
||||
COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count
|
||||
FROM notes n
|
||||
LEFT JOIN (
|
||||
SELECT source_note_id, COUNT(*) AS cnt FROM note_links
|
||||
WHERE target_note_id IS NOT NULL GROUP BY source_note_id
|
||||
) outbound ON outbound.source_note_id = n.id
|
||||
LEFT JOIN (
|
||||
SELECT target_note_id, COUNT(*) AS cnt FROM note_links
|
||||
WHERE target_note_id IS NOT NULL GROUP BY target_note_id
|
||||
) inbound ON inbound.target_note_id = n.id
|
||||
WHERE n.user_id = $1
|
||||
ORDER BY n.updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer nodeRows.Close()
|
||||
|
||||
for nodeRows.Next() {
|
||||
var node models.NoteGraphNode
|
||||
var tags []string
|
||||
if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath,
|
||||
pq.Array(&tags), &node.UpdatedAt, &node.LinkCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
node.Tags = tags
|
||||
graph.Nodes = append(graph.Nodes, node)
|
||||
}
|
||||
if err := nodeRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolved edges
|
||||
edgeRows, err := DB.QueryContext(ctx, `
|
||||
SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE n.user_id = $1
|
||||
AND nl.target_note_id IS NOT NULL`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer edgeRows.Close()
|
||||
|
||||
for edgeRows.Next() {
|
||||
var edge models.NoteGraphEdge
|
||||
if err := edgeRows.Scan(&edge.Source, &edge.Target,
|
||||
&edge.Title, &edge.IsTransclusion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
graph.Edges = append(graph.Edges, edge)
|
||||
}
|
||||
if err := edgeRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unresolved links (dangling)
|
||||
danglingRows, err := DB.QueryContext(ctx, `
|
||||
SELECT nl.source_note_id, nl.target_title
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE n.user_id = $1
|
||||
AND nl.target_note_id IS NULL`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer danglingRows.Close()
|
||||
|
||||
for danglingRows.Next() {
|
||||
var d models.NoteGraphDangling
|
||||
if err := danglingRows.Scan(&d.Source, &d.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
graph.Unresolved = append(graph.Unresolved, d)
|
||||
}
|
||||
|
||||
return graph, danglingRows.Err()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Messages: NewMessageStore(),
|
||||
Audit: NewAuditStore(),
|
||||
Notes: NewNoteStore(),
|
||||
NoteLinks: NewNoteLinkStore(),
|
||||
GlobalConfig: NewGlobalConfigStore(),
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
|
||||
@@ -22,11 +22,12 @@ func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
||||
n.CreatedAt = now
|
||||
n.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
|
||||
ArrayToJSON(n.Tags), ToJSON(n.Metadata),
|
||||
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
|
||||
models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID),
|
||||
models.NullString(n.TeamID),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
@@ -34,21 +35,22 @@ func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
||||
|
||||
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||
var tagsJSON, metadataJSON string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, title, content, folder_path, tags, metadata,
|
||||
source_channel_id, team_id, created_at, updated_at
|
||||
source_channel_id, source_message_id, team_id, created_at, updated_at
|
||||
FROM notes WHERE id = ?`, id).Scan(
|
||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
&tagsJSON, &metadataJSON,
|
||||
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
|
||||
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Tags = ScanArray(tagsJSON)
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
||||
return &n, nil
|
||||
@@ -82,7 +84,7 @@ func (s *NoteStore) Delete(ctx context.Context, id string) error {
|
||||
|
||||
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
|
||||
b := NewSelect(
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
||||
"notes",
|
||||
).Where("user_id = ?", userID)
|
||||
|
||||
@@ -126,7 +128,7 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
|
||||
}
|
||||
|
||||
b := NewSelect(
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
|
||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
||||
"notes",
|
||||
).Where("user_id = ?", userID)
|
||||
|
||||
@@ -153,6 +155,32 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
|
||||
return s.scanNotes(rows, total)
|
||||
}
|
||||
|
||||
func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, title, folder_path
|
||||
FROM notes
|
||||
WHERE user_id = ? AND title LIKE '%' || ? || '%' COLLATE NOCASE
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`, userID, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []models.Note
|
||||
for rows.Next() {
|
||||
var n models.Note
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, n)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
@@ -181,16 +209,17 @@ func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, er
|
||||
var result []models.Note
|
||||
for rows.Next() {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||
var tagsJSON, metadataJSON string
|
||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
&tagsJSON, &metadataJSON,
|
||||
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
|
||||
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
n.Tags = ScanArray(tagsJSON)
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
||||
result = append(result, n)
|
||||
|
||||
182
server/store/sqlite/note_link.go
Normal file
182
server/store/sqlite/note_link.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type NoteLinkStore struct{}
|
||||
|
||||
func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} }
|
||||
|
||||
func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete existing links for this source note
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"DELETE FROM note_links WHERE source_note_id = ?", sourceNoteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new links
|
||||
if len(links) > 0 {
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT OR IGNORE INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, l := range links {
|
||||
var targetID interface{}
|
||||
if l.TargetNoteID != nil {
|
||||
targetID = *l.TargetNoteID
|
||||
}
|
||||
isTransclusion := 0
|
||||
if l.IsTransclusion {
|
||||
isTransclusion = 1
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
sourceNoteID, targetID, l.TargetTitle, l.DisplayText, isTransclusion,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE note_links
|
||||
SET target_note_id = ?
|
||||
WHERE target_note_id IS NULL
|
||||
AND LOWER(target_title) = LOWER(?)
|
||||
AND source_note_id IN (SELECT id FROM notes WHERE user_id = ?)`,
|
||||
targetNoteID, title, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '')
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE nl.target_note_id = ?
|
||||
ORDER BY n.updated_at DESC`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []models.NoteLinkResult
|
||||
for rows.Next() {
|
||||
var r models.NoteLinkResult
|
||||
if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath,
|
||||
st(&r.UpdatedAt), &r.DisplayText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) {
|
||||
graph := &models.NoteGraph{
|
||||
Nodes: make([]models.NoteGraphNode, 0),
|
||||
Edges: make([]models.NoteGraphEdge, 0),
|
||||
Unresolved: make([]models.NoteGraphDangling, 0),
|
||||
}
|
||||
|
||||
// Nodes: all user's notes with link counts
|
||||
nodeRows, err := DB.QueryContext(ctx, `
|
||||
SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at,
|
||||
COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count
|
||||
FROM notes n
|
||||
LEFT JOIN (
|
||||
SELECT source_note_id, COUNT(*) AS cnt FROM note_links
|
||||
WHERE target_note_id IS NOT NULL GROUP BY source_note_id
|
||||
) outbound ON outbound.source_note_id = n.id
|
||||
LEFT JOIN (
|
||||
SELECT target_note_id, COUNT(*) AS cnt FROM note_links
|
||||
WHERE target_note_id IS NOT NULL GROUP BY target_note_id
|
||||
) inbound ON inbound.target_note_id = n.id
|
||||
WHERE n.user_id = ?
|
||||
ORDER BY n.updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer nodeRows.Close()
|
||||
|
||||
for nodeRows.Next() {
|
||||
var node models.NoteGraphNode
|
||||
var tagsJSON string
|
||||
if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath,
|
||||
&tagsJSON, &node.UpdatedAt, &node.LinkCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.Tags = ScanArray(tagsJSON)
|
||||
if node.Tags == nil {
|
||||
node.Tags = []string{}
|
||||
}
|
||||
graph.Nodes = append(graph.Nodes, node)
|
||||
}
|
||||
if err := nodeRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolved edges
|
||||
edgeRows, err := DB.QueryContext(ctx, `
|
||||
SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE n.user_id = ?
|
||||
AND nl.target_note_id IS NOT NULL`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer edgeRows.Close()
|
||||
|
||||
for edgeRows.Next() {
|
||||
var edge models.NoteGraphEdge
|
||||
var isTransclusion int
|
||||
if err := edgeRows.Scan(&edge.Source, &edge.Target,
|
||||
&edge.Title, &isTransclusion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edge.IsTransclusion = isTransclusion != 0
|
||||
graph.Edges = append(graph.Edges, edge)
|
||||
}
|
||||
if err := edgeRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unresolved links (dangling)
|
||||
danglingRows, err := DB.QueryContext(ctx, `
|
||||
SELECT nl.source_note_id, nl.target_title
|
||||
FROM note_links nl
|
||||
JOIN notes n ON n.id = nl.source_note_id
|
||||
WHERE n.user_id = ?
|
||||
AND nl.target_note_id IS NULL`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer danglingRows.Close()
|
||||
|
||||
for danglingRows.Next() {
|
||||
var d models.NoteGraphDangling
|
||||
if err := danglingRows.Scan(&d.Source, &d.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
graph.Unresolved = append(graph.Unresolved, d)
|
||||
}
|
||||
|
||||
return graph, danglingRows.Err()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Messages: NewMessageStore(),
|
||||
Audit: NewAuditStore(),
|
||||
Notes: NewNoteStore(),
|
||||
NoteLinks: NewNoteLinkStore(),
|
||||
GlobalConfig: NewGlobalConfigStore(),
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
|
||||
Reference in New Issue
Block a user