Add HTML bookmark import via Netscape format parser

This commit is contained in:
Einar 2026-05-11 14:46:29 +02:00
parent c64b64c571
commit c64ddd41a2
3 changed files with 150 additions and 3 deletions

View file

@ -1,4 +1,5 @@
from fastapi import FastAPI, UploadFile, File, HTTPException
from html.parser import HTMLParser
from fastapi.responses import JSONResponse, HTMLResponse
import json as json_lib
from fastapi.middleware.cors import CORSMiddleware
@ -285,3 +286,120 @@ async def export_html():
body = "\n".join(lines)
headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.html"}
return HTMLResponse(content=body, headers=headers)
class BookmarkParser(HTMLParser):
def __init__(self):
super().__init__()
self.categories = []
self._cat = None
self._sub = None
self._depth = 0
self._in_h3 = False
self._in_a = False
self._current_href = None
self._dl_depth = 0
self._cat_dl_depth = None
self._sub_dl_depth = None
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'dl':
self._dl_depth += 1
elif tag == 'h3':
self._in_h3 = True
elif tag == 'a':
self._in_a = True
self._current_href = attrs.get('href', '')
def handle_endtag(self, tag):
if tag == 'dl':
if self._cat_dl_depth is not None and self._dl_depth == self._cat_dl_depth:
self._cat = None
self._cat_dl_depth = None
self._sub_dl_depth = None
elif self._sub_dl_depth is not None and self._dl_depth == self._sub_dl_depth:
self._sub = None
self._sub_dl_depth = None
self._dl_depth -= 1
elif tag == 'h3':
self._in_h3 = False
elif tag == 'a':
self._in_a = False
self._current_href = None
def handle_data(self, data):
data = data.strip()
if not data:
return
if self._in_h3:
if self._cat is None:
self._cat = {"name": data, "subcategories": []}
self.categories.append(self._cat)
self._cat_dl_depth = self._dl_depth + 1
else:
self._sub = {"name": data, "links": []}
self._cat["subcategories"].append(self._sub)
self._sub_dl_depth = self._dl_depth + 1
elif self._in_a and self._current_href:
if self._sub is None:
if not self._cat["subcategories"] or self._cat["subcategories"][-1]["name"] != "General":
self._sub = {"name": "General", "links": []}
self._cat["subcategories"].append(self._sub)
else:
self._sub = self._cat["subcategories"][-1]
self._sub["links"].append({"label": data, "url": self._current_href})
@app.post("/import/html")
async def import_html(file: UploadFile = File(...)):
content = (await file.read()).decode("utf-8", errors="replace")
parser = BookmarkParser()
parser.feed(content)
imported_cats = 0
imported_subs = 0
imported_links = 0
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA foreign_keys = ON")
async with db.execute("SELECT COALESCE(MAX(position), -1) FROM categories") as cur:
row = await cur.fetchone()
cat_pos = (row[0] or -1) + 1
for cat_data in parser.categories:
cursor = await db.execute(
"INSERT INTO categories (name, icon, position) VALUES (?, ?, ?)",
(cat_data["name"], "ti-folder", cat_pos)
)
cat_id = cursor.lastrowid
cat_pos += 1
imported_cats += 1
sub_pos = 0
for sub_data in cat_data["subcategories"]:
cursor = await db.execute(
"INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)",
(cat_id, sub_data["name"], sub_pos)
)
sub_id = cursor.lastrowid
sub_pos += 1
imported_subs += 1
for link_pos, link_data in enumerate(sub_data["links"]):
await db.execute(
"INSERT INTO links (subcategory_id, label, url, favicon, position) VALUES (?, ?, ?, ?, ?)",
(sub_id, link_data["label"], link_data["url"], None, link_pos)
)
imported_links += 1
await db.commit()
return {
"imported": {
"categories": imported_cats,
"subcategories": imported_subs,
"links": imported_links
}
}

View file

@ -442,3 +442,27 @@ document.getElementById('export-btn').addEventListener('click', (e) => {
document.addEventListener('click', () => {
document.getElementById('export-dropdown').classList.remove('open');
});
// Import
function triggerImport() {
document.getElementById('export-dropdown').classList.remove('open');
document.getElementById('import-file-input').click();
}
document.getElementById('import-file-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(API + '/import/html', { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const result = await res.json();
const i = result.imported;
alert('Imported: ' + i.categories + ' categories, ' + i.subcategories + ' subcategories, ' + i.links + ' links.');
await loadCategories();
} catch (err) {
alert('Import failed: ' + err.message);
}
e.target.value = '';
});

View file

@ -20,17 +20,22 @@
</button>
<div class="export-wrapper">
<button id="export-btn" class="btn-ghost">
<i class="ti ti-download"></i> Export
<i class="ti ti-transfer"></i> Import / Export
</button>
<div class="export-dropdown" id="export-dropdown">
<button class="export-option" onclick="triggerImport()">
<i class="ti ti-upload"></i> Import browser bookmarks
</button>
<div style="height:1px;background:var(--border);margin:4px 0;"></div>
<button class="export-option" onclick="exportBookmarks('json')">
<i class="ti ti-braces"></i> JSON
<i class="ti ti-braces"></i> Export as JSON
</button>
<button class="export-option" onclick="exportBookmarks('html')">
<i class="ti ti-bookmark"></i> Browser bookmarks (HTML)
<i class="ti ti-bookmark"></i> Export as browser bookmarks
</button>
</div>
</div>
<input type="file" id="import-file-input" accept=".html,.htm" style="display:none" />
</div>
<nav id="category-nav"></nav>
<div id="subcategory-bar"></div>