diff --git a/components/modules/bookmarks-module.tsx b/components/modules/bookmarks-module.tsx index 855af4e..88428b0 100644 --- a/components/modules/bookmarks-module.tsx +++ b/components/modules/bookmarks-module.tsx @@ -26,6 +26,7 @@ import { api } from "@/src/lib/trpc/client"; import { AlertCircle, Bookmark, + Download, Edit, ExternalLink, Loader2, @@ -33,6 +34,7 @@ import { Search, Star, Trash2, + Upload, X, } from "lucide-react"; import { useState } from "react"; @@ -65,6 +67,7 @@ export function BookmarksModule({ // State const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); const [editingBookmark, setEditingBookmark] = useState(null); const [bookmarkToDelete, setBookmarkToDelete] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -72,6 +75,16 @@ export function BookmarksModule({ const [favoritesOnly, setFavoritesOnly] = useState(false); const [newTag, setNewTag] = useState(""); const [isFetchingMetadata, setIsFetchingMetadata] = useState(false); + const [parsedBookmarks, setParsedBookmarks] = useState< + Array<{ + url: string; + title: string; + favicon?: string; + tags: string[]; + selected: boolean; + }> + >([]); + const [selectAll, setSelectAll] = useState(true); // Form state const [formData, setFormData] = useState({ @@ -169,6 +182,31 @@ export function BookmarksModule({ }, }); + const parseHTMLMutation = api.bookmarks.parseHTMLFile.useMutation({ + onSuccess: (data) => { + setParsedBookmarks(data.map((b) => ({ ...b, selected: true }))); + setSelectAll(true); + setImportDialogOpen(true); + toast.success(`Found ${data.length} bookmarks`); + }, + onError: (error) => { + toast.error("Failed to parse HTML file"); + }, + }); + + const importMutation = api.bookmarks.importBookmarks.useMutation({ + onSuccess: (data) => { + toast.success(`Imported ${data.count} bookmarks successfully`); + utils.bookmarks.getAll.invalidate(); + utils.bookmarks.getTags.invalidate(); + setImportDialogOpen(false); + setParsedBookmarks([]); + }, + onError: (error) => { + toast.error(error.message || "Failed to import bookmarks"); + }, + }); + // Handlers const handleFetchMetadata = () => { if (!formData.url.trim()) { @@ -302,6 +340,75 @@ export function BookmarksModule({ } }; + const handleImport = async (event: React.ChangeEvent) => { + if (!isAuthenticated) { + onAuthRequired?.(); + return; + } + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + parseHTMLMutation.mutate({ html: text }); + } catch (error) { + toast.error("Failed to read HTML file"); + } + + event.target.value = ""; + }; + + const handleExport = async () => { + if (!isAuthenticated) { + onAuthRequired?.(); + return; + } + try { + const html = await utils.bookmarks.exportHTML.fetch(); + const blob = new Blob([html], { type: "text/html" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `bookmarks-${ + new Date().toISOString().split("T")[0] + }.html`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast.success("Bookmarks exported successfully"); + } catch (error) { + toast.error("Failed to export bookmarks"); + } + }; + + const handleToggleSelectAll = () => { + const newSelectAll = !selectAll; + setSelectAll(newSelectAll); + setParsedBookmarks((prev) => + prev.map((b) => ({ ...b, selected: newSelectAll })) + ); + }; + + const handleToggleBookmark = (index: number) => { + setParsedBookmarks((prev) => + prev.map((b, i) => (i === index ? { ...b, selected: !b.selected } : b)) + ); + }; + + const handleConfirmImport = () => { + const selectedBookmarks = parsedBookmarks + .filter((b) => b.selected) + .map(({ url, title, favicon, tags }) => ({ url, title, favicon, tags })); + + if (selectedBookmarks.length === 0) { + toast.error("Please select at least one bookmark to import"); + return; + } + + importMutation.mutate({ bookmarks: selectedBookmarks }); + }; + return (
{/* Header Actions */} - +
+ + +
+ + + +
+
{/* Filters */}
@@ -674,6 +817,108 @@ export function BookmarksModule({ + + {/* Import Selection Dialog */} + + + + Import Bookmarks + + Select the bookmarks you want to import ( + {parsedBookmarks.filter((b) => b.selected).length} of{" "} + {parsedBookmarks.length} selected) + + + +
+ {/* Select All */} +
+ + +
+ + {/* Bookmarks List */} +
+ {parsedBookmarks.map((bookmark, index) => ( +
+ handleToggleBookmark(index)} + className="h-4 w-4 mt-0.5 shrink-0" + /> +
+ +

+ {bookmark.url} +

+ {bookmark.tags.length > 0 && ( +
+ {bookmark.tags.map((tag, tagIndex) => ( + + {tag} + + ))} +
+ )} +
+
+ ))} +
+
+ + + + + +
+
); diff --git a/package.json b/package.json index f4d90b0..7ddc85f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check": "pnpm lint && pnpm typecheck && pnpm build" }, "dependencies": { "@base-ui/react": "^1.0.0", @@ -35,10 +36,12 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vercel/analytics": "^1.6.1", "better-auth": "^1.4.7", + "cheerio": "^1.1.2", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "deepl-node": "^1.24.0", + "domhandler": "^5.0.3", "dotenv": "^17.2.3", "lucide-react": "^0.562.0", "next": "16.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cddae65..ade1128 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: better-auth: specifier: ^1.4.7 version: 1.4.7(@prisma/client@7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@16.1.0(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + cheerio: + specifier: ^1.1.2 + version: 1.1.2 chroma-js: specifier: ^3.2.0 version: 3.2.0 @@ -68,6 +71,9 @@ importers: deepl-node: specifier: ^1.24.0 version: 1.24.0 + domhandler: + specifier: ^5.0.3 + version: 5.0.3 dotenv: specifier: ^17.2.3 version: 17.2.3 diff --git a/src/server/routers/bookmarks.ts b/src/server/routers/bookmarks.ts index 441a640..098ad3c 100644 --- a/src/server/routers/bookmarks.ts +++ b/src/server/routers/bookmarks.ts @@ -2,6 +2,10 @@ import { db } from "@/src/lib/db"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { extractMetadata } from "../services/metadata-extractor"; +import { + generateBookmarksHTML, + parseBookmarksHTML, +} from "../services/bookmark-html"; const bookmarkSchema = z.object({ url: z.string().url("Invalid URL format"), @@ -188,4 +192,58 @@ export const bookmarksRouter = createTRPCRouter({ return uniqueTags; }), + + // Parse HTML file and return bookmarks for user selection + parseHTMLFile: protectedProcedure + .input(z.object({ html: z.string() })) + .mutation(async ({ input }) => { + const parsedBookmarks = parseBookmarksHTML(input.html); + return parsedBookmarks; + }), + + // Import selected bookmarks + importBookmarks: protectedProcedure + .input( + z.object({ + bookmarks: z.array( + z.object({ + url: z.string(), + title: z.string(), + favicon: z.string().optional(), + tags: z.array(z.string()).default([]), + }) + ), + }) + ) + .mutation(async ({ ctx, input }) => { + const created = await db.bookmark.createMany({ + data: input.bookmarks.map((bookmark) => ({ + userId: ctx.userId, + url: bookmark.url, + title: bookmark.title, + favicon: bookmark.favicon || undefined, + tags: bookmark.tags, + isFavorite: false, + })), + }); + + return { count: created.count }; + }), + + // Export all bookmarks as HTML + exportHTML: protectedProcedure.query(async ({ ctx }) => { + const bookmarks = await db.bookmark.findMany({ + where: { userId: ctx.userId }, + orderBy: { createdAt: "desc" }, + select: { + url: true, + title: true, + description: true, + favicon: true, + tags: true, + }, + }); + + return generateBookmarksHTML(bookmarks); + }), }); diff --git a/src/server/services/bookmark-html.ts b/src/server/services/bookmark-html.ts new file mode 100644 index 0000000..12432ee --- /dev/null +++ b/src/server/services/bookmark-html.ts @@ -0,0 +1,214 @@ +import * as cheerio from "cheerio"; +import type { Element } from "domhandler"; + +export interface ParsedBookmark { + url: string; + title: string; + favicon?: string; + tags: string[]; +} + +/** + * Parse HTML bookmarks file (Netscape format) + * Extracts folder hierarchy as tags + */ +export function parseBookmarksHTML(html: string): ParsedBookmark[] { + const $ = cheerio.load(html); + const bookmarks: ParsedBookmark[] = []; + + // Recursive function to parse bookmarks and their folder hierarchy + function parseNode( + node: Element, + folderPath: string[] = [], + isRootLevel = false + ) { + const $node = $(node); + + $node.children().each((_, child) => { + const $child = $(child); + + // If it's a folder (H3) + if (child.name === "h3") { + const folderName = $child.text().trim().toLowerCase(); + + // Skip root "Bookmarks" folder and "Bookmarks bar" variants + const isRootBookmarksFolder = + isRootLevel && + (folderName === "bookmarks" || + folderName === "bookmarks bar" || + folderName === "barre personnelle"); + + // Find the next DL sibling for nested content + const $nextDL = $child.next("dl"); + if ($nextDL.length > 0) { + if (isRootBookmarksFolder) { + // Skip adding this folder to the path, just recurse + parseNode($nextDL[0], folderPath, false); + } else { + parseNode($nextDL[0], [...folderPath, folderName], false); + } + } + } + // If it's a DL, recurse into it + else if (child.name === "dl") { + parseNode(child, folderPath, isRootLevel); + } + // If it's a DT, check for links or folders + else if (child.name === "dt") { + // Check for nested folders first (H3 tags) + const $h3 = $child.children("h3").first(); + if ($h3.length > 0) { + const folderName = $h3.text().trim().toLowerCase(); + const isRootBookmarksFolder = + isRootLevel && + (folderName === "bookmarks" || + folderName === "bookmarks bar" || + folderName === "barre personnelle"); + + const $nextDL = $h3.next("dl"); + if ($nextDL.length > 0) { + if (isRootBookmarksFolder) { + parseNode($nextDL[0], folderPath, false); + } else { + parseNode($nextDL[0], [...folderPath, folderName], false); + } + } + } + // Otherwise check for direct bookmark links (A tags) + else { + const $link = $child.children("a").first(); + if ($link.length > 0) { + const url = $link.attr("href"); + const title = $link.text().trim(); + const icon = $link.attr("icon"); + + if (url && title) { + bookmarks.push({ + url, + title, + favicon: icon || undefined, + tags: folderPath, + }); + } + } + } + } + }); + } + + // Start parsing from the root + parseNode($("body")[0] || $("html")[0], [], true); + + return bookmarks; +} + +/** + * Generate HTML bookmarks file (Netscape format) + * Creates folder hierarchy based on tags + */ +export function generateBookmarksHTML( + bookmarks: Array<{ + url: string; + title: string; + favicon?: string | null; + description?: string | null; + tags: string[]; + }> +): string { + const timestamp = Math.floor(Date.now() / 1000); + + let html = ` + + +Bookmarks +

Bookmarks from AdaTools

+

+`; + + // Group bookmarks by their tag path + const bookmarksByPath = new Map(); + + bookmarks.forEach((bookmark) => { + const pathKey = bookmark.tags.join("/") || "_root"; + if (!bookmarksByPath.has(pathKey)) { + bookmarksByPath.set(pathKey, []); + } + bookmarksByPath.get(pathKey)!.push(bookmark); + }); + + // Sort paths to handle nested folders correctly + const sortedPaths = Array.from(bookmarksByPath.keys()).sort(); + + // Track which folders we've already opened + const openFolders = new Set(); + + function generateFolderStructure( + path: string[], + bookmarksInPath: typeof bookmarks, + indent: number + ): string { + let result = ""; + + if (path.length > 0) { + // Open folders for this path + for (let i = 0; i < path.length; i++) { + const folderPath = path.slice(0, i + 1).join("/"); + if (!openFolders.has(folderPath)) { + const folderIndent = " ".repeat(indent + i); + result += `${folderIndent}

${escapeHtml( + path[i] + )}

\n`; + result += `${folderIndent}

\n`; + openFolders.add(folderPath); + } + } + } + + // Add bookmarks + const bookmarkIndent = " ".repeat(indent + path.length); + bookmarksInPath.forEach((bookmark) => { + const iconAttr = bookmark.favicon ? ` ICON="${bookmark.favicon}"` : ""; + result += `${bookmarkIndent}

${escapeHtml( + bookmark.title + )}\n`; + }); + + return result; + } + + // Process all paths + sortedPaths.forEach((pathKey) => { + const bookmarksInPath = bookmarksByPath.get(pathKey)!; + const path = pathKey === "_root" ? [] : pathKey.split("/"); + html += generateFolderStructure(path, bookmarksInPath, 1); + }); + + // Close all opened folders + const allPaths = Array.from(openFolders) + .map((p) => p.split("/")) + .sort((a, b) => b.length - a.length); // Close deepest first + + allPaths.forEach((path) => { + const indent = " ".repeat(path.length); + html += `${indent}

\n`; + }); + + html += `

`; + + return html; +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (m) => map[m] || m); +}