marky

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

commit c67b180bc5d8eaddc92be658dfcb21ba71cf7e12
parent fc28f4062ad1e4a6c60a911a6d0bcf5193955cb6
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 14:41:52 -0400

go fmt

Diffstat:
MMakefile | 4+++-
Mcmd/marky.go | 714++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/archiver.go | 390++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/database.go | 960++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/static.go | 17++++++++---------
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 } -