Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View 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;

View 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;

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
// =========================================

View 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
}

View 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)
}
})
}
}

View File

@@ -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
// =========================================

View File

@@ -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()
}

View 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()
}

View File

@@ -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(),

View File

@@ -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)

View 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()
}

View File

@@ -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(),