diff --git a/README.md b/README.md index a5771d8..a5be82b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Minimal self-hosted personal wiki. Folders are pages. - **Special folder types** folders can opt into custom rendering (e.g. a photo diary with calendar navigation). See the [Special Folder Types](#special-folder-types) section for details. +- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section. + ## Build ```bash @@ -79,3 +81,19 @@ Each diary root exposes three stable paths intended for browser bookmarks. They | `/today/` | `/YYYY/MM/DD/` (or `…/?edit` if the day folder does not exist yet) | | `/this-month/` | `/YYYY/MM/` | | `/this-year/` | `/YYYY/` | + +## Quick-Add Bookmarklet + +Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): + +```javascript +javascript:(function(){var s=window.getSelection().toString().trim();var t=s||document.title;var u=location.href;var to='/Topics/Bookmarks/';var q='?to='+encodeURIComponent(to)+'&url='+encodeURIComponent(u)+'&title='+encodeURIComponent(t);window.open('https://wiki.host/quickadd'+q,'quickadd','width=480,height=320');})(); +``` + +Each save appends an entry of the following form to the destination page's `index.md`: + +```markdown +- [Example Page](https://example.com) + 2026-05-11 14:30 + optional comment +``` diff --git a/assets/quickadd.html b/assets/quickadd.html new file mode 100644 index 0000000..d299dcf --- /dev/null +++ b/assets/quickadd.html @@ -0,0 +1,93 @@ + + + + + + Save link + + + + + +
+
+ Save to + {{.To}} +
+
+ Title + {{.Title}} +
+
+ URL + {{.URL}} +
+
+ + +
+
+ + +
+
+
+ + + diff --git a/main.go b/main.go index f3b5f34..c219d61 100644 --- a/main.go +++ b/main.go @@ -98,6 +98,7 @@ func main() { })) http.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_reindex", h.handleReindex) + http.HandleFunc("/quickadd", h.handleQuickAdd) http.Handle("/", h) // Build the folder index off the request path so the listener can start @@ -323,6 +324,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs h.handleToggle(w, r, fsPath) return } + if query.Has("append") { + h.handleAppend(w, r, urlPath, fsPath) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) diff --git a/quickadd.go b/quickadd.go new file mode 100644 index 0000000..e62a53e --- /dev/null +++ b/quickadd.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +var quickAddTmpl = template.Must(template.ParseFS(assets, "assets/quickadd.html")) + +type quickAddData struct { + To, URL, Title string +} + +// handleQuickAdd serves the bookmarklet popup at /quickadd. +func (h *handler) handleQuickAdd(w http.ResponseWriter, r *http.Request) { + if !h.checkAuth(w, r) { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + q := r.URL.Query() + to := strings.TrimSpace(q.Get("to")) + if to == "" { + http.Error(w, "missing to", http.StatusBadRequest) + return + } + if !strings.HasPrefix(to, "/") { + to = "/" + to + } + data := quickAddData{ + To: to, + URL: q.Get("url"), + Title: q.Get("title"), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := quickAddTmpl.Execute(w, data); err != nil { + log.Printf("quickadd template: %v", err) + } +} + +// handleAppend appends one link entry to index.md at fsPath. Creates the +// folder and index.md if missing. Body is form-encoded with `url` (required), +// `title` and `comment` (both optional). +func (h *handler) handleAppend(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + rawURL := strings.TrimSpace(r.FormValue("url")) + title := strings.TrimSpace(r.FormValue("title")) + comment := strings.TrimSpace(r.FormValue("comment")) + if rawURL == "" { + http.Error(w, "missing url", http.StatusBadRequest) + return + } + if title == "" { + title = rawURL + } + + entry := formatAppendEntry(title, rawURL, comment, time.Now()) + + _, statErr := os.Stat(fsPath) + newlyCreated := os.IsNotExist(statErr) + if err := os.MkdirAll(fsPath, 0755); err != nil { + http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) + return + } + + indexPath := filepath.Join(fsPath, "index.md") + existing, err := os.ReadFile(indexPath) + if err != nil && !os.IsNotExist(err) { + http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) + return + } + + var buf bytes.Buffer + if len(existing) > 0 { + buf.Write(existing) + if existing[len(existing)-1] != '\n' { + buf.WriteByte('\n') + } + } + buf.WriteString(entry) + + if err := os.WriteFile(indexPath, buf.Bytes(), 0644); err != nil { + http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) + return + } + + if newlyCreated { + if rel, err := filepath.Rel(h.root, fsPath); err == nil { + folderIndexAdd(filepath.ToSlash(rel)) + } + } + w.WriteHeader(http.StatusNoContent) +} + +// formatAppendEntry builds a CommonMark multi-line list item: a link with a +// continuation-indented timestamp line and an optional comment line. Lines +// after the first share an indent so goldmark folds them into one paragraph. +func formatAppendEntry(title, rawURL, comment string, ts time.Time) string { + var b strings.Builder + b.WriteString("- [") + b.WriteString(escapeLinkLabel(title)) + b.WriteString("](") + b.WriteString(rawURL) + b.WriteString(")
") + b.WriteString(ts.Format("2006-01-02 15:04")) + b.WriteString("
") + if comment != "" { + b.WriteString(" ") + b.WriteString(comment) + b.WriteByte('\n') + } + return b.String() +} + +// escapeLinkLabel backslash-escapes the brackets that would otherwise close +// the markdown link label early. The label text is rendered verbatim, so we +// keep all other characters as-is. +func escapeLinkLabel(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `[`, `\[`) + s = strings.ReplaceAll(s, `]`, `\]`) + return s +}