From b161d79a03cd8f12bb4f45cb7c0160dc2c14eedd Mon Sep 17 00:00:00 2001 From: Michael Schwobe Date: Tue, 31 Oct 2023 14:35:58 -0500 Subject: [PATCH] Added bookmark exporting --- README.md | 1 - app/components/bookmarks-table.tsx | 60 ++- app/routes/bookmarks[.csv].tsx | 8 + app/routes/bookmarks[.html].tsx | 8 + app/routes/bookmarks[.json].tsx | 8 + app/routes/bookmarks[.md].tsx | 8 + app/routes/bookmarks[.txt].tsx | 8 + app/utils/bookmark-exports.server.test.ts | 464 ++++++++++++++++++++++ app/utils/bookmark-exports.server.ts | 138 +++++++ app/utils/bookmark.ts | 19 + 10 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 app/routes/bookmarks[.csv].tsx create mode 100644 app/routes/bookmarks[.html].tsx create mode 100644 app/routes/bookmarks[.json].tsx create mode 100644 app/routes/bookmarks[.md].tsx create mode 100644 app/routes/bookmarks[.txt].tsx create mode 100644 app/utils/bookmark-exports.server.test.ts create mode 100644 app/utils/bookmark-exports.server.ts diff --git a/README.md b/README.md index d8632ec..3d664d6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ TagsForDays extends traditional bookmarking with advanced organization and searc - TODO: Tag: tag colors or other fields - TODO: Bookmark: assess orderBy (title, date, etc.) -- TODO: Bookmark: export/selection (text, csv, json, html) - TODO: Bookmark: watchtower route (dead links, redirects, etc.) - TODO: Bookmark: suggest/postfetch-opt-in title/description - TODO: Bookmark: suggest tags diff --git a/app/components/bookmarks-table.tsx b/app/components/bookmarks-table.tsx index d849344..846afa3 100644 --- a/app/components/bookmarks-table.tsx +++ b/app/components/bookmarks-table.tsx @@ -1,3 +1,4 @@ +import { Form } from "@remix-run/react"; import type { ColumnDef } from "@tanstack/react-table"; import { createColumnHelper, @@ -25,6 +26,7 @@ import { } from "~/components/ui/table"; import type { getBookmarks } from "~/models/bookmark.server"; import type { ItemWithFaviconSrcProp } from "~/models/favicon.server"; +import { BOOKMARK_EXPORT_LABEL_MAP } from "~/utils/bookmark"; import { cn } from "~/utils/misc"; type GetBookmarksData = Awaited>; @@ -253,8 +255,12 @@ export function BookmarksTable({ /> - {selectedIds.length} of {table.getRowModel().rows.length} Rows - selected + + + @@ -349,3 +355,53 @@ function ButtonFavorite({ /> ); } + +function ButtonExportGroup() { + return ( +
+ {Object.entries(BOOKMARK_EXPORT_LABEL_MAP).map(([ext, label]) => ( + + {label} + + ))} +
+ ); +} + +function ButtonExport({ + children, + formAction, +}: { + children: React.ReactNode; + formAction: string; +}) { + return ( + + ); +} + +function SelectedBookmarksForm({ + children, + selectedIds, + totalLength, +}: { + children: React.ReactNode; + selectedIds: string[]; + totalLength: number; +}) { + return ( +
+ +
+
+ {selectedIds.length} of {totalLength} rows selected. +
+ {children} +
+
+ ); +} diff --git a/app/routes/bookmarks[.csv].tsx b/app/routes/bookmarks[.csv].tsx new file mode 100644 index 0000000..cb64b34 --- /dev/null +++ b/app/routes/bookmarks[.csv].tsx @@ -0,0 +1,8 @@ +import { + createExportAction, + createExportLoader, +} from "~/utils/bookmark-exports.server"; + +export const loader = createExportLoader("csv"); + +export const action = createExportAction("csv"); diff --git a/app/routes/bookmarks[.html].tsx b/app/routes/bookmarks[.html].tsx new file mode 100644 index 0000000..27c30f9 --- /dev/null +++ b/app/routes/bookmarks[.html].tsx @@ -0,0 +1,8 @@ +import { + createExportAction, + createExportLoader, +} from "~/utils/bookmark-exports.server"; + +export const loader = createExportLoader("html"); + +export const action = createExportAction("html"); diff --git a/app/routes/bookmarks[.json].tsx b/app/routes/bookmarks[.json].tsx new file mode 100644 index 0000000..f676d39 --- /dev/null +++ b/app/routes/bookmarks[.json].tsx @@ -0,0 +1,8 @@ +import { + createExportAction, + createExportLoader, +} from "~/utils/bookmark-exports.server"; + +export const loader = createExportLoader("json"); + +export const action = createExportAction("json"); diff --git a/app/routes/bookmarks[.md].tsx b/app/routes/bookmarks[.md].tsx new file mode 100644 index 0000000..d2c6a18 --- /dev/null +++ b/app/routes/bookmarks[.md].tsx @@ -0,0 +1,8 @@ +import { + createExportAction, + createExportLoader, +} from "~/utils/bookmark-exports.server"; + +export const loader = createExportLoader("md"); + +export const action = createExportAction("md"); diff --git a/app/routes/bookmarks[.txt].tsx b/app/routes/bookmarks[.txt].tsx new file mode 100644 index 0000000..00e5f27 --- /dev/null +++ b/app/routes/bookmarks[.txt].tsx @@ -0,0 +1,8 @@ +import { + createExportAction, + createExportLoader, +} from "~/utils/bookmark-exports.server"; + +export const loader = createExportLoader("txt"); + +export const action = createExportAction("txt"); diff --git a/app/utils/bookmark-exports.server.test.ts b/app/utils/bookmark-exports.server.test.ts new file mode 100644 index 0000000..555a6a9 --- /dev/null +++ b/app/utils/bookmark-exports.server.test.ts @@ -0,0 +1,464 @@ +import { + exportResponse, + formatExportAsCsv, + formatExportAsHtml, + formatExportAsJson, + formatExportAsMarkdown, + formatExportAsText, +} from "./bookmark-exports.server"; + +describe("exportResponse", () => { + let currDate: Date; + + beforeEach(() => { + vi.useFakeTimers(); + currDate = new Date("1970-01-01T00:00:00.000+00:00"); + vi.setSystemTime(currDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + { fileExtension: "csv" as const, mimeType: "text/csv" }, + { fileExtension: "html" as const, mimeType: "text/html" }, + { fileExtension: "json" as const, mimeType: "application/json" }, + { fileExtension: "md" as const, mimeType: "text/markdown" }, + { fileExtension: "txt" as const, mimeType: "text/plain" }, + ])( + "should return a response with the correct mimeType when given $fileExtension", + ({ fileExtension, mimeType }) => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + + const response = exportResponse({ data, fileExtension }); + + expect(response).toBeDefined(); + expect(response).toHaveProperty("body"); + expect(response).toHaveProperty("status", 200); + expect(response.headers.get("content-type")).toEqual(mimeType); + }, + ); +}); + +describe("formatExportAsCsv", () => { + let currDate: Date; + + beforeEach(() => { + vi.useFakeTimers(); + currDate = new Date("1970-01-01T00:00:00.000+00:00"); + vi.setSystemTime(currDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return the mimeType", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: "Example", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsCsv(data); + expect(result.mimeType).toEqual("text/csv"); + }); + + it("should return the row header", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsCsv(data); + expect(result.body.split("\n")[0]).toEqual("text,href,date"); + }); + + it("should return the row items", () => { + const data = [ + { + id: "", + url: "https://example.com/0", + title: "Example 0", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + { + id: "", + url: "https://example.com/1", + title: "Example 1", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsCsv(data); + expect(result.body.split("\n")[1]).toEqual( + "Example 0,https://example.com/0,Thu\\, 01 Jan 1970 00:00:00 GMT", + ); + expect(result.body.split("\n")[2]).toEqual( + "Example 1,https://example.com/1,Thu\\, 01 Jan 1970 00:00:00 GMT", + ); + }); + + it("should return a fallback if the title is missing", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsCsv(data); + + expect(result.body.split("\n")[1]).toContain("Untitled"); + }); + + it("should return comma-escaped strings for title and createdAt", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: "Example, with comma", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsCsv(data); + expect(result.body.split("\n")[1]).toEqual( + "Example\\, with comma,https://example.com,Thu\\, 01 Jan 1970 00:00:00 GMT", + ); + }); +}); + +describe("formatExportAsHtml", () => { + let currDate: Date; + + beforeEach(() => { + vi.useFakeTimers(); + currDate = new Date("1970-01-01T00:00:00.000+00:00"); + vi.setSystemTime(currDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return the mimeType", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: "Example", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsHtml(data); + expect(result.mimeType).toEqual("text/html"); + }); + + it("should return the row items", () => { + const data = [ + { + id: "", + url: "https://example.com/0", + title: "Example 0", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + { + id: "", + url: "https://example.com/1", + title: "Example 1", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsHtml(data); + expect(result.body).toContain( + [ + '
Example 0
', + '
Example 1
', + ].join("\n"), + ); + }); + + it("should return a fallback if the title is missing", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsHtml(data); + expect(result.body).toContain( + '
Untitled
', + ); + }); +}); + +describe("formatExportAsJson", () => { + let currDate: Date; + + beforeEach(() => { + vi.useFakeTimers(); + currDate = new Date("1970-01-01T00:00:00.000+00:00"); + vi.setSystemTime(currDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return the mimeType", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: "Example", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsJson(data); + expect(result.mimeType).toEqual("application/json"); + }); + + it("should return the row items", () => { + const data = [ + { + id: "", + url: "https://example.com/0", + title: "Example 0", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + { + id: "", + url: "https://example.com/1", + title: "Example 1", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsJson(data); + const expectedBody = JSON.stringify( + [ + { + createdAt: currDate, + title: "Example 0", + url: "https://example.com/0", + }, + { + createdAt: currDate, + title: "Example 1", + url: "https://example.com/1", + }, + ], + null, + 2, + ); + expect(result.body).toEqual(expectedBody); + }); + + it("should handle missing titles", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsJson(data); + const expectedBody = JSON.stringify( + [{ createdAt: currDate, title: null, url: "https://example.com" }], + null, + 2, + ); + expect(result.body).toEqual(expectedBody); + }); +}); + +describe("formatExportAsMarkdown", () => { + let currDate: Date; + + beforeEach(() => { + vi.useFakeTimers(); + currDate = new Date("1970-01-01T00:00:00.000+00:00"); + vi.setSystemTime(currDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return the mimeType", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: "Example", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsMarkdown(data); + expect(result.mimeType).toEqual("text/markdown"); + }); + + it("should return the row items", () => { + const data = [ + { + id: "", + url: "https://example.com/0", + title: "Example 0", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + { + id: "", + url: "https://example.com/1", + title: "Example 1", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsMarkdown(data); + expect(result.body).toContain( + [ + "- **Example 0**

Thu, 01 Jan 1970 00:00:00 GMT", + "- **Example 1**

Thu, 01 Jan 1970 00:00:00 GMT", + ].join("\n"), + ); + }); + + it("should return a fallback if the title is missing", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsMarkdown(data); + expect(result.body).toContain( + "- **Untitled**

Thu, 01 Jan 1970 00:00:00 GMT", + ); + }); +}); + +describe("formatExportAsText", () => { + let currDate: Date; + + beforeEach(() => { + vi.useFakeTimers(); + currDate = new Date("1970-01-01T00:00:00.000+00:00"); + vi.setSystemTime(currDate); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return the mimeType", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: "Example", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsText(data); + expect(result.mimeType).toEqual("text/plain"); + }); + + it("should return the row items", () => { + const data = [ + { + id: "", + url: "https://example.com/0", + title: "Example 0", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + { + id: "", + url: "https://example.com/1", + title: "Example 1", + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsText(data); + expect(result.body).toContain( + [ + "Example 0\nhttps://example.com/0\nThu, 01 Jan 1970 00:00:00 GMT", + "Example 1\nhttps://example.com/1\nThu, 01 Jan 1970 00:00:00 GMT", + ].join("\n\n"), + ); + }); + + it("should return a fallback if the title is missing", () => { + const data = [ + { + id: "", + url: "https://example.com", + title: null, + favorite: null, + createdAt: currDate, + _count: { tags: 0 }, + }, + ]; + const result = formatExportAsText(data); + expect(result.body).toContain("Untitled"); + }); +}); diff --git a/app/utils/bookmark-exports.server.ts b/app/utils/bookmark-exports.server.ts new file mode 100644 index 0000000..b72bdc2 --- /dev/null +++ b/app/utils/bookmark-exports.server.ts @@ -0,0 +1,138 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { getBookmarks } from "~/models/bookmark.server"; +import { requireUserId } from "~/utils/auth.server"; +import type { BookmarkExportFileExtension } from "~/utils/bookmark"; + +type GetBookmarksData = Awaited>; + +export function createExportLoader(fileExtension: BookmarkExportFileExtension) { + return async function loader({ request }: LoaderFunctionArgs) { + await requireUserId(request); + + const bookmarks = await getBookmarks(); + + return exportResponse({ data: bookmarks, fileExtension }); + }; +} + +export function createExportAction(fileExtension: BookmarkExportFileExtension) { + return async function action({ request }: ActionFunctionArgs) { + await requireUserId(request); + + const formData = await request.formData(); + const selectedIds = String(formData.get("selected-ids") ?? "") + .split(",") + .filter(Boolean); + + const bookmarks = await getBookmarks(); + + return exportResponse({ + data: + selectedIds.length > 0 + ? bookmarks.filter((el) => selectedIds.includes(el.id)) + : bookmarks, + fileExtension, + }); + }; +} + +export const mappedExportFunctions = { + csv: formatExportAsCsv, + html: formatExportAsHtml, + json: formatExportAsJson, + md: formatExportAsMarkdown, + txt: formatExportAsText, +} as const; + +export function exportResponse({ + data, + fileExtension, +}: { + data: GetBookmarksData; + fileExtension: BookmarkExportFileExtension; +}) { + const { body, mimeType } = mappedExportFunctions[fileExtension](data); + return new Response(body, { + status: 200, + headers: { "Content-Type": mimeType }, + }); +} + +export function formatExportAsCsv(data: GetBookmarksData) { + const head = ["text", "href", "date"].join(","); + + const rows = data.map(({ createdAt, title, url }) => { + const text = title?.replace(",", "\\,") ?? "Untitled"; + const href = url; + const date = createdAt.toUTCString().replace(",", "\\,"); + const row = [text, href, date].join(","); + return row; + }); + + const body = [head, rows.join("\n")].join("\n"); + + return { body, mimeType: "text/csv" }; +} + +export function formatExportAsHtml(data: GetBookmarksData) { + const rows = data.map(({ createdAt, title, url }) => { + const text = title ?? "Untitled"; + const href = url; + const date = String(createdAt.getTime()).slice(0, 10); + const row = `
${text}
`; + return row; + }); + + const body = [ + "", + "", + '', + "Bookmarks", + "

Bookmarks

", + "
", + ...rows, + "
", + ].join("\n"); + + return { body, mimeType: "text/html" }; +} + +export function formatExportAsJson(data: GetBookmarksData) { + const rows = data.map(({ createdAt, title, url }) => { + return { createdAt, title, url }; + }); + + const body = JSON.stringify(rows, null, 2); + + return { body, mimeType: "application/json" }; +} + +export function formatExportAsMarkdown(data: GetBookmarksData) { + const rows = data.map(({ createdAt, title, url }) => { + const text = `**${title ?? "Untitled"}**`; + const href = `<${url}>`; + const date = createdAt.toUTCString(); + const row = "- ".concat([text, href, date].filter(Boolean).join("
")); + return row; + }); + + const body = ["# Bookmarks", "", ...rows.concat("\n")].join("\n"); + + return { body, mimeType: "text/markdown" }; +} + +export function formatExportAsText(data: GetBookmarksData) { + const rows = data.map(({ createdAt, title, url }) => { + const text = title ?? "Untitled"; + const href = url; + const date = createdAt.toUTCString(); + const row = [text, href, date].filter(Boolean).join("\n"); + return row; + }); + + const body = rows.concat("\n").join("\n\n"); + + return { body, mimeType: "text/plain" }; +} diff --git a/app/utils/bookmark.ts b/app/utils/bookmark.ts index c3143ca..31647e4 100644 --- a/app/utils/bookmark.ts +++ b/app/utils/bookmark.ts @@ -1,3 +1,22 @@ +export const BOOKMARK_EXPORT_FILE_EXTENSIONS = [ + "csv", + "html", + "json", + "md", + "txt", +] as const; + +export type BookmarkExportFileExtension = + (typeof BOOKMARK_EXPORT_FILE_EXTENSIONS)[number]; + +export const BOOKMARK_EXPORT_LABEL_MAP = { + csv: "CSV", + html: "HTML", + json: "JSON", + md: "Markdown", + txt: "Text", +} satisfies Readonly>; + export const BOOKMARK_SEARCH_KEYS = [ "url", "title",