Skip to main content

bookmarks_webapp/
lib.rs

1//! Embedded HTMX webapp for bookmarks
2
3use axum::Router;
4use axum::extract::{Path, Query, State};
5use axum::response::Html;
6use axum::routing::{get, post};
7use std::net::SocketAddr;
8use std::sync::{Arc, Mutex};
9
10use bookmarks_core::config::{Config, UrlEntry};
11use bookmarks_core::storage::Storage;
12use bookmarks_core::strings;
13
14const HTMX_URL: &str = "https://unpkg.com/htmx.org@2.0.4";
15
16struct AppState {
17    storage: Mutex<Box<dyn Storage>>,
18}
19
20impl AppState {
21    fn lock_storage(&self) -> std::sync::MutexGuard<'_, Box<dyn Storage>> {
22        self.storage.lock().unwrap_or_else(|e| e.into_inner())
23    }
24
25    fn load_config(&self) -> Config {
26        self.lock_storage().load().unwrap_or_default()
27    }
28
29    /// Hold the lock across the entire load-modify-save cycle to prevent
30    /// TOCTOU races between concurrent requests.
31    fn modify_config<F>(&self, f: F) -> Result<(), String>
32    where
33        F: FnOnce(&mut Config) -> Result<(), String>,
34    {
35        let storage = self.lock_storage();
36        let mut config = storage.load().unwrap_or_default();
37        f(&mut config)?;
38        storage.save(&config).map_err(|e| e.to_string())
39    }
40}
41
42fn escape(s: &str) -> String {
43    s.replace('&', "&amp;")
44        .replace('<', "&lt;")
45        .replace('>', "&gt;")
46        .replace('"', "&quot;")
47}
48
49fn escape_js(s: &str) -> String {
50    s.replace('\\', "\\\\")
51        .replace('\'', "\\'")
52        .replace('"', "&quot;")
53        .replace('<', "\\x3c")
54        .replace('>', "\\x3e")
55}
56
57// -- HTML rendering ----------------------------------------------------------
58
59fn page(body: &str) -> String {
60    let project_url = strings::PROJECT_URL;
61    format!(
62        r##"<!DOCTYPE html>
63<html lang="en">
64<head>
65  <meta charset="utf-8">
66  <meta name="viewport" content="width=device-width, initial-scale=1">
67  <title>bookmarks</title>
68  <script src="{HTMX_URL}"></script>
69  <style>
70    * {{ margin: 0; padding: 0; box-sizing: border-box; }}
71    html {{ background: #1a1a29; }}
72    body {{ font-family: system-ui, -apple-system, sans-serif; background: #1a1a29; color: #8c8ca6; width: 640px; margin: 0 auto; padding: 32px 0; }}
73    h1 {{ font-size: 1.4rem; color: #8c8ca6; margin-bottom: 8px; font-weight: 500; }}
74    .subtitle {{ font-size: 0.85rem; color: #8c8ca6; margin-bottom: 24px; }}
75    .subtitle a {{ color: #bf4dff; text-decoration: none; }}
76    .subtitle a:hover {{ text-decoration: underline; }}
77    h2 {{ font-size: 1rem; color: #8c8ca6; margin-bottom: 12px; text-transform: lowercase; }}
78    .section {{ margin-bottom: 28px; }}
79    table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
80    col.col-check {{ width: 28px; }}
81    col.col-name {{ width: 130px; }}
82    col.col-value {{ }}
83    col.col-actions {{ width: 70px; }}
84    th {{ text-align: left; font-size: 0.75rem; color: #666680; text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 8px; border-bottom: 1px solid #2e2e47; }}
85    th.sortable {{ cursor: pointer; user-select: none; }}
86    th.sortable:hover {{ color: #8c8ca6; }}
87    th.active {{ color: #bf4dff; }}
88    td {{ padding: 6px 8px; border-bottom: 1px solid #242438; font-size: 0.85rem; vertical-align: top; overflow: hidden; text-overflow: ellipsis; }}
89    td.check {{ text-align: center; overflow: visible; }}
90    td.check input {{ cursor: pointer; accent-color: #bf4dff; }}
91    th.check {{ text-align: center; overflow: visible; }}
92    th.check input {{ cursor: pointer; accent-color: #bf4dff; }}
93    td.name {{ color: #bf4dff; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
94    td.name a {{ color: #bf4dff; text-decoration: none; }}
95    td.name a:hover {{ text-decoration: underline; }}
96    td.url a {{ color: #22d3ee; text-decoration: none; word-break: break-all; }}
97    td.url a:hover {{ text-decoration: underline; color: #67e8f9; }}
98    td.aliases {{ color: #a640f2; font-size: 0.8rem; }}
99    td.entries a {{ color: #a640f2; text-decoration: none; }}
100    td.entries a:hover {{ text-decoration: underline; color: #bf4dff; }}
101    .actions {{ text-align: right; white-space: nowrap; }}
102    .btn {{ background: none; border: 1px solid #2e2e47; color: #8c8ca6; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }}
103    .btn:hover {{ border-color: #666680; color: #edeedf; }}
104    .btn-danger {{ border-color: #5c2a2a; color: #ff7373; }}
105    .btn-danger:hover {{ border-color: #ff7373; color: #ffa0a0; }}
106    .btn-add {{ background: #242438; border-color: #2e2e47; color: #bf4dff; white-space: nowrap; width: 72px; text-align: center; flex-shrink: 0; }}
107    .btn-add:hover {{ background: #2e2e47; border-color: #666680; }}
108    .bulk-bar {{ display: none; align-items: center; gap: 8px; margin-bottom: 12px; padding: 8px 12px; background: #242438; border: 1px solid #2e2e47; border-radius: 6px; }}
109    .bulk-bar.visible {{ display: flex; }}
110    .bulk-bar .bulk-count {{ font-size: 0.8rem; color: #bf4dff; }}
111    .bulk-bar .btn {{ font-size: 0.75rem; }}
112    form.inline {{ display: flex; gap: 6px; align-items: center; margin-top: 6px; }}
113    form.inline input {{ background: #242438; border: 1px solid #2e2e47; color: #edeedf; padding: 5px 8px; border-radius: 4px; font-size: 0.8rem; min-width: 0; }}
114    form.inline input:first-of-type {{ flex: 2; }}
115    form.inline input:nth-of-type(2) {{ flex: 3; }}
116    form.inline input::placeholder {{ color: #666680; }}
117    form.inline input:focus {{ outline: none; border-color: #bf4dff; }}
118    .copy-btn {{ background: none; border: none; color: #666680; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; vertical-align: middle; }}
119    .copy-btn:hover {{ color: #8c8ca6; }}
120    .copy-btn.copied {{ color: #4ade80; }}
121    td.url {{ }}
122    td.url .url-cell {{ display: flex; align-items: center; gap: 6px; }}
123    .error-banner {{ background: #3a1a2a; border: 1px solid #5c2a2a; color: #ff7373; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-size: 0.8rem; cursor: pointer; }}
124    .editable {{ cursor: pointer; }}
125    .editable:hover {{ background: #2e2e47; border-radius: 3px; }}
126    .edit-input {{ background: #242438; border: 1px solid #bf4dff; color: #edeedf; padding: 3px 6px; border-radius: 3px; font-size: 0.8rem; width: 100%; font-family: inherit; }}
127    .edit-input:focus {{ outline: none; }}
128    .empty {{ color: #666680; font-style: italic; font-size: 0.85rem; padding: 12px 0; }}
129    .toolbar {{ display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }}
130    .toolbar input {{ background: #242438; border: 1px solid #2e2e47; color: #edeedf; padding: 5px 8px; border-radius: 4px; font-size: 0.8rem; width: 200px; }}
131    .toolbar input::placeholder {{ color: #666680; }}
132    .toolbar input:focus {{ outline: none; border-color: #bf4dff; }}
133    .tabs {{ display: flex; gap: 4px; flex-shrink: 0; }}
134    .tab {{ background: none; border: 1px solid #2e2e47; color: #8c8ca6; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }}
135    .tab:hover {{ color: #edeedf; border-color: #666680; }}
136    .tab.active {{ color: #bf4dff; border-color: #bf4dff; background: #382952; }}
137    .counts {{ font-size: 0.7rem; color: #666680; margin-left: 3px; }}
138    /* confirm modal */
139    .modal-overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }}
140    .modal-overlay.visible {{ display: flex; }}
141    .modal {{ background: #141421; border: 1px solid #2e2e47; border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; }}
142    .modal h3 {{ color: #edeedf; font-size: 1rem; margin-bottom: 8px; }}
143    .modal p {{ color: #8c8ca6; font-size: 0.85rem; margin-bottom: 16px; line-height: 1.4; }}
144    .modal .modal-actions {{ display: flex; gap: 8px; justify-content: flex-end; }}
145    .modal .btn-cancel {{ border-color: #2e2e47; color: #8c8ca6; padding: 6px 16px; font-size: 0.8rem; }}
146    .modal .btn-cancel:hover {{ border-color: #666680; color: #edeedf; }}
147    .modal .btn-confirm {{ background: #3a1a2a; border-color: #ff7373; color: #ff7373; padding: 6px 16px; font-size: 0.8rem; }}
148    .modal .btn-confirm:hover {{ background: #4a2030; border-color: #ffa0a0; color: #ffa0a0; }}
149    @media (max-width: 680px) {{
150      body {{ width: auto; padding: 24px 16px; }}
151      .toolbar {{ flex-wrap: wrap; }}
152      .toolbar input {{ width: 100%; }}
153      .tabs {{ width: 100%; }}
154      .tab {{ flex: 1; text-align: center; }}
155      form.inline {{ flex-wrap: wrap; }}
156      form.inline input:first-of-type {{ flex: 1 1 100%; }}
157      form.inline input:nth-of-type(2) {{ flex: 1 1 auto; }}
158      .btn-add {{ flex-shrink: 0; }}
159      col.col-name {{ width: 100px; }}
160      col.col-actions {{ width: 60px; }}
161    }}
162  </style>
163</head>
164<body>
165  <h1>Bookmarks</h1>
166  <p class="subtitle"><a href="{project_url}" target="_blank" rel="noopener">bookmarks</a> in your filesystem</p>
167  <div id="content">
168    {body}
169  </div>
170
171  <!-- confirm modal -->
172  <div class="modal-overlay" id="confirm-modal">
173    <div class="modal">
174      <h3 id="confirm-title">confirm delete</h3>
175      <p id="confirm-message"></p>
176      <div class="modal-actions">
177        <button class="btn btn-cancel" onclick="closeModal()">cancel</button>
178        <button class="btn btn-confirm" id="confirm-btn">delete</button>
179      </div>
180    </div>
181  </div>
182
183  <script>
184    // -- confirm modal ---
185    var pendingAction = null;
186    function confirmDelete(title, message, action) {{
187      document.getElementById('confirm-title').textContent = title;
188      document.getElementById('confirm-message').textContent = message;
189      document.getElementById('confirm-modal').classList.add('visible');
190      pendingAction = action;
191      // wire up confirm button
192      var btn = document.getElementById('confirm-btn');
193      btn.onclick = function() {{
194        var action = pendingAction;
195        closeModal();
196        if (action) action();
197      }};
198    }}
199    function closeModal() {{
200      document.getElementById('confirm-modal').classList.remove('visible');
201      pendingAction = null;
202    }}
203    // close on escape or clicking overlay
204    document.getElementById('confirm-modal').addEventListener('click', function(e) {{
205      if (e.target === this) closeModal();
206    }});
207    document.addEventListener('keydown', function(e) {{
208      if (e.key === 'Escape') closeModal();
209    }});
210
211    // -- open all group URLs ---
212    function openGroup(urls) {{
213      urls.forEach(function(u) {{ window.open(u, '_blank', 'noopener'); }});
214    }}
215
216    // -- copy to clipboard ---
217    function copyUrl(btn, text) {{
218      navigator.clipboard.writeText(text).then(function() {{
219        btn.classList.add('copied');
220        btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
221        setTimeout(function() {{
222          btn.classList.remove('copied');
223          btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
224        }}, 1500);
225      }});
226    }}
227
228    // -- inline edit ---
229    function startEdit(type, name, field, currentValue) {{
230      var cell = event.target.closest('td');
231      if (cell.querySelector('.edit-input')) return; // already editing
232      var original = cell.innerHTML;
233      var done = false;
234      var input = document.createElement('input');
235      input.className = 'edit-input';
236      input.value = currentValue;
237      function finish(save) {{
238        if (done) return;
239        done = true;
240        if (save && input.value.trim() && input.value !== currentValue) {{
241          submitEdit(type, name, field, input.value.trim(), cell, original);
242        }} else {{
243          cell.innerHTML = original;
244        }}
245      }}
246      input.addEventListener('keydown', function(e) {{
247        if (e.key === 'Enter') {{ e.preventDefault(); finish(true); }}
248        if (e.key === 'Escape') {{ finish(false); }}
249      }});
250      input.addEventListener('blur', function() {{ finish(true); }});
251      cell.innerHTML = '';
252      cell.appendChild(input);
253      input.focus();
254      input.select();
255    }}
256    function submitEdit(type, name, field, value, cell, original) {{
257      var params = new URLSearchParams();
258      if (field === 'name') params.append('new_name', value);
259      if (field === 'url') params.append('new_url', value);
260      if (field === 'aliases') params.append('new_aliases', value);
261      if (field === 'entries') params.append('new_entries', value);
262      fetch('/edit/' + type + '/' + encodeURIComponent(name), {{method: 'POST', headers: {{'Content-Type': 'application/x-www-form-urlencoded'}}, body: params.toString()}})
263        .then(function(r) {{ if (!r.ok) throw new Error(r.statusText); return r.text(); }})
264        .then(function(html) {{ document.getElementById('content').innerHTML = html; }})
265        .catch(function() {{ cell.innerHTML = original; }});
266    }}
267
268    // -- single delete via modal ---
269    function deleteSingle(type, name) {{
270      confirmDelete(
271        'delete ' + type,
272        'are you sure you want to delete ' + type + ' "' + name + '"? this cannot be undone.',
273        function() {{
274          htmx.ajax('POST', '/delete/' + type + '/' + encodeURIComponent(name), {{target: '#content', swap: 'innerHTML'}});
275        }}
276      );
277    }}
278
279    // -- checkbox selection ---
280    function updateBulkBar() {{
281      var checked = document.querySelectorAll('input.row-check:checked');
282      var bar = document.getElementById('bulk-bar');
283      var count = document.getElementById('bulk-count');
284      if (checked.length > 0) {{
285        bar.classList.add('visible');
286        count.textContent = checked.length + ' selected';
287      }} else {{
288        bar.classList.remove('visible');
289      }}
290    }}
291    function toggleAll(src) {{
292      var boxes = document.querySelectorAll('input.row-check');
293      // only toggle visible rows
294      boxes.forEach(function(cb) {{
295        if (cb.closest('tr').style.display !== 'none') cb.checked = src.checked;
296      }});
297      updateBulkBar();
298    }}
299    function deleteSelected() {{
300      var checked = document.querySelectorAll('input.row-check:checked');
301      if (checked.length === 0) return;
302      var items = [];
303      checked.forEach(function(cb) {{ items.push(cb.dataset.type + ' "' + cb.dataset.name + '"'); }});
304      var count = checked.length;
305      confirmDelete(
306        'delete ' + count + ' item' + (count > 1 ? 's' : ''),
307        'are you sure you want to delete: ' + items.join(', ') + '? this cannot be undone.',
308        function() {{
309          // collect names grouped by type
310          var toDelete = [];
311          checked.forEach(function(cb) {{
312            toDelete.push({{type: cb.dataset.type, name: cb.dataset.name}});
313          }});
314          // delete sequentially, refresh at end
315          var i = 0;
316          function next() {{
317            if (i >= toDelete.length) {{
318              htmx.ajax('GET', '/content', {{target: '#content', swap: 'innerHTML'}});
319              return;
320            }}
321            var item = toDelete[i++];
322            fetch('/delete/' + item.type + '/' + encodeURIComponent(item.name), {{method: 'POST'}}).then(next);
323          }}
324          next();
325        }}
326      );
327    }}
328
329    // -- filter ---
330    function filterRows() {{
331      var q = document.getElementById('search').value.toLowerCase();
332      document.querySelectorAll('table tr[data-filter]').forEach(function(row) {{
333        row.style.display = row.getAttribute('data-filter').toLowerCase().includes(q) ? '' : 'none';
334      }});
335    }}
336    function showTab(tab) {{
337      ['urls','groups'].forEach(function(t) {{
338        var el = document.getElementById('section-' + t);
339        var btn = document.getElementById('tab-' + t);
340        if (el) el.style.display = (t === tab || tab === 'all') ? '' : 'none';
341        if (btn) btn.classList.toggle('active', t === tab);
342      }});
343      var allBtn = document.getElementById('tab-all');
344      if (allBtn) allBtn.classList.toggle('active', tab === 'all');
345      filterRows();
346    }}
347
348    // re-bind checkboxes after htmx swaps
349    document.body.addEventListener('htmx:afterSwap', function() {{
350      updateBulkBar();
351      // uncheck select-all headers
352      document.querySelectorAll('.select-all').forEach(function(cb) {{ cb.checked = false; }});
353    }});
354  </script>
355</body>
356</html>"##
357    )
358}
359
360fn resolve_url<'a>(name: &str, config: &'a Config) -> Option<&'a str> {
361    bookmarks_core::open::resolve_uri(name, config).ok()
362}
363
364fn linked_name(name: &str, url: &str) -> String {
365    let n = escape(name);
366    let u = escape(url);
367    format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{n}</a>"##)
368}
369
370fn copy_btn(url: &str) -> String {
371    let uj = escape_js(url);
372    format!(
373        r##"<button class="copy-btn" onclick="copyUrl(this,'{uj}')" title="copy to clipboard"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>"##
374    )
375}
376
377fn url_row(name: &str, entry: &UrlEntry) -> String {
378    let n = escape(name);
379    let nj = escape_js(name);
380    let url = entry.url();
381    let u = escape(url);
382    let uj = escape_js(url);
383    let name_link = linked_name(name, url);
384    let copy = copy_btn(url);
385    let aliases = entry.aliases();
386    let aliases_raw = aliases.join(", ");
387    let aliases_raw_js = escape_js(&aliases_raw);
388    let aliases_html = if aliases.is_empty() {
389        r#"<span class="editable" style="color:#666680;font-size:0.8rem;font-style:italic">+ aliases</span>"#.to_string()
390    } else {
391        let escaped: Vec<String> = aliases.iter().map(|a| escape(a)).collect();
392        format!(
393            r#"<span style="color:#a640f2;font-size:0.8rem">{}</span>"#,
394            escaped.join(", ")
395        )
396    };
397    format!(
398        r##"<tr data-filter="{n} {u} {aliases_filter}">
399  <td class="check"><input type="checkbox" class="row-check" data-type="url" data-name="{n}" onchange="updateBulkBar()"></td>
400  <td class="name editable" ondblclick="startEdit('url','{nj}','name','{nj}')">{name_link}</td>
401  <td class="url editable" ondblclick="startEdit('url','{nj}','url','{uj}')"><span class="url-cell">{copy}<a href="{u}" target="_blank" rel="noopener">{u}</a></span></td>
402  <td class="aliases editable" ondblclick="startEdit('url','{nj}','aliases','{aliases_raw_js}')">{aliases_html}</td>
403  <td class="actions">
404    <button class="btn btn-danger" onclick="deleteSingle('url','{nj}')">delete</button>
405  </td>
406</tr>"##,
407        aliases_filter = escape(&aliases.join(" ")),
408    )
409}
410
411fn group_row(name: &str, entries: &[String], config: &Config) -> String {
412    let n = escape(name);
413    let nj = escape_js(name);
414    // Collect resolved URLs for the "open all" action
415    let urls: Vec<String> = entries
416        .iter()
417        .filter_map(|entry| resolve_url(entry, config).map(escape_js))
418        .collect();
419    let urls_arr: String = urls
420        .iter()
421        .map(|u| format!("'{u}'"))
422        .collect::<Vec<_>>()
423        .join(",");
424
425    let entry_links: Vec<String> = entries
426        .iter()
427        .map(|entry| {
428            let e = escape(entry);
429            if let Some(url) = resolve_url(entry, config) {
430                let u = escape(url);
431                format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{e}</a>"##)
432            } else {
433                e
434            }
435        })
436        .collect();
437    let entries_html = entry_links.join(", ");
438    let filter_str: String = entries
439        .iter()
440        .map(|e| escape(e))
441        .collect::<Vec<_>>()
442        .join(", ");
443    let name_cell = if urls.is_empty() {
444        n.clone()
445    } else {
446        format!(
447            r##"<a href="#" onclick="openGroup([{urls_arr}]);return false;" title="open all {count} urls">{n}</a>"##,
448            count = urls.len()
449        )
450    };
451    let entries_raw_js = escape_js(&entries.join(", "));
452    format!(
453        r##"<tr data-filter="{n} {filter_str}">
454  <td class="check"><input type="checkbox" class="row-check" data-type="group" data-name="{n}" onchange="updateBulkBar()"></td>
455  <td class="name editable" ondblclick="startEdit('group','{nj}','name','{nj}')">{name_cell}</td>
456  <td class="entries editable" ondblclick="startEdit('group','{nj}','entries','{entries_raw_js}')">{entries_html}</td>
457  <td class="actions">
458    <button class="btn btn-danger" onclick="deleteSingle('group','{nj}')">delete</button>
459  </td>
460</tr>"##
461    )
462}
463
464#[derive(Debug, Clone, Copy, PartialEq)]
465enum SortField {
466    Name,
467    Url,
468}
469
470fn render_content(config: &Config, sort: SortField, error: Option<&str>) -> String {
471    let mut urls: Vec<_> = config.urls.iter().collect();
472    let mut groups: Vec<_> = config.groups.iter().collect();
473
474    match sort {
475        SortField::Name => {
476            urls.sort_by_key(|(k, _)| k.as_str());
477            groups.sort_by_key(|(k, _)| k.as_str());
478        }
479        SortField::Url => {
480            urls.sort_by_key(|(_, v)| v.url());
481            groups.sort_by_key(|(k, _)| k.as_str());
482        }
483    }
484
485    let name_cls = if sort == SortField::Name {
486        " active"
487    } else {
488        ""
489    };
490    let url_cls = if sort == SortField::Url {
491        " active"
492    } else {
493        ""
494    };
495
496    let mut html = String::new();
497
498    // Toolbar: search + tab filter
499    html.push_str(&format!(
500        r##"<div class="toolbar">
501  <input id="search" type="text" placeholder="{ph_filter}" oninput="filterRows()" autocomplete="off">
502  <div class="tabs">
503    <button id="tab-all" class="tab active" onclick="showTab('all')">all</button>
504    <button id="tab-urls" class="tab" onclick="showTab('urls')">urls<span class="counts">{uc}</span></button>
505    <button id="tab-groups" class="tab" onclick="showTab('groups')">groups<span class="counts">{gc}</span></button>
506  </div>
507</div>"##,
508        ph_filter = strings::PH_FILTER,
509        uc = urls.len(),
510        gc = groups.len(),
511    ));
512
513    // Error banner
514    if let Some(msg) = error {
515        let m = escape(msg);
516        html.push_str(&format!(
517            r##"<div class="error-banner" onclick="this.remove()">{m} <span style="margin-left:8px;cursor:pointer;opacity:0.6">✕</span></div>"##
518        ));
519    }
520
521    // Bulk action bar (hidden until selection)
522    html.push_str(
523        r##"<div class="bulk-bar" id="bulk-bar">
524  <span class="bulk-count" id="bulk-count">0 selected</span>
525  <button class="btn btn-danger" onclick="deleteSelected()">delete selected</button>
526  <button class="btn" onclick="document.querySelectorAll('input.row-check').forEach(function(c){{c.checked=false}});updateBulkBar()">clear</button>
527</div>"##,
528    );
529
530    // Add forms at the top
531    html.push_str(&format!(
532        r##"<div class="section">
533<form class="inline" hx-post="/add/url" hx-target="#content">
534  <input name="name" placeholder="{ph_url_name}" required>
535  <input name="url" placeholder="{ph_url}" required>
536  <button class="btn btn-add" type="submit">+ url</button>
537</form>
538<form class="inline" hx-post="/add/group" hx-target="#content">
539  <input name="name" placeholder="{ph_group_name}" required>
540  <input name="entries" placeholder="{ph_group_entries}" required>
541  <button class="btn btn-add" type="submit">+ group</button>
542</form>
543</div>"##,
544        ph_url_name = strings::PH_URL_NAME,
545        ph_url = strings::PH_URL,
546        ph_group_name = strings::PH_GROUP_NAME,
547        ph_group_entries = strings::PH_GROUP_ENTRIES,
548    ));
549
550    // Urls section
551    html.push_str(r##"<div class="section" id="section-urls"><h2>urls</h2>"##);
552    if urls.is_empty() {
553        html.push_str(r#"<p class="empty">no urls yet</p>"#);
554    } else {
555        html.push_str(&format!(
556            r##"<table><colgroup><col class="col-check"><col class="col-name"><col class="col-value"><col style="width:120px"><col class="col-actions"></colgroup><tr><th class="check"><input type="checkbox" class="select-all" onchange="toggleAll(this)"></th><th class="sortable{name_cls}" hx-get="/content?sort=name" hx-target="#content">name</th><th class="sortable{url_cls}" hx-get="/content?sort=url" hx-target="#content">url</th><th>aliases</th><th></th></tr>"##,
557        ));
558        for (name, entry) in &urls {
559            html.push_str(&url_row(name, entry));
560        }
561        html.push_str("</table>");
562    }
563    html.push_str("</div>");
564
565    // Groups section
566    html.push_str(r##"<div class="section" id="section-groups"><h2>groups</h2>"##);
567    if groups.is_empty() {
568        html.push_str(r#"<p class="empty">no groups yet</p>"#);
569    } else {
570        html.push_str(&format!(
571            r##"<table><colgroup><col class="col-check"><col class="col-name"><col class="col-value"><col class="col-actions"></colgroup><tr><th class="check"><input type="checkbox" class="select-all" onchange="toggleAll(this)"></th><th class="sortable{name_cls}" hx-get="/content?sort=name" hx-target="#content">group</th><th>entries</th><th></th></tr>"##,
572        ));
573        for (name, entries) in &groups {
574            html.push_str(&group_row(name, entries, config));
575        }
576        html.push_str("</table>");
577    }
578    html.push_str("</div>");
579
580    html
581}
582
583// -- Handlers ----------------------------------------------------------------
584
585type S = State<Arc<AppState>>;
586type Form = axum::extract::Form<std::collections::HashMap<String, String>>;
587
588#[derive(Debug, serde::Deserialize, Default)]
589struct ContentQuery {
590    #[serde(default)]
591    sort: Option<String>,
592}
593
594fn parse_sort(q: &ContentQuery) -> SortField {
595    match q.sort.as_deref() {
596        Some("url") => SortField::Url,
597        _ => SortField::Name,
598    }
599}
600
601async fn index(State(state): S, q: Query<ContentQuery>) -> Html<String> {
602    Html(page(&render_content(
603        &state.load_config(),
604        parse_sort(&q),
605        None,
606    )))
607}
608
609async fn content(State(state): S, q: Query<ContentQuery>) -> Html<String> {
610    Html(render_content(&state.load_config(), parse_sort(&q), None))
611}
612
613fn content_ok(state: &Arc<AppState>) -> Html<String> {
614    Html(render_content(&state.load_config(), SortField::Name, None))
615}
616
617fn content_err(state: &Arc<AppState>, msg: &str) -> Html<String> {
618    Html(render_content(
619        &state.load_config(),
620        SortField::Name,
621        Some(msg),
622    ))
623}
624
625fn modify_or_err(
626    state: &Arc<AppState>,
627    f: impl FnOnce(&mut Config) -> Result<(), String>,
628) -> Html<String> {
629    match state.modify_config(f) {
630        Ok(()) => content_ok(state),
631        Err(e) => content_err(state, &e),
632    }
633}
634
635async fn add_url(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
636    let name = form.get("name").cloned().unwrap_or_default();
637    let url = form.get("url").cloned().unwrap_or_default();
638    if !name.is_empty() && !url.is_empty() {
639        return modify_or_err(&state, |config| {
640            config.urls.insert(name, UrlEntry::Simple(url));
641            Ok(())
642        });
643    }
644    content_ok(&state)
645}
646
647async fn add_group(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
648    let name = form.get("name").cloned().unwrap_or_default();
649    let entries_raw = form.get("entries").cloned().unwrap_or_default();
650    if !name.is_empty() && !entries_raw.is_empty() {
651        let entries: Vec<String> = entries_raw
652            .split(',')
653            .map(|s| s.trim().to_string())
654            .filter(|s| !s.is_empty())
655            .collect();
656        if !entries.is_empty() {
657            return modify_or_err(&state, |config| {
658                let missing: Vec<&str> = entries
659                    .iter()
660                    .filter(|e| !config.contains(e))
661                    .map(String::as_str)
662                    .collect();
663                if !missing.is_empty() {
664                    return Err(strings::err_group_entries_missing(&missing));
665                }
666                config.groups.insert(name, entries);
667                Ok(())
668            });
669        }
670    }
671    content_ok(&state)
672}
673
674async fn delete_url(State(state): S, Path(name): Path<String>) -> Html<String> {
675    modify_or_err(&state, |config| {
676        config.delete_url(&name).map_err(|e| e.to_string())
677    })
678}
679
680async fn delete_group(State(state): S, Path(name): Path<String>) -> Html<String> {
681    modify_or_err(&state, |config| {
682        config.delete_group(&name).map_err(|e| e.to_string())
683    })
684}
685
686// -- Edit handlers -----------------------------------------------------------
687
688async fn edit_url(
689    State(state): S,
690    Path(name): Path<String>,
691    axum::extract::Form(form): Form,
692) -> Html<String> {
693    let new_name = form.get("new_name").filter(|s| !s.is_empty()).cloned();
694    let new_url = form.get("new_url").filter(|s| !s.is_empty()).cloned();
695    let new_aliases = form.get("new_aliases").cloned();
696
697    modify_or_err(&state, |config| {
698        // Rename first (can fail), then update value on the (possibly new) key
699        let key = if let Some(ref new_name) = new_name
700            && new_name != &name
701        {
702            config
703                .rename_url(&name, new_name)
704                .map_err(|e| e.to_string())?;
705            new_name.clone()
706        } else {
707            name
708        };
709
710        if let Some(ref new_url) = new_url
711            && let Some(entry) = config.urls.get_mut(&key)
712        {
713            entry.set_url(new_url.clone());
714        }
715
716        // Update aliases if provided
717        if let Some(ref new_aliases) = new_aliases {
718            let aliases: Vec<String> = new_aliases
719                .split(',')
720                .map(|s| s.trim().to_string())
721                .filter(|s| !s.is_empty())
722                .collect();
723            if let Some(entry) = config.urls.get_mut(&key) {
724                match entry {
725                    UrlEntry::Simple(url) => {
726                        if !aliases.is_empty() {
727                            *entry = UrlEntry::Full {
728                                url: url.clone(),
729                                aliases,
730                            };
731                        }
732                    }
733                    UrlEntry::Full {
734                        aliases: existing, ..
735                    } => {
736                        *existing = aliases;
737                    }
738                }
739            }
740        }
741
742        Ok(())
743    })
744}
745
746async fn edit_group(
747    State(state): S,
748    Path(name): Path<String>,
749    axum::extract::Form(form): Form,
750) -> Html<String> {
751    let new_name = form.get("new_name").filter(|s| !s.is_empty()).cloned();
752    let new_entries = form.get("new_entries").filter(|s| !s.is_empty()).cloned();
753
754    modify_or_err(&state, |config| {
755        // Parse and validate entries before any mutation
756        let parsed_entries = if let Some(ref new_entries) = new_entries {
757            let entries: Vec<String> = new_entries
758                .split(',')
759                .map(|s| s.trim().to_string())
760                .filter(|s| !s.is_empty())
761                .collect();
762            let missing: Vec<&str> = entries
763                .iter()
764                .filter(|e| !config.contains(e))
765                .map(String::as_str)
766                .collect();
767            if !missing.is_empty() {
768                return Err(strings::err_group_entries_missing(&missing));
769            }
770            Some(entries)
771        } else {
772            None
773        };
774
775        // Rename first, then update entries on the (possibly new) key
776        let key = if let Some(ref new_name) = new_name
777            && new_name != &name
778        {
779            config
780                .rename_group(&name, new_name)
781                .map_err(|e| e.to_string())?;
782            new_name.clone()
783        } else {
784            name
785        };
786
787        if let Some(entries) = parsed_entries
788            && let Some(existing) = config.groups.get_mut(&key)
789        {
790            *existing = entries;
791        }
792
793        Ok(())
794    })
795}
796
797// -- Server ------------------------------------------------------------------
798
799fn create_router(storage: Box<dyn Storage>) -> Router {
800    let state = Arc::new(AppState {
801        storage: Mutex::new(storage),
802    });
803
804    Router::new()
805        .route("/", get(index))
806        .route("/content", get(content))
807        .route("/add/url", post(add_url))
808        .route("/add/group", post(add_group))
809        .route("/delete/url/{name}", post(delete_url))
810        .route("/delete/group/{name}", post(delete_group))
811        .route("/edit/url/{name}", post(edit_url))
812        .route("/edit/group/{name}", post(edit_group))
813        .with_state(state)
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use axum::body::Body;
820    use http_body_util::BodyExt;
821    use tower::ServiceExt;
822
823    /// In-memory storage backend for tests.
824    struct MemStorage {
825        config: Mutex<Config>,
826    }
827
828    impl MemStorage {
829        fn new() -> Self {
830            Self {
831                config: Mutex::new(Config::default()),
832            }
833        }
834    }
835
836    impl Storage for MemStorage {
837        fn load(&self) -> anyhow::Result<Config> {
838            Ok(self.config.lock().unwrap().clone())
839        }
840
841        fn save(&self, config: &Config) -> anyhow::Result<()> {
842            *self.config.lock().unwrap() = config.clone();
843            Ok(())
844        }
845
846        fn init(&self) -> anyhow::Result<()> {
847            Ok(())
848        }
849
850        fn backend_name(&self) -> &str {
851            "memory"
852        }
853    }
854
855    fn test_app() -> Router {
856        create_router(Box::new(MemStorage::new()))
857    }
858
859    async fn response_status(
860        app: Router,
861        method: &str,
862        uri: &str,
863        body: Option<&str>,
864    ) -> (axum::http::StatusCode, String) {
865        let req = axum::http::Request::builder().method(method).uri(uri);
866
867        let req = if let Some(b) = body {
868            req.header("content-type", "application/x-www-form-urlencoded")
869                .body(Body::from(b.to_string()))
870                .unwrap()
871        } else {
872            req.body(Body::empty()).unwrap()
873        };
874
875        let resp = app.oneshot(req).await.unwrap();
876        let status = resp.status();
877        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
878        let text = String::from_utf8_lossy(&bytes).to_string();
879        (status, text)
880    }
881
882    #[tokio::test]
883    async fn get_index_returns_200() {
884        let (status, body) = response_status(test_app(), "GET", "/", None).await;
885        assert_eq!(status, 200);
886        assert!(body.contains("Bookmarks"));
887    }
888
889    #[tokio::test]
890    async fn get_content_returns_200() {
891        let (status, _) = response_status(test_app(), "GET", "/content", None).await;
892        assert_eq!(status, 200);
893    }
894
895    #[tokio::test]
896    async fn add_and_delete_url() {
897        let app = test_app();
898
899        // Add a URL
900        let (status, body) = response_status(
901            app.clone(),
902            "POST",
903            "/add/url",
904            Some("name=rust&url=https%3A%2F%2Frust-lang.org"),
905        )
906        .await;
907        assert_eq!(status, 200);
908        assert!(body.contains("rust"));
909        assert!(body.contains("https://rust-lang.org"));
910
911        // Verify it appears on the index
912        let (_, body) = response_status(app.clone(), "GET", "/", None).await;
913        assert!(body.contains("rust-lang.org"));
914
915        // Delete it
916        let (status, body) = response_status(app.clone(), "POST", "/delete/url/rust", None).await;
917        assert_eq!(status, 200);
918        assert!(!body.contains("rust-lang.org"));
919    }
920
921    #[tokio::test]
922    async fn add_url_empty_fields_is_noop() {
923        let app = test_app();
924        let (status, body) =
925            response_status(app.clone(), "POST", "/add/url", Some("name=&url=")).await;
926        assert_eq!(status, 200);
927        assert!(body.contains("no urls yet"));
928    }
929
930    #[tokio::test]
931    async fn add_group_with_valid_entries() {
932        let app = test_app();
933
934        // First add a URL so the group can reference it
935        let _ = response_status(
936            app.clone(),
937            "POST",
938            "/add/url",
939            Some("name=gh&url=https%3A%2F%2Fgithub.com"),
940        )
941        .await;
942
943        // Add a group referencing the URL
944        let (status, body) = response_status(
945            app.clone(),
946            "POST",
947            "/add/group",
948            Some("name=dev&entries=gh"),
949        )
950        .await;
951        assert_eq!(status, 200);
952        assert!(body.contains("dev"));
953    }
954
955    #[tokio::test]
956    async fn add_group_with_missing_entries_shows_error() {
957        let app = test_app();
958
959        // Try to add a group referencing a non-existent URL
960        let (status, body) = response_status(
961            app.clone(),
962            "POST",
963            "/add/group",
964            Some("name=bad&entries=nonexistent"),
965        )
966        .await;
967        assert_eq!(status, 200);
968        // Should show an error banner
969        assert!(body.contains("error-banner"));
970    }
971
972    #[tokio::test]
973    async fn delete_nonexistent_url_shows_error() {
974        let app = test_app();
975        let (status, body) = response_status(app.clone(), "POST", "/delete/url/nope", None).await;
976        assert_eq!(status, 200);
977        assert!(body.contains("error-banner"));
978    }
979
980    #[tokio::test]
981    async fn edit_url_rename() {
982        let app = test_app();
983
984        // Add a URL
985        let _ = response_status(
986            app.clone(),
987            "POST",
988            "/add/url",
989            Some("name=old&url=https%3A%2F%2Fexample.com"),
990        )
991        .await;
992
993        // Rename it
994        let (status, body) =
995            response_status(app.clone(), "POST", "/edit/url/old", Some("new_name=fresh")).await;
996        assert_eq!(status, 200);
997        assert!(body.contains("fresh"));
998        assert!(!body.contains(">old<"));
999    }
1000
1001    #[tokio::test]
1002    async fn sort_by_url() {
1003        let app = test_app();
1004        let (status, _) = response_status(app.clone(), "GET", "/content?sort=url", None).await;
1005        assert_eq!(status, 200);
1006    }
1007}
1008
1009pub fn run_webapp(storage: Box<dyn Storage>) -> anyhow::Result<()> {
1010    let rt = tokio::runtime::Runtime::new()?;
1011    rt.block_on(async {
1012        let port: u16 = 1414;
1013        let app = create_router(storage);
1014        let addr = SocketAddr::from(([127, 0, 0, 1], port));
1015
1016        println!("bookmarks webapp: http://localhost:{port}");
1017        let _ = open::that(format!("http://localhost:{port}"));
1018
1019        let listener = tokio::net::TcpListener::bind(addr).await?;
1020        axum::serve(listener, app)
1021            .with_graceful_shutdown(async {
1022                tokio::signal::ctrl_c()
1023                    .await
1024                    .expect("failed to listen for ctrl+c");
1025                println!("\nshutting down...");
1026            })
1027            .await?;
1028        Ok(())
1029    })
1030}