commit 8ac19254959e7d1081562ceb96cd6414279a8a42
parent f19195e1eb8b2fe527b57fafd4751d01661ac5c9
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 15:15:04 -0400
Add tests
Diffstat:
10 files changed, 818 insertions(+), 417 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1 +1,2 @@
bin
+coverage.out
diff --git a/Makefile b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build clean fmt
+.PHONY: build clean fmt test
BINARY := bin/marky
SRC := cmd/marky.go
@@ -12,3 +12,8 @@ clean:
fmt:
gofmt -s -w .
+
+test:
+ GOFLAGS=-count=1 go test -cover -coverprofile=coverage.out ./...
+ @echo ""
+ @go tool cover -func=coverage.out | tail -n 1
diff --git a/cmd/marky.go b/cmd/marky.go
@@ -2,53 +2,16 @@ package main
import (
"context"
- "encoding/json"
- "encoding/xml"
- "fmt"
"log"
"net/http"
- "net/url"
"os"
"os/signal"
- "strconv"
- "strings"
"syscall"
"time"
dbpkg "marky/internal"
)
-type createBookmarkRequest struct {
- URL string `json:"url"`
- Title string `json:"title"`
- Description *string `json:"description,omitempty"`
-}
-
-type bookmarkResponse struct {
- ID int64 `json:"id"`
- URL string `json:"url"`
- Title string `json:"title"`
- Description *string `json:"description,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- Excerpt *string `json:"excerpt,omitempty"`
-}
-
-func toResp(b *dbpkg.Bookmark) bookmarkResponse {
- var desc *string
- if b.Description.Valid {
- desc = &b.Description.String
- }
- return bookmarkResponse{
- ID: b.ID,
- URL: b.URL,
- Title: b.Title,
- Description: desc,
- CreatedAt: b.CreatedAt,
- UpdatedAt: b.UpdatedAt,
- }
-}
-
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -60,321 +23,15 @@ func main() {
}
defer db.Close()
- mux := http.NewServeMux()
- // Static web UI at "/"
- if h, err := dbpkg.StaticHandler(); err != nil {
- log.Fatalf("static handler: %v", err)
- } else {
- mux.Handle("/", h)
- }
- bookmarksHandler := func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodPost:
- if r.Header.Get("Content-Type") != "" {
- // Accept application/json and variations with charset
- ct := r.Header.Get("Content-Type")
- if ct != "application/json" && ct != "application/json; charset=utf-8" && ct != "application/json;charset=utf-8" {
- // Be lenient but enforce JSON type
- // If not JSON, return 415
- if ct != "" && len(ct) >= 16 && ct[:16] != "application/json" {
- http.Error(w, "unsupported media type", http.StatusUnsupportedMediaType)
- return
- }
- }
- }
- decoder := json.NewDecoder(r.Body)
- decoder.DisallowUnknownFields()
- var req createBookmarkRequest
- if err := decoder.Decode(&req); err != nil {
- http.Error(w, "invalid JSON payload", http.StatusBadRequest)
- return
- }
- if req.URL == "" {
- http.Error(w, "url is required", http.StatusBadRequest)
- return
- }
-
- // Always archive synchronously during creation (pure-Go archiver)
- if _, err := url.ParseRequestURI(req.URL); err != nil {
- http.Error(w, "invalid url", http.StatusBadRequest)
- return
- }
- ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
- defer cancel()
- fetchedTitle, txt, err := dbpkg.TitleAndText(ctx, req.URL)
- if err != nil {
- http.Error(w, "failed to archive URL", http.StatusBadGateway)
- return
- }
- title := req.Title
- if strings.TrimSpace(title) == "" {
- if strings.TrimSpace(fetchedTitle) != "" {
- title = fetchedTitle
- } else {
- title = req.URL
- }
- }
- // If URL exists, re-archive and touch updated_at; else create
- if existing, err := db.GetBookmarkByURL(r.Context(), req.URL); err != nil {
- log.Printf("lookup by url error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- } else if existing != nil {
- if err := db.SaveArchive(r.Context(), existing.ID, txt); err != nil {
- log.Printf("save archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if err := db.TouchBookmark(r.Context(), existing.ID); err != nil {
- log.Printf("touch bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- updated, err := db.GetBookmark(r.Context(), existing.ID)
- if err != nil || updated == nil {
- if err != nil {
- log.Printf("refetch bookmark error: %v", err)
- }
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- // 200 OK since we updated existing bookmark
- _ = json.NewEncoder(w).Encode(toResp(updated))
- return
- } else {
- created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, title, req.Description, txt)
- if err != nil {
- log.Printf("create bookmark with archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(w).Encode(toResp(created))
- }
-
- case http.MethodGet:
- // Optional pagination and search: limit, offset, q
- q := r.URL.Query()
- limit := 50
- offset := 0
- query := strings.TrimSpace(q.Get("q"))
- if s := q.Get("limit"); s != "" {
- if v, err := strconv.Atoi(s); err == nil && v > 0 {
- if v > 200 {
- v = 200
- }
- limit = v
- } else if s != "" {
- http.Error(w, "invalid limit", http.StatusBadRequest)
- return
- }
- }
- if s := q.Get("offset"); s != "" {
- if v, err := strconv.Atoi(s); err == nil && v >= 0 {
- offset = v
- } else {
- http.Error(w, "invalid offset", http.StatusBadRequest)
- return
- }
- }
-
- var resp []bookmarkResponse
- if query != "" {
- hits, err := db.SearchBookmarksWithExcerpt(r.Context(), query, limit, offset)
- if err != nil {
- log.Printf("list bookmarks error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- resp = make([]bookmarkResponse, 0, len(hits))
- for i := range hits {
- h := hits[i]
- br := toResp(&h.Bookmark)
- if h.Excerpt.Valid {
- ex := h.Excerpt.String
- br.Excerpt = &ex
- }
- resp = append(resp, br)
- }
- } else {
- items, err := db.ListBookmarks(r.Context(), limit, offset)
- if err != nil {
- log.Printf("list bookmarks error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- resp = make([]bookmarkResponse, 0, len(items))
- for i := range items {
- b := items[i]
- resp = append(resp, toResp(&b))
- }
- }
- // Choose format: JSON (default) or RSS
- wantsRSS := r.URL.Path == "/bookmarks.xml" || strings.EqualFold(r.URL.Query().Get("format"), "rss") || strings.Contains(r.Header.Get("Accept"), "application/rss+xml")
- if wantsRSS {
- w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
- if err := writeRSS(w, r, resp); err != nil {
- log.Printf("write rss error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- }
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(resp)
-
- default:
- w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
+ // Build the shared HTTP handler (API + UI)
+ h, err := dbpkg.NewHandler(db)
+ if err != nil {
+ log.Fatalf("build handler: %v", err)
}
- mux.HandleFunc("/bookmarks", bookmarksHandler)
- mux.HandleFunc("/bookmarks.xml", bookmarksHandler)
-
- // Archive endpoints: GET /bookmarks/{id}/archive
- mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
- // Basic parsing without a third-party router
- path := r.URL.Path
- rest := strings.TrimPrefix(path, "/bookmarks/")
- if rest == "" || strings.HasPrefix(rest, "/") {
- http.NotFound(w, r)
- return
- }
- if strings.HasSuffix(rest, "/archive") {
- // "/bookmarks/{id}/archive"
- idPart := strings.TrimSuffix(rest, "/archive")
- idPart = strings.TrimSuffix(idPart, "/")
- id64, err := strconv.ParseInt(idPart, 10, 64)
- if err != nil || id64 <= 0 {
- http.Error(w, "invalid id", http.StatusBadRequest)
- return
- }
- switch r.Method {
- case http.MethodGet:
- // If not present and fetch=1, attempt on-demand fetch and persist
- content, _, ok, err := db.GetArchive(r.Context(), id64)
- if err != nil {
- log.Printf("get archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !ok {
- if r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true" {
- b, err := db.GetBookmark(r.Context(), id64)
- if err != nil {
- log.Printf("lookup bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if b == nil {
- http.NotFound(w, r)
- return
- }
- ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
- defer cancel()
- txt, err := dbpkg.ArchiveToText(ctx, b.URL)
- if err != nil {
- http.Error(w, "failed to fetch archive", http.StatusBadGateway)
- return
- }
- if err := db.SaveArchive(ctx, id64, txt); err != nil {
- log.Printf("save archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- content = txt
- ok = true
- }
- }
- if !ok {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- _, _ = w.Write([]byte(content))
- default:
- w.Header().Set("Allow", http.MethodGet)
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- return
- }
-
- // "/bookmarks/{id}" for PUT/DELETE
- idPart := strings.TrimSuffix(rest, "/")
- if strings.Contains(idPart, "/") { // no extra segments allowed
- http.NotFound(w, r)
- return
- }
- id64, err := strconv.ParseInt(idPart, 10, 64)
- if err != nil || id64 <= 0 {
- http.Error(w, "invalid id", http.StatusBadRequest)
- return
- }
- switch r.Method {
- case http.MethodPut:
- decoder := json.NewDecoder(r.Body)
- decoder.DisallowUnknownFields()
- var req createBookmarkRequest
- if err := decoder.Decode(&req); err != nil {
- http.Error(w, "invalid JSON payload", http.StatusBadRequest)
- return
- }
- if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" {
- http.Error(w, "url and title are required", http.StatusBadRequest)
- return
- }
- // Preserve description if omitted
- desc := req.Description
- if desc == nil {
- existing, err := db.GetBookmark(r.Context(), id64)
- if err != nil {
- log.Printf("get bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if existing == nil {
- http.NotFound(w, r)
- return
- }
- if existing.Description.Valid {
- d := existing.Description.String
- desc = &d
- }
- }
- updated, err := db.UpdateBookmark(r.Context(), id64, req.URL, req.Title, desc)
- if err != nil {
- log.Printf("update bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if updated == nil {
- http.NotFound(w, r)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(toResp(updated))
- case http.MethodDelete:
- ok, err := db.DeleteBookmark(r.Context(), id64)
- if err != nil {
- log.Printf("delete bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !ok {
- http.NotFound(w, r)
- return
- }
- w.WriteHeader(http.StatusNoContent)
- default:
- w.Header().Set("Allow", http.MethodPut+", "+http.MethodDelete+", "+http.MethodGet)
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- })
srv := &http.Server{
Addr: ":8080",
- Handler: logging(mux),
+ Handler: logging(h),
ReadHeaderTimeout: 5 * time.Second,
}
@@ -400,57 +57,3 @@ func logging(next http.Handler) http.Handler {
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
-
-// RSS feed structures and writer
-type rssFeed struct {
- XMLName xml.Name `xml:"rss"`
- Version string `xml:"version,attr"`
- Channel rssChannel `xml:"channel"`
-}
-type rssChannel struct {
- Title string `xml:"title"`
- Link string `xml:"link"`
- Description string `xml:"description"`
- Items []rssItem `xml:"item"`
-}
-type rssItem struct {
- Title string `xml:"title"`
- Link string `xml:"link"`
- GUID string `xml:"guid"`
- Description string `xml:"description,omitempty"`
- PubDate string `xml:"pubDate"`
-}
-
-func writeRSS(w http.ResponseWriter, r *http.Request, items []bookmarkResponse) error {
- chanTitle := "marky bookmarks"
- // Derive a base link if possible
- chanLink := ""
- if r != nil && r.Host != "" {
- scheme := "http"
- if r.TLS != nil {
- scheme = "https"
- }
- chanLink = fmt.Sprintf("%s://%s/bookmarks", scheme, r.Host)
- }
- chanDesc := "Bookmarks feed"
- out := rssFeed{Version: "2.0", Channel: rssChannel{Title: chanTitle, Link: chanLink, Description: chanDesc}}
- out.Channel.Items = make([]rssItem, 0, len(items))
- for _, b := range items {
- desc := ""
- if b.Excerpt != nil && *b.Excerpt != "" {
- desc = *b.Excerpt
- } else if b.Description != nil {
- desc = *b.Description
- }
- out.Channel.Items = append(out.Channel.Items, rssItem{
- Title: b.Title,
- Link: b.URL,
- GUID: fmt.Sprintf("marky:%d", b.ID),
- Description: desc,
- PubDate: b.UpdatedAt.Format(time.RFC1123Z),
- })
- }
- enc := xml.NewEncoder(w)
- enc.Indent("", " ")
- return enc.Encode(out)
-}
diff --git a/internal/api.go b/internal/api.go
@@ -0,0 +1,407 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// NewHandler constructs the HTTP API handler, including the embedded web UI.
+func NewHandler(db *DB) (http.Handler, error) {
+ mux := http.NewServeMux()
+
+ // Static web UI at "/"
+ if h, err := StaticHandler(); err != nil {
+ return nil, err
+ } else {
+ mux.Handle("/", h)
+ }
+
+ // Shared handler for JSON and RSS
+ bookmarksHandler := func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodPost:
+ if r.Header.Get("Content-Type") != "" {
+ ct := r.Header.Get("Content-Type")
+ if ct != "application/json" && ct != "application/json; charset=utf-8" && ct != "application/json;charset=utf-8" {
+ if ct != "" && len(ct) >= 16 && ct[:16] != "application/json" {
+ http.Error(w, "unsupported media type", http.StatusUnsupportedMediaType)
+ return
+ }
+ }
+ }
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ var req createBookmarkRequest
+ if err := decoder.Decode(&req); err != nil {
+ http.Error(w, "invalid JSON payload", http.StatusBadRequest)
+ return
+ }
+ if req.URL == "" {
+ http.Error(w, "url is required", http.StatusBadRequest)
+ return
+ }
+
+ if _, err := url.ParseRequestURI(req.URL); err != nil {
+ http.Error(w, "invalid url", http.StatusBadRequest)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
+ defer cancel()
+ fetchedTitle, txt, err := TitleAndText(ctx, req.URL)
+ if err != nil {
+ http.Error(w, "failed to archive URL", http.StatusBadGateway)
+ return
+ }
+ title := req.Title
+ if strings.TrimSpace(title) == "" {
+ if strings.TrimSpace(fetchedTitle) != "" {
+ title = fetchedTitle
+ } else {
+ title = req.URL
+ }
+ }
+
+ if existing, err := db.GetBookmarkByURL(r.Context(), req.URL); err != nil {
+ log.Printf("lookup by url error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ } else if existing != nil {
+ if err := db.SaveArchive(r.Context(), existing.ID, txt); err != nil {
+ log.Printf("save archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if err := db.TouchBookmark(r.Context(), existing.ID); err != nil {
+ log.Printf("touch bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ updated, err := db.GetBookmark(r.Context(), existing.ID)
+ if err != nil || updated == nil {
+ if err != nil {
+ log.Printf("refetch bookmark error: %v", err)
+ }
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(toResp(updated))
+ return
+ } else {
+ created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, title, req.Description, txt)
+ if err != nil {
+ log.Printf("create bookmark with archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(toResp(created))
+ }
+
+ case http.MethodGet:
+ q := r.URL.Query()
+ limit := 50
+ offset := 0
+ query := strings.TrimSpace(q.Get("q"))
+ if s := q.Get("limit"); s != "" {
+ if v, err := strconv.Atoi(s); err == nil && v > 0 {
+ if v > 200 {
+ v = 200
+ }
+ limit = v
+ } else if s != "" {
+ http.Error(w, "invalid limit", http.StatusBadRequest)
+ return
+ }
+ }
+ if s := q.Get("offset"); s != "" {
+ if v, err := strconv.Atoi(s); err == nil && v >= 0 {
+ offset = v
+ } else {
+ http.Error(w, "invalid offset", http.StatusBadRequest)
+ return
+ }
+ }
+
+ var resp []bookmarkResponse
+ if query != "" {
+ hits, err := db.SearchBookmarksWithExcerpt(r.Context(), query, limit, offset)
+ if err != nil {
+ log.Printf("list bookmarks error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ resp = make([]bookmarkResponse, 0, len(hits))
+ for i := range hits {
+ h := hits[i]
+ br := toResp(&h.Bookmark)
+ if h.Excerpt.Valid {
+ ex := h.Excerpt.String
+ br.Excerpt = &ex
+ }
+ resp = append(resp, br)
+ }
+ } else {
+ items, err := db.ListBookmarks(r.Context(), limit, offset)
+ if err != nil {
+ log.Printf("list bookmarks error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ resp = make([]bookmarkResponse, 0, len(items))
+ for i := range items {
+ b := items[i]
+ resp = append(resp, toResp(&b))
+ }
+ }
+
+ wantsRSS := r.URL.Path == "/bookmarks.xml" || strings.EqualFold(r.URL.Query().Get("format"), "rss") || strings.Contains(r.Header.Get("Accept"), "application/rss+xml")
+ if wantsRSS {
+ w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+ if err := writeRSS(w, r, resp); err != nil {
+ log.Printf("write rss error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+
+ default:
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+ mux.HandleFunc("/bookmarks", bookmarksHandler)
+ mux.HandleFunc("/bookmarks.xml", bookmarksHandler)
+
+ // Archive, PUT, DELETE under /bookmarks/{id}
+ mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Path
+ rest := strings.TrimPrefix(path, "/bookmarks/")
+ if rest == "" || strings.HasPrefix(rest, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ if strings.HasSuffix(rest, "/archive") {
+ idPart := strings.TrimSuffix(rest, "/archive")
+ idPart = strings.TrimSuffix(idPart, "/")
+ id64, err := strconv.ParseInt(idPart, 10, 64)
+ if err != nil || id64 <= 0 {
+ http.Error(w, "invalid id", http.StatusBadRequest)
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ content, _, ok, err := db.GetArchive(r.Context(), id64)
+ if err != nil {
+ log.Printf("get archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ if r.URL.Query().Get("fetch") == "1" || r.URL.Query().Get("fetch") == "true" {
+ b, err := db.GetBookmark(r.Context(), id64)
+ if err != nil {
+ log.Printf("lookup bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if b == nil {
+ http.NotFound(w, r)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ txt, err := ArchiveToText(ctx, b.URL)
+ if err != nil {
+ http.Error(w, "failed to fetch archive", http.StatusBadGateway)
+ return
+ }
+ if err := db.SaveArchive(ctx, id64, txt); err != nil {
+ log.Printf("save archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ content = txt
+ ok = true
+ }
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(content))
+ default:
+ w.Header().Set("Allow", http.MethodGet)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ return
+ }
+
+ idPart := strings.TrimSuffix(rest, "/")
+ if strings.Contains(idPart, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ id64, err := strconv.ParseInt(idPart, 10, 64)
+ if err != nil || id64 <= 0 {
+ http.Error(w, "invalid id", http.StatusBadRequest)
+ return
+ }
+ switch r.Method {
+ case http.MethodPut:
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ var req createBookmarkRequest
+ if err := decoder.Decode(&req); err != nil {
+ http.Error(w, "invalid JSON payload", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" {
+ http.Error(w, "url and title are required", http.StatusBadRequest)
+ return
+ }
+ desc := req.Description
+ if desc == nil {
+ existing, err := db.GetBookmark(r.Context(), id64)
+ if err != nil {
+ log.Printf("get bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if existing == nil {
+ http.NotFound(w, r)
+ return
+ }
+ if existing.Description.Valid {
+ d := existing.Description.String
+ desc = &d
+ }
+ }
+ updated, err := db.UpdateBookmark(r.Context(), id64, req.URL, req.Title, desc)
+ if err != nil {
+ log.Printf("update bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if updated == nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(toResp(updated))
+ case http.MethodDelete:
+ ok, err := db.DeleteBookmark(r.Context(), id64)
+ if err != nil {
+ log.Printf("delete bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.Header().Set("Allow", http.MethodPut+", "+http.MethodDelete+", "+http.MethodGet)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
+
+ return mux, nil
+}
+
+type createBookmarkRequest struct {
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description *string `json:"description,omitempty"`
+}
+
+type bookmarkResponse struct {
+ ID int64 `json:"id"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description *string `json:"description,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Excerpt *string `json:"excerpt,omitempty"`
+}
+
+func toResp(b *Bookmark) bookmarkResponse {
+ var desc *string
+ if b.Description.Valid {
+ desc = &b.Description.String
+ }
+ return bookmarkResponse{
+ ID: b.ID,
+ URL: b.URL,
+ Title: b.Title,
+ Description: desc,
+ CreatedAt: b.CreatedAt,
+ UpdatedAt: b.UpdatedAt,
+ }
+}
+
+// RSS structures and writer
+type rssFeed struct {
+ XMLName xml.Name `xml:"rss"`
+ Version string `xml:"version,attr"`
+ Channel rssChannel `xml:"channel"`
+}
+type rssChannel struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+ Items []rssItem `xml:"item"`
+}
+type rssItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ GUID string `xml:"guid"`
+ Description string `xml:"description,omitempty"`
+ PubDate string `xml:"pubDate"`
+}
+
+func writeRSS(w http.ResponseWriter, r *http.Request, items []bookmarkResponse) error {
+ chanTitle := "marky bookmarks"
+ chanLink := ""
+ if r != nil && r.Host != "" {
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ chanLink = fmt.Sprintf("%s://%s/bookmarks", scheme, r.Host)
+ }
+ chanDesc := "Bookmarks feed"
+ out := rssFeed{Version: "2.0", Channel: rssChannel{Title: chanTitle, Link: chanLink, Description: chanDesc}}
+ out.Channel.Items = make([]rssItem, 0, len(items))
+ for _, b := range items {
+ desc := ""
+ if b.Excerpt != nil && *b.Excerpt != "" {
+ desc = *b.Excerpt
+ } else if b.Description != nil {
+ desc = *b.Description
+ }
+ out.Channel.Items = append(out.Channel.Items, rssItem{
+ Title: b.Title,
+ Link: b.URL,
+ GUID: fmt.Sprintf("marky:%d", b.ID),
+ Description: desc,
+ PubDate: b.UpdatedAt.Format(time.RFC1123Z),
+ })
+ }
+ enc := xml.NewEncoder(w)
+ enc.Indent("", " ")
+ return enc.Encode(out)
+}
diff --git a/internal/archiver_test.go b/internal/archiver_test.go
@@ -0,0 +1,53 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestTitleAndText(t *testing.T) {
+ t.Parallel()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte(`<!doctype html><html><head><title>Test Page</title></head><body><p>Hello <a href="https://example.org">there</a>!</p></body></html>`))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ title, txt, err := TitleAndText(context.Background(), srv.URL)
+ if err != nil {
+ t.Fatalf("TitleAndText: %v", err)
+ }
+ if title != "Test Page" {
+ t.Fatalf("expected title 'Test Page', got %q", title)
+ }
+ if want := "Hello"; txt == "" || !containsFold(txt, want) {
+ t.Fatalf("expected text to contain %q, got: %q", want, txt)
+ }
+}
+
+func containsFold(s, sub string) bool {
+ return len(s) >= len(sub) && (stringFold(s, sub) || containsFold(s[1:], sub))
+}
+
+func stringFold(s, sub string) bool {
+ if len(sub) > len(s) {
+ return false
+ }
+ for i := 0; i < len(sub); i++ {
+ a, b := s[i], sub[i]
+ if 'A' <= a && a <= 'Z' {
+ a = a + 32
+ }
+ if 'A' <= b && b <= 'Z' {
+ b = b + 32
+ }
+ if a != b {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/database.go b/internal/database.go
@@ -626,9 +626,10 @@ func makeExcerpt(content, query string) string {
// ftsReplace replaces a row in the contentless FTS5 table using DELETE + INSERT,
// because UPSERT/REPLACE is not implemented for virtual tables.
func ftsReplace(ctx context.Context, ex execer, rowid int64, title, url string, description any, content string) error {
- // For contentless FTS5, use special 'delete' command to remove existing row
- if err := ftsDelete(ctx, ex, rowid); err != nil {
- return err
+ // Try regular DELETE first (works for contentful FTS); if it fails, fall back to special delete.
+ if _, err := ex.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, rowid); err != nil {
+ // Fallback to special delete for contentless/external-content tables
+ _, _ = ex.ExecContext(ctx, `INSERT INTO bookmarks_fts(bookmarks_fts, rowid) VALUES('delete', ?)`, rowid)
}
_, err := ex.ExecContext(ctx, `
INSERT INTO bookmarks_fts(rowid, title, url, description, content)
@@ -637,13 +638,11 @@ func ftsReplace(ctx context.Context, ex execer, rowid int64, title, url string,
return err
}
-// ftsDelete issues the FTS5 special 'delete' command which is required for
-// contentless tables instead of a regular DELETE statement.
func ftsDelete(ctx context.Context, ex execer, rowid int64) error {
- // This no-ops if the row doesn't exist.
- // Syntax: INSERT INTO <tbl>(<tbl>, rowid) VALUES('delete', ?)
- _, err := ex.ExecContext(ctx, `
- INSERT INTO bookmarks_fts(bookmarks_fts, rowid) VALUES('delete', ?)
- `, rowid)
+ if _, err := ex.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, rowid); err == nil {
+ return nil
+ }
+ // Fallback to special delete form
+ _, err := ex.ExecContext(ctx, `INSERT INTO bookmarks_fts(bookmarks_fts, rowid) VALUES('delete', ?)`, rowid)
return err
}
diff --git a/internal/database_test.go b/internal/database_test.go
@@ -0,0 +1,118 @@
+package internal
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestMigrationsCRUDAndSearch(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ dir := t.TempDir()
+ path := dir + "/test.db"
+
+ db, err := OpenAtPath(ctx, path)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer db.Close()
+
+ // Create with archive content
+ b, err := db.CreateBookmarkWithArchive(ctx, "http://example.local/1", "Example 1", nil, "The city of Durham has great food.")
+ if err != nil {
+ t.Fatalf("create with archive: %v", err)
+ }
+ if b.ID == 0 {
+ t.Fatalf("expected id > 0")
+ }
+
+ // Archive present
+ content, _, ok, err := db.GetArchive(ctx, b.ID)
+ if err != nil || !ok {
+ t.Fatalf("get archive: ok=%v err=%v", ok, err)
+ }
+ if !strings.Contains(strings.ToLower(content), "durham") {
+ t.Fatalf("expected archive to contain durham, got: %q", content)
+ }
+
+ // Search with excerpt
+ hits, err := db.SearchBookmarksWithExcerpt(ctx, "durham", 10, 0)
+ if err != nil {
+ t.Fatalf("search: %v", err)
+ }
+ if len(hits) != 1 {
+ t.Fatalf("expected 1 hit, got %d", len(hits))
+ }
+ if !hits[0].Excerpt.Valid || !strings.Contains(strings.ToLower(hits[0].Excerpt.String), "«durham»") {
+ t.Fatalf("expected excerpt with highlighted token, got: %q", hits[0].Excerpt.String)
+ }
+
+ // Delete and ensure gone from search
+ okDel, err := db.DeleteBookmark(ctx, b.ID)
+ if err != nil || !okDel {
+ t.Fatalf("delete: ok=%v err=%v", okDel, err)
+ }
+ hits, err = db.SearchBookmarksWithExcerpt(ctx, "durham", 10, 0)
+ if err != nil {
+ t.Fatalf("search after delete: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Fatalf("expected 0 hits after delete, got %d", len(hits))
+ }
+}
+
+func TestUniqueURL(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ dir := t.TempDir()
+ path := dir + "/uniq.db"
+ db, err := OpenAtPath(ctx, path)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer db.Close()
+
+ if _, err := db.CreateBookmark(ctx, "http://dupe.local", "One", nil); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ if _, err := db.CreateBookmark(ctx, "http://dupe.local", "Two", nil); err == nil {
+ t.Fatalf("expected unique constraint error, got nil")
+ }
+}
+
+func TestFTSIndexUpdatesOnArchive(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ dir := t.TempDir()
+ path := dir + "/fts.db"
+ db, err := OpenAtPath(ctx, path)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer db.Close()
+
+ b, err := db.CreateBookmark(ctx, "http://no-archive.local", "No Archive", nil)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ // Should not find term before archive content exists
+ hits, err := db.SearchBookmarksWithExcerpt(ctx, "needle", 5, 0)
+ if err != nil {
+ t.Fatalf("search pre-archive: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Fatalf("expected 0 hits before archiving, got %d", len(hits))
+ }
+ // Save archive and ensure search works
+ if err := db.SaveArchive(ctx, b.ID, "finding a needle in a haystack"); err != nil {
+ t.Fatalf("save archive: %v", err)
+ }
+ hits, err = db.SearchBookmarksWithExcerpt(ctx, "needle", 5, 0)
+ if err != nil {
+ t.Fatalf("search post-archive: %v", err)
+ }
+ if len(hits) != 1 {
+ t.Fatalf("expected 1 hit after archiving, got %d", len(hits))
+ }
+}
diff --git a/internal/e2e_test.go b/internal/e2e_test.go
@@ -0,0 +1,193 @@
+package internal
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
+ t.Parallel()
+
+ // Content server that our archiver will fetch
+ content := `<!doctype html><html><head><title>E2E Title</title></head><body><p>Hello Durham token!</p></body></html>`
+ page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte(content))
+ }))
+ defer page.Close()
+
+ // DB + API server
+ dir := t.TempDir()
+ db, err := OpenAtPath(t.Context(), dir+"/e2e.db")
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ h, err := NewHandler(db)
+ if err != nil {
+ t.Fatalf("new handler: %v", err)
+ }
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // 1) Create bookmark without title -> title inferred; archive stored
+ body := map[string]any{"url": page.URL}
+ b := postJSON[map[string]any](t, srv.URL+"/bookmarks", body, 201)
+ idFloat, _ := b["id"].(float64)
+ if idFloat <= 0 {
+ t.Fatalf("expected id > 0, got %v", idFloat)
+ }
+ if title, _ := b["title"].(string); title != "E2E Title" {
+ t.Fatalf("expected inferred title 'E2E Title', got %q", title)
+ }
+
+ id := int(idFloat)
+
+ // 2) RSS feed
+ resp := get(t, srv.URL+"/bookmarks.xml", 200)
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/rss+xml") {
+ t.Fatalf("expected rss content-type, got %q", ct)
+ }
+ rssBytes, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if !bytes.Contains(rssBytes, []byte("<rss")) {
+ t.Fatalf("expected <rss> in feed")
+ }
+
+ // 3) Search JSON with excerpt
+ list := getJSON[[]map[string]any](t, srv.URL+"/bookmarks?q=durham", 200)
+ if len(list) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(list))
+ }
+ if ex, _ := list[0]["excerpt"].(string); !strings.Contains(strings.ToLower(ex), "«durham»") {
+ t.Fatalf("expected excerpt highlighting token, got %q", ex)
+ }
+
+ // 4) GET archive
+ arch := get(t, srv.URL+"/bookmarks/"+itoa(id)+"/archive", 200)
+ archBody, _ := io.ReadAll(arch.Body)
+ _ = arch.Body.Close()
+ if !bytes.Contains(bytes.ToLower(archBody), []byte("hello")) {
+ t.Fatalf("expected archived text, got: %q", string(archBody))
+ }
+
+ // 5) Update title via PUT
+ upd := map[string]any{"url": page.URL, "title": "New Title"}
+ b2 := putJSON[map[string]any](t, srv.URL+"/bookmarks/"+itoa(id), upd, 200)
+ if b2["title"].(string) != "New Title" {
+ t.Fatalf("expected updated title, got %q", b2["title"])
+ }
+
+ // 6) Delete
+ del(t, srv.URL+"/bookmarks/"+itoa(id), 204)
+ list2 := getJSON[[]map[string]any](t, srv.URL+"/bookmarks", 200)
+ if len(list2) != 0 {
+ t.Fatalf("expected 0 bookmarks after delete, got %d", len(list2))
+ }
+}
+
+// Helpers
+func itoa(i int) string { return strconvItoa(i) }
+
+func postJSON[T any](t *testing.T, url string, v any, want int) T {
+ t.Helper()
+ b, _ := json.Marshal(v)
+ resp, err := http.Post(url, "application/json", bytes.NewReader(b))
+ if err != nil {
+ t.Fatalf("post json: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != want {
+ t.Fatalf("POST %s: got %d want %d", url, resp.StatusCode, want)
+ }
+ var out T
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return out
+}
+
+func putJSON[T any](t *testing.T, url string, v any, want int) T {
+ t.Helper()
+ b, _ := json.Marshal(v)
+ req, _ := http.NewRequest(http.MethodPut, url, bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("put json: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != want {
+ t.Fatalf("PUT %s: got %d want %d", url, resp.StatusCode, want)
+ }
+ var out T
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return out
+}
+
+func getJSON[T any](t *testing.T, url string, want int) T {
+ t.Helper()
+ resp := get(t, url, want)
+ defer resp.Body.Close()
+ var out T
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return out
+}
+
+func get(t *testing.T, url string, want int) *http.Response {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if resp.StatusCode != want {
+ t.Fatalf("GET %s: got %d want %d", url, resp.StatusCode, want)
+ }
+ return resp
+}
+
+func del(t *testing.T, url string, want int) {
+ t.Helper()
+ req, _ := http.NewRequest(http.MethodDelete, url, nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != want {
+ t.Fatalf("DELETE %s: got %d want %d", url, resp.StatusCode, want)
+ }
+}
+
+// strconv.Itoa substitute without extra imports
+func strconvItoa(i int) string {
+ if i == 0 {
+ return "0"
+ }
+ neg := i < 0
+ if neg {
+ i = -i
+ }
+ var buf [20]byte
+ bp := len(buf)
+ for i > 0 {
+ bp--
+ buf[bp] = byte('0' + i%10)
+ i /= 10
+ }
+ if neg {
+ bp--
+ buf[bp] = '-'
+ }
+ return string(buf[bp:])
+}
diff --git a/internal/migrations/0003_fts.sql b/internal/migrations/0003_fts.sql
@@ -1,10 +1,9 @@
-- FTS5 virtual table for full-text search across bookmark metadata and archived content.
--- We use a contentless FTS5 table and manage rows explicitly from application code.
+-- Standalone (contentful) FTS table managed explicitly by the application.
+DROP TABLE IF EXISTS bookmarks_fts;
CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
title,
url,
description,
- content,
- content=''
+ content
);
-
diff --git a/internal/static_test.go b/internal/static_test.go
@@ -0,0 +1,23 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestStaticHandlerServesIndex(t *testing.T) {
+ h, err := StaticHandler()
+ if err != nil {
+ t.Fatalf("StaticHandler: %v", err)
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ h.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+ if body := rr.Body.String(); body == "" || len(body) < 20 {
+ t.Fatalf("expected non-empty index body")
+ }
+}