feat: added first class citizen tags

This commit is contained in:
didier committed 2026-09-16 07:14:31 -04:00
1 parent 57bcd549d1
commit 4dc42d91c2
4 files changed
+469 -38

No files matched your search

+186 -3
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -59,6 +60,10 @@ func main() {
registerStoreFlashcard(mcpServer, store, searchEngine, logger) registerStoreFlashcard(mcpServer, store, searchEngine, logger)
registerSearchFlashcard(mcpServer, searchEngine) registerSearchFlashcard(mcpServer, searchEngine)
registerListRecentFlashcard(mcpServer, store, logger) registerListRecentFlashcard(mcpServer, store, logger)
registerListTags(mcpServer, store, logger)
registerCreateTag(mcpServer, store, logger)
registerUpdateTag(mcpServer, store, logger)
registerMergeTag(mcpServer, store, logger)
registerUpdateFlashcard(mcpServer, store, searchEngine, logger) registerUpdateFlashcard(mcpServer, store, searchEngine, logger)
registerDeleteFlashcard(mcpServer, store, searchEngine, logger) registerDeleteFlashcard(mcpServer, store, searchEngine, logger)
registerVoteFlashcard(mcpServer, store, logger) registerVoteFlashcard(mcpServer, store, logger)
@@ -204,7 +209,7 @@ func createSearchEngine(logger *zap.Logger, sqliteStore *sqliteStore, openAIKey
func registerStoreFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) { func registerStoreFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) {
mcpServer.AddTool( mcpServer.AddTool(
mcp.NewTool("store_flashcard", mcp.NewTool("store_flashcard",
mcp.WithDescription("Store a flashcard for later retrieval. Use this to persist useful information discovered during a session."), mcp.WithDescription("Store a flashcard for later retrieval. Use this to persist useful information discovered during a session. Use as few tags as possible — ideally 0 to 2 — and reuse existing tags (see list_tags) rather than inventing new ones; create a genuinely new tag with create_tag first."),
mcp.WithString("note", mcp.WithString("note",
mcp.Required(), mcp.Required(),
mcp.Description("The markdown body — keep it concise, one paragraph usually"), mcp.Description("The markdown body — keep it concise, one paragraph usually"),
@@ -269,6 +274,184 @@ func registerListRecentFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStor
) )
} }
func registerListTags(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("list_tags",
mcp.WithDescription("List the tag vocabulary — every known tag with its description and how many flashcards use it. Use this before storing a flashcard to reuse existing tags instead of inventing new ones."),
),
handleListTags(sqlite, logger),
)
}
func handleListTags(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
tags, err := sqlite.listTags(ctx)
if err != nil {
logger.Error("list tags failed", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("list tags failed: %v", err)), nil
}
output, err := json.MarshalIndent(tags, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal results: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerCreateTag(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("create_tag",
mcp.WithDescription("Create a tag in the vocabulary. Tags are shared across flashcards. Check list_tags first and reuse an existing tag when one fits; create a new one only when nothing does. Fails if the tag already exists."),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Tag name — short, lowercase, no spaces (e.g. 'preference', 'workaround')"),
),
mcp.WithString("description",
mcp.Description("What the tag means and when to use it"),
),
),
handleCreateTag(sqlite, logger),
)
}
func handleCreateTag(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
name := strings.TrimSpace(parseOptionalString(args["name"]))
if name == "" {
return mcp.NewToolResultError("name is required and must be a non-empty string"), nil
}
tag, err := sqlite.createTag(ctx, name, parseOptionalString(args["description"]))
if err != nil {
logger.Error("create tag failed", zap.String("name", name), zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("failed to create tag: %v", err)), nil
}
output, err := json.MarshalIndent(tag, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerUpdateTag(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("update_tag",
mcp.WithDescription("Update a tag's name and/or description. Renaming keeps the tag linked to its flashcards. Fails if the new name is already used by another tag."),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Current tag name"),
),
mcp.WithString("new_name",
mcp.Description("New name, if renaming"),
),
mcp.WithString("description",
mcp.Description("New description; pass an empty string to clear it"),
),
),
handleUpdateTag(sqlite, logger),
)
}
func handleUpdateTag(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
name := strings.TrimSpace(parseOptionalString(args["name"]))
if name == "" {
return mcp.NewToolResultError("name is required and must be a non-empty string"), nil
}
newName := strings.TrimSpace(parseOptionalString(args["new_name"]))
description, setDescription := "", false
if raw, present := args["description"]; present && raw != nil {
description, setDescription = parseOptionalString(raw), true
}
if newName == "" && !setDescription {
return mcp.NewToolResultError("nothing to update: provide new_name and/or description"), nil
}
tag, err := sqlite.updateTag(ctx, name, newName, description, setDescription)
if err != nil {
logger.Error("update tag failed", zap.String("name", name), zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("failed to update tag: %v", err)), nil
}
output, err := json.MarshalIndent(tag, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerMergeTag(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("merge_tag",
mcp.WithDescription("Merge tag 'from' into tag 'into': every flashcard using 'from' is repointed to 'into', then 'from' is deleted. Use this to collapse near-duplicate tags (e.g. merge 'golang' into 'go'). Optionally set the merged tag's description."),
mcp.WithString("from",
mcp.Required(),
mcp.Description("Tag to merge away and delete"),
),
mcp.WithString("into",
mcp.Required(),
mcp.Description("Tag that survives and absorbs the flashcards"),
),
mcp.WithString("description",
mcp.Description("Description to set on the surviving tag; omitted keeps its current description"),
),
),
handleMergeTag(sqlite, logger),
)
}
func handleMergeTag(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
from := strings.TrimSpace(parseOptionalString(args["from"]))
into := strings.TrimSpace(parseOptionalString(args["into"]))
if from == "" || into == "" {
return mcp.NewToolResultError("from and into are required and must be non-empty strings"), nil
}
description, setDescription := "", false
if raw, present := args["description"]; present && raw != nil {
description, setDescription = parseOptionalString(raw), true
}
tag, err := sqlite.mergeTag(ctx, from, into, description, setDescription)
if err != nil {
logger.Error("merge tag failed", zap.String("from", from), zap.String("into", into), zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("failed to merge tag: %v", err)), nil
}
output, err := json.MarshalIndent(tag, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func handleListRecentFlashcard(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc { func handleListRecentFlashcard(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any) args, ok := request.Params.Arguments.(map[string]any)
@@ -385,7 +568,7 @@ func handleUpdateFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap
} }
version := latest.Version + 1 version := latest.Version + 1
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID) id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID, version)
if err != nil { if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist update: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to persist update: %v", err)), nil
} }
@@ -564,7 +747,7 @@ func handleStoreFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap.
context_ := parseOptionalString(args["context"]) context_ := parseOptionalString(args["context"])
publicID := parseInt64(args["public_id"]) publicID := parseInt64(args["public_id"])
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID) id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID, 1)
if err != nil { if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil
} }
+40
View File
@@ -0,0 +1,40 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS tag (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS flashcard_tag (
flashcard_id INTEGER NOT NULL REFERENCES flashcard(_id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tag(id),
PRIMARY KEY (flashcard_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_flashcard_tag_tag_id ON flashcard_tag(tag_id);
INSERT OR IGNORE INTO tag(name)
SELECT DISTINCT j.value
FROM flashcard f, json_each(f.tags) j
WHERE f.tags IS NOT NULL AND j.value != '';
INSERT OR IGNORE INTO flashcard_tag(flashcard_id, tag_id)
SELECT f._id, t.id
FROM flashcard f, json_each(f.tags) j
JOIN tag t ON t.name = j.value
WHERE f.tags IS NOT NULL AND j.value != '';
ALTER TABLE flashcard DROP COLUMN tags;
-- +goose Down
ALTER TABLE flashcard ADD COLUMN tags TEXT;
UPDATE flashcard SET tags = (
SELECT json_group_array(t.name)
FROM flashcard_tag ft JOIN tag t ON t.id = ft.tag_id
WHERE ft.flashcard_id = flashcard._id
);
DROP INDEX IF EXISTS idx_flashcard_tag_tag_id;
DROP TABLE IF EXISTS flashcard_tag;
DROP TABLE IF EXISTS tag;
+228 -26
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"embed" "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -31,7 +32,7 @@ func newSqliteStore(path string) (*sqliteStore, error) {
} }
} }
db, err := sql.Open("sqlite", path) db, err := sql.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil { if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err) return nil, fmt.Errorf("open sqlite: %w", err)
} }
@@ -63,14 +64,17 @@ func newSqliteStore(path string) (*sqliteStore, error) {
return &sqliteStore{db: db}, nil return &sqliteStore{db: db}, nil
} }
func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string, publicID int64) (int64, error) { func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string, publicID int64, version int) (int64, error) {
reposJSON := marshalStringSlice(repos) tx, err := s.db.BeginTx(ctx, nil)
tagsJSON := marshalStringSlice(tags) if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
result, err := s.db.ExecContext(ctx, result, err := tx.ExecContext(ctx,
`INSERT INTO flashcard (public_id, version, note, repos, host, tags, "user", context) `INSERT INTO flashcard (public_id, version, note, repos, host, "user", context)
VALUES (?, 1, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
publicID, note, reposJSON, host, tagsJSON, user, context_, publicID, version, note, marshalStringSlice(repos), host, user, context_,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("insert: %w", err) return 0, fmt.Errorf("insert: %w", err)
@@ -82,20 +86,57 @@ func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ strin
} }
if publicID == 0 { if publicID == 0 {
_, err = s.db.ExecContext(ctx, `UPDATE flashcard SET public_id = ? WHERE _id = ?`, id, id) if _, err := tx.ExecContext(ctx, `UPDATE flashcard SET public_id = ? WHERE _id = ?`, id, id); err != nil {
if err != nil {
return 0, fmt.Errorf("set public_id: %w", err) return 0, fmt.Errorf("set public_id: %w", err)
} }
} }
if err := insertTags(ctx, tx, id, tags); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit: %w", err)
}
return id, nil return id, nil
} }
func insertTags(ctx context.Context, tx *sql.Tx, flashcardID int64, tags []string) error {
for _, raw := range tags {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO tag(name) VALUES (?) ON CONFLICT(name) DO NOTHING`, name,
); err != nil {
return fmt.Errorf("upsert tag %q: %w", name, err)
}
var tagID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM tag WHERE name = ?`, name).Scan(&tagID); err != nil {
return fmt.Errorf("read tag %q: %w", name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO flashcard_tag(flashcard_id, tag_id) VALUES (?, ?)`, flashcardID, tagID,
); err != nil {
return fmt.Errorf("link tag %q: %w", name, err)
}
}
return nil
}
const tagsJSONSelect = `(SELECT json_group_array(t.name ORDER BY t.name) FROM flashcard_tag ft JOIN tag t ON t.id = ft.tag_id WHERE ft.flashcard_id = f._id)`
const selectFlashcard = `SELECT f._id, f.public_id, f.version, f.note, f.repos, f.host, ` + tagsJSONSelect + `,
f."user", f.context, f.delete_timestamp, f.created
FROM flashcard f`
func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) { func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f._id = ?`, id)
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM flashcard WHERE _id = ?`, id,
)
var e entry var e entry
var reposJSON, tagsJSON sql.NullString var reposJSON, tagsJSON sql.NullString
@@ -132,10 +173,7 @@ func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) {
} }
func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (*entry, error) { func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (*entry, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f.public_id = ? ORDER BY f.version DESC LIMIT 1`, publicID)
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM flashcard WHERE public_id = ? ORDER BY version DESC LIMIT 1`, publicID,
)
var e entry var e entry
var reposJSON, tagsJSON sql.NullString var reposJSON, tagsJSON sql.NullString
@@ -173,7 +211,7 @@ func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (
func (s *sqliteStore) listAll(ctx context.Context) ([]flashcardSummary, error) { func (s *sqliteStore) listAll(ctx context.Context) ([]flashcardSummary, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT f.public_id, f.version, f.note, f.host, f.user, f.tags, f.repos, f.created `SELECT f.public_id, f.version, f.note, f.host, f.user, `+tagsJSONSelect+`, f.repos, f.created
FROM flashcard f FROM flashcard f
WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id) WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id)
AND f.delete_timestamp IS NULL AND f.delete_timestamp IS NULL
@@ -208,10 +246,7 @@ func (s *sqliteStore) listAll(ctx context.Context) ([]flashcardSummary, error) {
} }
func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*entry, error) { func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*entry, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f.public_id = ? AND f.version = ?`, publicID, version)
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM flashcard WHERE public_id = ? AND version = ?`, publicID, version,
)
var e entry var e entry
var reposJSON, tagsJSON sql.NullString var reposJSON, tagsJSON sql.NullString
@@ -369,7 +404,7 @@ func (s *sqliteStore) listRecent(ctx context.Context, tag, repo, host string, li
limit = 20 limit = 20
} }
q := `SELECT f.public_id, f.version, f.note, f.host, f.user, f.tags, f.repos, f.created q := `SELECT f.public_id, f.version, f.note, f.host, f.user, ` + tagsJSONSelect + `, f.repos, f.created
FROM flashcard f FROM flashcard f
WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id) WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id)
AND f.delete_timestamp IS NULL AND f.delete_timestamp IS NULL
@@ -377,8 +412,8 @@ func (s *sqliteStore) listRecent(ctx context.Context, tag, repo, host string, li
var args []any var args []any
if tag != "" { if tag != "" {
q += ` AND f.tags LIKE ?` q += ` AND EXISTS (SELECT 1 FROM flashcard_tag ft JOIN tag t ON t.id = ft.tag_id WHERE ft.flashcard_id = f._id AND t.name = ?)`
args = append(args, `%"`+tag+`"%`) args = append(args, tag)
} }
if repo != "" { if repo != "" {
q += ` AND f.repos LIKE ?` q += ` AND f.repos LIKE ?`
@@ -420,6 +455,173 @@ func (s *sqliteStore) listRecent(ctx context.Context, tag, repo, host string, li
return summaries, nil return summaries, nil
} }
const tagCountExpr = `(SELECT COUNT(*) FROM flashcard_tag ft
JOIN flashcard f ON f._id = ft.flashcard_id
WHERE ft.tag_id = t.id
AND f.delete_timestamp IS NULL
AND f.public_id != 0
AND f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id))`
var (
errTagNotFound = errors.New("tag not found")
errTagExists = errors.New("tag already exists")
)
func (s *sqliteStore) getTagByName(ctx context.Context, name string) (*tagCount, error) {
row := s.db.QueryRowContext(ctx,
`SELECT t.name, t.description, `+tagCountExpr+` FROM tag t WHERE t.name = ?`, name,
)
var t tagCount
err := row.Scan(&t.Tag, &t.Description, &t.Count)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query tag: %w", err)
}
return &t, nil
}
func (s *sqliteStore) createTag(ctx context.Context, name, description string) (*tagCount, error) {
result, err := s.db.ExecContext(ctx,
`INSERT INTO tag(name, description) VALUES (?, ?) ON CONFLICT(name) DO NOTHING`,
name, description,
)
if err != nil {
return nil, fmt.Errorf("insert tag: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("rows affected: %w", err)
}
if affected == 0 {
return nil, errTagExists
}
return s.getTagByName(ctx, name)
}
func (s *sqliteStore) updateTag(ctx context.Context, name, newName, description string, setDescription bool) (*tagCount, error) {
existing, err := s.getTagByName(ctx, name)
if err != nil {
return nil, err
}
if existing == nil {
return nil, errTagNotFound
}
if newName == "" {
newName = name
}
if newName != name {
clash, err := s.getTagByName(ctx, newName)
if err != nil {
return nil, err
}
if clash != nil {
return nil, errTagExists
}
}
if !setDescription {
description = existing.Description
}
if _, err := s.db.ExecContext(ctx,
`UPDATE tag SET name = ?, description = ? WHERE name = ?`, newName, description, name,
); err != nil {
return nil, fmt.Errorf("update tag: %w", err)
}
return s.getTagByName(ctx, newName)
}
func (s *sqliteStore) mergeTag(ctx context.Context, from, into, description string, setDescription bool) (*tagCount, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
fromID, err := tagIDByName(ctx, tx, from)
if err != nil {
return nil, err
}
intoID, err := tagIDByName(ctx, tx, into)
if err != nil {
return nil, err
}
if fromID == intoID {
return nil, fmt.Errorf("cannot merge tag %q into itself", from)
}
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO flashcard_tag(flashcard_id, tag_id)
SELECT flashcard_id, ? FROM flashcard_tag WHERE tag_id = ?`,
intoID, fromID,
); err != nil {
return nil, fmt.Errorf("repoint links: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM flashcard_tag WHERE tag_id = ?`, fromID); err != nil {
return nil, fmt.Errorf("delete source links: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM tag WHERE id = ?`, fromID); err != nil {
return nil, fmt.Errorf("delete source tag: %w", err)
}
if setDescription {
if _, err := tx.ExecContext(ctx, `UPDATE tag SET description = ? WHERE id = ?`, description, intoID); err != nil {
return nil, fmt.Errorf("update description: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.getTagByName(ctx, into)
}
func tagIDByName(ctx context.Context, tx *sql.Tx, name string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM tag WHERE name = ?`, name).Scan(&id)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("%w: %s", errTagNotFound, name)
}
if err != nil {
return 0, fmt.Errorf("query tag %q: %w", name, err)
}
return id, nil
}
func (s *sqliteStore) listTags(ctx context.Context) ([]tagCount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT t.name, t.description, `+tagCountExpr+` AS count
FROM tag t
ORDER BY count DESC, t.name ASC`,
)
if err != nil {
return nil, fmt.Errorf("list tags: %w", err)
}
defer rows.Close()
var tags []tagCount
for rows.Next() {
var t tagCount
if err := rows.Scan(&t.Tag, &t.Description, &t.Count); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
tags = append(tags, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows: %w", err)
}
return tags, nil
}
func (s *sqliteStore) close() error { func (s *sqliteStore) close() error {
return s.db.Close() return s.db.Close()
} }
+15 -9
View File
@@ -15,15 +15,21 @@ type entry struct {
} }
type flashcardSummary struct { type flashcardSummary struct {
URL string `json:"url"` URL string `json:"url"`
PublicID int64 `json:"id"` PublicID int64 `json:"id"`
Version int `json:"version"` Version int `json:"version"`
Note string `json:"note"` Note string `json:"note"`
Repos []string `json:"repos,omitempty"` Repos []string `json:"repos,omitempty"`
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
Created string `json:"created"` Created string `json:"created"`
}
type tagCount struct {
Tag string `json:"tag"`
Description string `json:"description"`
Count int64 `json:"count"`
} }
type vote struct { type vote struct {