commit d4738d9c29bbc0e736ab458a18d71ab831b6ef27
parent 30d957b154c4b9e96e32e707ee273000589f90eb
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 14:27:52 -0400
Edit/delete
Diffstat:
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; }