marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit 8ac19254959e7d1081562ceb96cd6414279a8a42
parent f19195e1eb8b2fe527b57fafd4751d01661ac5c9
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 15:15:04 -0400

Add tests

Diffstat:
M.gitignore | 1+
MMakefile | 7++++++-
Mcmd/marky.go | 407+------------------------------------------------------------------------------
Ainternal/api.go | 407+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/archiver_test.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/database.go | 19+++++++++----------
Ainternal/database_test.go | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/e2e_test.go | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/migrations/0003_fts.sql | 7+++----
Ainternal/static_test.go | 23+++++++++++++++++++++++
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") + } +}