Sync Bookmarks across Vanadium and Trivalent on secureblue

I got tired of using Brave just because of bookmark sync, especially on desktop when Trivalent is not missing import/export HTML that Vanadium is missing. So I wrote two scripts to create my own sync. Syncthing is necessary for this to work. This is a one way sync but there is a way to bypass that, I will tell how I solve this in the end (using KDE Connect).

This project is only possible because of GitHub - Baro82/grapheneos-vanadium-bookmarks-helper: A small utility to export Chromium-based browser bookmarks (including favicons) into an HTML+JS format that can be viewed and used directly inside the Vanadium browser on GrapheneOS. and ChatGPT for optimizing my terrible bash and adding regex search to the project UI.

To do this:

On secureblue, change paths and run this command from the home directory:

Script 1
mkdir -p ~/Documents/Bookmarks && cat > ~/Documents/Bookmarks/bookmarks.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Bookmarks</title>
    <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
    <style>
        :root {
            --bg: #0f151e;
            --panel: #111827;
            --muted: #9aa4b2;
            --text: #e5e7eb;
            --accent: #60a5fa;
            --border: #1f2937;
            --bgfolder: #192330;
            --bgitem: #314258;
        }
        html, body { height: 100%; }
        body {
            background-color: var(--bg);
            min-height: 100%;
            margin: 0px; 
            font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
            font-size: 14px;
            color: var(--text);
        }
        
        main { padding: 16px; }

        .viewer { padding: 0px; overflow: auto; }
        ul.tree { list-style: none; padding-left: 0; margin: 0; }
        ul li { padding: 10px 015px; border-radius: 6px; background-color: var(--bgitem); margin: 10px 0px; }
        ul li.liRoot { background-color: transparent !important; margin: 0px; padding: 0px; border:none; }
        ul li.liFolder { background-color: var(--bgfolder); }
        ul li.liFolder:hover {  background-color: color-mix(in srgb, var(--bgfolder) 95%, white); cursor: pointer; -webkit-tap-highlight-color: transparent; }

        .children { margin-left: 8px; border-left: 1px dashed #263246; padding-left: 13px; }
        
        .caret { user-select: none; font-size: 14px; text-align: center; }
        .caret::before { content: '\25B6'; display: inline-block; margin-right: 10px; transform: rotate(0deg); transition: transform 120ms ease; }
        .caret.open::before { transform: rotate(90deg); }

        .node { display: grid;  align-items: center; }
        
        .node:not(.bookmark) { grid-template-columns: 30px 1fr auto; grid-template-rows: auto auto; gap:4px; }
        
        .node:not(.bookmark) .title { font-size: 16px; font-weight: bold; }
        .node.folder:not(.bookmark) .count { font-size: 0.75em; color: var(--muted); }

        .node.bookmark { grid-template-columns: 35px 1fr; grid-template-rows: auto auto; gap:0px; }
        .node.bookmark img       { grid-row: 1 / 3; grid-column: 1 / 2; width: 22px; vertical-align: middle; }
        .node.bookmark .title    { grid-row: 1 / 2; grid-column: 2 / 3;  font-size: 0.90em; overflow-wrap: break-word; }
        .node.bookmark .count    { grid-row: 2 / 3; grid-column: 2 / 3;  font-size: 0.75em; overflow-wrap: break-word; color: var(--muted); }

        a.link { color: var(--text); text-decoration: none; }
        a.link:hover { color: var(--accent); text-decoration: underline; }

        .empty { color: var(--muted); padding: 12px; }

    </style>
</head>
<body>
    
    <main>
    <input
  id="searchBox"
  placeholder="Regex search (title, url, domain)…"
  style="width:100%; padding:10px; margin-bottom:12px;"
>

        <section class="viewer">
            <ul class="tree" id="tree"></ul>
            <div class="empty" id="emptyState">No data loaded.</div>
        </section>
    </main>

    <script src="bookmarks_data.js"></script>
    <script>

        const $tree = $('#tree');
        const $empty = $('#emptyState');
        const $totals = $('#totals');

        const isFolder = (n) => n && n.type === 'folder';
        const isBookmark = (n) => n && (n.type === 'url' || (n.url && !n.children));

        function rootsFrom(data) {
            if (!data || !data.roots) return [];

            return Object.entries(data.roots)
                .filter(([_, value]) => Array.isArray(value.children) && value.children.length > 0)
                .map(([key, value]) => ({ key, ...value }));
        }


        function countNodes(node){
            let folders=0, links=0;
            function walk(n){
                if(isFolder(n)){ folders++; (n.children||[]).forEach(walk); }
                else if(isBookmark(n)){ links++; }
            }
            walk(node);
            return { folders, links };
        }

        function buildFolderNode(folder){
            const id = 'f_' + Math.random().toString(36).slice(2,9);
            const $li = $('<li class="liFolder">');
            const { folders, links } = countNodes(folder);
            const $row = $('<div class="node folder">');
            const $caret = $('<span class="caret" aria-label="toggle" role="button" tabindex="0"></span>');
            const $title = $('<span class="title"></span>').text(folder.name || '(folder)');
            const $count = $('<span class="count"></span>').text(`${folders-1} folders Β· ${links} link`);
            const $children = $('<ul class="children" hidden></ul>').attr('id', id);

            $row.append($caret, $title, $count);
            $li.append($row, $children);

            $row.on('click keydown', (e)=>{ if(e.type==='click' || e.key==='Enter' || e.key===' '){ $caret.toggleClass('open'); $children.prop('hidden', !$children.prop('hidden')); }});

            (folder.children||[]).forEach(child => {
                if(isFolder(child)) $children.append(buildFolderNode(child));
                else if(isBookmark(child)) $children.append(buildBookmarkNode(child));
            });

            return $li;
        }

        function buildBookmarkNode(bm) {

            const $li = $('<li>');
            const $row = $('<div class="node bookmark">');

            const $title = $('<span class="title"></span>');
            const $details = $('<span class="count"></span>');
            const title = bm.name || bm.url || '(blank title)';
            const url = bm.url || '#';
            const favicon = bm.favicon || 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAANFJREFUSEtjZKAxYKSx+QwDY8H9+/cNWFhYzpPiuz9//mgqKireQNeD1QePHz/+T4rhMLWysrIY5uG1AJsGbBbDHDRqAc5oGQ0igil2NIiGSRA9efJk+v///zNA3mFkZFwgIyOTCPMa2ZH8588fSWipKoEjnF6ASlEWFpb3IHmSyyI0Q0/8+fPHESTGwsKynYGBwQHdUnItaABqbMRRitYDxRvIKq4ZGRktZWRkThBMQgwMDE+ePLH4////fllZWU6iKhxiDCVWzcDUycS6jhh1AE5lnxnG/RVzAAAAAElFTkSuQmCC';
            const $favicon = $(`<img src="${favicon}"/>`);
            $title.append($('<a class="link" target="_blank" rel="noopener noreferrer"></a>').attr('href', url).text(title));
            
            try {
                const u = new URL(url);
                $details.text(u.hostname);
            } catch(_) { $details.text(''); }

            $row.append($favicon, $title, $details);
            $li.append($row);
            return $li;
        }

        function render(data){
            $tree.empty();
            if(!data){ $empty.show(); return; }
            $empty.hide();
            const rts = rootsFrom(data);
            let totalLinks=0, totalFolders=0;
            rts.forEach(rt => {

                const {folders, links} = countNodes(rt);

                const $section = $('<li class="liRoot">');
                const $header = $('<div class="node" style="font-weight:600"></div>');
                const label = rt.name;
                $header.append('<span class="caret open"></span>');
                $header.append($('<span class="title"></span>').text(label));
                
                totalLinks += links; totalFolders += folders; 
                $header.append($('<span class="count"></span>').text(`${folders-1} folders Β· ${links} link`));
                const $children = $('<ul class="children"></ul>');
                (rt.children||[]).forEach(child => {
                    if(isFolder(child)) $children.append(buildFolderNode(child));
                    else if(isBookmark(child)) $children.append(buildBookmarkNode(child));
                });
                $section.append($header, $children);
                $tree.append($section);
            });
            $('#statLinks').text(totalLinks);
            $('#statFolders').text(Math.max(0,totalFolders - rootsFrom(data).length));
            $('#statRoots').text(rts.length);
            $totals.text(`${totalLinks} link Β· ${Math.max(0,totalFolders - rts.length)} folders`);
        }

        render(window.BOOKMARKS_JSON);

    </script>
    <script>
(() => {
    const $search = $('#searchBox');

    $search.on('input', () => {
        let regex;
        const value = $search.val().trim();

        // RESET: show full tree when search is empty
        if (!value) {
            $('li').show();
            $('.children').each(function () {
                const $caret = $(this).siblings('.node').find('.caret');
                if (!$caret.hasClass('open')) {
                    $(this).prop('hidden', true);
                }
            });
            return;
        }

        try {
            regex = new RegExp(value, 'i');
        } catch {
            return; // invalid regex
        }

        // Hide everything first
        $('li').hide();

        // Match bookmarks
        $('.node.bookmark').each(function () {
            const $bm = $(this);
            const text =
                $bm.find('.title').text() + ' ' +
                ($bm.find('.link').attr('href') || '') + ' ' +
                $bm.find('.count').text();

            if (regex.test(text)) {
                const $li = $bm.closest('li');

                // Show bookmark
                $li.show();

                // Show & expand all parents
                $li.parents('li').each(function () {
                    $(this).show();
                    $(this)
                        .children('.children')
                        .prop('hidden', false)
                        .siblings('.node')
                        .find('.caret')
                        .addClass('open');
                });
            }
        });
    });
})();
</script>

</body>
</html>
EOF

Then you need open the Bookmarks folder in Documents and periodically run this script from there (Use cron or whatever, I use kde connect which I talk about at the end):

Script 2
rm bookmarks_data.js; cp /home/user/.config/trivalent/Default/Bookmarks /home/user/Documents/Bookmarks && mv Bookmarks bookmarks_data.js && sed -i '1iwindow.BOOKMARKS_JSON = ' bookmarks_data.js && echo ";"  >>  bookmarks_data.js && echo "" && echo -e "\e[31mThanks to Baro82 for their project: https://github.com/Baro82/grapheneos-vanadium-bookmarks-helper . I just added a search bar to the UI and wrote a bash script instead of python script.\e[0m"

Use syncthing to share this folder between PC and mobile.

Open GrapheneOS:

  1. Create a folder in Documents called Bookmarks that the PC folder syncs to
  2. Settings β†’ Apps β†’ Vanadium β†’ Permissions β†’ Photos and Videos β†’ Enable Storage Scopes β†’ Add the created folder
  3. Add this folder to syncthing and connect it with the desktop folder we created
  4. Vanadium β†’ Settings β†’ Homepage β†’ Set the custom address to β€œfile:///storage/emulated/0/Documents/Bookmarks/bookmarks.html”
  5. Enjoy.

Since this only syncs Trivalent β†’ Vanadium, you can use KDE Connect commands to run the second script on demand, and share links to desktop that you want to bookmark. Bookmarking on Vanadium will not sync across, since there is no way built in right now.

Hope this helped.

3 Likes

Here is an update. The setup is refined a lot now, just a python script you need to run periodically using cron, and add the index html file to the same folder where you run the script. That is it, it will handle everything else for you. In the future I aim to automate this too.

New features:

  1. Rewrite from scratch, this ditches the original reference project.
  2. Improved UI and animations
  3. Automatic day/night theme with a theme switch
  4. Fuzzy and exact search options
  5. Favicon caching and database for management (handles caching, deletion, etc. for all favicons).
  6. User facing progress indicators for script.

Future Updates:

  1. Automate 100%, including suggesting installing syncthing if system does not have it. User just runs the script and everything happens.
  2. Some way to add and remove bookmarks (research topic since it is impossible to get browser bookmark data directly on Android without root).

Then this would be feature complete. Maybe add alternate themes in the future.

Out of scope;

  1. Making this work for non-trivalent/vanadium browsers
  2. Making this work on non-Secureblue/GrapheneOS systems

People are free to modify it for the above two goals, the license is MIT :slight_smile:

Will look into putting these smaller projects in an non-personal git repository.

Bookmarks Script

generate_bookmarks.py
import json
import base64
import hashlib
import sqlite3
import sys
import time
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlopen
from urllib.error import URLError, HTTPError

# -------------------- Paths --------------------

BOOKMARKS_PATH = Path("/home/user/.config/trivalent/Default/Bookmarks")
OUTPUT_FILE = Path("bookmarks_data.js")
DB_PATH = Path("favicon_cache.db")

# -------------------- Config --------------------

STALE_DAYS = 30
PROGRESS_UPDATE_INTERVAL = 0.25  # seconds

# -------------------- Constants --------------------

PLACEHOLDER_FAVICON = (
    "data:image/png;base64,"
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
)

# -------------------- SQLite Setup --------------------

conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()

cur.execute("""
CREATE TABLE IF NOT EXISTS favicons (
    domain TEXT PRIMARY KEY,
    url_hash TEXT NOT NULL,
    favicon TEXT NOT NULL,
    last_seen INTEGER NOT NULL
)
""")

# -------------------- Stale Cache Cleanup --------------------

now = int(time.time())
cutoff = now - (STALE_DAYS * 86400)

cur.execute(
    "DELETE FROM favicons WHERE last_seen < ?",
    (cutoff,)
)
deleted = cur.rowcount
conn.commit()

# -------------------- Helpers --------------------

def url_hash(url: str) -> str:
    return hashlib.sha256(url.encode("utf-8")).hexdigest()

def get_domain(url: str) -> str | None:
    return urlparse(url).hostname

def fetch_favicon(domain: str) -> str:
    try:
        favicon_url = f"https://www.google.com/s2/favicons?sz=16&domain={domain}"
        with urlopen(favicon_url, timeout=5) as resp:
            data = resp.read()
        return "data:image/png;base64," + base64.b64encode(data).decode()
    except (URLError, HTTPError, TimeoutError):
        return PLACEHOLDER_FAVICON

def print_progress(done: int, total: int, start: float):
    percent = (done / total * 100) if total else 100
    elapsed = time.time() - start
    rate = done / elapsed if elapsed else 0
    sys.stdout.write(
        f"\rProcessing: {done}/{total} "
        f"({percent:5.1f}%) | {rate:6.1f} urls/s"
    )
    sys.stdout.flush()

# -------------------- Load Bookmarks --------------------

with BOOKMARKS_PATH.open("r", encoding="utf-8") as f:
    bookmarks = json.load(f)

# -------------------- Count URLs --------------------

def count_urls(nodes):
    count = 0
    stack = list(nodes)
    while stack:
        node = stack.pop()
        if node.get("type") == "url":
            count += 1
        stack.extend(node.get("children", []))
    return count

roots = list(bookmarks.get("roots", {}).values())
total_urls = count_urls(roots)

# -------------------- Process Bookmarks --------------------

stack = roots[:]
processed = 0
new_fetches = 0
updates = 0
cache_hits = 0

start = time.time()
last_update = 0

while stack:
    node = stack.pop()

    if node.get("type") == "url":
        url = node.get("url", "")
        domain = get_domain(url)
        h = url_hash(url)

        if not domain:
            node["favicon"] = PLACEHOLDER_FAVICON
        else:
            cur.execute(
                "SELECT url_hash, favicon FROM favicons WHERE domain=?",
                (domain,)
            )
            row = cur.fetchone()

            if row:
                cached_hash, cached_favicon = row
                if cached_hash == h:
                    node["favicon"] = cached_favicon
                    cache_hits += 1
                else:
                    favicon = fetch_favicon(domain)
                    cur.execute(
                        "UPDATE favicons "
                        "SET url_hash=?, favicon=?, last_seen=? "
                        "WHERE domain=?",
                        (h, favicon, now, domain)
                    )
                    node["favicon"] = favicon
                    updates += 1
            else:
                favicon = fetch_favicon(domain)
                cur.execute(
                    "INSERT INTO favicons VALUES (?, ?, ?, ?)",
                    (domain, h, favicon, now)
                )
                node["favicon"] = favicon
                new_fetches += 1

            # Touch entry
            cur.execute(
                "UPDATE favicons SET last_seen=? WHERE domain=?",
                (now, domain)
            )

        processed += 1

        t = time.time()
        if t - last_update >= PROGRESS_UPDATE_INTERVAL:
            print_progress(processed, total_urls, start)
            last_update = t

    stack.extend(node.get("children", []))

conn.commit()
conn.close()

print_progress(processed, total_urls, start)
print()

# -------------------- Write Output --------------------

if OUTPUT_FILE.exists():
    OUTPUT_FILE.unlink()

with OUTPUT_FILE.open("w", encoding="utf-8") as f:
    f.write("const BOOKMARKS_DATA = ")
    json.dump(bookmarks, f, ensure_ascii=False)
    f.write(";")

print(
    f"Done βœ”  New: {new_fetches}, Updated: {updates}, "
    f"Cache hits: {cache_hits}, Stale removed: {deleted}"
)

Homepage

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bookmarks Explorer</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
  --bg:#f5f5f5; --surface:#fff; --surface-variant:#f1f3f4; --surface-tint:rgba(0,0,0,.03);
  --text-primary:#202124; --text-secondary:#5f6368; --primary:#1a73e8; --divider:#e0e0e0;
  --folder:#f9ab00; --radius-lg:20px; --radius-md:14px;
  --elevation-1:0 1px 2px rgba(0,0,0,.08); --header-height:64px; --search-height:64px;
}

[data-theme="dark"] {
  --bg:#0f172a; --surface:#111827; --surface-variant:#1f2937; --surface-tint:rgba(255,255,255,.04);
  --text-primary:#e5e7eb; --text-secondary:#9ca3af; --primary:#38bdf8; --divider:#1f2937;
  --folder:#facc15;
}

* { box-sizing:border-box; margin:0; font-family:Roboto,system-ui,sans-serif; }
body { background:var(--bg); color:var(--text-primary); }

/* HEADER */
header {
  position:fixed; top:0; left:0; right:0; height:var(--header-height);
  display:flex; align-items:center; justify-content:space-between;
  padding:0 20px; background:var(--surface); box-shadow:var(--elevation-1); z-index:20;
}
#theme-toggle { width:40px; height:40px; border-radius:50%; border:none; background:var(--surface-variant); font-size:1.2rem; cursor:pointer; }

/* SEARCH BAR */
#search-container {
  position:fixed; top: var(--header-height); left:0; right:0; height: var(--search-height);
  padding:12px 16px; background:var(--surface); box-shadow:0 2px 10px rgba(0,0,0,.1);
  z-index:15; display:flex; justify-content:center; align-items:center; gap:12px;
}

#search {
  flex:1; max-width:500px; padding:14px 18px; border-radius:999px; border:1px solid var(--divider);
  font-size:1rem; background:var(--surface-variant); color:var(--text-primary); transition: box-shadow .2s ease;
}
#search:focus { outline:none; box-shadow:0 0 0 2px var(--primary); }

/* TOGGLE SWITCH WITH LABELS */
.toggle-container { display:flex; align-items:center; gap:8px; font-size:0.9rem; color:var(--text-secondary); }
.switch { position: relative; display: inline-block; width:50px; height:26px; }
.switch input { opacity:0; width:0; height:0; }
.slider {
  position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0;
  background:var(--surface-variant); transition:.4s; border-radius:26px; border:1px solid var(--divider);
}
.slider:before {
  position:absolute; content:""; height:20px; width:20px; left:3px; bottom:3px;
  background:var(--surface); transition:.4s; border-radius:50%;
}
input:checked + .slider { background:var(--primary); }
input:checked + .slider:before { transform:translateX(24px); }

/* MAIN CONTENT */
main { max-width:960px; margin:auto; padding: calc(var(--header-height) + var(--search-height) + 16px) 16px 20px; }

/* FOLDERS */
.folder { margin:14px 0; }
.folder-title {
  display:flex; align-items:center; gap:12px; padding:14px 18px; border-radius:var(--radius-lg);
  cursor:pointer; background:var(--surface-variant); box-shadow:0 1px 2px rgba(0,0,0,.08); transition: background .2s;
}
.folder-title:hover { background: var(--surface); }
.folder-icon { color:var(--folder); }
.chevron { margin-left:auto; transition:transform .3s cubic-bezier(.2,1.4,.4,1); }
.folder.open .chevron { transform:rotate(90deg); }
.folder-count { margin-left:6px; padding:2px 8px; font-size:.75rem; font-weight:600; border-radius:999px; background:var(--surface-tint); }
.folder-content { margin-left:24px; padding-left:12px; border-left:1px solid var(--divider); display:none; }
.folder.open > .folder-content { display:block; }

/* BOOKMARKS */
.bookmark { display:flex; align-items:center; gap:10px; margin:6px 0; padding:10px 14px; border-radius:var(--radius-md); transition: background .2s; }
.bookmark:hover { background:var(--surface-variant); }
.bookmark img { width:18px; height:18px; border-radius:4px; }
.bookmark a { color:var(--primary); text-decoration:none; word-break:break-word; }
.hidden { display:none !important; }
</style>
</head>
<body>

<header>
  <h1>Bookmarks Explorer</h1>
  <button id="theme-toggle">πŸŒ™</button>
</header>

<div id="search-container">
  <input id="search" type="search" placeholder="Search bookmarks…">
  <div class="toggle-container">
    <span>Exact</span>
    <label class="switch">
      <input type="checkbox" id="fuzzy-toggle">
      <span class="slider"></span>
    </label>
    <span>Fuzzy</span>
  </div>
</div>

<main id="tree"></main>

<script src="bookmarks_data.js"></script>
<script>
/* ELEMENTS */
const tree = document.getElementById("tree");
const searchInput = document.getElementById("search");
const themeToggle = document.getElementById("theme-toggle");
const fuzzyToggle = document.getElementById("fuzzy-toggle");

/* THEME */
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
document.body.dataset.theme = prefersDark ? "dark" : "light";
themeToggle.textContent = prefersDark ? "β˜€οΈ" : "πŸŒ™";
themeToggle.onclick = () => {
  const dark = document.body.dataset.theme === "dark";
  document.body.dataset.theme = dark ? "light" : "dark";
  themeToggle.textContent = dark ? "πŸŒ™" : "β˜€οΈ";
};

/* LEVENSHTEIN & FUZZY SEARCH */
const levenshtein = (a,b) => {
  const dp = Array(a.length+1).fill(0).map(()=>Array(b.length+1).fill(0));
  for(let i=0;i<=a.length;i++) dp[i][0]=i;
  for(let j=0;j<=b.length;j++) dp[0][j]=j;
  for(let i=1;i<=a.length;i++){
    for(let j=1;j<=b.length;j++){
      if(a[i-1]===b[j-1]) dp[i][j]=dp[i-1][j-1];
      else dp[i][j]=1+Math.min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1]);
    }
  }
  return dp[a.length][b.length];
};

const fuzzyScore = (text, query) => {
  if(!query) return 1;
  text=text.toLowerCase(); query=query.toLowerCase();
  if(text.includes(query)) return 100;
  const t=text.split(/\s+/), q=query.split(/\s+/);
  let score=0;
  for(const qw of q){
    let best=Infinity;
    for(const tw of t) best=Math.min(best,levenshtein(qw,tw));
    if((qw.length<=4 && best<=1)||(qw.length>4 && best<=2)) score+=10;
  }
  return score;
};

/* RENDER FOLDERS */
const renderFolder = folder => {
  const wrap = document.createElement("div");
  wrap.className = "folder open";
  const title = document.createElement("div");
  title.className = "folder-title";
  title.innerHTML = `<span class="folder-icon">πŸ“</span>${folder.name}<span class="folder-count"></span><span class="chevron">β–Ά</span>`;
  const content = document.createElement("div");
  content.className = "folder-content";
  title.onclick = () => wrap.classList.toggle("open");
  folder.children?.forEach(item => {
    if(item.type === "folder") content.appendChild(renderFolder(item));
    else {
      const bm = document.createElement("div");
      bm.className = "bookmark";
      bm.dataset.search = `${item.name} ${item.url}`.toLowerCase();
      bm.innerHTML = `<img src="${item.favicon||""}"><a href="${item.url}" target="_blank">${item.name}</a>`;
      content.appendChild(bm);
    }
  });
  wrap.append(title, content);
  return wrap;
};

/* INITIAL RENDER */
Object.values(BOOKMARKS_DATA.roots).forEach(root => {
  if(root.children) tree.appendChild(renderFolder(root));
});

/* FILTER FUNCTION WITHOUT HIGHLIGHTING */
const filterFolder = folder => {
  let count = 0;
  const q = searchInput.value.trim().toLowerCase();
  const useFuzzy = fuzzyToggle.checked;
  const content = folder.querySelector(".folder-content");
  const bookmarks = Array.from(content.children).filter(c=>c.classList.contains("bookmark"));

  bookmarks.forEach(bm => {
    const text = bm.dataset.search;
    let match = false, score = 0;
    if(!q) match = true;
    else if(useFuzzy){ 
      score = fuzzyScore(text, q);
      match = score > 0;
    } else { 
      match = text.includes(q);
      score = match ? 100 : 0;
    }
    bm.classList.toggle("hidden", q && !match);
    bm.dataset.score = score;
    bm.querySelector("a").textContent = bm.querySelector("a").textContent; // remove highlight
    if(match) count++;
  });

  bookmarks.filter(b => !b.classList.contains("hidden"))
           .sort((a,b) => b.dataset.score - a.dataset.score)
           .forEach(b => content.appendChild(b));

  const subfolders = Array.from(content.children).filter(c => c.classList.contains("folder"));
  subfolders.forEach(f => { const c = filterFolder(f); f.classList.toggle("hidden", q && c===0); count += c; });

  folder.querySelector(".folder-count").textContent = count;
  subfolders.filter(f => !f.classList.contains("hidden"))
            .sort((a,b) => parseInt(b.querySelector(".folder-count").textContent) - parseInt(a.querySelector(".folder-count").textContent))
            .forEach(f => content.appendChild(f));

  folder.classList.toggle("hidden", q && count===0);
  return count;
};

/* SEARCH HANDLER */
const doFilter = () => Array.from(tree.children).forEach(filterFolder);
searchInput.addEventListener("input", doFilter);
fuzzyToggle.addEventListener("change", doFilter);
</script>

</body>
</html>

As before, this is an entirely Vibe coded project done because:

  1. I wish to be free from cloud dependency for even bookmark sync
  2. I wish to see how people can use AI to create hyper personalized simple tools