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 fastapi import FastAPI, UploadFile, File, HTTPException
from html.parser import HTMLParser
from fastapi.responses import JSONResponse, HTMLResponse from fastapi.responses import JSONResponse, HTMLResponse
import json as json_lib import json as json_lib
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -285,3 +286,120 @@ async def export_html():
body = "\n".join(lines) body = "\n".join(lines)
headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.html"} headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.html"}
return HTMLResponse(content=body, headers=headers) 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.addEventListener('click', () => {
document.getElementById('export-dropdown').classList.remove('open'); 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> </button>
<div class="export-wrapper"> <div class="export-wrapper">
<button id="export-btn" class="btn-ghost"> <button id="export-btn" class="btn-ghost">
<i class="ti ti-download"></i> Export <i class="ti ti-transfer"></i> Import / Export
</button> </button>
<div class="export-dropdown" id="export-dropdown"> <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')"> <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>
<button class="export-option" onclick="exportBookmarks('html')"> <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> </button>
</div> </div>
</div> </div>
<input type="file" id="import-file-input" accept=".html,.htm" style="display:none" />
</div> </div>
<nav id="category-nav"></nav> <nav id="category-nav"></nav>
<div id="subcategory-bar"></div> <div id="subcategory-bar"></div>