Skip to content

Instantly share code, notes, and snippets.

@stek29
Created November 26, 2025 13:48
Show Gist options
  • Select an option

  • Save stek29/5c1ceefc03e833c7ddf9af9bc3c0c5ab to your computer and use it in GitHub Desktop.

Select an option

Save stek29/5c1ceefc03e833c7ddf9af9bc3c0c5ab to your computer and use it in GitHub Desktop.
Safari Reading List exporter in JSON and CSV to migrate to Readeck
#!/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