Created
November 26, 2025 13:48
-
-
Save stek29/5c1ceefc03e833c7ddf9af9bc3c0c5ab to your computer and use it in GitHub Desktop.
Safari Reading List exporter in JSON and CSV to migrate to Readeck
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S deno run --allow-read --allow-write | |
| import { readAll } from "jsr:@std/[email protected]/read-all"; | |
| import { parseArgs } from "jsr:@std/[email protected]/parse-args"; | |
| import { parse } from "npm:@plist/[email protected]"; | |
| import { Value, Dictionary } from "npm:@plist/[email protected]"; | |
| async function readStdinAsArrayBuffer(): Promise<ArrayBuffer> { | |
| const bytes = await readAll(Deno.stdin); | |
| let buffer = bytes.buffer.slice( | |
| bytes.byteOffset, | |
| bytes.byteOffset + bytes.byteLength, | |
| ); | |
| if (buffer instanceof SharedArrayBuffer) { | |
| const unshared = new ArrayBuffer(buffer.byteLength); | |
| new Uint8Array(unshared).set(new Uint8Array(buffer)); | |
| buffer = unshared; | |
| } | |
| return buffer; | |
| } | |
| export const STDIN_FILENAME = "-"; | |
| export const READING_LIST_TITLE = "com.apple.ReadingList"; | |
| export const COMMAND_EXTRACT_ITEMS = "extract-items"; | |
| export const COMMAND_FETCH_PAGES = "fetch-pages"; | |
| type OutputFormat = "json" | "csv"; | |
| const SUPPORTED_FORMATS: OutputFormat[] = ["json", "csv"]; | |
| async function readFileAsArrayBuffer( | |
| path: string | URL, | |
| ): Promise<ArrayBuffer> { | |
| if (path === STDIN_FILENAME) { | |
| return readStdinAsArrayBuffer(); | |
| } | |
| const bytes = await Deno.readFile(path); | |
| return bytes.buffer.slice( | |
| bytes.byteOffset, | |
| bytes.byteOffset + bytes.byteLength, | |
| ); | |
| } | |
| export function findReadingList(plist: Value | null): null | Dictionary { | |
| const readingList = ((plist as Dictionary).Children as Value[]).find(( | |
| e: Value, | |
| ) => (e as Dictionary).Title === READING_LIST_TITLE) as Dictionary; | |
| return readingList || null; | |
| } | |
| export interface ReadingListItem { | |
| DateAdded: Date; | |
| DateLastViewed?: Date; | |
| URLString: string; | |
| Title?: string; | |
| PreviewText?: string; | |
| UUID: string; | |
| } | |
| interface PlistReadingListItem { | |
| ReadingList: { | |
| DateAdded: Date; | |
| // date the item was read | |
| DateLastViewed?: Date; | |
| PreviewText?: string; | |
| }; | |
| ReadingListNonSync: { | |
| DateLastFetched?: Date; | |
| FetchResult?: number; | |
| NumberOfFailedLoadsWithUnknownOrNonRecoverableError?: number; | |
| Title?: string; | |
| didAttemptToFetchIconFromImageUrlKey?: boolean; | |
| neverFetchMetadata?: boolean; | |
| siteName?: string; | |
| }; | |
| URIDictionary: { | |
| title: string; | |
| }; | |
| URLString: string; | |
| WebBookmarkType: string; | |
| WebBookmarkUUID: string; | |
| } | |
| export function extractReadingListItems( | |
| readingList: Dictionary, | |
| ): ReadingListItem[] { | |
| const readingListItems = readingList | |
| .Children as unknown as PlistReadingListItem[]; | |
| const items: ReadingListItem[] = []; | |
| for (const rawItem of readingListItems) { | |
| const item: ReadingListItem = { | |
| DateAdded: rawItem.ReadingList.DateAdded, | |
| URLString: rawItem.URLString, | |
| Title: rawItem.URIDictionary?.title ?? rawItem.ReadingListNonSync?.Title, | |
| PreviewText: rawItem.ReadingList.PreviewText, | |
| UUID: rawItem.WebBookmarkUUID, | |
| }; | |
| items.push(item); | |
| } | |
| return items; | |
| } | |
| function printHelp() { | |
| console.log(`usage: ${Deno.args[0]} --input=path --dest=path | |
| --input: path to read plist history from (default: ${STDIN_FILENAME}) | |
| use ${STDIN_FILENAME} to read from stdin | |
| example: --input ~/Library/Safari/Bookmarks.plist | |
| --dest: destination path | |
| filename to export bookmarks to | |
| --format: output format | |
| json or csv. default: csv | |
| csv is compatible with Readeck | |
| `); | |
| } | |
| // Helper function to load and parse the plist and extract items | |
| async function loadReadingListItems( | |
| inputPath: string, | |
| ): Promise<ReadingListItem[]> { | |
| const plistData = await readFileAsArrayBuffer(inputPath); | |
| const plist = parse(plistData); | |
| const readingList = findReadingList(plist); | |
| if (!readingList) { | |
| throw new Error("Could not find Reading List in the input file."); | |
| } | |
| return extractReadingListItems(readingList); | |
| } | |
| function escapeCsvValue(value: string): string { | |
| const needsQuote = /[",\n\r]/.test(value); | |
| const escaped = value.replace(/"/g, '""'); | |
| return needsQuote ? `"${escaped}"` : escaped; | |
| } | |
| function formatAsCsv(items: ReadingListItem[]): string { | |
| const headers = ["url", "title", "created"]; | |
| const rows = items.map((item) => { | |
| const created = item.DateAdded instanceof Date | |
| ? item.DateAdded.toISOString() | |
| : ""; | |
| const values = [ | |
| item.URLString ?? "", | |
| item.Title ?? "", | |
| created, | |
| ]; | |
| return values.map(escapeCsvValue).join(","); | |
| }); | |
| return [headers.join(","), ...rows].join("\n"); | |
| } | |
| function serializeItems( | |
| items: ReadingListItem[], | |
| format: OutputFormat, | |
| ): string { | |
| if (format === "csv") { | |
| return formatAsCsv(items); | |
| } | |
| return JSON.stringify(items, null, 2); | |
| } | |
| // Handler for the extract-items command | |
| async function handleExtractItems( | |
| inputPath: string, | |
| destPath: string, | |
| format: OutputFormat, | |
| ) { | |
| const items = await loadReadingListItems(inputPath); | |
| const serialized = serializeItems(items, format); | |
| await Deno.writeTextFile(destPath, serialized); | |
| console.log(`Extracted ${items.length} items to ${destPath}`); | |
| } | |
| async function main() { | |
| // dirty hack to suppress console.debug | |
| console.debug = () => { }; | |
| const flags = parseArgs(Deno.args, { | |
| boolean: ["help"], | |
| string: ["input", "dest", "format"], | |
| default: { input: STDIN_FILENAME, format: "csv" }, | |
| }); | |
| if (flags.help) { | |
| console.log(flags); | |
| return printHelp(); | |
| } | |
| if (!flags.dest) { | |
| console.error("dest is required"); | |
| Deno.exitCode = 1; | |
| return printHelp(); | |
| } | |
| const format = String(flags.format).toLowerCase(); | |
| if (!SUPPORTED_FORMATS.includes(format as OutputFormat)) { | |
| console.error(`unsupported format "${flags.format}". use: json or csv`); | |
| Deno.exitCode = 1; | |
| return; | |
| } | |
| await handleExtractItems(flags.input, flags.dest!, format as OutputFormat); | |
| } | |
| if (import.meta.main) { | |
| main(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment