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

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