diff --git a/backend/main.py b/backend/main.py index 6710eb5..eda3e68 100644 --- a/backend/main.py +++ b/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 + } + } diff --git a/frontend/app.js b/frontend/app.js index 4527542..42a4d46 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = ''; +}); diff --git a/frontend/index.html b/frontend/index.html index 6922708..a0b1b1b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,17 +20,22 @@
+ +
+