diff --git a/main.go b/main.go index 2ecdfa2..005f05e 100644 --- a/main.go +++ b/main.go @@ -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 } diff --git a/migrations/000005_normalize_tags.sql b/migrations/000005_normalize_tags.sql new file mode 100644 index 0000000..9e093bc --- /dev/null +++ b/migrations/000005_normalize_tags.sql @@ -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; diff --git a/sqlite.go b/sqlite.go index 775babc..89c2a03 100644 --- a/sqlite.go +++ b/sqlite.go @@ -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() } diff --git a/types.go b/types.go index 0b0b7b6..b4dfd02 100644 --- a/types.go +++ b/types.go @@ -15,15 +15,21 @@ type entry struct { } type flashcardSummary struct { - URL string `json:"url"` - PublicID int64 `json:"id"` - Version int `json:"version"` - Note string `json:"note"` - Repos []string `json:"repos,omitempty"` - Host string `json:"host,omitempty"` - Tags []string `json:"tags,omitempty"` - User string `json:"user,omitempty"` - Created string `json:"created"` + URL string `json:"url"` + PublicID int64 `json:"id"` + Version int `json:"version"` + Note string `json:"note"` + Repos []string `json:"repos,omitempty"` + Host string `json:"host,omitempty"` + Tags []string `json:"tags,omitempty"` + User string `json:"user,omitempty"` + Created string `json:"created"` +} + +type tagCount struct { + Tag string `json:"tag"` + Description string `json:"description"` + Count int64 `json:"count"` } type vote struct {