commit c67b180bc5d8eaddc92be658dfcb21ba71cf7e12
parent fc28f4062ad1e4a6c60a911a6d0bcf5193955cb6
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 14:41:52 -0400
go fmt
Diffstat:
5 files changed, 1043 insertions(+), 1042 deletions(-)
diff --git a/Makefile b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build clean
+.PHONY: build clean fmt
BINARY := bin/marky
SRC := cmd/marky.go
@@ -10,3 +10,5 @@ build:
clean:
rm -f $(BINARY)
+fmt:
+ gofmt -s -w .
diff --git a/cmd/marky.go b/cmd/marky.go
@@ -1,388 +1,388 @@
package main
import (
- "context"
- "encoding/json"
- "log"
- "net/http"
- "net/url"
- "os"
- "os/signal"
- "strconv"
- "strings"
- "syscall"
- "time"
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
- dbpkg "marky/internal"
+ dbpkg "marky/internal"
)
type createBookmarkRequest struct {
- URL string `json:"url"`
- Title string `json:"title"`
- Description *string `json:"description,omitempty"`
+ 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"`
+ 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,
- }
+ 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()
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
- // Open database (creates and migrates if needed)
- db, err := dbpkg.Open(ctx)
- if err != nil {
- log.Fatalf("open db: %v", err)
- }
- defer db.Close()
+ // Open database (creates and migrates if needed)
+ db, err := dbpkg.Open(ctx)
+ if err != nil {
+ log.Fatalf("open db: %v", err)
+ }
+ 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)
- }
- mux.HandleFunc("/bookmarks", 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
- }
+ mux := http.NewServeMux()
+ // Static web UI at "/"
+ if h, err := dbpkg.StaticHandler(); err != nil {
+ log.Fatalf("static handler: %v", err)
+ } else {
+ mux.Handle("/", h)
+ }
+ mux.HandleFunc("/bookmarks", 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))
- }
+ // 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
- }
- }
+ 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))
- }
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(resp)
+ 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))
+ }
+ }
+ 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)
- }
- })
+ default:
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
- // 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
- }
+ // 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)
- }
- })
+ // "/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),
- ReadHeaderTimeout: 5 * time.Second,
- }
+ srv := &http.Server{
+ Addr: ":8080",
+ Handler: logging(mux),
+ ReadHeaderTimeout: 5 * time.Second,
+ }
- go func() {
- log.Printf("marky API listening on %s", srv.Addr)
- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatalf("server error: %v", err)
- }
- }()
+ go func() {
+ log.Printf("marky API listening on %s", srv.Addr)
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("server error: %v", err)
+ }
+ }()
- <-ctx.Done()
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if err := srv.Shutdown(shutdownCtx); err != nil {
- log.Printf("server shutdown error: %v", err)
- }
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Printf("server shutdown error: %v", err)
+ }
}
func logging(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
- next.ServeHTTP(w, r)
- log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
- })
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ next.ServeHTTP(w, r)
+ log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
+ })
}
diff --git a/internal/archiver.go b/internal/archiver.go
@@ -1,63 +1,63 @@
package internal
import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
- neturl "net/url"
- "strings"
- "time"
-
- "golang.org/x/net/html"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ neturl "net/url"
+ "strings"
+ "time"
+
+ "golang.org/x/net/html"
)
// ArchiveToText fetches the given URL and returns a plaintext rendering.
// It attempts to extract the main content via Readability and then converts
// the resulting HTML to text; falls back to full document if extraction fails.
func ArchiveToText(ctx context.Context, rawURL string) (string, error) {
- _, txt, err := TitleAndText(ctx, rawURL)
- return txt, err
+ _, txt, err := TitleAndText(ctx, rawURL)
+ return txt, err
}
// TitleAndText fetches the URL and returns the page <title> (if any) and a
// plaintext rendering of the document.
func TitleAndText(ctx context.Context, rawURL string) (string, string, error) {
- if _, err := neturl.ParseRequestURI(rawURL); err != nil {
- return "", "", fmt.Errorf("invalid url: %w", err)
- }
- body, err := fetchHTML(ctx, rawURL)
- if err != nil {
- return "", "", err
- }
- htmlStr := string(body)
- title := extractTitle(htmlStr)
- txt := strings.TrimSpace(htmlToText(htmlStr))
- return title, txt, nil
+ if _, err := neturl.ParseRequestURI(rawURL); err != nil {
+ return "", "", fmt.Errorf("invalid url: %w", err)
+ }
+ body, err := fetchHTML(ctx, rawURL)
+ if err != nil {
+ return "", "", err
+ }
+ htmlStr := string(body)
+ title := extractTitle(htmlStr)
+ txt := strings.TrimSpace(htmlToText(htmlStr))
+ return title, txt, nil
}
func fetchHTML(ctx context.Context, rawURL string) ([]byte, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
- if err != nil {
- return nil, fmt.Errorf("build request: %w", err)
- }
- req.Header.Set("User-Agent", "marky-archiver/1.0 (+https://example.invalid)")
-
- client := &http.Client{Timeout: 30 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("fetch: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("unexpected status: %s", resp.Status)
- }
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("read body: %w", err)
- }
- return body, nil
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("User-Agent", "marky-archiver/1.0 (+https://example.invalid)")
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetch: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("unexpected status: %s", resp.Status)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ return body, nil
}
// htmlToText converts HTML to a readable plaintext representation.
@@ -65,175 +65,175 @@ func fetchHTML(ctx context.Context, rawURL string) ([]byte, error) {
type liState struct{ hasContent bool }
func htmlToText(s string) string {
- doc, err := html.Parse(strings.NewReader(s))
- if err != nil {
- return strings.TrimSpace(stripTagsFallback(s))
- }
- var b bytes.Buffer
- var walk func(n *html.Node, linkHref string, li *liState)
- walk = func(n *html.Node, linkHref string, li *liState) {
- if n.Type == html.ElementNode {
- tag := strings.ToLower(n.Data)
- switch tag {
- case "script", "style":
- return
- case "br":
- ensureNL(&b, 1)
- case "p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote":
- // Ensure blank line before block paragraphs/headers
- ensureNL(&b, 2)
- case "ul", "ol":
- ensureNL(&b, 1)
- case "li":
- ensureNL(&b, 1)
- }
- if tag == "a" {
- // capture href
- if href := getAttr(n, "href"); href != "" {
- linkHref = href
- }
- }
- }
-
- if n.Type == html.TextNode {
- // Only emit bullet when the first non-empty text appears within this <li>
- trimmed := strings.TrimSpace(n.Data)
- if li != nil && !li.hasContent && trimmed != "" {
- ensureNL(&b, 1)
- b.WriteString("- ")
- li.hasContent = true
- }
- writeCollapsed(&b, n.Data)
- }
-
- // children
- startLen := b.Len()
- // If we are at an <li>, propagate a fresh liState to children
- var childLI *liState
- if n.Type == html.ElementNode && strings.EqualFold(n.Data, "li") {
- childLI = &liState{}
- } else {
- childLI = li
- }
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- walk(c, linkHref, childLI)
- }
-
- if n.Type == html.ElementNode {
- tag := strings.ToLower(n.Data)
- // If this was an anchor with text and href, append inline reference
- if tag == "a" && linkHref != "" && b.Len() > startLen {
- b.WriteString(" [")
- b.WriteString(linkHref)
- b.WriteString("]")
- }
- switch tag {
- case "p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote":
- ensureNL(&b, 2) // blank line after paragraphs/headers
- case "li":
- ensureNL(&b, 1)
- case "tr":
- ensureNL(&b, 1)
- }
- }
- }
- walk(doc, "", nil)
- return b.String()
+ doc, err := html.Parse(strings.NewReader(s))
+ if err != nil {
+ return strings.TrimSpace(stripTagsFallback(s))
+ }
+ var b bytes.Buffer
+ var walk func(n *html.Node, linkHref string, li *liState)
+ walk = func(n *html.Node, linkHref string, li *liState) {
+ if n.Type == html.ElementNode {
+ tag := strings.ToLower(n.Data)
+ switch tag {
+ case "script", "style":
+ return
+ case "br":
+ ensureNL(&b, 1)
+ case "p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote":
+ // Ensure blank line before block paragraphs/headers
+ ensureNL(&b, 2)
+ case "ul", "ol":
+ ensureNL(&b, 1)
+ case "li":
+ ensureNL(&b, 1)
+ }
+ if tag == "a" {
+ // capture href
+ if href := getAttr(n, "href"); href != "" {
+ linkHref = href
+ }
+ }
+ }
+
+ if n.Type == html.TextNode {
+ // Only emit bullet when the first non-empty text appears within this <li>
+ trimmed := strings.TrimSpace(n.Data)
+ if li != nil && !li.hasContent && trimmed != "" {
+ ensureNL(&b, 1)
+ b.WriteString("- ")
+ li.hasContent = true
+ }
+ writeCollapsed(&b, n.Data)
+ }
+
+ // children
+ startLen := b.Len()
+ // If we are at an <li>, propagate a fresh liState to children
+ var childLI *liState
+ if n.Type == html.ElementNode && strings.EqualFold(n.Data, "li") {
+ childLI = &liState{}
+ } else {
+ childLI = li
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c, linkHref, childLI)
+ }
+
+ if n.Type == html.ElementNode {
+ tag := strings.ToLower(n.Data)
+ // If this was an anchor with text and href, append inline reference
+ if tag == "a" && linkHref != "" && b.Len() > startLen {
+ b.WriteString(" [")
+ b.WriteString(linkHref)
+ b.WriteString("]")
+ }
+ switch tag {
+ case "p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote":
+ ensureNL(&b, 2) // blank line after paragraphs/headers
+ case "li":
+ ensureNL(&b, 1)
+ case "tr":
+ ensureNL(&b, 1)
+ }
+ }
+ }
+ walk(doc, "", nil)
+ return b.String()
}
// extractTitle parses the HTML and returns the document <title> text if found.
func extractTitle(s string) string {
- doc, err := html.Parse(strings.NewReader(s))
- if err != nil {
- return ""
- }
- var title string
- var walk func(*html.Node)
- walk = func(n *html.Node) {
- if n.Type == html.ElementNode && strings.EqualFold(n.Data, "title") {
- // Collect text nodes under <title>
- var b strings.Builder
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- if c.Type == html.TextNode {
- b.WriteString(c.Data)
- }
- }
- title = strings.TrimSpace(b.String())
- return
- }
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- if title != "" {
- break
- }
- walk(c)
- }
- }
- walk(doc)
- return title
+ doc, err := html.Parse(strings.NewReader(s))
+ if err != nil {
+ return ""
+ }
+ var title string
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && strings.EqualFold(n.Data, "title") {
+ // Collect text nodes under <title>
+ var b strings.Builder
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type == html.TextNode {
+ b.WriteString(c.Data)
+ }
+ }
+ title = strings.TrimSpace(b.String())
+ return
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if title != "" {
+ break
+ }
+ walk(c)
+ }
+ }
+ walk(doc)
+ return title
}
func getAttr(n *html.Node, key string) string {
- for _, a := range n.Attr {
- if strings.EqualFold(a.Key, key) {
- return a.Val
- }
- }
- return ""
+ for _, a := range n.Attr {
+ if strings.EqualFold(a.Key, key) {
+ return a.Val
+ }
+ }
+ return ""
}
func ensureNL(b *bytes.Buffer, n int) {
- // Ensure exactly at least n trailing newlines
- count := 0
- bs := b.Bytes()
- for i := len(bs) - 1; i >= 0 && bs[i] == '\n'; i-- {
- count++
- }
- for count < n {
- b.WriteByte('\n')
- count++
- }
+ // Ensure exactly at least n trailing newlines
+ count := 0
+ bs := b.Bytes()
+ for i := len(bs) - 1; i >= 0 && bs[i] == '\n'; i-- {
+ count++
+ }
+ for count < n {
+ b.WriteByte('\n')
+ count++
+ }
}
func writeCollapsed(b *bytes.Buffer, s string) {
- // collapse all whitespace runs to single spaces; trim leading spaces at line start
- space := false
- for _, r := range s {
- if r == '\n' || r == '\r' || r == '\t' || r == ' ' || r == '\f' {
- space = true
- continue
- }
- if space {
- if last := lastByte(b); last != 0 && last != '\n' && last != ' ' {
- b.WriteByte(' ')
- }
- space = false
- }
- b.WriteRune(r)
- }
+ // collapse all whitespace runs to single spaces; trim leading spaces at line start
+ space := false
+ for _, r := range s {
+ if r == '\n' || r == '\r' || r == '\t' || r == ' ' || r == '\f' {
+ space = true
+ continue
+ }
+ if space {
+ if last := lastByte(b); last != 0 && last != '\n' && last != ' ' {
+ b.WriteByte(' ')
+ }
+ space = false
+ }
+ b.WriteRune(r)
+ }
}
func lastByte(b *bytes.Buffer) byte {
- if b.Len() == 0 {
- return 0
- }
- return b.Bytes()[b.Len()-1]
+ if b.Len() == 0 {
+ return 0
+ }
+ return b.Bytes()[b.Len()-1]
}
func stripTagsFallback(s string) string {
- // naive fallback: remove angle-bracketed tags
- out := make([]rune, 0, len(s))
- inTag := false
- for _, r := range s {
- switch r {
- case '<':
- inTag = true
- case '>':
- inTag = false
- default:
- if !inTag {
- out = append(out, r)
- }
- }
- }
- return string(out)
+ // naive fallback: remove angle-bracketed tags
+ out := make([]rune, 0, len(s))
+ inTag := false
+ for _, r := range s {
+ switch r {
+ case '<':
+ inTag = true
+ case '>':
+ inTag = false
+ default:
+ if !inTag {
+ out = append(out, r)
+ }
+ }
+ }
+ return string(out)
}
diff --git a/internal/database.go b/internal/database.go
@@ -1,19 +1,19 @@
package internal
import (
- "context"
- "database/sql"
- "embed"
- "errors"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- _ "modernc.org/sqlite"
+ "context"
+ "database/sql"
+ "embed"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
@@ -21,84 +21,84 @@ var migrationsFS embed.FS
// Bookmark represents a single bookmark row.
type Bookmark struct {
- ID int64
- URL string
- Title string
- Description sql.NullString
- CreatedAt time.Time
- UpdatedAt time.Time
+ ID int64
+ URL string
+ Title string
+ Description sql.NullString
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// SearchResult is a bookmark match with an optional excerpt snippet.
type SearchResult struct {
- Bookmark Bookmark
- Excerpt sql.NullString
+ Bookmark Bookmark
+ Excerpt sql.NullString
}
// DB wraps *sql.DB with helpers.
type DB struct {
- sql *sql.DB
- dsn string
+ sql *sql.DB
+ dsn string
}
// execer abstracts *sql.DB and *sql.Tx for executing SQL with context.
type execer interface {
- ExecContext(context.Context, string, ...any) (sql.Result, error)
+ ExecContext(context.Context, string, ...any) (sql.Result, error)
}
// DefaultPath returns the default SQLite file path: ~/.marky/bookmarks.sql
func DefaultPath() (string, error) {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("resolve home dir: %w", err)
- }
- dir := filepath.Join(home, ".marky")
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return "", fmt.Errorf("mkdir %s: %w", dir, err)
- }
- return filepath.Join(dir, "bookmarks.sql"), nil
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".marky")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", fmt.Errorf("mkdir %s: %w", dir, err)
+ }
+ return filepath.Join(dir, "bookmarks.sql"), nil
}
// Open opens (and initializes) the database at the default path.
func Open(ctx context.Context) (*DB, error) {
- p, err := DefaultPath()
- if err != nil {
- return nil, err
- }
- return OpenAtPath(ctx, p)
+ p, err := DefaultPath()
+ if err != nil {
+ return nil, err
+ }
+ return OpenAtPath(ctx, p)
}
// OpenAtPath opens (and initializes) the database at a specific path.
func OpenAtPath(ctx context.Context, path string) (*DB, error) {
- // Use a file: DSN and apply sensible pragmas after open.
- dsn := fmt.Sprintf("file:%s", filepath.ToSlash(path))
-
- sqldb, err := sql.Open("sqlite", dsn)
- if err != nil {
- return nil, fmt.Errorf("open sqlite: %w", err)
- }
-
- // Connection settings.
- // WAL improves concurrency; busy_timeout makes writes friendlier.
- pragmas := []string{
- "PRAGMA journal_mode=WAL",
- "PRAGMA busy_timeout=5000",
- "PRAGMA foreign_keys=ON",
- }
- for _, p := range pragmas {
- if _, execErr := sqldb.ExecContext(ctx, p); execErr != nil {
- _ = sqldb.Close()
- return nil, fmt.Errorf("apply pragma %q: %w", p, execErr)
- }
- }
-
- // Migrate schema.
- if err := applyMigrations(ctx, sqldb); err != nil {
- _ = sqldb.Close()
- return nil, err
- }
-
- return &DB{sql: sqldb, dsn: dsn}, nil
+ // Use a file: DSN and apply sensible pragmas after open.
+ dsn := fmt.Sprintf("file:%s", filepath.ToSlash(path))
+
+ sqldb, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("open sqlite: %w", err)
+ }
+
+ // Connection settings.
+ // WAL improves concurrency; busy_timeout makes writes friendlier.
+ pragmas := []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA foreign_keys=ON",
+ }
+ for _, p := range pragmas {
+ if _, execErr := sqldb.ExecContext(ctx, p); execErr != nil {
+ _ = sqldb.Close()
+ return nil, fmt.Errorf("apply pragma %q: %w", p, execErr)
+ }
+ }
+
+ // Migrate schema.
+ if err := applyMigrations(ctx, sqldb); err != nil {
+ _ = sqldb.Close()
+ return nil, err
+ }
+
+ return &DB{sql: sqldb, dsn: dsn}, nil
}
// Close closes the database.
@@ -106,367 +106,367 @@ func (db *DB) Close() error { return db.sql.Close() }
// applyMigrations executes embedded SQL migration files in order.
func applyMigrations(ctx context.Context, sqldb *sql.DB) error {
- if _, err := sqldb.ExecContext(ctx, `
+ if _, err := sqldb.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`); err != nil {
- return fmt.Errorf("create schema_migrations: %w", err)
- }
-
- entries, err := fs.ReadDir(migrationsFS, "migrations")
- if err != nil {
- return fmt.Errorf("read migrations: %w", err)
- }
-
- // Sort by filename ascending.
- sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
-
- for _, e := range entries {
- name := e.Name()
- if e.IsDir() || !strings.HasSuffix(name, ".sql") {
- continue
- }
-
- // Skip if applied.
- var exists int
- if err := sqldb.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- return fmt.Errorf("check migration %s: %w", name, err)
- }
- } else {
- // already applied
- continue
- }
-
- // Read SQL.
- b, err := migrationsFS.ReadFile(filepath.Join("migrations", name))
- if err != nil {
- return fmt.Errorf("read migration %s: %w", name, err)
- }
-
- tx, err := sqldb.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("begin tx for %s: %w", name, err)
- }
- if _, err := tx.ExecContext(ctx, string(b)); err != nil {
- _ = tx.Rollback()
- return fmt.Errorf("exec migration %s: %w", name, err)
- }
- if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
- _ = tx.Rollback()
- return fmt.Errorf("record migration %s: %w", name, err)
- }
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("commit migration %s: %w", name, err)
- }
- }
- return nil
+ return fmt.Errorf("create schema_migrations: %w", err)
+ }
+
+ entries, err := fs.ReadDir(migrationsFS, "migrations")
+ if err != nil {
+ return fmt.Errorf("read migrations: %w", err)
+ }
+
+ // Sort by filename ascending.
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
+
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".sql") {
+ continue
+ }
+
+ // Skip if applied.
+ var exists int
+ if err := sqldb.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf("check migration %s: %w", name, err)
+ }
+ } else {
+ // already applied
+ continue
+ }
+
+ // Read SQL.
+ b, err := migrationsFS.ReadFile(filepath.Join("migrations", name))
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", name, err)
+ }
+
+ tx, err := sqldb.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin tx for %s: %w", name, err)
+ }
+ if _, err := tx.ExecContext(ctx, string(b)); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("exec migration %s: %w", name, err)
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("record migration %s: %w", name, err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit migration %s: %w", name, err)
+ }
+ }
+ return nil
}
// CreateBookmark inserts a new bookmark and returns it.
func (db *DB) CreateBookmark(ctx context.Context, url, title string, description *string) (*Bookmark, error) {
- if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
- return nil, fmt.Errorf("url and title are required")
- }
- now := time.Now().UTC()
- var desc sql.NullString
- if description != nil {
- desc = sql.NullString{String: *description, Valid: true}
- }
- res, err := db.sql.ExecContext(ctx, `
+ if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
+ return nil, fmt.Errorf("url and title are required")
+ }
+ now := time.Now().UTC()
+ var desc sql.NullString
+ if description != nil {
+ desc = sql.NullString{String: *description, Valid: true}
+ }
+ res, err := db.sql.ExecContext(ctx, `
INSERT INTO bookmarks(url, title, description, created_at, updated_at)
VALUES(?, ?, ?, ?, ?)
`, url, title, nullString(desc), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
- if err != nil {
- return nil, fmt.Errorf("insert bookmark: %w", err)
- }
- id, err := res.LastInsertId()
- if err != nil {
- return nil, fmt.Errorf("last insert id: %w", err)
- }
- // Replace FTS row (empty content until archive exists)
- ftsContent := ""
- if c, _, ok, _ := db.GetArchive(ctx, id); ok {
- ftsContent = c
- }
- if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil {
- return nil, fmt.Errorf("fts replace after create: %w", err)
- }
- return &Bookmark{
- ID: id,
- URL: url,
- Title: title,
- Description: desc,
- CreatedAt: now,
- UpdatedAt: now,
- }, nil
+ if err != nil {
+ return nil, fmt.Errorf("insert bookmark: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, fmt.Errorf("last insert id: %w", err)
+ }
+ // Replace FTS row (empty content until archive exists)
+ ftsContent := ""
+ if c, _, ok, _ := db.GetArchive(ctx, id); ok {
+ ftsContent = c
+ }
+ if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil {
+ return nil, fmt.Errorf("fts replace after create: %w", err)
+ }
+ return &Bookmark{
+ ID: id,
+ URL: url,
+ Title: title,
+ Description: desc,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
}
// GetBookmark returns a bookmark by ID.
func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
- var b Bookmark
- var created, updated string
- if err := db.sql.QueryRowContext(ctx, `
+ var b Bookmark
+ var created, updated string
+ if err := db.sql.QueryRowContext(ctx, `
SELECT id, url, title, description, created_at, updated_at
FROM bookmarks WHERE id = ?
`, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil, nil
- }
- return nil, fmt.Errorf("select bookmark: %w", err)
- }
- var err error
- if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
- return nil, fmt.Errorf("parse created_at: %w", err)
- }
- if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
- return nil, fmt.Errorf("parse updated_at: %w", err)
- }
- return &b, nil
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("select bookmark: %w", err)
+ }
+ var err error
+ if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
+ return nil, fmt.Errorf("parse created_at: %w", err)
+ }
+ if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
+ return nil, fmt.Errorf("parse updated_at: %w", err)
+ }
+ return &b, nil
}
// GetBookmarkByURL returns a bookmark by exact URL.
func (db *DB) GetBookmarkByURL(ctx context.Context, u string) (*Bookmark, error) {
- var b Bookmark
- var created, updated string
- if err := db.sql.QueryRowContext(ctx, `
+ var b Bookmark
+ var created, updated string
+ if err := db.sql.QueryRowContext(ctx, `
SELECT id, url, title, description, created_at, updated_at
FROM bookmarks WHERE url = ?
`, u).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil, nil
- }
- return nil, fmt.Errorf("select bookmark by url: %w", err)
- }
- var err error
- if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
- return nil, fmt.Errorf("parse created_at: %w", err)
- }
- if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
- return nil, fmt.Errorf("parse updated_at: %w", err)
- }
- return &b, nil
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("select bookmark by url: %w", err)
+ }
+ var err error
+ if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
+ return nil, fmt.Errorf("parse created_at: %w", err)
+ }
+ if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
+ return nil, fmt.Errorf("parse updated_at: %w", err)
+ }
+ return &b, nil
}
// ListBookmarks returns bookmarks ordered by created_at desc.
func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark, error) {
- if limit <= 0 {
- limit = 50
- }
- rows, err := db.sql.QueryContext(ctx, `
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := db.sql.QueryContext(ctx, `
SELECT id, url, title, description, created_at, updated_at
FROM bookmarks
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, limit, offset)
- if err != nil {
- return nil, fmt.Errorf("query bookmarks: %w", err)
- }
- defer rows.Close()
- out := make([]Bookmark, 0, limit)
- for rows.Next() {
- var b Bookmark
- var created, updated string
- if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
- return nil, fmt.Errorf("scan bookmark: %w", err)
- }
- if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
- return nil, fmt.Errorf("parse created_at: %w", err)
- }
- if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
- return nil, fmt.Errorf("parse updated_at: %w", err)
- }
- out = append(out, b)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate bookmarks: %w", err)
- }
- return out, nil
+ if err != nil {
+ return nil, fmt.Errorf("query bookmarks: %w", err)
+ }
+ defer rows.Close()
+ out := make([]Bookmark, 0, limit)
+ for rows.Next() {
+ var b Bookmark
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan bookmark: %w", err)
+ }
+ if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
+ return nil, fmt.Errorf("parse created_at: %w", err)
+ }
+ if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
+ return nil, fmt.Errorf("parse updated_at: %w", err)
+ }
+ out = append(out, b)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate bookmarks: %w", err)
+ }
+ return out, nil
}
// UpdateBookmark updates fields for a given bookmark.
func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, description *string) (*Bookmark, error) {
- if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
- return nil, fmt.Errorf("url and title are required")
- }
- now := time.Now().UTC()
- var desc sql.NullString
- if description != nil {
- desc = sql.NullString{String: *description, Valid: true}
- }
- res, err := db.sql.ExecContext(ctx, `
+ if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
+ return nil, fmt.Errorf("url and title are required")
+ }
+ now := time.Now().UTC()
+ var desc sql.NullString
+ if description != nil {
+ desc = sql.NullString{String: *description, Valid: true}
+ }
+ res, err := db.sql.ExecContext(ctx, `
UPDATE bookmarks
SET url = ?, title = ?, description = ?, updated_at = ?
WHERE id = ?
`, url, title, nullString(desc), now.Format(time.RFC3339Nano), id)
- if err != nil {
- return nil, fmt.Errorf("update bookmark: %w", err)
- }
- n, err := res.RowsAffected()
- if err != nil {
- return nil, fmt.Errorf("rows affected: %w", err)
- }
- if n == 0 {
- return nil, nil
- }
- // Update FTS metadata while preserving content
- var descForFTS sql.NullString
- if description != nil {
- descForFTS = sql.NullString{String: *description, Valid: true}
- }
- ftsContent := ""
- if c, _, ok, _ := db.GetArchive(ctx, id); ok {
- ftsContent = c
- }
- if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
- return nil, fmt.Errorf("fts replace after update: %w", err)
- }
- return db.GetBookmark(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("update bookmark: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return nil, fmt.Errorf("rows affected: %w", err)
+ }
+ if n == 0 {
+ return nil, nil
+ }
+ // Update FTS metadata while preserving content
+ var descForFTS sql.NullString
+ if description != nil {
+ descForFTS = sql.NullString{String: *description, Valid: true}
+ }
+ ftsContent := ""
+ if c, _, ok, _ := db.GetArchive(ctx, id); ok {
+ ftsContent = c
+ }
+ if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
+ return nil, fmt.Errorf("fts replace after update: %w", err)
+ }
+ return db.GetBookmark(ctx, id)
}
// TouchBookmark updates only the updated_at timestamp.
func (db *DB) TouchBookmark(ctx context.Context, id int64) error {
- _, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339Nano), id)
- if err != nil {
- return fmt.Errorf("touch bookmark: %w", err)
- }
- return nil
+ _, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339Nano), id)
+ if err != nil {
+ return fmt.Errorf("touch bookmark: %w", err)
+ }
+ return nil
}
// DeleteBookmark deletes a bookmark by ID. Returns true if a row was deleted.
func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) {
- res, err := db.sql.ExecContext(ctx, `DELETE FROM bookmarks WHERE id = ?`, id)
- if err != nil {
- return false, fmt.Errorf("delete bookmark: %w", err)
- }
- n, err := res.RowsAffected()
- if err != nil {
- return false, fmt.Errorf("rows affected: %w", err)
- }
- if n > 0 {
- if err := ftsDelete(ctx, db.sql, id); err != nil {
- return false, fmt.Errorf("delete fts row: %w", err)
- }
- }
- return n > 0, nil
+ res, err := db.sql.ExecContext(ctx, `DELETE FROM bookmarks WHERE id = ?`, id)
+ if err != nil {
+ return false, fmt.Errorf("delete bookmark: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return false, fmt.Errorf("rows affected: %w", err)
+ }
+ if n > 0 {
+ if err := ftsDelete(ctx, db.sql, id); err != nil {
+ return false, fmt.Errorf("delete fts row: %w", err)
+ }
+ }
+ return n > 0, nil
}
func nullString(ns sql.NullString) any {
- if ns.Valid {
- return ns.String
- }
- return nil
+ if ns.Valid {
+ return ns.String
+ }
+ return nil
}
// CreateBookmarkWithArchive creates a bookmark and saves its plaintext archive
// within a single transaction to ensure consistency.
func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string, description *string, archiveContent string) (*Bookmark, error) {
- if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
- return nil, fmt.Errorf("url and title are required")
- }
- now := time.Now().UTC()
- var desc sql.NullString
- if description != nil {
- desc = sql.NullString{String: *description, Valid: true}
- }
-
- tx, err := db.sql.BeginTx(ctx, nil)
- if err != nil {
- return nil, fmt.Errorf("begin tx: %w", err)
- }
- res, err := tx.ExecContext(ctx, `
+ if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
+ return nil, fmt.Errorf("url and title are required")
+ }
+ now := time.Now().UTC()
+ var desc sql.NullString
+ if description != nil {
+ desc = sql.NullString{String: *description, Valid: true}
+ }
+
+ tx, err := db.sql.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("begin tx: %w", err)
+ }
+ res, err := tx.ExecContext(ctx, `
INSERT INTO bookmarks(url, title, description, created_at, updated_at)
VALUES(?, ?, ?, ?, ?)
`, url, title, nullString(desc), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
- if err != nil {
- _ = tx.Rollback()
- return nil, fmt.Errorf("insert bookmark: %w", err)
- }
- id, err := res.LastInsertId()
- if err != nil {
- _ = tx.Rollback()
- return nil, fmt.Errorf("last insert id: %w", err)
- }
- if _, err := tx.ExecContext(ctx, `
+ if err != nil {
+ _ = tx.Rollback()
+ return nil, fmt.Errorf("insert bookmark: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ _ = tx.Rollback()
+ return nil, fmt.Errorf("last insert id: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx, `
INSERT INTO archives(bookmark_id, content, archived_at)
VALUES(?, ?, ?)
`, id, archiveContent, now.Format(time.RFC3339Nano)); err != nil {
- _ = tx.Rollback()
- return nil, fmt.Errorf("insert archive: %w", err)
- }
- // Replace FTS row within the same transaction
- if err := ftsReplace(ctx, tx, id, title, url, nullString(desc), archiveContent); err != nil {
- _ = tx.Rollback()
- return nil, fmt.Errorf("insert fts row: %w", err)
- }
- if err := tx.Commit(); err != nil {
- return nil, fmt.Errorf("commit: %w", err)
- }
- return &Bookmark{
- ID: id,
- URL: url,
- Title: title,
- Description: desc,
- CreatedAt: now,
- UpdatedAt: now,
- }, nil
+ _ = tx.Rollback()
+ return nil, fmt.Errorf("insert archive: %w", err)
+ }
+ // Replace FTS row within the same transaction
+ if err := ftsReplace(ctx, tx, id, title, url, nullString(desc), archiveContent); err != nil {
+ _ = tx.Rollback()
+ return nil, fmt.Errorf("insert fts row: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit: %w", err)
+ }
+ return &Bookmark{
+ ID: id,
+ URL: url,
+ Title: title,
+ Description: desc,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
}
// SaveArchive upserts the plaintext archive content for a bookmark.
func (db *DB) SaveArchive(ctx context.Context, bookmarkID int64, content string) error {
- now := time.Now().UTC().Format(time.RFC3339Nano)
- _, err := db.sql.ExecContext(ctx, `
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err := db.sql.ExecContext(ctx, `
INSERT INTO archives(bookmark_id, content, archived_at)
VALUES(?, ?, ?)
ON CONFLICT(bookmark_id) DO UPDATE SET
content=excluded.content,
archived_at=excluded.archived_at
`, bookmarkID, content, now)
- if err != nil {
- return fmt.Errorf("save archive: %w", err)
- }
- // Replace into FTS with latest content and metadata
- b, err := db.GetBookmark(ctx, bookmarkID)
- if err != nil {
- return fmt.Errorf("fetch bookmark for fts: %w", err)
- }
- if b != nil {
- if err := ftsReplace(ctx, db.sql, bookmarkID, b.Title, b.URL, nullString(b.Description), content); err != nil {
- return fmt.Errorf("fts replace after archive: %w", err)
- }
- }
- return nil
+ if err != nil {
+ return fmt.Errorf("save archive: %w", err)
+ }
+ // Replace into FTS with latest content and metadata
+ b, err := db.GetBookmark(ctx, bookmarkID)
+ if err != nil {
+ return fmt.Errorf("fetch bookmark for fts: %w", err)
+ }
+ if b != nil {
+ if err := ftsReplace(ctx, db.sql, bookmarkID, b.Title, b.URL, nullString(b.Description), content); err != nil {
+ return fmt.Errorf("fts replace after archive: %w", err)
+ }
+ }
+ return nil
}
// GetArchive returns archived plaintext and timestamp for a bookmark.
// ok is false if no archive exists.
func (db *DB) GetArchive(ctx context.Context, bookmarkID int64) (content string, archivedAt time.Time, ok bool, err error) {
- var ts string
- err = db.sql.QueryRowContext(ctx, `
+ var ts string
+ err = db.sql.QueryRowContext(ctx, `
SELECT content, archived_at FROM archives WHERE bookmark_id = ?
`, bookmarkID).Scan(&content, &ts)
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return "", time.Time{}, false, nil
- }
- return "", time.Time{}, false, fmt.Errorf("get archive: %w", err)
- }
- at, parseErr := time.Parse(time.RFC3339Nano, ts)
- if parseErr != nil {
- return content, time.Time{}, true, fmt.Errorf("parse archived_at: %w", parseErr)
- }
- return content, at, true, nil
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", time.Time{}, false, nil
+ }
+ return "", time.Time{}, false, fmt.Errorf("get archive: %w", err)
+ }
+ at, parseErr := time.Parse(time.RFC3339Nano, ts)
+ if parseErr != nil {
+ return content, time.Time{}, true, fmt.Errorf("parse archived_at: %w", parseErr)
+ }
+ return content, at, true, nil
}
// SearchBookmarks performs a full-text search using FTS5 and returns matching bookmarks
// ordered by a simple relevance score derived from offsets() length.
func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset int) ([]Bookmark, error) {
- if limit <= 0 {
- limit = 50
- }
- rows, err := db.sql.QueryContext(ctx, `
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := db.sql.QueryContext(ctx, `
SELECT b.id, b.url, b.title, b.description, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
@@ -474,38 +474,38 @@ func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset i
ORDER BY bm25(bookmarks_fts)
LIMIT ? OFFSET ?
`, query, limit, offset)
- if err != nil {
- return nil, fmt.Errorf("fts query: %w", err)
- }
- defer rows.Close()
- out := make([]Bookmark, 0, limit)
- for rows.Next() {
- var b Bookmark
- var created, updated string
- if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
- return nil, fmt.Errorf("scan fts row: %w", err)
- }
- if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
- return nil, fmt.Errorf("parse created_at: %w", err)
- }
- if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
- return nil, fmt.Errorf("parse updated_at: %w", err)
- }
- out = append(out, b)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate fts: %w", err)
- }
- return out, nil
+ if err != nil {
+ return nil, fmt.Errorf("fts query: %w", err)
+ }
+ defer rows.Close()
+ out := make([]Bookmark, 0, limit)
+ for rows.Next() {
+ var b Bookmark
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan fts row: %w", err)
+ }
+ if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
+ return nil, fmt.Errorf("parse created_at: %w", err)
+ }
+ if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
+ return nil, fmt.Errorf("parse updated_at: %w", err)
+ }
+ out = append(out, b)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate fts: %w", err)
+ }
+ return out, nil
}
// SearchBookmarksWithExcerpt performs an FTS5 search and also returns a snippet excerpt
// from the archived content column highlighting matches.
func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limit, offset int) ([]SearchResult, error) {
- if limit <= 0 {
- limit = 50
- }
- rows, err := db.sql.QueryContext(ctx, `
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := db.sql.QueryContext(ctx, `
SELECT b.id, b.url, b.title, b.description, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
@@ -513,137 +513,137 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limi
ORDER BY bm25(bookmarks_fts)
LIMIT ? OFFSET ?
`, query, limit, offset)
- if err != nil {
- return nil, fmt.Errorf("fts query: %w", err)
- }
- defer rows.Close()
- out := make([]SearchResult, 0, limit)
- for rows.Next() {
- var b Bookmark
- var created, updated string
- if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
- return nil, fmt.Errorf("scan fts row: %w", err)
- }
- if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
- return nil, fmt.Errorf("parse created_at: %w", err)
- }
- if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
- return nil, fmt.Errorf("parse updated_at: %w", err)
- }
- // Build excerpt from archives content in Go for portability
- if content, _, ok, _ := db.GetArchive(ctx, b.ID); ok {
- ex := sql.NullString{String: makeExcerpt(content, query), Valid: true}
- out = append(out, SearchResult{Bookmark: b, Excerpt: ex})
- } else {
- out = append(out, SearchResult{Bookmark: b})
- }
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate fts: %w", err)
- }
- return out, nil
+ if err != nil {
+ return nil, fmt.Errorf("fts query: %w", err)
+ }
+ defer rows.Close()
+ out := make([]SearchResult, 0, limit)
+ for rows.Next() {
+ var b Bookmark
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan fts row: %w", err)
+ }
+ if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
+ return nil, fmt.Errorf("parse created_at: %w", err)
+ }
+ if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
+ return nil, fmt.Errorf("parse updated_at: %w", err)
+ }
+ // Build excerpt from archives content in Go for portability
+ if content, _, ok, _ := db.GetArchive(ctx, b.ID); ok {
+ ex := sql.NullString{String: makeExcerpt(content, query), Valid: true}
+ out = append(out, SearchResult{Bookmark: b, Excerpt: ex})
+ } else {
+ out = append(out, SearchResult{Bookmark: b})
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate fts: %w", err)
+ }
+ return out, nil
}
// makeExcerpt returns a short excerpt from content highlighting the first
// occurrence of any query token using [brackets]. If no token is found, returns
// the leading portion of the content.
func makeExcerpt(content, query string) string {
- c := content
- if len(c) == 0 {
- return ""
- }
- lc := strings.ToLower(c)
- // Split query to tokens by whitespace; ignore empty tokens
- fields := strings.Fields(strings.ToLower(query))
- // Find first occurrence index among tokens
- idx := -1
- token := ""
- for _, t := range fields {
- if t == "" {
- continue
- }
- if i := strings.Index(lc, t); i >= 0 && (idx == -1 || i < idx) {
- idx = i
- token = t
- }
- }
- // Window
- const pre = 80
- const post = 120
- if idx == -1 {
- end := len(c)
- if end > pre+post {
- end = pre + post
- }
- s := c[:end]
- if end < len(c) {
- s += "…"
- }
- return s
- }
- start := idx - pre
- if start < 0 {
- start = 0
- }
- end := idx + len(token) + post
- if end > len(c) {
- end = len(c)
- }
- s := c[start:end]
- if start > 0 {
- s = "…" + s
- }
- if end < len(c) {
- s = s + "…"
- }
- // Highlight all tokens (basic, case-insensitive) using guillemets « » to avoid
- // conflict with brackets used elsewhere.
- ls := strings.ToLower(s)
- var b strings.Builder
- for i := 0; i < len(s); {
- matched := false
- for _, t := range fields {
- if t == "" {
- continue
- }
- if i+len(t) <= len(s) && ls[i:i+len(t)] == t {
- b.WriteRune('«')
- b.WriteString(s[i : i+len(t)])
- b.WriteRune('»')
- i += len(t)
- matched = true
- break
- }
- }
- if !matched {
- b.WriteByte(s[i])
- i++
- }
- }
- return b.String()
+ c := content
+ if len(c) == 0 {
+ return ""
+ }
+ lc := strings.ToLower(c)
+ // Split query to tokens by whitespace; ignore empty tokens
+ fields := strings.Fields(strings.ToLower(query))
+ // Find first occurrence index among tokens
+ idx := -1
+ token := ""
+ for _, t := range fields {
+ if t == "" {
+ continue
+ }
+ if i := strings.Index(lc, t); i >= 0 && (idx == -1 || i < idx) {
+ idx = i
+ token = t
+ }
+ }
+ // Window
+ const pre = 80
+ const post = 120
+ if idx == -1 {
+ end := len(c)
+ if end > pre+post {
+ end = pre + post
+ }
+ s := c[:end]
+ if end < len(c) {
+ s += "…"
+ }
+ return s
+ }
+ start := idx - pre
+ if start < 0 {
+ start = 0
+ }
+ end := idx + len(token) + post
+ if end > len(c) {
+ end = len(c)
+ }
+ s := c[start:end]
+ if start > 0 {
+ s = "…" + s
+ }
+ if end < len(c) {
+ s = s + "…"
+ }
+ // Highlight all tokens (basic, case-insensitive) using guillemets « » to avoid
+ // conflict with brackets used elsewhere.
+ ls := strings.ToLower(s)
+ var b strings.Builder
+ for i := 0; i < len(s); {
+ matched := false
+ for _, t := range fields {
+ if t == "" {
+ continue
+ }
+ if i+len(t) <= len(s) && ls[i:i+len(t)] == t {
+ b.WriteRune('«')
+ b.WriteString(s[i : i+len(t)])
+ b.WriteRune('»')
+ i += len(t)
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ b.WriteByte(s[i])
+ i++
+ }
+ }
+ return b.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
- }
- _, err := ex.ExecContext(ctx, `
+ // For contentless FTS5, use special 'delete' command to remove existing row
+ if err := ftsDelete(ctx, ex, rowid); err != nil {
+ return err
+ }
+ _, err := ex.ExecContext(ctx, `
INSERT INTO bookmarks_fts(rowid, title, url, description, content)
VALUES(?, ?, ?, ?, ?)
`, rowid, title, url, description, content)
- return err
+ 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, `
+ // 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)
- return err
+ return err
}
diff --git a/internal/static.go b/internal/static.go
@@ -1,9 +1,9 @@
package internal
import (
- "embed"
- "io/fs"
- "net/http"
+ "embed"
+ "io/fs"
+ "net/http"
)
//go:embed web/*
@@ -11,10 +11,9 @@ var webAssets embed.FS
// StaticHandler returns an http.Handler serving the embedded web UI.
func StaticHandler() (http.Handler, error) {
- sub, err := fs.Sub(webAssets, "web")
- if err != nil {
- return nil, err
- }
- return http.FileServer(http.FS(sub)), nil
+ sub, err := fs.Sub(webAssets, "web")
+ if err != nil {
+ return nil, err
+ }
+ return http.FileServer(http.FS(sub)), nil
}
-