Add HTML bookmark import via Netscape format parser
This commit is contained in:
parent
c64b64c571
commit
c64ddd41a2
3 changed files with 150 additions and 3 deletions
118
backend/main.py
118
backend/main.py
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue