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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue