commit e8c2bc7e4ade56f893c8fb23eac4cdb754555389 Author: Justin Hawkins <justin@hawkins.id.au> Date: Sat May 3 12:36:18 2025 +0930 Clean up menu/version commit 1993533a4613a5db7bbb2ecd6b7a2694f48acd52 Author: Justin Hawkins <justin@hawkins.id.au> Date: Sat May 3 12:31:50 2025 +0930 Update README commit 044cc830dcb3d05b43ab770e8ea42959873823dd Author: Justin Hawkins <justin@hawkins.id.au> Date: Sat May 3 12:22:33 2025 +0930 No longer needed commit a7c37ad7c5c12fcac1d5589271e0e66af4f086e8 Author: Justin Hawkins <justin@hawkins.id.au> Date: Sat May 3 12:14:59 2025 +0930 Fixup version handling commit ade0b748e978ce1d9533a928bd6369f1714ca8a8 Author: Justin Hawkins <justin@hawkins.id.au> Date: Sat May 3 11:58:15 2025 +0930 Use the correct analyser for searches commit e5a65cf5cfdd82ba7aa3d2470c12c6e82c851a3f Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri May 2 19:51:54 2025 +0930 Fix version in template commit 0171be0ee4d11796cfc0419a00b412f8037f4d97 Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri May 2 19:51:41 2025 +0930 Rescrape all links if needed on startup commit ae654998f751c3883f8b3bd76005afae38209edf Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri May 2 19:51:25 2025 +0930 Spelling commit bfe9bbee028e5515be5b6d406e9090c61cdbbebd Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri May 2 19:34:06 2025 +0930 Make goreleaser set the version commit 4436313413f52b2b29a08e510ac4496016aae8a0 Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri May 2 11:43:58 2025 +0930 Make release matrix sane commit 7b467ecee7a3d74ba194b723034dd16bf1c53b53 Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri May 2 11:40:07 2025 +0930 I hate YAML, so much. commit b578e0f044e754ca70cd4ace95fdc6dc3cdcfe07 Author: Justin Hawkins <justin@hawkins.id.au> Date: Thu May 1 23:47:07 2025 +0930 Update goreleaser commit fba84f0827d35359a450fb847b03f5fa78960a1f Author: Justin Hawkins <justin@hawkins.id.au> Date: Thu May 1 23:45:46 2025 +0930 Update version commit e4edb08bd19c560b6f2ea5e572d5950896ac6062 Author: Justin Hawkins <justin@hawkins.id.au> Date: Thu May 1 23:42:59 2025 +0930 Deps commit 58b6692d1b397de91f3531c35c6518fb58ad285a Author: Justin Hawkins <justin@hawkins.id.au> Date: Thu May 1 23:39:51 2025 +0930 Mostly done, first cut commit badbe5e92ffa415b444535eca385d87fa00afa68 Author: Justin Hawkins <justin@hawkins.id.au> Date: Sun Apr 27 20:28:37 2025 +0930 Remove unused code commit 903240dd18cdcd7722705f04ff40b30661969d4d Author: Justin Hawkins <justin@hawkins.id.au> Date: Sun Apr 27 20:26:19 2025 +0930 Update deps commit de90b9951a82c420be0588e6c2c2b63fe0b10eff Author: Justin Hawkins <justin@hawkins.id.au> Date: Sun Apr 27 20:21:33 2025 +0930 Keep on bleving commit 9b15528510a06661237249eaf7bf64c3ce71fc3c Author: Justin Hawkins <justin@hawkins.id.au> Date: Fri Apr 25 23:57:04 2025 +0930 Start of blevification
513 lines
12 KiB
Go
513 lines
12 KiB
Go
package web
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tardisx/linkwallet/db"
|
|
"github.com/tardisx/linkwallet/entity"
|
|
"github.com/tardisx/linkwallet/meta"
|
|
"github.com/tardisx/linkwallet/version"
|
|
|
|
"github.com/gomarkdown/markdown"
|
|
"github.com/hako/durafmt"
|
|
|
|
"github.com/gin-contrib/gzip"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gonum.org/v1/plot"
|
|
"gonum.org/v1/plot/font"
|
|
"gonum.org/v1/plot/plotter"
|
|
"gonum.org/v1/plot/vg"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
//go:embed templates/*
|
|
var templateFiles embed.FS
|
|
|
|
// Server represents a SCUD web server.
|
|
// The SCUD web service can serve 2 different kinda of responses. The first is basic static
|
|
// vendor-provided files (called "assetFiles" here). An arbitrary number of them can be placed
|
|
// in assets/ and served up via a path prefix of /assets. They do not need individual routes
|
|
// to be specified.
|
|
// The second is htmx responses fragments. We never automatically serve templates (ie no mapping
|
|
// from template name to a URL route), there will always be a specific route or routes which
|
|
// use one or more templates to return a response.
|
|
type Server struct {
|
|
engine *gin.Engine
|
|
templ *template.Template
|
|
bmm *db.BookmarkManager
|
|
}
|
|
|
|
type ColumnInfo struct {
|
|
Name string
|
|
Param string
|
|
Class string
|
|
}
|
|
|
|
// Create creates a new web server instance and sets up routing.
|
|
func Create(bmm *db.BookmarkManager, cmm *db.ConfigManager) *Server {
|
|
|
|
// Set the default font for graphs
|
|
plot.DefaultFont = font.Font{
|
|
Typeface: "Liberation",
|
|
Variant: "Mono",
|
|
}
|
|
|
|
// setup routes for the static assets (vendor includes)
|
|
staticFS, err := fs.Sub(staticFiles, "static")
|
|
if err != nil {
|
|
log.Fatalf("problem with assetFS: %s", err)
|
|
}
|
|
|
|
// templ := template.Must(template.New("").Funcs(template.FuncMap{"dict": dictHelper}).ParseFS(templateFiles, "templates/*.html"))
|
|
templ := template.Must(template.New("").Funcs(
|
|
template.FuncMap{
|
|
"nicetime": niceTime,
|
|
"niceURL": niceURL,
|
|
"niceSizeMB": func(s int) string { return fmt.Sprintf("%.1f", float32(s)/1024/1024) },
|
|
"join": strings.Join,
|
|
"version": func() *version.Info { return &version.VersionInfo },
|
|
"meminfo": meta.MemInfo,
|
|
"markdown": func(s string) template.HTML { return template.HTML(string(markdown.ToHTML([]byte(s), nil, nil))) },
|
|
}).ParseFS(templateFiles, "templates/*.html"))
|
|
|
|
config, err := cmm.LoadConfig()
|
|
if err != nil {
|
|
log.Fatalf("could not start server - failed to load config: %s", err)
|
|
}
|
|
|
|
r := gin.Default()
|
|
|
|
server := &Server{
|
|
engine: r,
|
|
templ: templ,
|
|
bmm: bmm,
|
|
}
|
|
|
|
r.Use(headersByURI())
|
|
r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedExtensions([]string{".pdf", ".mp4"})))
|
|
|
|
r.SetHTMLTemplate(templ)
|
|
r.StaticFS("/assets", http.FS(staticFS))
|
|
|
|
r.GET("/", func(c *gin.Context) {
|
|
meta := gin.H{"page": "root", "config": config}
|
|
c.HTML(http.StatusOK,
|
|
"_layout.html", meta,
|
|
)
|
|
})
|
|
|
|
r.GET("/manage", func(c *gin.Context) {
|
|
results, _ := bmm.Search(db.SearchOptions{All: true})
|
|
meta := gin.H{"page": "manage", "config": config, "results": results}
|
|
c.HTML(http.StatusOK,
|
|
"_layout.html", meta,
|
|
)
|
|
})
|
|
|
|
r.POST("/manage/results", func(c *gin.Context) {
|
|
query := c.PostForm("query")
|
|
|
|
results := make([]entity.BookmarkSearchResult, 0)
|
|
if query == "" {
|
|
results, _ = bmm.Search(db.SearchOptions{All: true, Results: 100})
|
|
} else {
|
|
results, _ = bmm.Search(db.SearchOptions{Query: query})
|
|
}
|
|
meta := gin.H{"config": config, "results": results}
|
|
|
|
colTitle := &ColumnInfo{Name: "Title/URL", Param: "title"}
|
|
colCreated := &ColumnInfo{Name: "Created", Param: "created", Class: "show-for-large"}
|
|
colScraped := &ColumnInfo{Name: "Scraped", Param: "scraped", Class: "show-for-large"}
|
|
|
|
cols := gin.H{
|
|
"title": colTitle,
|
|
"created": colCreated,
|
|
"scraped": colScraped,
|
|
}
|
|
meta["column"] = cols
|
|
|
|
c.HTML(http.StatusOK, "manage_results.html", meta)
|
|
|
|
})
|
|
|
|
r.GET("/config", func(c *gin.Context) {
|
|
meta := gin.H{"page": "config", "config": config}
|
|
c.HTML(http.StatusOK,
|
|
"_layout.html", meta,
|
|
)
|
|
})
|
|
|
|
r.POST("/config", func(c *gin.Context) {
|
|
config.BaseURL = c.PostForm("baseurl")
|
|
config.BaseURL = strings.TrimRight(config.BaseURL, "/")
|
|
cmm.SaveConfig(&config)
|
|
meta := gin.H{"config": config}
|
|
|
|
c.HTML(http.StatusOK, "config_form.html", meta)
|
|
})
|
|
|
|
r.POST("/search", func(c *gin.Context) {
|
|
query := c.PostForm("query")
|
|
|
|
// no query, return an empty response
|
|
if len(query) == 0 {
|
|
c.Status(http.StatusNoContent)
|
|
c.Writer.Write([]byte{})
|
|
return
|
|
}
|
|
|
|
sr, err := bmm.Search(db.SearchOptions{Query: query})
|
|
data := gin.H{
|
|
"results": sr,
|
|
"error": err,
|
|
}
|
|
|
|
c.HTML(http.StatusOK,
|
|
"search_results.html", data,
|
|
)
|
|
})
|
|
|
|
r.POST("/add", func(c *gin.Context) {
|
|
url := c.PostForm("url")
|
|
tags := []string{}
|
|
if c.PostForm("tags_hidden") != "" {
|
|
tags = strings.Split(c.PostForm("tags_hidden"), "|")
|
|
}
|
|
bm := entity.Bookmark{
|
|
ID: 0,
|
|
URL: url,
|
|
Tags: tags,
|
|
}
|
|
err := bmm.AddBookmark(&bm)
|
|
|
|
data := gin.H{
|
|
"bm": bm,
|
|
"error": err,
|
|
}
|
|
|
|
if err != nil {
|
|
data["url"] = url
|
|
data["tags"] = tags
|
|
data["tags_hidden"] = c.PostForm("tags_hidden")
|
|
}
|
|
|
|
c.HTML(http.StatusOK, "add_url_form.html", data)
|
|
})
|
|
|
|
r.POST("/add_bulk", func(c *gin.Context) {
|
|
urls := c.PostForm("urls")
|
|
|
|
urlsSplit := strings.Split(urls, "\n")
|
|
urlsTrimmed := make([]string, 0, 0)
|
|
for _, url := range urlsSplit {
|
|
urlsTrimmed = append(urlsTrimmed, strings.TrimSpace(url))
|
|
}
|
|
totalErrors := make([]string, 0, 0)
|
|
added := 0
|
|
for _, url := range urlsTrimmed {
|
|
if url != "" {
|
|
bm := entity.Bookmark{
|
|
ID: 0,
|
|
URL: url,
|
|
}
|
|
|
|
err := bmm.AddBookmark(&bm)
|
|
if err != nil {
|
|
totalErrors = append(totalErrors, fmt.Sprintf("url: %s (%s)", url, err.Error()))
|
|
} else {
|
|
added++
|
|
}
|
|
}
|
|
}
|
|
|
|
data := gin.H{
|
|
"added": added,
|
|
"errors": totalErrors,
|
|
}
|
|
c.HTML(http.StatusOK, "add_url_form_bulk.html", data)
|
|
})
|
|
|
|
r.GET("/bulk_add", func(c *gin.Context) {
|
|
c.HTML(http.StatusOK, "add_url_form_bulk.html", nil)
|
|
})
|
|
|
|
r.POST("/tags", func(c *gin.Context) {
|
|
|
|
newTag := c.PostForm("tag") // new tag
|
|
oldTags := strings.Split(c.PostForm("tags_hidden"), "|")
|
|
|
|
remove := c.Query("remove")
|
|
if remove != "" {
|
|
log.Printf("removing %s", remove)
|
|
trimmedTags := []string{}
|
|
for _, k := range oldTags {
|
|
if k != remove {
|
|
trimmedTags = append(trimmedTags, k)
|
|
}
|
|
}
|
|
oldTags = trimmedTags
|
|
}
|
|
|
|
tags := append(oldTags, newTag)
|
|
tags = cleanupTags(tags)
|
|
tagsHidden := strings.Join(tags, "|")
|
|
|
|
data := gin.H{"tags": tags, "tags_hidden": tagsHidden}
|
|
c.HTML(http.StatusOK, "tags_widget.html", data)
|
|
})
|
|
|
|
r.GET("/single_add", func(c *gin.Context) {
|
|
c.HTML(http.StatusOK, "add_url_form.html", nil)
|
|
})
|
|
|
|
// XXX this should properly replace the button
|
|
r.POST("/scrape/:id", func(c *gin.Context) {
|
|
id := c.Params.ByName("id")
|
|
idNum, _ := strconv.ParseInt(id, 10, 32)
|
|
bm := bmm.LoadBookmarkByID(uint64(idNum))
|
|
bmm.QueueScrape(&bm)
|
|
c.String(http.StatusOK, "<p>scrape queued</p>")
|
|
})
|
|
|
|
r.GET("/export", func(c *gin.Context) {
|
|
c.Writer.Header().Set("Content-Type", "text/plain")
|
|
c.Writer.Header().Set("Content-Disposition", "attachment; filename=\"bookmarks.txt\"")
|
|
err := bmm.ExportBookmarks(c.Writer)
|
|
// this is a bit late, but we already added headers, so at least log it.
|
|
if err != nil {
|
|
log.Printf("got error when exporting: %s", err)
|
|
}
|
|
})
|
|
|
|
r.GET("/bookmarklet", func(c *gin.Context) {
|
|
url := c.Query("url")
|
|
|
|
meta := gin.H{"page": "bookmarklet_click", "config": config, "url": url}
|
|
|
|
// check if they just clicked it from the actual app
|
|
if strings.Index(url, config.BaseURL) == 0 {
|
|
meta["clicked"] = true
|
|
}
|
|
|
|
c.HTML(http.StatusOK,
|
|
"_layout.html", meta,
|
|
)
|
|
})
|
|
|
|
r.GET("/edit/:id", func(c *gin.Context) {
|
|
bookmarkIDstring := c.Param("id")
|
|
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
|
|
if ok != nil {
|
|
c.String(http.StatusBadRequest, "bad id")
|
|
return
|
|
}
|
|
|
|
bookmark := bmm.LoadBookmarkByID(bookmarkID)
|
|
meta := gin.H{"page": "edit", "bookmark": bookmark, "tw": gin.H{"tags": bookmark.Tags, "tags_hidden": strings.Join(bookmark.Tags, "|")}}
|
|
|
|
c.HTML(http.StatusOK,
|
|
"_layout.html", meta,
|
|
)
|
|
})
|
|
|
|
r.POST("/edit/:id", func(c *gin.Context) {
|
|
bookmarkIDstring := c.Param("id")
|
|
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
|
|
if ok != nil {
|
|
c.String(http.StatusBadRequest, "bad id")
|
|
return
|
|
}
|
|
|
|
bookmark := bmm.LoadBookmarkByID(bookmarkID)
|
|
|
|
// update title and override title
|
|
overrideTitle := c.PostForm("override_title")
|
|
if overrideTitle != "" {
|
|
title := c.PostForm("title")
|
|
bookmark.Info.Title = title
|
|
bookmark.PreserveTitle = true
|
|
} else {
|
|
bookmark.PreserveTitle = false
|
|
}
|
|
|
|
// freshen tags
|
|
if c.PostForm("tags_hidden") == "" {
|
|
// empty
|
|
bookmark.Tags = []string{}
|
|
} else {
|
|
bookmark.Tags = strings.Split(c.PostForm("tags_hidden"), "|")
|
|
}
|
|
|
|
bmm.SaveBookmark(&bookmark)
|
|
bmm.UpdateIndexForBookmark(&bookmark) // because title may have changed
|
|
|
|
meta := gin.H{"page": "edit", "bookmark": bookmark, "saved": true, "tw": gin.H{"tags": bookmark.Tags, "tags_hidden": strings.Join(bookmark.Tags, "|")}}
|
|
|
|
c.HTML(http.StatusOK,
|
|
"edit_form.html", meta,
|
|
)
|
|
})
|
|
|
|
r.DELETE("/edit/:id", func(c *gin.Context) {
|
|
bookmarkIDstring := c.Param("id")
|
|
bookmarkID, ok := strconv.ParseUint(bookmarkIDstring, 10, 64)
|
|
if ok != nil {
|
|
c.String(http.StatusBadRequest, "bad id")
|
|
return
|
|
}
|
|
|
|
bookmark := bmm.LoadBookmarkByID(bookmarkID)
|
|
err := bmm.DeleteBookmark(&bookmark)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
c.HTML(http.StatusOK,
|
|
"edit_form_deleted.html", nil,
|
|
)
|
|
})
|
|
|
|
r.GET("/info", func(c *gin.Context) {
|
|
dbStats, err := bmm.Stats()
|
|
if err != nil {
|
|
panic("could not load stats for info page")
|
|
}
|
|
meta := gin.H{"page": "info", "stats": dbStats, "config": config}
|
|
c.HTML(http.StatusOK,
|
|
"_layout.html", meta,
|
|
)
|
|
})
|
|
|
|
r.GET("/graph/:type", func(c *gin.Context) {
|
|
|
|
graphType := c.Param("type")
|
|
p := plot.New()
|
|
|
|
dbStats, err := bmm.Stats()
|
|
if err != nil {
|
|
panic("could not load stats for graph page")
|
|
}
|
|
|
|
sortedKeys := make([]time.Time, 0)
|
|
for k := range dbStats.History {
|
|
sortedKeys = append(sortedKeys, k)
|
|
}
|
|
sort.Slice(sortedKeys, func(i, j int) bool {
|
|
return sortedKeys[i].Before(sortedKeys[j])
|
|
})
|
|
|
|
xTicks := plot.TimeTicks{Format: "2006-01-02"}
|
|
p.X.Tick.Marker = xTicks
|
|
|
|
plotPoints(sortedKeys, dbStats, p, graphType)
|
|
|
|
writerTo, err := p.WriterTo(vg.Points(640), vg.Points(480), "png")
|
|
if err != nil {
|
|
panic("error creating WriterTo: " + err.Error())
|
|
}
|
|
|
|
c.Header("Content-Type", "image/png")
|
|
writerTo.WriteTo(c.Writer)
|
|
|
|
})
|
|
|
|
return server
|
|
}
|
|
|
|
func plotPoints(sortedKeys []time.Time, dbStats entity.DBStats, p *plot.Plot, k string) {
|
|
|
|
if k == "bookmarks" {
|
|
p.Title.Text = "Bookmarks over time"
|
|
p.Y.Label.Text = "Bookmarks"
|
|
} else {
|
|
panic("bad k")
|
|
}
|
|
p.X.Label.Text = "Date"
|
|
|
|
pts := make(plotter.XYs, len(sortedKeys))
|
|
for i := range sortedKeys {
|
|
pts[i].X = float64(sortedKeys[i].Unix())
|
|
if k == "bookmarks" {
|
|
pts[i].Y = float64(dbStats.History[sortedKeys[i]].Bookmarks)
|
|
} else {
|
|
panic("bad key")
|
|
}
|
|
}
|
|
|
|
l, err := plotter.NewLine(pts)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
p.Add(l)
|
|
}
|
|
|
|
// headersByURI sets the headers for some special cases, set a custom long cache time for
|
|
// static resources.
|
|
func headersByURI() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if strings.HasPrefix(c.Request.URL.String(), "/assets/") {
|
|
c.Header("Cache-Control", "max-age=86400")
|
|
c.Header("Expires", time.Now().Add(time.Hour*24*1).Format("Mon 2 Jan 2006 15:04:05 MST"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start starts the web server, blocking forever.
|
|
func (s *Server) Start() {
|
|
s.engine.Run()
|
|
}
|
|
|
|
func cleanupTags(tags []string) []string {
|
|
keys := make(map[string]struct{})
|
|
for _, k := range tags {
|
|
if k != "" && k != "|" {
|
|
for _, subKey := range strings.Split(k, ",") {
|
|
subKey := strings.Trim(subKey, " ")
|
|
keys[strings.ToLower(subKey)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
out := []string{}
|
|
for k := range keys {
|
|
out = append(out, k)
|
|
}
|
|
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
type timeVariations struct {
|
|
HumanDuration string
|
|
}
|
|
|
|
func niceTime(t time.Time) timeVariations {
|
|
|
|
u := "y:y,w:w,d:d,h:h,m:m,s:s,ms:ms,us:us"
|
|
units, err := durafmt.DefaultUnitsCoder.Decode(u)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
ago := durafmt.Parse(time.Since(t)).LimitFirstN(1).Format(units)
|
|
ago = strings.ReplaceAll(ago, " ", "")
|
|
|
|
return timeVariations{HumanDuration: ago}
|
|
}
|
|
|
|
func niceURL(url string) string {
|
|
if len(url) > 50 {
|
|
return url[0:50] + " ..."
|
|
}
|
|
return url
|
|
}
|