diff --git a/api.go b/api.go new file mode 100644 index 0000000..726577f --- /dev/null +++ b/api.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "go.uber.org/zap" +) + +func registerAPI(mux *http.ServeMux, sqlite *sqliteStore, logger *zap.Logger) { + mux.HandleFunc("/api/v1/flashcards", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + ids, err := sqlite.listAllPublicIDs(r.Context()) + if err != nil { + logger.Error("list flashcards", zap.Error(err)) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + urls := make([]string, 0, len(ids)) + for _, id := range ids { + urls = append(urls, fmt.Sprintf("/api/v1/flashcards/%d", id)) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string][]string{"flashcards": urls}) + }) + + mux.HandleFunc("/api/v1/flashcards/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/flashcards/"), "/") + if len(segments) == 0 || segments[0] == "" { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + + publicID, err := strconv.ParseInt(segments[0], 10, 64) + if err != nil { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + + if len(segments) >= 2 && segments[1] != "" { + version, err := strconv.Atoi(segments[1]) + if err != nil { + http.Error(w, `{"error":"invalid version"}`, http.StatusBadRequest) + return + } + e, err := sqlite.getByPublicIDAndVersion(r.Context(), publicID, version) + writeEntry(w, e, err) + return + } + + e, err := sqlite.getLatestByPublicID(r.Context(), publicID) + writeEntry(w, e, err) + }) +} + +func writeEntry(w http.ResponseWriter, e *entry, err error) { + if err != nil { + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + if e == nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(e) +} diff --git a/main.go b/main.go index ad304ff..81d9c34 100644 --- a/main.go +++ b/main.go @@ -70,6 +70,7 @@ func main() { }) }) mux.Handle("/mcp", authMiddleware(token, streamableServer)) + registerAPI(mux, sqliteStore, logger) httpServer := &http.Server{ Addr: ":" + port, diff --git a/sqlite.go b/sqlite.go index b9eb7d0..2093965 100644 --- a/sqlite.go +++ b/sqlite.go @@ -166,6 +166,73 @@ func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) ( return &e, nil } +func (s *sqliteStore) listAllPublicIDs(ctx context.Context) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT f.public_id 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.public_id`, + ) + if err != nil { + return nil, fmt.Errorf("list ids: %w", err) + } + defer rows.Close() + + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows: %w", err) + } + return ids, nil +} + +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, + ) + + 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) 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)