686 lines
17 KiB
Go
686 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/pressly/goose/v3"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var embedMigrations embed.FS
|
|
|
|
type sqliteStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func newSqliteStore(path string) (*sqliteStore, error) {
|
|
dir := path
|
|
if idx := strings.LastIndexByte(path, '/'); idx > 0 {
|
|
dir = path[:idx]
|
|
if fi, err := os.Stat(dir); err != nil {
|
|
return nil, fmt.Errorf("sqlite dir %s: %w", dir, err)
|
|
} else if fi.Mode().Perm()&0200 == 0 {
|
|
return nil, fmt.Errorf("sqlite dir %s is not writable (mode %o)", dir, fi.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
|
|
db.SetMaxOpenConns(1)
|
|
|
|
if _, err := db.Exec("SELECT 1"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("sqlite ping: %w", err)
|
|
}
|
|
|
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
|
}
|
|
|
|
goose.SetBaseFS(embedMigrations)
|
|
|
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("set dialect: %w", err)
|
|
}
|
|
|
|
if err := goose.Up(db, "migrations"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
return &sqliteStore{db: db}, nil
|
|
}
|
|
|
|
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 := 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)
|
|
}
|
|
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("last insert id: %w", err)
|
|
}
|
|
|
|
if publicID == 0 {
|
|
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, selectFlashcard+` WHERE f._id = ?`, id)
|
|
|
|
var e entry
|
|
var reposJSON, tagsJSON sql.NullString
|
|
var host, user, context_, deleteTimestamp sql.NullString
|
|
|
|
err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query: %w", err)
|
|
}
|
|
|
|
if reposJSON.Valid {
|
|
e.Repos = unmarshalStringSlice(reposJSON.String)
|
|
}
|
|
if tagsJSON.Valid {
|
|
e.Tags = unmarshalStringSlice(tagsJSON.String)
|
|
}
|
|
if host.Valid {
|
|
e.Host = host.String
|
|
}
|
|
if user.Valid {
|
|
e.User = user.String
|
|
}
|
|
if context_.Valid {
|
|
e.Context = context_.String
|
|
}
|
|
if deleteTimestamp.Valid {
|
|
e.DeleteTimestamp = deleteTimestamp.String
|
|
}
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (*entry, error) {
|
|
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
|
|
var host, user, context_, deleteTimestamp sql.NullString
|
|
|
|
err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query: %w", err)
|
|
}
|
|
|
|
if reposJSON.Valid {
|
|
e.Repos = unmarshalStringSlice(reposJSON.String)
|
|
}
|
|
if tagsJSON.Valid {
|
|
e.Tags = unmarshalStringSlice(tagsJSON.String)
|
|
}
|
|
if host.Valid {
|
|
e.Host = host.String
|
|
}
|
|
if user.Valid {
|
|
e.User = user.String
|
|
}
|
|
if context_.Valid {
|
|
e.Context = context_.String
|
|
}
|
|
if deleteTimestamp.Valid {
|
|
e.DeleteTimestamp = deleteTimestamp.String
|
|
}
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
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, `+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
|
|
AND f.public_id != 0
|
|
ORDER BY f.created DESC`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var summaries []flashcardSummary
|
|
for rows.Next() {
|
|
var s flashcardSummary
|
|
var tagsJSON, reposJSON *string
|
|
if err := rows.Scan(&s.PublicID, &s.Version, &s.Note, &s.Host, &s.User, &tagsJSON, &reposJSON, &s.Created); err != nil {
|
|
return nil, fmt.Errorf("scan: %w", err)
|
|
}
|
|
if tagsJSON != nil {
|
|
json.Unmarshal([]byte(*tagsJSON), &s.Tags)
|
|
}
|
|
if reposJSON != nil {
|
|
json.Unmarshal([]byte(*reposJSON), &s.Repos)
|
|
}
|
|
s.URL = fmt.Sprintf("/api/v1/flashcards/%d", s.PublicID)
|
|
summaries = append(summaries, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("rows: %w", err)
|
|
}
|
|
return summaries, nil
|
|
}
|
|
|
|
func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*entry, error) {
|
|
row := s.db.QueryRowContext(ctx, selectFlashcard+` WHERE f.public_id = ? AND f.version = ?`, publicID, version)
|
|
|
|
var e entry
|
|
var reposJSON, tagsJSON sql.NullString
|
|
var host, user, context_, deleteTimestamp sql.NullString
|
|
|
|
err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query: %w", err)
|
|
}
|
|
|
|
if reposJSON.Valid {
|
|
e.Repos = unmarshalStringSlice(reposJSON.String)
|
|
}
|
|
if tagsJSON.Valid {
|
|
e.Tags = unmarshalStringSlice(tagsJSON.String)
|
|
}
|
|
if host.Valid {
|
|
e.Host = host.String
|
|
}
|
|
if user.Valid {
|
|
e.User = user.String
|
|
}
|
|
if context_.Valid {
|
|
e.Context = context_.String
|
|
}
|
|
if deleteTimestamp.Valid {
|
|
e.DeleteTimestamp = deleteTimestamp.String
|
|
}
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
func (s *sqliteStore) saveVote(ctx context.Context, flashcardID int64, value int, user, repo, message string) (*vote, error) {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO vote (flashcard_id, value, "user", repo, message)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(flashcard_id, "user", repo)
|
|
DO UPDATE SET value = excluded.value, message = excluded.message`,
|
|
flashcardID, value, user, repo, message,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert vote: %w", err)
|
|
}
|
|
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT _id, flashcard_id, value, "user", repo, message, created
|
|
FROM vote WHERE flashcard_id = ? AND "user" = ? AND repo = ?`,
|
|
flashcardID, user, repo,
|
|
)
|
|
|
|
var v vote
|
|
err = row.Scan(&v.ID, &v.FlashcardID, &v.Value, &v.User, &v.Repo, &v.Message, &v.Created)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read vote: %w", err)
|
|
}
|
|
|
|
return &v, nil
|
|
}
|
|
|
|
func (s *sqliteStore) tombstone(ctx context.Context, publicID int64, version int) (int64, error) {
|
|
result, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO flashcard (public_id, version, note, delete_timestamp)
|
|
VALUES (?, ?, '', datetime('now'))`,
|
|
publicID, version,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("tombstone: %w", err)
|
|
}
|
|
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("last insert id: %w", err)
|
|
}
|
|
|
|
return id, nil
|
|
}
|
|
|
|
func (s *sqliteStore) indexFTS(ctx context.Context, id int64, note string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO flashcard_fts(rowid, note) VALUES (?, ?)`,
|
|
id, note,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("index fts: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *sqliteStore) deleteFTS(ctx context.Context, id int64) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM flashcard_fts WHERE rowid = ?`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("delete fts: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *sqliteStore) searchFTS(ctx context.Context, query string, limit uint64) ([]searchResult, error) {
|
|
if limit == 0 {
|
|
limit = 5
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT rowid, rank FROM flashcard_fts WHERE note MATCH ? ORDER BY rank LIMIT ?`,
|
|
query, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fts query: %w", err)
|
|
}
|
|
|
|
type hit struct {
|
|
id int64
|
|
rank float64
|
|
}
|
|
var hits []hit
|
|
for rows.Next() {
|
|
var h hit
|
|
if err := rows.Scan(&h.id, &h.rank); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scan: %w", err)
|
|
}
|
|
hits = append(hits, h)
|
|
}
|
|
rows.Close()
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("rows: %w", err)
|
|
}
|
|
|
|
results := make([]searchResult, 0, len(hits))
|
|
for _, h := range hits {
|
|
entry, err := s.getByID(ctx, h.id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get entry: %w", err)
|
|
}
|
|
if entry == nil {
|
|
continue
|
|
}
|
|
results = append(results, searchResult{
|
|
Entry: *entry,
|
|
Score: float32(-h.rank),
|
|
})
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *sqliteStore) listRecent(ctx context.Context, tag, repo, host string, limit uint64) ([]flashcardSummary, error) {
|
|
if limit == 0 {
|
|
limit = 20
|
|
}
|
|
|
|
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
|
|
AND f.public_id != 0`
|
|
var args []any
|
|
|
|
if 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 ?`
|
|
args = append(args, `%"`+repo+`"%`)
|
|
}
|
|
if host != "" {
|
|
q += ` AND f.host = ?`
|
|
args = append(args, host)
|
|
}
|
|
|
|
q += ` ORDER BY f.created DESC LIMIT ?`
|
|
args = append(args, limit)
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list recent: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var summaries []flashcardSummary
|
|
for rows.Next() {
|
|
var s flashcardSummary
|
|
var tagsJSON, reposJSON *string
|
|
if err := rows.Scan(&s.PublicID, &s.Version, &s.Note, &s.Host, &s.User, &tagsJSON, &reposJSON, &s.Created); err != nil {
|
|
return nil, fmt.Errorf("scan: %w", err)
|
|
}
|
|
if tagsJSON != nil {
|
|
json.Unmarshal([]byte(*tagsJSON), &s.Tags)
|
|
}
|
|
if reposJSON != nil {
|
|
json.Unmarshal([]byte(*reposJSON), &s.Repos)
|
|
}
|
|
s.URL = fmt.Sprintf("/api/v1/flashcards/%d", s.PublicID)
|
|
summaries = append(summaries, s)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("rows: %w", err)
|
|
}
|
|
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()
|
|
}
|
|
|
|
func marshalStringSlice(ss []string) string {
|
|
if len(ss) == 0 {
|
|
return "[]"
|
|
}
|
|
b, _ := json.Marshal(ss)
|
|
return string(b)
|
|
}
|
|
|
|
func unmarshalStringSlice(raw string) []string {
|
|
if raw == "" || raw == "[]" || raw == "null" {
|
|
return nil
|
|
}
|
|
var ss []string
|
|
if err := json.Unmarshal([]byte(raw), &ss); err != nil {
|
|
return nil
|
|
}
|
|
return ss
|
|
}
|
|
|
|
func parseStringSlice(raw any) []string {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
arr, ok := raw.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(arr))
|
|
for _, v := range arr {
|
|
if s, ok := v.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseOptionalString(raw any) string {
|
|
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseInt64(raw any) int64 {
|
|
if raw == nil {
|
|
return 0
|
|
}
|
|
switch v := raw.(type) {
|
|
case float64:
|
|
return int64(v)
|
|
case int64:
|
|
return v
|
|
case int:
|
|
return int64(v)
|
|
}
|
|
return 0
|
|
}
|