package api import ( "encoding/json" "log" "net/http" "strings" "time" "bookmarkmanager/backend/internal/store" ) // Handler serves the userscript-facing JSON bookmark API. type Handler struct { Store *store.Store } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if v != nil { if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("encode response: %v", err) } } } // List returns all bookmarks. GET /bookmarks func (h *Handler) List(w http.ResponseWriter, r *http.Request) { items, err := h.Store.List() if err != nil { log.Printf("list: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, items) } // Put upserts one bookmark. PUT /bookmarks/{key} func (h *Handler) Put(w http.ResponseWriter, r *http.Request) { key := r.PathValue("key") if key == "" { http.Error(w, "missing key", http.StatusBadRequest) return } var b store.Bookmark if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } // Path key is authoritative; derive site/series_id from it when the body // omits them so the stored row is always self-consistent. b.Key = key if b.Site == "" || b.SeriesID == "" { if site, series, ok := strings.Cut(key, ":"); ok { if b.Site == "" { b.Site = site } if b.SeriesID == "" { b.SeriesID = series } } } // An empty status is "no opinion" and Upsert keeps the stored bucket. // Finishing a series is a web-UI decision, so the JSON API refuses it // rather than trusting every client to leave it alone. switch b.Status { case "", store.StatusReading, store.StatusArchived: case store.StatusFinished: http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI", http.StatusBadRequest) return default: http.Error(w, "invalid status", http.StatusBadRequest) return } // Same rule as status: empty means "keep the stored value". An unknown // value is a client bug, not something to silently coerce to manga. switch b.Kind { case "", store.KindManga, store.KindNovel: default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid kind"}) return } // Candidate timestamp, not a decision: Upsert keeps the stored one unless // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() stored, err := h.Store.Upsert(b) if err != nil { log.Printf("upsert: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } // Echo the stored row: clients adopt this as their cached copy, so it must // carry the authoritative updated_at rather than the candidate above. writeJSON(w, http.StatusOK, stored) } // Delete removes one bookmark. DELETE /bookmarks/{key} func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { key := r.PathValue("key") if key == "" { http.Error(w, "missing key", http.StatusBadRequest) return } if err := h.Store.Delete(key); err != nil { log.Printf("delete: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // Healthz answers the unauthenticated liveness check. GET /healthz func Healthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }