marky

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

commit 89a6f4e50261569fdc5c2fb110fcd5a41b783a14
parent c67b180bc5d8eaddc92be658dfcb21ba71cf7e12
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 14:50:15 -0400

RSS

Diffstat:
MREADME.md | 7+++++--
Mcmd/marky.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 75 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md @@ -67,14 +67,15 @@ curl -i -X POST http://localhost:8080/bookmarks \ ### GET /bookmarks -List bookmarks in reverse chronological order, or search with full-text. When `q` is provided, results include an `excerpt` with highlighted matches. +List bookmarks in reverse chronological order, or search with full-text. When `q` is provided, results include an `excerpt` with highlighted matches. The endpoint can return JSON (default) or RSS. - Query params: - `limit` (optional, default 50, max 200) - `offset` (optional, default 0) - `q` (optional) full-text query across title, url, description, and archived content + - `format=rss` (optional) to return an RSS 2.0 feed - Responses: - - `200 OK` with JSON array of bookmarks + - `200 OK` with JSON array of bookmarks or RSS feed - `400 Bad Request` for invalid `limit` or `offset` - `500 Internal Server Error` on unexpected errors - curl examples: @@ -82,6 +83,8 @@ List bookmarks in reverse chronological order, or search with full-text. When `q curl -s http://localhost:8080/bookmarks | jq curl -s 'http://localhost:8080/bookmarks?limit=20&offset=20' | jq curl -s 'http://localhost:8080/bookmarks?q=example' | jq +curl -s 'http://localhost:8080/bookmarks?format=rss' | xmllint --format - +curl -s 'http://localhost:8080/bookmarks.xml?q=example' | xmllint --format - ``` Notes on search excerpts: diff --git a/cmd/marky.go b/cmd/marky.go @@ -3,6 +3,8 @@ package main import ( "context" "encoding/json" + "encoding/xml" + "fmt" "log" "net/http" "net/url" @@ -65,7 +67,7 @@ func main() { } else { mux.Handle("/", h) } - mux.HandleFunc("/bookmarks", func(w http.ResponseWriter, r *http.Request) { + bookmarksHandler := func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: if r.Header.Get("Content-Type") != "" { @@ -209,6 +211,16 @@ func main() { resp = append(resp, toResp(&b)) } } + // Choose format: JSON (default) or RSS + wantsRSS := r.URL.Path == "/bookmarks.xml" || strings.EqualFold(r.URL.Query().Get("format"), "rss") || strings.Contains(r.Header.Get("Accept"), "application/rss+xml") + if wantsRSS { + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + if err := writeRSS(w, r, resp); err != nil { + log.Printf("write rss error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) @@ -216,7 +228,9 @@ func main() { w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } - }) + } + mux.HandleFunc("/bookmarks", bookmarksHandler) + mux.HandleFunc("/bookmarks.xml", bookmarksHandler) // Archive endpoints: GET /bookmarks/{id}/archive mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) { @@ -386,3 +400,57 @@ func logging(next http.Handler) http.Handler { log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) }) } + +// RSS feed structures and writer +type rssFeed struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel rssChannel `xml:"channel"` +} +type rssChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []rssItem `xml:"item"` +} +type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + GUID string `xml:"guid"` + Description string `xml:"description,omitempty"` + PubDate string `xml:"pubDate"` +} + +func writeRSS(w http.ResponseWriter, r *http.Request, items []bookmarkResponse) error { + chanTitle := "marky bookmarks" + // Derive a base link if possible + chanLink := "" + if r != nil && r.Host != "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + chanLink = fmt.Sprintf("%s://%s/bookmarks", scheme, r.Host) + } + chanDesc := "Bookmarks feed" + out := rssFeed{Version: "2.0", Channel: rssChannel{Title: chanTitle, Link: chanLink, Description: chanDesc}} + out.Channel.Items = make([]rssItem, 0, len(items)) + for _, b := range items { + desc := "" + if b.Excerpt != nil && *b.Excerpt != "" { + desc = *b.Excerpt + } else if b.Description != nil { + desc = *b.Description + } + out.Channel.Items = append(out.Channel.Items, rssItem{ + Title: b.Title, + Link: b.URL, + GUID: fmt.Sprintf("marky:%d", b.ID), + Description: desc, + PubDate: b.UpdatedAt.Format(time.RFC1123Z), + }) + } + enc := xml.NewEncoder(w) + enc.Indent("", " ") + return enc.Encode(out) +}