From 6c5a0bda970c63256629fe32961d90ac14d70465 Mon Sep 17 00:00:00 2001 From: Aamir Mansoor Date: Wed, 12 Feb 2025 19:03:53 -0700 Subject: [PATCH 1/7] feat: add CSV bookmark import support - Integrated PapaParse library for CSV parsing - Added CSV format detection and import functionality - Updated types, README, and UI to support CSV bookmark imports - Created new importFromCSV module for parsing and creating bookmarks --- README.md | 2 +- package.json | 2 + pnpm-lock.yaml | 20 ++- src/common/lib/detectFormat.ts | 39 +++++ src/common/lib/importers/importFromCSV.ts | 197 ++++++++++++++++++++++ src/common/lib/importers/index.ts | 1 + src/common/types.ts | 2 +- src/components/importBookmarksButton.tsx | 7 +- src/tabs/welcome.tsx | 2 +- 9 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 src/common/lib/importers/importFromCSV.ts diff --git a/README.md b/README.md index 2a648d0..b85aa78 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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 🛠️ diff --git a/package.json b/package.json index 010753d..02af429 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "@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..5d43d36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,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 +44,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) @@ -1995,6 +2001,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 +3123,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'} @@ -5837,6 +5849,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 +6334,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 +6912,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/importers/importFromCSV.ts b/src/common/lib/importers/importFromCSV.ts new file mode 100644 index 0000000..93aa00f --- /dev/null +++ b/src/common/lib/importers/importFromCSV.ts @@ -0,0 +1,197 @@ +/** + * 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) { + // Search for existing folder with the same name under the parent + 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" + ) + } + + // Search for existing "Imported 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 { + // Validate URL + 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 = "" + + // Create or traverse folder structure + 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! + } + + // Add bookmark to current level + 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..e173a0f 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. diff --git a/src/components/importBookmarksButton.tsx b/src/components/importBookmarksButton.tsx index 0ae29b0..12a4aad 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) @@ -53,7 +56,7 @@ export const ImportBookmarksButton = ({ className = "" }): JSX.Element => { diff --git a/src/tabs/welcome.tsx b/src/tabs/welcome.tsx index 3750d32..2e1f8fd 100644 --- a/src/tabs/welcome.tsx +++ b/src/tabs/welcome.tsx @@ -63,7 +63,7 @@ export default function WelcomePage(): JSX.Element { Date: Sat, 15 Feb 2025 13:08:27 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20CSV=20export=20fo?= =?UTF-8?q?rmat=20and=20unified=20export=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/locales/en/messages.json | 32 ++++ assets/locales/es/messages.json | 36 +++- package.json | 1 + pnpm-lock.yaml | 50 ++++++ src/common/lib/exporters/exportToCSV.ts | 192 +++++++++++++++++++++ src/common/lib/exporters/index.ts | 1 + src/common/lib/importers/importFromCSV.ts | 5 - src/common/types.ts | 2 +- src/components/advancedExport/header.tsx | 21 +-- src/components/advancedExport/settings.tsx | 3 +- src/components/advancedExportButton.tsx | 2 +- src/components/exportFormatSelector.tsx | 42 +++++ src/components/exportToHTMLButton.tsx | 31 ---- src/components/exportToJSONButton.tsx | 32 ---- src/components/importBookmarksButton.tsx | 2 +- src/components/index.ts | 4 +- src/components/ui/button.tsx | 17 +- src/components/ui/card.tsx | 12 +- src/components/ui/checkbox.tsx | 17 +- src/components/ui/dialog.tsx | 9 +- src/components/ui/index.ts | 21 +-- src/components/ui/input.tsx | 7 +- src/components/ui/label.tsx | 2 +- src/components/ui/select.tsx | 160 +++++++++++++++++ src/components/ui/separator.tsx | 6 +- src/components/ui/switch.tsx | 7 +- src/components/ui/tabs.tsx | 2 +- src/components/ui/tooltip.tsx | 4 +- src/popup.tsx | 50 +++++- src/tabs/advanced-export.tsx | 85 +++++---- src/tabs/welcome.tsx | 12 +- 31 files changed, 683 insertions(+), 184 deletions(-) create mode 100644 src/common/lib/exporters/exportToCSV.ts create mode 100644 src/components/exportFormatSelector.tsx delete mode 100644 src/components/exportToHTMLButton.tsx delete mode 100644 src/components/exportToJSONButton.tsx create mode 100644 src/components/ui/select.tsx diff --git a/assets/locales/en/messages.json b/assets/locales/en/messages.json index ab7dc4b..1987329 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,33 @@ }, "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" } } diff --git a/assets/locales/es/messages.json b/assets/locales/es/messages.json index dd0de69..dbe3476 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,33 @@ }, "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" } } diff --git a/package.json b/package.json index 55ac3d3..fc89dd5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d43d36..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) @@ -1393,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==} @@ -1597,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: @@ -5321,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)': @@ -5510,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) 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 index 93aa00f..24d43d3 100644 --- a/src/common/lib/importers/importFromCSV.ts +++ b/src/common/lib/importers/importFromCSV.ts @@ -39,7 +39,6 @@ const createBookmarks = async (nodes: ParsedBookmark[], parentId: string) => { url: node.url }) } else if (node.children && node.children.length > 0) { - // Search for existing folder with the same name under the parent const existingFolders = await chrome.bookmarks.search({ title: node.title }) const existingFolder = existingFolders.find( folder => folder.parentId === parentId @@ -81,7 +80,6 @@ const processBookmarks = async ( ) } - // Search for existing "Imported bookmarks" folder const existingFolders = await chrome.bookmarks.search({ title: "Imported bookmarks" }) const importedFolder = existingFolders.length > 0 ? existingFolders[0] @@ -110,7 +108,6 @@ const processCSVData = (csvData: CSVRow[]): ParsedBookmark[] => { if (!title || !url) return try { - // Validate URL new URL(url) } catch { console.warn(`Skipping invalid URL for bookmark "${title}": ${url}`) @@ -124,7 +121,6 @@ const processCSVData = (csvData: CSVRow[]): ParsedBookmark[] => { let currentLevel = rootBookmarks let currentPath = "" - // Create or traverse folder structure for (const folder of folders) { currentPath = currentPath ? `${currentPath}/${folder}` : folder @@ -142,7 +138,6 @@ const processCSVData = (csvData: CSVRow[]): ParsedBookmark[] => { currentLevel = bookmarks[currentPath].children! } - // Add bookmark to current level currentLevel.push({ title, url, diff --git a/src/common/types.ts b/src/common/types.ts index e173a0f..1b80367 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -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/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 bd8b7d8..ff207ea 100644 --- a/src/components/importBookmarksButton.tsx +++ b/src/components/importBookmarksButton.tsx @@ -50,7 +50,7 @@ export const ImportBookmarksButton = ({ className = "" }): JSX.Element => { return ( <> + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

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

+ {...props} + > - - + className={cn("plasmo-flex plasmo-items-center plasmo-justify-center plasmo-text-current")} + > + )) 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 +84,12 @@ function IndexPopup(): JSX.Element { activeTab === "export" ? "" : "plasmo-hidden" }`}>

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

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

diff --git a/src/tabs/advanced-export.tsx b/src/tabs/advanced-export.tsx index c87746c..d52b088 100644 --- a/src/tabs/advanced-export.tsx +++ b/src/tabs/advanced-export.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { exportToHTML, exportToJSON } from "~common/lib" +import { exportToCSV, exportToHTML, exportToJSON } from "~common/lib" import type { BookmarkTreeHandle, ExtendedBookmarkTreeNode @@ -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" + } } } } diff --git a/src/tabs/welcome.tsx b/src/tabs/welcome.tsx index 8a2f7c2..f02d01e 100644 --- a/src/tabs/welcome.tsx +++ b/src/tabs/welcome.tsx @@ -1,5 +1,5 @@ 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" @@ -60,6 +60,11 @@ export default function WelcomePage(): JSX.Element { title={chrome.i18n.getMessage("exportToHTML")} description={chrome.i18n.getMessage("exportToHTMLDescription")} /> + +

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

From 2cf7b1cd0ffafb1d6d36c9e24bb08145bda673cb Mon Sep 17 00:00:00 2001 From: AndryOre Date: Sat, 15 Feb 2025 21:35:40 -0400 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20=F0=9F=93=9D=20add=20Contributors?= =?UTF-8?q?=20section=20with=20auto=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/contributors.yml | 19 +++++++++++++++++++ README.md | 8 ++++++++ 2 files changed, 27 insertions(+) create mode 100644 .github/workflows/contributors.yml 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 b85aa78..88bf7cf 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,14 @@ 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. + + + + + ## License 📄 This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. From c827b1e830c4235a0dfbd130406d6d21c93cc059 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 16 Feb 2025 01:35:52 +0000 Subject: [PATCH 4/7] docs(contributor): contrib-readme-action has updated readme --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 88bf7cf..7db3bc2 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,26 @@ pnpm package **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 +
+
From 47173755ac014d519277d5fc75eba7e3b9840664 Mon Sep 17 00:00:00 2001 From: AndryOre Date: Sat, 15 Feb 2025 21:37:56 -0400 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=F0=9F=93=9D=20Update=20README=20wi?= =?UTF-8?q?th=20CSV=20export=20and=20import=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7db3bc2..51f74a0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## Features 🌟 -- ⬇️ **Bookmark Exporting**: Easily export your bookmarks to different formats (JSON and HTML) for better accessibility and management. +- ⬇️ **Bookmark Exporting**: Easily export your bookmarks to different formats (HTML, JSON, CSV) 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. - 🔍 **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. @@ -34,7 +34,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: From b6e497733b49e106c21781d60c35d250e8c01cc5 Mon Sep 17 00:00:00 2001 From: AndryOre Date: Sun, 16 Feb 2025 02:18:05 -0400 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20theme=20provider?= =?UTF-8?q?=20and=20dark=20mode=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../advancedExport/bookmarkTree.tsx | 8 +- src/components/index.ts | 3 +- src/components/themeProvider.tsx | 73 +++++++++++ src/components/ui/checkbox.tsx | 27 +++-- src/popup.tsx | 9 +- src/style.css | 114 ++++++++++-------- src/tabs/advanced-export.tsx | 38 +++--- src/tabs/update.tsx | 15 ++- src/tabs/welcome.tsx | 36 ++++-- 9 files changed, 212 insertions(+), 111 deletions(-) create mode 100644 src/components/themeProvider.tsx 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/index.ts b/src/components/index.ts index 8661f9c..ebad503 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,4 +2,5 @@ export * from "./ui" export * from "./importBookmarksButton" export * from "./advancedExportButton" export * from "./advancedExport" -export * from "./featureCard" \ No newline at end of file +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/checkbox.tsx b/src/components/ui/checkbox.tsx index fc393c0..d369754 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,6 +1,6 @@ import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { Check } from "lucide-react" +import { Check, Minus } from "lucide-react" import { cn } from "~common/lib/utils" @@ -9,19 +9,20 @@ const Checkbox = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( + - - - - + "plasmo-flex plasmo-items-center plasmo-justify-center plasmo-text-current" + )}> + + + + )) Checkbox.displayName = CheckboxPrimitive.Root.displayName diff --git a/src/popup.tsx b/src/popup.tsx index f83cbb4..16fad0b 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -7,7 +7,8 @@ import { Tabs, TabsContent, TabsList, - TabsTrigger + TabsTrigger, + ThemeProvider } from "~components" import { ExportFormatSelector, type ExportFormat } from "~components/exportFormatSelector" @@ -110,7 +111,8 @@ function IndexPopup(): JSX.Element { ) return ( -
+ +

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

@@ -130,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 d52b088..62e66cd 100644 --- a/src/tabs/advanced-export.tsx +++ b/src/tabs/advanced-export.tsx @@ -5,7 +5,7 @@ import type { BookmarkTreeHandle, ExtendedBookmarkTreeNode } from "~common/types" -import { BookmarkTree, Header } from "~components" +import { BookmarkTree, Header, ThemeProvider } from "~components" import "~style.css" @@ -162,7 +162,7 @@ export default function AdvancedExportPage(): JSX.Element { } ): Promise<{ exportedData: string; fileName: string; mimeType: string }> => { let exportedData: string - + switch (format) { case "html": { exportedData = await exportToHTML( @@ -235,24 +235,26 @@ export default function AdvancedExportPage(): JSX.Element { } return ( -
-
-
- +
+
+
+ +
-
+ ) } diff --git a/src/tabs/update.tsx b/src/tabs/update.tsx index f65e9c4..28f2026 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" @@ -198,11 +199,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 f02d01e..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, FileSpreadsheet, FileText, Languages, 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")} @@ -108,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()} +
+ ) } From 785e432f70038d02de7f16cd80b2d7feb462e2ad Mon Sep 17 00:00:00 2001 From: AndryOre Date: Sun, 16 Feb 2025 02:58:56 -0400 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=F0=9F=93=9D=20update=20README=20an?= =?UTF-8?q?d=20changelog=20with=20theme=20and=20language=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- assets/locales/en/messages.json | 7 +++++++ assets/locales/es/messages.json | 7 +++++++ package.json | 2 +- src/tabs/update.tsx | 9 +++++++++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 51f74a0..47920e7 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,12 @@ ## Features 🌟 - ⬇️ **Bookmark Exporting**: Easily export your bookmarks to different formats (HTML, JSON, CSV) 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 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 🧰 diff --git a/assets/locales/en/messages.json b/assets/locales/en/messages.json index 1987329..0191f5f 100644 --- a/assets/locales/en/messages.json +++ b/assets/locales/en/messages.json @@ -425,5 +425,12 @@ "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 dbe3476..3eb714e 100644 --- a/assets/locales/es/messages.json +++ b/assets/locales/es/messages.json @@ -424,5 +424,12 @@ "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 fc89dd5..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": { diff --git a/src/tabs/update.tsx b/src/tabs/update.tsx index 28f2026..225253a 100644 --- a/src/tabs/update.tsx +++ b/src/tabs/update.tsx @@ -17,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"),