diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml new file mode 100644 index 0000000..1ef1c9a --- /dev/null +++ b/.github/workflows/contributors.yml @@ -0,0 +1,19 @@ +on: + push: + branches: + - develop + +jobs: + contrib-readme-job: + runs-on: ubuntu-latest + name: A job to automate contrib in readme + permissions: + contents: write + pull-requests: write + steps: + - name: Contribute List + uses: akhilmhdh/contributors-readme-action@v2.3.10 + with: + use_username: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 2a648d0..47920e7 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ ## Features 🌟 -- ⬇️ **Bookmark Exporting**: Easily export your bookmarks to different formats (JSON and HTML) for better accessibility and management. -- ⬆️ **Bookmark Importing**: Import bookmarks from JSON and HTML files, allowing you to transfer your bookmarks between browsers or restore from backups. +- ⬇️ **Bookmark Exporting**: Easily export your bookmarks to different formats (HTML, JSON, CSV) for better accessibility and management. +- ⬆️ **Bookmark Importing**: Import bookmarks from HTML, JSON and CSV files, allowing you to transfer your bookmarks between browsers or restore from backups. - 🔍 **Advanced Export**: Use the advanced export feature to selectively export bookmarks, search through your bookmark collection, and customize export settings. - 🌐 **Browser Compatibility**: Works seamlessly with Chromium-based web browsers, ensuring smooth operation across different platforms. - 📑 **Minimal Interface**: Clean and easy-to-use interface for quick access and efficient management of bookmarks. +- 🌙 **Theme Support**: Automatically adapts to your system's theme preferences for a consistent look. +- 🌍 **Multi-language**: Available in English and Spanish, automatically matching your browser's language. ## Tech Stack 🧰 @@ -34,7 +36,7 @@ Note: While this extension is primarily listed on the Chrome Web Store, it is co 1. Click on the extension icon in your browser toolbar to open the popup. 2. Choose between "Export" and "Import" tabs. 3. For basic exporting: - - Select either HTML or JSON format. + - Select either HTML, JSON or CSV format. - Click the corresponding button to export your bookmarks. - Choose a location on your device to save the exported file. 4. For advanced exporting: @@ -45,7 +47,7 @@ Note: While this extension is primarily listed on the Chrome Web Store, it is co - Choose the export format and click the export button. 5. For importing: - Click the "Import" button. - - Select a JSON or HTML file containing bookmarks. + - Select a CSV, JSON or HTML file containing bookmarks. - The extension will automatically detect the format and import the bookmarks. ## Local Development 🛠️ @@ -85,6 +87,34 @@ pnpm build pnpm package ``` +## Contributors 🤝 + +**We welcome your contributions!** If you'd like to be part of this list, simply fork this repository, make your changes, and open a pull request. Once merged, your avatar will appear below automatically. + + + + + + + + + +
+ + AndryOre +
+ AndryOre +
+
+ + aam1r +
+ aam1r +
+
+ + + ## License 📄 This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/assets/locales/en/messages.json b/assets/locales/en/messages.json index ab7dc4b..0191f5f 100644 --- a/assets/locales/en/messages.json +++ b/assets/locales/en/messages.json @@ -179,6 +179,10 @@ "message": "Add last modification dates for folders to the exported file", "description": "Include folder modification dates description" }, + "includeDateGroupModifiedTooltip": { + "message": "Note: Only available for HTML and JSON exports. Not applicable for CSV exports as they only include individual bookmarks.", + "description": "Include folder modification dates tooltip" + }, "hideOtherBookmarks": { "message": "Hide 'Other bookmarks' folder", "description": "Hide 'Other bookmarks' folder" @@ -393,5 +397,40 @@ }, "changelog_1_2_0_1": { "message": "Now automatically adapts to your browser's language! Available in English and Spanish." + }, + "exportFileNameCSV": { + "message": "Bookmarks.csv", + "description": "Export file name" + }, + "exportError": { + "message": "Failed to export bookmarks", + "description": "Export error" + }, + "exportCSV": { + "message": "Export CSV", + "description": "Export CSV" + }, + "exportToCSV": { + "message": "Export to CSV", + "description": "Export to CSV" + }, + "exportToCSVDescription": { + "message": "Export your bookmarks to CSV format for easy data manipulation and backup.", + "description": "Export to CSV description" + }, + "multiLanguageSupport": { + "message": "Multi-language support", + "description": "Multi-language support" + }, + "multiLanguageSupportDescription": { + "message": "Automatically adapts to your browser's language! Available in English and Spanish.", + "description": "Multi-language support description" + }, + "changelog_1_3_0_date": { + "message": "February 16, 2025", + "description": "Changelog 1.3.0 date" + }, + "changelog_1_3_0_1": { + "message": "Added dark mode support that automatically matches your system theme preferences." } } diff --git a/assets/locales/es/messages.json b/assets/locales/es/messages.json index dd0de69..3eb714e 100644 --- a/assets/locales/es/messages.json +++ b/assets/locales/es/messages.json @@ -179,6 +179,10 @@ "message": "Agregar fechas de modificación de carpetas al archivo exportado", "description": "Incluir fechas de modificación de carpetas descripción" }, + "includeDateGroupModifiedTooltip": { + "message": "Nota: Solo disponible para exportaciones HTML y JSON. No aplica para exportaciones CSV ya que estas solo incluyen marcadores individuales.", + "description": "Incluir fechas de modificación de carpetas tooltip" + }, "hideOtherBookmarks": { "message": "Ocultar carpeta 'Otros marcadores'", "description": "Ocultar carpeta 'Otros marcadores'" @@ -319,10 +323,6 @@ "message": "Marcadores.json", "description": "Nombre del archivo exportado" }, - "exportError": { - "message": "Error al exportar marcadores", - "description": "Error al exportar marcadores" - }, "close": { "message": "Cerrar", "description": "Cerrar" @@ -396,5 +396,40 @@ }, "changelog_1_2_0_1": { "message": "¡Ahora se adapta automáticamente al idioma de tu navegador! Disponible en español e inglés." + }, + "exportFileNameCSV": { + "message": "Marcadores.csv", + "description": "Nombre del archivo exportado" + }, + "exportError": { + "message": "Error al exportar marcadores", + "description": "Error al exportar marcadores" + }, + "exportCSV": { + "message": "Exportar CSV", + "description": "Exportar CSV" + }, + "exportToCSV": { + "message": "Exportar a CSV", + "description": "Exportar a CSV" + }, + "exportToCSVDescription": { + "message": "Exporta tus marcadores a formato CSV para facilitar la manipulación y el respaldo de datos.", + "description": "Exportar a CSV descripción" + }, + "multiLanguageSupport": { + "message": "Soporte Multi-idioma", + "description": "Soporte Multi-idioma" + }, + "multiLanguageSupportDescription": { + "message": "¡Se adapta automáticamente al idioma de tu navegador! Disponible en español e inglés.", + "description": "Soporte Multi-idioma descripción" + }, + "changelog_1_3_0_date": { + "message": "16 de febrero de 2025", + "description": "Fecha del changelog 1.3.0" + }, + "changelog_1_3_0_1": { + "message": "Añadido modo oscuro que se adapta automáticamente a las preferencias de tema de tu sistema." } } diff --git a/package.json b/package.json index 9d6ab5d..57fc5aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bookmarks-import-export", "displayName": "Bookmark Import/Export", - "version": "1.2.0", + "version": "1.3.0", "description": "__MSG_extensionDescription__", "author": "AndryOre", "scripts": { @@ -13,14 +13,17 @@ "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", + "@types/papaparse": "5.3.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", + "papaparse": "5.5.2", "plasmo": "0.90.2", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f1b6ce..40815a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-select': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-separator': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -32,6 +35,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/papaparse': + specifier: 5.3.15 + version: 5.3.15 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -41,6 +47,9 @@ importers: lucide-react: specifier: ^0.475.0 version: 0.475.0(react@18.2.0) + papaparse: + specifier: 5.5.2 + version: 5.5.2 plasmo: specifier: 0.90.2 version: 0.90.2(@swc/core@1.10.15(@swc/helpers@0.5.15))(@swc/helpers@0.5.15)(@types/node@20.11.5)(lodash@4.17.21)(postcss@8.5.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1387,6 +1396,9 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} + '@radix-ui/number@1.1.0': + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -1591,6 +1603,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.1.6': + resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.2': resolution: {integrity: sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==} peerDependencies: @@ -1995,6 +2020,9 @@ packages: '@types/node@20.11.5': resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} + '@types/papaparse@5.3.15': + resolution: {integrity: sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -3114,6 +3142,9 @@ packages: resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} engines: {node: '>=18'} + papaparse@5.5.2: + resolution: {integrity: sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5309,6 +5340,8 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@radix-ui/number@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-arrow@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -5498,6 +5531,35 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-select@2.1.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-direction': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.1.2(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + aria-hidden: 1.2.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.6.3(@types/react@18.2.48)(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-separator@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -5837,6 +5899,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/papaparse@5.3.15': + dependencies: + '@types/node': 20.11.5 + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.12': {} @@ -6318,7 +6384,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fastq@1.17.1: dependencies: @@ -6896,6 +6962,8 @@ snapshots: registry-url: 6.0.1 semver: 7.7.1 + papaparse@5.5.2: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/src/common/lib/detectFormat.ts b/src/common/lib/detectFormat.ts index b3334b4..99c4cf7 100644 --- a/src/common/lib/detectFormat.ts +++ b/src/common/lib/detectFormat.ts @@ -1,4 +1,5 @@ import { type BookmarkFormat } from "~common/types" +import Papa from "papaparse" /** * Detects the format of the bookmark content. @@ -16,6 +17,9 @@ export const detectFormat = ( case "application/json": return isValidJSON(content) ? "json" : "unknown" + case "text/csv": + return isValidCSV(content) ? "csv" : "unknown" + case "text/html": return isValidHTML(content) ? "html" : "unknown" @@ -46,3 +50,38 @@ const isValidJSON = (content: string): boolean => { const isValidHTML = (content: string): boolean => { return content.trim().startsWith("") } + +/** + * Checks if the content is valid CSV bookmark format. + * @param {string} content - The content to check. + * @returns {boolean} True if the content appears to be valid CSV bookmark format, false otherwise. + */ +const isValidCSV = (content: string): boolean => { + try { + const result = Papa.parse(content.trim(), { + header: true, + skipEmptyLines: true, + preview: 3 // Only parse first 3 rows for validation + }) + + // Check if parsing was successful and has data + if (result.errors.length > 0 || result.data.length === 0) { + return false + } + + // Check if required fields are present + const fields = result.meta.fields || [] + const hasRequiredFields = fields.some(field => + field.toLowerCase().includes("title") && + fields.some(field => field.toLowerCase().includes("url")) + ) + + if (!hasRequiredFields) { + return false + } + + return true + } catch { + return false + } +} diff --git a/src/common/lib/exporters/exportToCSV.ts b/src/common/lib/exporters/exportToCSV.ts new file mode 100644 index 0000000..3b3e1ca --- /dev/null +++ b/src/common/lib/exporters/exportToCSV.ts @@ -0,0 +1,192 @@ +import Papa from "papaparse" +import { getFaviconBase64 } from "~common/lib/favicon" +import type { DateOptions, ExtendedBookmarkTreeNode } from "~common/types" + +const toSeconds = (timestamp: number): number => { + return Math.floor(timestamp / 1000) +} + +const processBookmark = async ( + node: ExtendedBookmarkTreeNode, + parentFolder: string, + includeIconData: boolean, + dateOptions: DateOptions +): Promise> => { + const row: Record = { + title: node.title || "", + url: node.url, + folder: parentFolder + } + + if (dateOptions.includeDateAdded && node.dateAdded) { + row.dateAdded = toSeconds(node.dateAdded) + } + + if (dateOptions.includeDateLastUsed && node.dateLastUsed) { + row.dateLastUsed = toSeconds(node.dateLastUsed) + } + + if (includeIconData && node.url) { + row.iconData = await getFaviconBase64(node.url) + } + + return row +} + +const processFolder = async ( + node: ExtendedBookmarkTreeNode, + parentFolder: string, + includeIconData: boolean, + dateOptions: DateOptions, + hideParentFolder: boolean +): Promise>> => { + if (hideParentFolder && node.id !== "1" && node.id !== "2") { + return processChildrenDirectly( + node, + parentFolder, + includeIconData, + dateOptions, + hideParentFolder + ) + } else { + return processFolderWithStructure( + node, + parentFolder, + includeIconData, + dateOptions, + hideParentFolder + ) + } +} + +const processChildrenDirectly = async ( + node: ExtendedBookmarkTreeNode, + parentFolder: string, + includeIconData: boolean, + dateOptions: DateOptions, + hideParentFolder: boolean +): Promise>> => { + let rows: Array> = [] + for (const child of node.children || []) { + const childRows = await processNode( + child, + parentFolder, + includeIconData, + dateOptions, + hideParentFolder + ) + rows.push(...childRows) + } + return rows +} + +const processFolderWithStructure = async ( + node: ExtendedBookmarkTreeNode, + parentFolder: string, + includeIconData: boolean, + dateOptions: DateOptions, + hideParentFolder: boolean +): Promise>> => { + const newParentFolder = parentFolder + ? `${parentFolder}/${node.title}` + : node.title || "" + + let rows: Array> = [] + for (const child of node.children || []) { + const childRows = await processNode( + child, + newParentFolder, + includeIconData, + dateOptions, + hideParentFolder + ) + rows.push(...childRows) + } + return rows +} + +const processNode = async ( + node: ExtendedBookmarkTreeNode, + parentFolder: string, + includeIconData: boolean, + dateOptions: DateOptions, + hideParentFolder: boolean = false +): Promise>> => { + if (node.url) { + return [await processBookmark(node, parentFolder, includeIconData, dateOptions)] + } else if (node.children) { + return processFolder( + node, + parentFolder, + includeIconData, + dateOptions, + hideParentFolder + ) + } + return [] +} + +export const exportToCSV = async ( + selectedBookmarks: ExtendedBookmarkTreeNode[] | null = null, + includeIconData: boolean = true, + includeDateAdded: boolean = true, + includeDateLastUsed: boolean = false, + hideParentFolder: boolean = false +): Promise => { + const dateOptions: DateOptions = { + includeDateAdded, + includeDateLastUsed, + includeDateGroupModified: false + } + + return new Promise((resolve, reject) => { + try { + chrome.bookmarks.getTree(async (bookmarkTreeNodes) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError) + return + } + + let rows: Array> = [] + + if (selectedBookmarks) { + for (const bookmark of selectedBookmarks) { + const bookmarkRows = await processNode( + bookmark, + "", + includeIconData, + dateOptions, + hideParentFolder + ) + rows.push(...bookmarkRows) + } + } else { + rows = await processNode( + bookmarkTreeNodes[0], + "", + includeIconData, + dateOptions, + hideParentFolder + ) + } + + const fields = ["title", "url", "folder"] + if (includeDateAdded) fields.push("dateAdded") + if (includeDateLastUsed) fields.push("dateLastUsed") + if (includeIconData) fields.push("iconData") + + const csv = Papa.unparse(rows, { + quotes: true, + delimiter: ",", + header: true, + columns: fields + }) + + resolve(csv) + }) + } catch (error) { + reject(error) + } + }) +} + diff --git a/src/common/lib/exporters/index.ts b/src/common/lib/exporters/index.ts index f8964b3..4895a7c 100644 --- a/src/common/lib/exporters/index.ts +++ b/src/common/lib/exporters/index.ts @@ -1,2 +1,3 @@ export * from "./exportToJSON" export * from "./exportToHTML" +export * from "./exportToCSV" diff --git a/src/common/lib/importers/importFromCSV.ts b/src/common/lib/importers/importFromCSV.ts new file mode 100644 index 0000000..24d43d3 --- /dev/null +++ b/src/common/lib/importers/importFromCSV.ts @@ -0,0 +1,192 @@ +/** + * This module provides functionality to import bookmarks from CSV format into Chrome's bookmark structure. + * @module importFromCSV + */ + +import Papa from "papaparse" +import { type ParsedBookmark } from "~common/types" + +type CSVRow = { + title: string + url: string + folder?: string + [key: string]: string | undefined +} + +/** + * Custom error class for bookmark import errors. + */ +class BookmarkImportError extends Error { + constructor(message: string) { + super(message) + this.name = "BookmarkImportError" + } +} + +/** + * Recursively creates bookmarks and folders in Chrome. + * @param {ParsedBookmark[]} nodes - Array of bookmark nodes to create. + * @param {string} parentId - ID of the parent folder where bookmarks will be created. + * @throws {BookmarkImportError} If creating a bookmark or folder fails. + */ +const createBookmarks = async (nodes: ParsedBookmark[], parentId: string) => { + for (const node of nodes) { + try { + if (node.url) { + await chrome.bookmarks.create({ + parentId, + title: node.title, + url: node.url + }) + } else if (node.children && node.children.length > 0) { + const existingFolders = await chrome.bookmarks.search({ title: node.title }) + const existingFolder = existingFolders.find( + folder => folder.parentId === parentId + ) + + const folderId = existingFolder + ? existingFolder.id + : (await chrome.bookmarks.create({ + parentId, + title: node.title + })).id + + await createBookmarks(node.children, folderId) + } + } catch (error) { + throw new BookmarkImportError( + `Error creating ${node.url ? "bookmark" : "folder"} ${node.title}: ${error.message}` + ) + } + } +} + +/** + * Processes parsed bookmarks and creates them in Chrome's bookmark structure. + * @param {chrome.bookmarks.BookmarkTreeNode[]} bookmarkTreeNodes - Chrome's existing bookmark tree. + * @param {ParsedBookmark[]} parsedBookmarks - Array of parsed bookmarks from CSV. + * @throws {BookmarkImportError} If processing bookmarks fails. + */ +const processBookmarks = async ( + bookmarkTreeNodes: chrome.bookmarks.BookmarkTreeNode[], + parsedBookmarks: ParsedBookmark[] +) => { + const bookmarksBar = bookmarkTreeNodes[0].children?.[0] + const otherBookmarks = bookmarkTreeNodes[0].children?.[1] + + if (!bookmarksBar || !otherBookmarks) { + throw new BookmarkImportError( + "Could not find Bookmarks bar or Other bookmarks folder" + ) + } + + const existingFolders = await chrome.bookmarks.search({ title: "Imported bookmarks" }) + const importedFolder = existingFolders.length > 0 + ? existingFolders[0] + : await chrome.bookmarks.create({ + title: "Imported bookmarks" + }) + + await createBookmarks(parsedBookmarks, importedFolder.id) +} + +/** + * Parses CSV data into the bookmark structure. + * @param {CSVRow[]} csvData - The parsed CSV data. + * @returns {ParsedBookmark[]} Array of parsed bookmarks with folder structure. + */ +const processCSVData = (csvData: CSVRow[]): ParsedBookmark[] => { + const bookmarks: { [key: string]: ParsedBookmark } = {} + const rootBookmarks: ParsedBookmark[] = [] + const now = Date.now() + + csvData.forEach((row) => { + const title = row.title?.trim() + const url = row.url?.trim() + const folderPath = row.folder?.trim() || "" + + if (!title || !url) return + + try { + new URL(url) + } catch { + console.warn(`Skipping invalid URL for bookmark "${title}": ${url}`) + return + } + + const folders = folderPath.split('/') + .map(f => f.trim()) + .filter(f => f) + + let currentLevel = rootBookmarks + let currentPath = "" + + for (const folder of folders) { + currentPath = currentPath ? `${currentPath}/${folder}` : folder + + if (!bookmarks[currentPath]) { + const newFolder: ParsedBookmark = { + title: folder, + dateAdded: now, + dateGroupModified: now, + children: [] + } + bookmarks[currentPath] = newFolder + currentLevel.push(newFolder) + } + + currentLevel = bookmarks[currentPath].children! + } + + currentLevel.push({ + title, + url, + dateAdded: now + }) + }) + + return rootBookmarks +} + +/** + * Imports bookmarks from a CSV string into Chrome's bookmark structure. + * @param {string} csv - The CSV string containing bookmarks to import. + * @returns {Promise} A promise that resolves when the import is complete. + * @throws {BookmarkImportError} If the import process fails at any stage. + */ +export const importFromCSV = async (csv: string): Promise => { + try { + const result = Papa.parse(csv.trim(), { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.toLowerCase().trim() + }) + + if (result.errors.length > 0) { + throw new Error(`CSV parsing errors: ${result.errors.map(e => e.message).join(", ")}`) + } + + const parsedBookmarks = processCSVData(result.data) + + return new Promise((resolve, reject) => { + chrome.bookmarks.getTree(async (bookmarkTreeNodes) => { + if (chrome.runtime.lastError) { + reject( + new BookmarkImportError( + `Chrome API error: ${chrome.runtime.lastError.message}` + ) + ) + } else { + try { + await processBookmarks(bookmarkTreeNodes, parsedBookmarks) + resolve() + } catch (error) { + reject(error) + } + } + }) + }) + } catch (error) { + throw new BookmarkImportError(`Import failed: ${error.message}`) + } +} diff --git a/src/common/lib/importers/index.ts b/src/common/lib/importers/index.ts index 7f494d2..aa2f077 100644 --- a/src/common/lib/importers/index.ts +++ b/src/common/lib/importers/index.ts @@ -1,2 +1,3 @@ +export * from "./importFromCSV" export * from "./importFromJSON" export * from "./importFromHTML" diff --git a/src/common/types.ts b/src/common/types.ts index 35f22b3..1b80367 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -54,7 +54,7 @@ export interface ExtendedBookmarkTreeNode /** * Supported bookmark export formats. */ -export type BookmarkFormat = "json" | "html" | "unknown" +export type BookmarkFormat = "json" | "html" | "csv" | "unknown" /** * Interface for the imperative handle of the BookmarkTree component. @@ -109,7 +109,7 @@ export interface HeaderProps { /** Total number of bookmarks */ totalCount: number /** Callback function to export bookmarks */ - onExport: (format: "html" | "json") => void + onExport: (format: "html" | "json" | "csv") => void } /** diff --git a/src/components/advancedExport/bookmarkTree.tsx b/src/components/advancedExport/bookmarkTree.tsx index 0c5769e..8ac9cde 100644 --- a/src/components/advancedExport/bookmarkTree.tsx +++ b/src/components/advancedExport/bookmarkTree.tsx @@ -16,7 +16,7 @@ import type { CheckedState, ExtendedBookmarkTreeNode } from "~common/types" -import { Checkbox } from "~components" +import { Checkbox, Label } from "~components" /** * BookmarkTreeComponent is a complex component that renders a tree structure of bookmarks. @@ -389,11 +389,11 @@ const BookmarkTreeComponent = forwardRef( ) : ( )} - + {isFolder && isExpanded && node.children && ( diff --git a/src/components/advancedExport/header.tsx b/src/components/advancedExport/header.tsx index 118e16f..a4c743d 100644 --- a/src/components/advancedExport/header.tsx +++ b/src/components/advancedExport/header.tsx @@ -1,8 +1,6 @@ import logo from "data-base64:assets/icon.png" import { CheckSquare, - Code, - FileText, RefreshCw, Settings, Square @@ -11,6 +9,7 @@ import { useState } from "react" import { type HeaderProps } from "~common/types" import { Button, SearchBar, SettingsDialog } from "~components" +import { ExportFormatSelector } from "~components/exportFormatSelector" /** * Header component for the advanced export page @@ -37,6 +36,7 @@ export function Header({ const handleRefresh = () => { setIsRefreshing(true) onRefresh() + onDeselectAll() setTimeout(() => setIsRefreshing(false), 1000) } @@ -56,7 +56,7 @@ export function Header({ {chrome.i18n.getMessage("extensionName")}

- {chrome.i18n.getMessage("advancedDescription")} + {chrome.i18n.getMessage("advancedDescription")}

@@ -67,7 +67,7 @@ export function Header({ * @returns {JSX.Element} The action buttons JSX */ const renderActionButtons = () => ( -
+
- +
) diff --git a/src/components/advancedExport/settings.tsx b/src/components/advancedExport/settings.tsx index 9b85862..8706054 100644 --- a/src/components/advancedExport/settings.tsx +++ b/src/components/advancedExport/settings.tsx @@ -198,7 +198,8 @@ export function SettingsDialog({ {renderSettingSwitch( "includeDateGroupModified", chrome.i18n.getMessage("includeDateGroupModified"), - chrome.i18n.getMessage("includeDateGroupModifiedDescription") + chrome.i18n.getMessage("includeDateGroupModifiedDescription"), + chrome.i18n.getMessage("includeDateGroupModifiedTooltip") )} {renderSettingSwitch( diff --git a/src/components/advancedExportButton.tsx b/src/components/advancedExportButton.tsx index d056885..1ab3bee 100644 --- a/src/components/advancedExportButton.tsx +++ b/src/components/advancedExportButton.tsx @@ -13,7 +13,7 @@ export const AdvancedExportButton = ({ className = "" }): JSX.Element => { return ( ) diff --git a/src/components/exportFormatSelector.tsx b/src/components/exportFormatSelector.tsx new file mode 100644 index 0000000..4e7c6c2 --- /dev/null +++ b/src/components/exportFormatSelector.tsx @@ -0,0 +1,42 @@ +import { Download } from "lucide-react" +import { useState } from "react" + +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~components/ui" + +/** + * ExportFormat type for the export format state + */ +export type ExportFormat = "html" | "json" | "csv" + +interface ExportFormatSelectorProps { + onExport: (format: ExportFormat) => void +} + +export function ExportFormatSelector({ onExport }: ExportFormatSelectorProps) { + const [format, setFormat] = useState("html") + + return ( +
+ + +
+ ) +} \ No newline at end of file diff --git a/src/components/exportToHTMLButton.tsx b/src/components/exportToHTMLButton.tsx deleted file mode 100644 index 453ebbb..0000000 --- a/src/components/exportToHTMLButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { FileText } from "lucide-react" - -import { exportToHTML } from "~common/lib" -import { Button } from "~components/ui" - -/** - * ExportToHTMLButton component for exporting bookmarks to HTML format. - * This button triggers the export of bookmarks to an HTML file. - */ -export const ExportToHTMLButton = ({ className = "" }): JSX.Element => { - const handleExport = async () => { - try { - const bookmarks = await exportToHTML() - const blob = new Blob([bookmarks], { type: "text/html" }) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.download = chrome.i18n.getMessage("exportFileNameHTML") - link.click() - } catch (error) { - console.error(chrome.i18n.getMessage("exportError"), error) - } - } - - return ( - - ) -} diff --git a/src/components/exportToJSONButton.tsx b/src/components/exportToJSONButton.tsx deleted file mode 100644 index 7a8136b..0000000 --- a/src/components/exportToJSONButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Code } from "lucide-react" - -import { exportToJSON } from "~common/lib" -import { Button } from "~components/ui" - -/** - * ExportToJSONButton component for exporting bookmarks to JSON format. - * This button triggers the export of bookmarks to a JSON file. - */ -export const ExportToJSONButton = ({ className = "" }): JSX.Element => { - const handleExport = async () => { - try { - const bookmarks = await exportToJSON() - const json = JSON.stringify(bookmarks, null, 2) - const blob = new Blob([json], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.download = chrome.i18n.getMessage("exportFileNameJSON") - link.click() - } catch (error) { - console.error(chrome.i18n.getMessage("exportError"), error) - } - } - - return ( - - ) -} diff --git a/src/components/importBookmarksButton.tsx b/src/components/importBookmarksButton.tsx index e7adc7e..ff207ea 100644 --- a/src/components/importBookmarksButton.tsx +++ b/src/components/importBookmarksButton.tsx @@ -1,7 +1,7 @@ import { Upload } from "lucide-react" import { useRef } from "react" -import { detectFormat, importFromHTML, importFromJSON } from "~common/lib" +import { detectFormat, importFromCSV, importFromHTML, importFromJSON } from "~common/lib" import { Button } from "~components/ui" /** @@ -25,6 +25,9 @@ export const ImportBookmarksButton = ({ className = "" }): JSX.Element => { const format = detectFormat(text, file.type) switch (format) { + case "csv": + await importFromCSV(text) + break case "json": const bookmarks = JSON.parse(text) await importFromJSON(bookmarks) @@ -47,13 +50,13 @@ export const ImportBookmarksButton = ({ className = "" }): JSX.Element => { return ( <> diff --git a/src/components/index.ts b/src/components/index.ts index 50ac2b4..ebad503 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,7 +1,6 @@ export * from "./ui" export * from "./importBookmarksButton" -export * from "./exportToJSONButton" -export * from "./exportToHTMLButton" export * from "./advancedExportButton" export * from "./advancedExport" export * from "./featureCard" +export * from "./themeProvider" diff --git a/src/components/themeProvider.tsx b/src/components/themeProvider.tsx new file mode 100644 index 0000000..d89a82e --- /dev/null +++ b/src/components/themeProvider.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useEffect, useState } from "react" + +type Theme = "dark" | "light" | "system" + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +} + +const ThemeProviderContext = createContext(initialState) + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ) + + useEffect(() => { + const root = window.document.documentElement + + root.classList.remove("light", "dark") + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light" + + root.classList.add(systemTheme) + return + } + + root.classList.add(theme) + }, [theme]) + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme) + setTheme(theme) + }, + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider") + + return context +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 67d408f..aa49825 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,16 +1,15 @@ +import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" -import * as React from "react" import { cn } from "~common/lib/utils" const buttonVariants = cva( - "plasmo-inline-flex plasmo-items-center plasmo-justify-center plasmo-whitespace-nowrap plasmo-rounded-md plasmo-text-sm plasmo-font-medium plasmo-ring-offset-background plasmo-transition-colors focus-visible:plasmo-outline-none focus-visible:plasmo-ring-2 focus-visible:plasmo-ring-ring focus-visible:plasmo-ring-offset-2 disabled:plasmo-pointer-events-none disabled:plasmo-opacity-50", + "plasmo-inline-flex plasmo-items-center plasmo-justify-center plasmo-gap-2 plasmo-whitespace-nowrap plasmo-rounded-md plasmo-text-sm plasmo-font-medium plasmo-ring-offset-background plasmo-transition-colors focus-visible:plasmo-outline-none focus-visible:plasmo-ring-2 focus-visible:plasmo-ring-ring focus-visible:plasmo-ring-offset-2 disabled:plasmo-pointer-events-none disabled:plasmo-opacity-50 [&_svg]:plasmo-pointer-events-none [&_svg]:plasmo-size-4 [&_svg]:plasmo-shrink-0", { variants: { variant: { - default: - "plasmo-bg-primary plasmo-text-primary-foreground hover:plasmo-bg-primary/90", + default: "plasmo-bg-primary plasmo-text-primary-foreground hover:plasmo-bg-primary/90", destructive: "plasmo-bg-destructive plasmo-text-destructive-foreground hover:plasmo-bg-destructive/90", outline: @@ -18,19 +17,19 @@ const buttonVariants = cva( secondary: "plasmo-bg-secondary plasmo-text-secondary-foreground hover:plasmo-bg-secondary/80", ghost: "hover:plasmo-bg-accent hover:plasmo-text-accent-foreground", - link: "plasmo-text-primary plasmo-underline-offset-4 hover:plasmo-underline" + link: "plasmo-text-primary plasmo-underline-offset-4 hover:plasmo-underline", }, size: { default: "plasmo-h-10 plasmo-px-4 plasmo-py-2", sm: "plasmo-h-9 plasmo-rounded-md plasmo-px-3", lg: "plasmo-h-11 plasmo-rounded-md plasmo-px-8", - icon: "plasmo-h-10 plasmo-w-10" - } + icon: "plasmo-h-10 plasmo-w-10", + }, }, defaultVariants: { variant: "default", - size: "default" - } + size: "default", + }, } ) diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 25b2d2d..b1fbb44 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -30,10 +30,10 @@ const CardHeader = React.forwardRef< CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+ HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

>(({ className, ...props }, ref) => ( + - - - - - + "plasmo-flex plasmo-items-center plasmo-justify-center plasmo-text-current" + )}> + + + + )) Checkbox.displayName = CheckboxPrimitive.Root.displayName diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index fbbec96..97d07ed 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,6 +1,8 @@ +"use client" + +import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" -import * as React from "react" import { cn } from "~common/lib/utils" @@ -39,7 +41,8 @@ const DialogContent = React.forwardRef< "plasmo-fixed plasmo-left-[50%] plasmo-top-[50%] plasmo-z-50 plasmo-grid plasmo-w-full plasmo-max-w-lg plasmo-translate-x-[-50%] plasmo-translate-y-[-50%] plasmo-gap-4 plasmo-border plasmo-bg-background plasmo-p-6 plasmo-shadow-lg plasmo-duration-200 data-[state=open]:plasmo-animate-in data-[state=closed]:plasmo-animate-out data-[state=closed]:plasmo-fade-out-0 data-[state=open]:plasmo-fade-in-0 data-[state=closed]:plasmo-zoom-out-95 data-[state=open]:plasmo-zoom-in-95 data-[state=closed]:plasmo-slide-out-to-left-1/2 data-[state=closed]:plasmo-slide-out-to-top-[48%] data-[state=open]:plasmo-slide-in-from-left-1/2 data-[state=open]:plasmo-slide-in-from-top-[48%] sm:plasmo-rounded-lg", className )} - {...props}> + {...props} + > {children} @@ -115,5 +118,5 @@ export { DialogHeader, DialogFooter, DialogTitle, - DialogDescription + DialogDescription, } diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 4c1ad06..89f05f1 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,10 +1,11 @@ -export * from "./button" -export * from "./card" -export * from "./checkbox" -export * from "./dialog" -export * from "./input" -export * from "./label" -export * from "./separator" -export * from "./switch" -export * from "./tabs" -export * from "./tooltip" +export * from './button'; +export * from './card'; +export * from './checkbox'; +export * from './dialog'; +export * from './input'; +export * from './label'; +export * from './select'; +export * from './separator'; +export * from './switch'; +export * from './tabs'; +export * from './tooltip'; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 1486f33..a90834e 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -2,16 +2,13 @@ import * as React from "react" import { cn } from "~common/lib/utils" -export interface InputProps - extends React.InputHTMLAttributes {} - -const Input = React.forwardRef( +const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { return ( , + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:plasmo-line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index 7c79a50..ee63ece 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -1,5 +1,5 @@ -import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" import { cn } from "~common/lib/utils" @@ -17,9 +17,7 @@ const Separator = React.forwardRef< orientation={orientation} className={cn( "plasmo-shrink-0 plasmo-bg-border", - orientation === "horizontal" - ? "plasmo-h-[1px] plasmo-w-full" - : "plasmo-h-full plasmo-w-[1px]", + orientation === "horizontal" ? "plasmo-h-[1px] plasmo-w-full" : "plasmo-h-full plasmo-w-[1px]", className )} {...props} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index f5f5341..d49b6ee 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -1,5 +1,7 @@ -import * as SwitchPrimitives from "@radix-ui/react-switch" +"use client" + import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" import { cn } from "~common/lib/utils" @@ -13,7 +15,8 @@ const Switch = React.forwardRef< className )} {...props} - ref={ref}> + ref={ref} + > ("export") + /** + * Handles export functionality + */ + const handleExport = async (format: ExportFormat) => { + try { + let content: string + let mimeType: string + let fileName: string + + switch (format) { + case "html": + content = await exportToHTML() + mimeType = "text/html" + fileName = chrome.i18n.getMessage("exportFileNameHTML") + break + case "json": + const jsonData = await exportToJSON() + content = JSON.stringify(jsonData, null, 2) + mimeType = "application/json" + fileName = chrome.i18n.getMessage("exportFileNameJSON") + break + case "csv": + content = await exportToCSV() + mimeType = "text/csv" + fileName = chrome.i18n.getMessage("exportFileNameCSV") + break + } + + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = fileName + link.click() + } catch (error) { + console.error(chrome.i18n.getMessage("exportError"), error) + } + } + /** * Handles tab change * @param {TabValue} value - The new tab value @@ -45,13 +85,12 @@ function IndexPopup(): JSX.Element { activeTab === "export" ? "" : "plasmo-hidden" }`}>

- - +
), - [activeTab] + [activeTab, handleExport] ) /** @@ -72,7 +111,8 @@ function IndexPopup(): JSX.Element { ) return ( -
+ +

{chrome.i18n.getMessage("extensionName")}

@@ -92,10 +132,11 @@ function IndexPopup(): JSX.Element { {renderImportTab()} -

+

{chrome.i18n.getMessage("extensionDescription")}

+
) } diff --git a/src/style.css b/src/style.css index 1fae57d..87981a2 100644 --- a/src/style.css +++ b/src/style.css @@ -3,58 +3,66 @@ @tailwind utilities; @layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } +@layer base { + * { + @apply plasmo-border-border; + } + body { + @apply plasmo-bg-background plasmo-text-foreground; + } } diff --git a/src/tabs/advanced-export.tsx b/src/tabs/advanced-export.tsx index c87746c..62e66cd 100644 --- a/src/tabs/advanced-export.tsx +++ b/src/tabs/advanced-export.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { exportToHTML, exportToJSON } from "~common/lib" +import { exportToCSV, exportToHTML, exportToJSON } from "~common/lib" import type { BookmarkTreeHandle, ExtendedBookmarkTreeNode } from "~common/types" -import { BookmarkTree, Header } from "~components" +import { BookmarkTree, Header, ThemeProvider } from "~components" import "~style.css" @@ -105,9 +105,9 @@ export default function AdvancedExportPage(): JSX.Element { /** * Export selected bookmarks in the specified format - * @param {("html" | "json")} format - The format to export bookmarks in + * @param {("html" | "json" | "csv")} format - The format to export bookmarks in */ - const handleExport = async (format: "html" | "json") => { + const handleExport = async (format: "html" | "json" | "csv") => { if (!bookmarkTreeRef.current) { console.error(chrome.i18n.getMessage("bookmarkTreeRefNotAvailable")) return @@ -145,12 +145,12 @@ export default function AdvancedExportPage(): JSX.Element { /** * Export bookmarks based on the specified format and configuration - * @param {("html" | "json")} format - The format to export bookmarks in + * @param {("html" | "json" | "csv")} format - The format to export bookmarks in * @param {Object} config - Export configuration * @returns {Promise<{exportedData: string, fileName: string, mimeType: string}>} Export result */ const exportBookmarks = async ( - format: "html" | "json", + format: "html" | "json" | "csv", config: { selectedBookmarks: ExtendedBookmarkTreeNode[] includeIconData: boolean @@ -161,32 +161,55 @@ export default function AdvancedExportPage(): JSX.Element { hideParentFolder: boolean } ): Promise<{ exportedData: string; fileName: string; mimeType: string }> => { - if (format === "html") { - const exportedData = await exportToHTML( - config.selectedBookmarks, - config.includeIconData, - config.includeDateAdded, - config.includeDateLastUsed, - config.includeDateGroupModified, - config.hideOtherBookmarks, - config.hideParentFolder - ) - return { exportedData, fileName: "bookmarks.html", mimeType: "text/html" } - } else { - const jsonData = await exportToJSON( - config.selectedBookmarks, - config.includeIconData, - config.includeDateAdded, - config.includeDateLastUsed, - config.includeDateGroupModified, - config.hideOtherBookmarks, - config.hideParentFolder - ) - const exportedData = JSON.stringify(jsonData, null, 2) - return { - exportedData, - fileName: "bookmarks.json", - mimeType: "application/json" + let exportedData: string + + switch (format) { + case "html": { + exportedData = await exportToHTML( + config.selectedBookmarks, + config.includeIconData, + config.includeDateAdded, + config.includeDateLastUsed, + config.includeDateGroupModified, + config.hideOtherBookmarks, + config.hideParentFolder + ) + return { + exportedData, + fileName: chrome.i18n.getMessage("exportFileNameHTML"), + mimeType: "text/html" + } + } + case "json": { + const jsonData = await exportToJSON( + config.selectedBookmarks, + config.includeIconData, + config.includeDateAdded, + config.includeDateLastUsed, + config.includeDateGroupModified, + config.hideOtherBookmarks, + config.hideParentFolder + ) + exportedData = JSON.stringify(jsonData, null, 2) + return { + exportedData, + fileName: chrome.i18n.getMessage("exportFileNameJSON"), + mimeType: "application/json" + } + } + case "csv": { + exportedData = await exportToCSV( + config.selectedBookmarks, + config.includeIconData, + config.includeDateAdded, + config.includeDateLastUsed, + config.includeDateGroupModified + ) + return { + exportedData, + fileName: chrome.i18n.getMessage("exportFileNameCSV"), + mimeType: "text/csv" + } } } } @@ -212,24 +235,26 @@ export default function AdvancedExportPage(): JSX.Element { } return ( -
-
-
- +
+
+
+ +
-
+ ) } diff --git a/src/tabs/update.tsx b/src/tabs/update.tsx index f65e9c4..225253a 100644 --- a/src/tabs/update.tsx +++ b/src/tabs/update.tsx @@ -1,5 +1,6 @@ import logo from "data-base64:assets/icon.png" import { Star } from "lucide-react" +import { ThemeProvider } from "~components" import "~style.css" @@ -16,6 +17,15 @@ interface ChangelogEntry { } const changelog: ChangelogEntry[] = [ + { + version: "1.3.0", + date: chrome.i18n.getMessage("changelog_1_3_0_date"), + changes: [ + { + description: chrome.i18n.getMessage("changelog_1_3_0_1") + } + ] + }, { version: "1.2.0", date: chrome.i18n.getMessage("changelog_1_2_0_date"), @@ -198,11 +208,13 @@ export default function UpdatePage(): JSX.Element { ) return ( -
- {renderHeader()} - {renderFeedbackLink()} - {renderChangelog()} - {renderFooter()} -
+ +
+ {renderHeader()} + {renderFeedbackLink()} + {renderChangelog()} + {renderFooter()} +
+
) } diff --git a/src/tabs/welcome.tsx b/src/tabs/welcome.tsx index 8a2f7c2..b519df7 100644 --- a/src/tabs/welcome.tsx +++ b/src/tabs/welcome.tsx @@ -1,7 +1,15 @@ import logo from "data-base64:assets/icon.png" -import { BookmarkPlus, FileJson, FileText, Settings, Star } from "lucide-react" +import { + BookmarkPlus, + FileJson, + FileSpreadsheet, + FileText, + Languages, + Settings, + Star +} from "lucide-react" -import { Card, CardHeader, FeatureCard } from "~components" +import { Card, CardHeader, FeatureCard, ThemeProvider } from "~components" import "~style.css" @@ -37,8 +45,8 @@ export default function WelcomePage(): JSX.Element { * @returns {JSX.Element} Card with instructions to start using the extension */ const renderGettingStarted = (): JSX.Element => ( - - + + {chrome.i18n.getMessage("gettingStarted")} @@ -60,6 +68,11 @@ export default function WelcomePage(): JSX.Element { title={chrome.i18n.getMessage("exportToHTML")} description={chrome.i18n.getMessage("exportToHTMLDescription")} /> + +

{chrome.i18n.getMessage("compatibleBrowsers")}

@@ -98,7 +116,7 @@ export default function WelcomePage(): JSX.Element { const renderFooter = (): JSX.Element => (

- {chrome.i18n.getMessage("builtBy")} {" "} + {chrome.i18n.getMessage("builtBy")}{" "} @AndryOre - . {chrome.i18n.getMessage("sourceCode")} {" "} + . {chrome.i18n.getMessage("sourceCode")}{" "} - {renderHeader()} - {renderGettingStarted()} - {renderFeatures()} - {renderFeedbackLink()} - {renderFooter()} - + +

+ {renderHeader()} + {renderGettingStarted()} + {renderFeatures()} + {renderFeedbackLink()} + {renderFooter()} +
+ ) }