marky

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

commit d4738d9c29bbc0e736ab458a18d71ab831b6ef27
parent 30d957b154c4b9e96e32e707ee273000589f90eb
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 14:27:52 -0400

Edit/delete

Diffstat:
Mcmd/marky.go | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Minternal/database.go | 23++++++++++++++++++-----
Minternal/web/app.js | 51++++++++++++++++++++++++++++++++++++++++++++++++---
Minternal/web/index.html | 21++++++++++++++++++---
Minternal/web/styles.css | 10++++++++--
5 files changed, 204 insertions(+), 57 deletions(-)

diff --git a/cmd/marky.go b/cmd/marky.go @@ -220,72 +220,140 @@ func main() { // Archive endpoints: GET /bookmarks/{id}/archive mux.HandleFunc("/bookmarks/", func(w http.ResponseWriter, r *http.Request) { - // Only handle the /bookmarks/{id}/archive path here - // Basic parsing without full router + // Basic parsing without a third-party router path := r.URL.Path - // Expect suffix "/archive" - if !strings.HasSuffix(path, "/archive") { + rest := strings.TrimPrefix(path, "/bookmarks/") + if rest == "" || strings.HasPrefix(rest, "/") { http.NotFound(w, r) return } - // Trim prefix and suffix to get the ID segment - // "/bookmarks/{id}/archive" -> "{id}" - trimmed := strings.TrimSuffix(strings.TrimPrefix(path, "/bookmarks/"), "/archive") - if trimmed == "" || strings.Contains(trimmed, "/") { + 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(trimmed, 10, 64) + 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 via w3m and persist - content, _, ok, err := db.GetArchive(r.Context(), id64) + 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("get archive error: %v", err) + log.Printf("update bookmark 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 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.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = w.Write([]byte(content)) + w.WriteHeader(http.StatusNoContent) default: - w.Header().Set("Allow", http.MethodGet) + w.Header().Set("Allow", http.MethodPut+", "+http.MethodDelete+", "+http.MethodGet) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }) diff --git a/internal/database.go b/internal/database.go @@ -346,7 +346,7 @@ func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) { return false, fmt.Errorf("rows affected: %w", err) } if n > 0 { - if _, err := db.sql.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, id); err != nil { + if err := ftsDelete(ctx, db.sql, id); err != nil { return false, fmt.Errorf("delete fts row: %w", err) } } @@ -596,7 +596,8 @@ func makeExcerpt(content, query string) string { if end < len(c) { s = s + "…" } - // Highlight all tokens (basic, case-insensitive) + // 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); { @@ -606,9 +607,9 @@ func makeExcerpt(content, query string) string { continue } if i+len(t) <= len(s) && ls[i:i+len(t)] == t { - b.WriteByte('[') + b.WriteRune('«') b.WriteString(s[i : i+len(t)]) - b.WriteByte(']') + b.WriteRune('»') i += len(t) matched = true break @@ -625,7 +626,8 @@ func makeExcerpt(content, query string) 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 { - if _, err := ex.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, rowid); err != nil { + // 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, ` @@ -634,3 +636,14 @@ func ftsReplace(ctx context.Context, ex execer, rowid int64, title, url string, `, rowid, title, url, description, content) 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, ` + INSERT INTO bookmarks_fts(bookmarks_fts, rowid) VALUES('delete', ?) + `, rowid) + return err +} diff --git a/internal/web/app.js b/internal/web/app.js @@ -17,6 +17,52 @@ createApp({ this.search(); }, methods: { + startEdit(b) { + b.editing = true; + b.editUrl = b.url; + b.editTitle = b.title; + b.editDescription = b.description || ""; + b.saving = false; + }, + cancelEdit(b) { + b.editing = false; + }, + async saveEdit(b) { + b.saving = true; + try { + const payload = { + url: b.editUrl, + title: b.editTitle, + description: b.editDescription, + }; + const res = await fetch(`/bookmarks/${b.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + Object.assign(b, data, { editing: false }); + } catch (e) { + alert(`Update failed: ${e.message || e}`); + } finally { + b.saving = false; + } + }, + async confirmDelete(b) { + if (!confirm('Delete this bookmark?')) return; + try { + const res = await fetch(`/bookmarks/${b.id}`, { method: 'DELETE' }); + if (res.status === 204) { + // Remove from local results + this.results = this.results.filter(x => x.id !== b.id); + } else if (!res.ok) { + throw new Error(await res.text()); + } + } catch (e) { + alert(`Delete failed: ${e.message || e}`); + } + }, async createBookmark() { this.error = ""; this.message = ""; @@ -68,9 +114,8 @@ createApp({ try { return new Date(iso).toLocaleString(); } catch { return iso; } }, formatExcerpt(ex) { - // Our API wraps matches with [...] — convert to <mark> - return ex.replace(/\[/g, '<mark>').replace(/\]/g, '</mark>'); + // Our API wraps matches with guillemets « » — convert to <mark> + return ex.replace(/«/g, '<mark>').replace(/»/g, '</mark>'); }, }, }).mount("#app"); - diff --git a/internal/web/index.html b/internal/web/index.html @@ -46,12 +46,27 @@ <article v-for="b in results" :key="b.id" class="card"> <h3> <a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a> - <a :href="`/bookmarks/${b.id}/archive`" target="_blank" class="archive-link" title="View archived text">🗃️</a> </h3> <p class="url">{{ b.url }}</p> - <p v-if="b.excerpt" class="excerpt" v-html="formatExcerpt(b.excerpt)"></p> + <div v-if="b.editing" class="edit-form"> + <label>URL <input v-model.trim="b.editUrl" type="url" required /></label> + <label>Title <input v-model.trim="b.editTitle" type="text" required /></label> + <label>Description <textarea v-model.trim="b.editDescription" rows="2"></textarea></label> + <div class="actions"> + <button @click="saveEdit(b)" :disabled="b.saving">{{ b.saving ? 'Saving…' : 'Save' }}</button> + <button @click="cancelEdit(b)">Cancel</button> + </div> + </div> + <p v-else-if="b.excerpt" class="excerpt" v-html="formatExcerpt(b.excerpt)"></p> <p class="meta">Updated: {{ formatDate(b.updated_at) }}</p> - <p v-if="!b.excerpt && b.description" class="desc">{{ b.description }}</p> + <p class="action-links"> + <a :href="`/bookmarks/${b.id}/archive`" target="_blank" title="View archived text">Archive</a> + <span class="sep">|</span> + <a href="#" @click.prevent="startEdit(b)">Edit</a> + <span class="sep">|</span> + <a href="#" @click.prevent="confirmDelete(b)">Delete</a> + </p> + <p v-if="!b.editing && !b.excerpt && b.description" class="desc">{{ b.description }}</p> </article> </div> </section> diff --git a/internal/web/styles.css b/internal/web/styles.css @@ -16,9 +16,15 @@ button { padding: 8px 12px; font-size: 14px; border-radius: 6px; border: 1px sol .results { display: grid; gap: 12px; margin-top: 12px; } .card { border: 1px solid #ddd3; border-radius: 8px; padding: 12px; } .card h3 { margin: 0 0 6px 0; font-size: 16px; } -.archive-link { font-size: 12px; margin-left: 8px; color: #555; text-decoration: none; } -.archive-link:hover { text-decoration: underline; } +.archive-link { display:none; } +.icon { display:none; } +.edit-form { display: grid; gap: 8px; margin: 8px 0; } +.edit-form .actions { display: flex; gap: 8px; } .url { color: #666; font-size: 12px; margin: 0 0 6px 0; word-break: break-all; } +.action-links { margin: 6px 0 0 0; font-size: 13px; color: #555; } +.action-links a { color: inherit; text-decoration: none; } +.action-links a:hover { text-decoration: underline; } +.action-links .sep { margin: 0 6px; opacity: 0.6; } .excerpt { margin: 6px 0; white-space: pre-wrap; } mark { background: #fff3b0; color: inherit; padding: 0 2px; border-radius: 2px; } .meta { color: #777; font-size: 12px; margin: 8px 0 0 0; }