Quickadd feature for link saving
This commit is contained in:
@@ -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.
|
- **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
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -79,3 +81,19 @@ Each diary root exposes three stable paths intended for browser bookmarks. They
|
|||||||
| `<diary>/today/` | `<diary>/YYYY/MM/DD/` (or `…/?edit` if the day folder does not exist yet) |
|
| `<diary>/today/` | `<diary>/YYYY/MM/DD/` (or `…/?edit` if the day folder does not exist yet) |
|
||||||
| `<diary>/this-month/` | `<diary>/YYYY/MM/` |
|
| `<diary>/this-month/` | `<diary>/YYYY/MM/` |
|
||||||
| `<diary>/this-year/` | `<diary>/YYYY/` |
|
| `<diary>/this-year/` | `<diary>/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
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Save link</title>
|
||||||
|
<link rel="icon" href="/_/favicon.ico" />
|
||||||
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
|
<style>
|
||||||
|
body { padding: 0.8rem; }
|
||||||
|
.qa-form { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.qa-row { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.qa-label { font-size: 0.7rem; }
|
||||||
|
.qa-value { word-break: break-all; font-size: 0.85rem; }
|
||||||
|
.qa-comment {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--bg-panel-hover);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.qa-actions { display: flex; gap: 1rem; }
|
||||||
|
.qa-status { min-height: 1em; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form id="qa-form" class="qa-form"
|
||||||
|
data-to="{{.To}}" data-url="{{.URL}}" data-title="{{.Title}}">
|
||||||
|
<div class="qa-row">
|
||||||
|
<span class="qa-label muted">Save to</span>
|
||||||
|
<span class="qa-value">{{.To}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qa-row">
|
||||||
|
<span class="qa-label muted">Title</span>
|
||||||
|
<span class="qa-value">{{.Title}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qa-row">
|
||||||
|
<span class="qa-label muted">URL</span>
|
||||||
|
<span class="qa-value">{{.URL}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qa-row">
|
||||||
|
<label class="qa-label muted" for="qa-comment">Comment</label>
|
||||||
|
<input id="qa-comment" name="comment" type="text" class="qa-comment" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="qa-actions">
|
||||||
|
<button type="submit" class="btn">SAVE</button>
|
||||||
|
<button type="button" class="btn" id="qa-cancel">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
<div id="qa-status" class="qa-status muted"></div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById("qa-form");
|
||||||
|
const status = document.getElementById("qa-status");
|
||||||
|
const comment = document.getElementById("qa-comment");
|
||||||
|
document
|
||||||
|
.getElementById("qa-cancel")
|
||||||
|
.addEventListener("click", () => window.close());
|
||||||
|
form.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
status.classList.remove("danger");
|
||||||
|
status.classList.add("muted");
|
||||||
|
status.textContent = "Saving…";
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("url", form.dataset.url);
|
||||||
|
body.set("title", form.dataset.title);
|
||||||
|
body.set("comment", comment.value);
|
||||||
|
try {
|
||||||
|
const res = await fetch(form.dataset.to + "?append", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
status.classList.remove("muted");
|
||||||
|
status.classList.add("danger");
|
||||||
|
status.textContent = text || "HTTP " + res.status;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = "Saved ✓";
|
||||||
|
setTimeout(() => window.close(), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
status.classList.remove("muted");
|
||||||
|
status.classList.add("danger");
|
||||||
|
status.textContent = (e && e.message) || "Network error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -98,6 +98,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
http.HandleFunc("/_reindex", h.handleReindex)
|
http.HandleFunc("/_reindex", h.handleReindex)
|
||||||
|
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
// Build the folder index off the request path so the listener can start
|
// 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)
|
h.handleToggle(w, r, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if query.Has("append") {
|
||||||
|
h.handleAppend(w, r, urlPath, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
|||||||
+134
@@ -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(")</br>")
|
||||||
|
b.WriteString(ts.Format("2006-01-02 15:04"))
|
||||||
|
b.WriteString("</br>")
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user