feat: added first class citizen tags
This commit is contained in:
1 parent
57bcd549d1
commit
4dc42d91c2
4 files changed
+460
-29
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -59,6 +60,10 @@ func main() {
|
||||
registerStoreFlashcard(mcpServer, store, searchEngine, logger)
|
||||
registerSearchFlashcard(mcpServer, searchEngine)
|
||||
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)
|
||||
registerDeleteFlashcard(mcpServer, store, searchEngine, 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) {
|
||||
mcpServer.AddTool(
|
||||
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.Required(),
|
||||
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 {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, ok := request.Params.Arguments.(map[string]any)
|
||||
@@ -385,7 +568,7 @@ func handleUpdateFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap
|
||||
}
|
||||
|
||||
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 {
|
||||
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"])
|
||||
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 {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"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 {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
@@ -63,14 +64,17 @@ func newSqliteStore(path string) (*sqliteStore, error) {
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string, publicID int64) (int64, error) {
|
||||
reposJSON := marshalStringSlice(repos)
|
||||
tagsJSON := marshalStringSlice(tags)
|
||||
func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string, publicID int64, version int) (int64, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO flashcard (public_id, version, note, repos, host, tags, "user", context)
|
||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?)`,
|
||||
publicID, note, reposJSON, host, tagsJSON, user, context_,
|
||||
result, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO flashcard (public_id, version, note, repos, host, "user", context)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
publicID, version, note, marshalStringSlice(repos), host, user, context_,
|
||||
)
|
||||
if err != nil {
|
||||
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 {
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE flashcard SET public_id = ? WHERE _id = ?`, id, id)
|
||||
if err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE flashcard SET public_id = ? WHERE _id = ?`, id, id); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
|
||||
FROM flashcard WHERE _id = ?`, id,
|
||||
)
|
||||
row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f._id = ?`, id)
|
||||
|
||||
var e entry
|
||||
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) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`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,
|
||||
)
|
||||
row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f.public_id = ? ORDER BY f.version DESC LIMIT 1`, publicID)
|
||||
|
||||
var e entry
|
||||
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) {
|
||||
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
|
||||
WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id)
|
||||
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) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
|
||||
FROM flashcard WHERE public_id = ? AND version = ?`, publicID, version,
|
||||
)
|
||||
row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f.public_id = ? AND f.version = ?`, publicID, version)
|
||||
|
||||
var e entry
|
||||
var reposJSON, tagsJSON sql.NullString
|
||||
@@ -369,7 +404,7 @@ func (s *sqliteStore) listRecent(ctx context.Context, tag, repo, host string, li
|
||||
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
|
||||
WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id)
|
||||
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
|
||||
|
||||
if tag != "" {
|
||||
q += ` AND f.tags LIKE ?`
|
||||
args = append(args, `%"`+tag+`"%`)
|
||||
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)
|
||||
}
|
||||
if repo != "" {
|
||||
q += ` AND f.repos LIKE ?`
|
||||
@@ -420,6 +455,173 @@ func (s *sqliteStore) listRecent(ctx context.Context, tag, repo, host string, li
|
||||
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 {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ type flashcardSummary struct {
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
type tagCount struct {
|
||||
Tag string `json:"tag"`
|
||||
Description string `json:"description"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type vote struct {
|
||||
ID int64 `json:"_id"`
|
||||
FlashcardID int64 `json:"flashcard_id"`
|
||||
|
||||
Reference in new issue
Block a user