Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,12 +1015,14 @@ export function buildCli() {
.command('md')
.description('Export bookmarks as individual markdown files')
.option('--force', 'Re-export all bookmarks (overwrite existing files)')
.option('--format <type>', 'Filename format: rev-iso (default), legacy', (v: string) => v as 'legacy' | 'rev-iso', 'rev-iso')
Comment thread
MrDwarf7 marked this conversation as resolved.
Outdated
.action(safe(async (options) => {
if (!requireIndex()) return;
let lastLine = '';
const spinner = createSpinner(() => lastLine);
const result = await exportBookmarks({
force: options.force,
filenameFormat: options.format,
onProgress: (s) => {
lastLine = s;
spinner.update();
Expand Down
164 changes: 129 additions & 35 deletions src/md-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,26 @@
* full tweet text, and [[wikilinks]] to wiki category/domain/entity pages.
* No LLM required — fast, deterministic, portable.
*
* Output: ~/.ft-bookmarks/md/bookmarks/<date>-<author>-<slug>.md
* Output: ~/.ft-bookmarks/md/bookmarks/<year>_<month>_<day>-<dow>-<slug>.md
*/

import fs from 'node:fs';
import path from 'node:path';
import { ensureDir, writeMd } from './fs.js';
import { mdDir } from './paths.js';
import { listBookmarks, countBookmarks, type BookmarkTimelineItem } from './bookmarks-db.js';
import { slug } from './md.js';
import fs from "node:fs";
import path from "node:path";
import { ensureDir, writeMd } from "./fs.js";
import { mdDir } from "./paths.js";
import {
listBookmarks,
countBookmarks,
type BookmarkTimelineItem,
} from "./bookmarks-db.js";
import { slug } from "./md.js";

export type FilenameFormat = "legacy" | "rev-iso";

export interface ExportOptions {
force?: boolean;
onProgress?: (status: string) => void;
filenameFormat?: FilenameFormat;
}

export interface ExportResult {
Expand All @@ -29,58 +36,136 @@ export interface ExportResult {
elapsed: number;
}

const DATE_STR_REGEX = RegExp(
/^(\w{3})\s+(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{4}\s+(\d{4})$/,
);

function bookmarksDir(): string {
return path.join(mdDir(), 'bookmarks');
return path.join(mdDir(), "bookmarks");
}

function bookmarkFilename(b: BookmarkTimelineItem): string {
const date = (b.postedAt ?? b.bookmarkedAt ?? '').slice(0, 10) || 'undated';
const author = b.authorHandle ? slug(b.authorHandle) : 'unknown';
function parsePostedAt(dateStr: string | null | undefined): {
year: string;
month: string;
day: string;
dow: string;
} | null {
if (!dateStr) return null;

if (dateStr.length === 10 && dateStr.includes("-")) {
const year = parseInt(dateStr.slice(0, 4));
const month = parseInt(dateStr.slice(5, 7)) - 1;
const day = parseInt(dateStr.slice(8, 10));
const dowNum = new Date(year, month, day).getDay();
const dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][dowNum];
return {
year: dateStr.slice(0, 4),
month: dateStr.slice(5, 7),
day: dateStr.slice(8, 10),
dow,
};
}

const match = dateStr.match(DATE_STR_REGEX);
if (match) {
const [, dow, , day, year] = match;
const monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const monthStr = match[2];
const month = String(monthNames.indexOf(monthStr) + 1).padStart(2, "0");
return { year, month, day: day.padStart(2, "0"), dow };
}

return null;
}

type Author = string | null | undefined;

function bookmarkFilenameLegacy(b: BookmarkTimelineItem, a: Author): string {
if (!a) a = "unknown";
const date = (b.postedAt ?? b.bookmarkedAt ?? "").slice(0, 10) || "undated";
const author = a;
const textSlug = slug(b.text.slice(0, 50)) || b.id;
return `${date}-${author}-${textSlug}.md`;
}

function bookmarkFilename(
b: BookmarkTimelineItem,
format: FilenameFormat = "rev-iso",
): string {
const author = b.authorHandle ? slug(b.authorHandle) : "unknown";
if (format === "legacy") {
return bookmarkFilenameLegacy(b, author);
}

const dateInfo = parsePostedAt(b.postedAt ?? b.bookmarkedAt ?? null);
const textSlug = slug(b.text.slice(0, 50)) || b.id;

if (dateInfo) {
return `${dateInfo.year}_${dateInfo.month}_${dateInfo.day}-${dateInfo.dow}-${author}-${textSlug}.md`;
}

return `undated-${author}-${textSlug}.md`;
}

function buildBookmarkMd(b: BookmarkTimelineItem): string {
const lines: string[] = [];

// ── Frontmatter ─────────────────────────────────────────────────────
lines.push('---');
lines.push("---");
if (b.authorHandle) lines.push(`author: "@${b.authorHandle}"`);
if (b.authorName) lines.push(`author_name: "${b.authorName.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')}"`);
if (b.authorName)
lines.push(
`author_name: "${b.authorName.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ")}"`,
);
if (b.postedAt) lines.push(`posted_at: ${b.postedAt.slice(0, 10)}`);
if (b.bookmarkedAt) lines.push(`bookmarked_at: ${b.bookmarkedAt.slice(0, 10)}`);
if (b.bookmarkedAt)
lines.push(`bookmarked_at: ${b.bookmarkedAt.slice(0, 10)}`);
if (b.primaryCategory) lines.push(`category: ${b.primaryCategory}`);
if (b.primaryDomain) lines.push(`domain: ${b.primaryDomain}`);
if (b.categories.length > 0) lines.push(`categories: [${b.categories.join(', ')}]`);
if (b.domains.length > 0) lines.push(`domains: [${b.domains.join(', ')}]`);
if (b.categories.length > 0)
lines.push(`categories: [${b.categories.join(", ")}]`);
if (b.domains.length > 0) lines.push(`domains: [${b.domains.join(", ")}]`);
lines.push(`source_url: ${b.url}`);
lines.push(`tweet_id: "${b.tweetId}"`);
if (b.likeCount) lines.push(`likes: ${b.likeCount}`);
if (b.repostCount) lines.push(`reposts: ${b.repostCount}`);
if (b.viewCount) lines.push(`views: ${b.viewCount}`);
lines.push('---');
lines.push('');
lines.push("---");
lines.push("");

// ── Title ───────────────────────────────────────────────────────────
const author = b.authorHandle ? `@${b.authorHandle}` : 'Unknown';
const author = b.authorHandle ? `@${b.authorHandle}` : "Unknown";
lines.push(`# ${author}`);
lines.push('');
lines.push("");

// ── Body ────────────────────────────────────────────────────────────
lines.push(b.text);
lines.push('');
lines.push("");

// ── Links ───────────────────────────────────────────────────────────
if (b.links.length > 0) {
lines.push('## Links');
lines.push("## Links");
for (const link of b.links) lines.push(`- ${link}`);
lines.push('');
lines.push("");
}

if (b.githubUrls.length > 0) {
lines.push('## GitHub');
lines.push("## GitHub");
for (const url of b.githubUrls) lines.push(`- ${url}`);
lines.push('');
lines.push("");
}

// ── Wikilinks to wiki pages ─────────────────────────────────────────
Expand All @@ -90,20 +175,23 @@ function buildBookmarkMd(b: BookmarkTimelineItem): string {
if (b.authorHandle) refs.push(`[[entities/${slug(b.authorHandle)}]]`);

if (refs.length > 0) {
lines.push('## Related');
lines.push("## Related");
for (const ref of refs) lines.push(`- ${ref}`);
lines.push('');
lines.push("");
}

// ── Source ──────────────────────────────────────────────────────────
lines.push(`[Original tweet](${b.url})`);
lines.push('');
lines.push("");

return lines.join('\n');
return lines.join("\n");
}

export async function exportBookmarks(options: ExportOptions = {}): Promise<ExportResult> {
const progress = options.onProgress ?? ((s: string) => fs.writeSync(2, s + '\n'));
export async function exportBookmarks(
options: ExportOptions = {},
): Promise<ExportResult> {
const progress =
options.onProgress ?? ((s: string) => fs.writeSync(2, s + "\n"));
const startTime = Date.now();

await ensureDir(bookmarksDir());
Expand All @@ -117,9 +205,11 @@ export async function exportBookmarks(options: ExportOptions = {}): Promise<Expo
try {
const files = fs.readdirSync(bookmarksDir());
for (const f of files) {
if (f.endsWith('.md')) existingFiles.add(f);
if (f.endsWith(".md")) existingFiles.add(f);
}
} catch { /* dir may not exist yet */ }
} catch {
/* dir may not exist yet */
}
}

let exported = 0;
Expand All @@ -128,11 +218,15 @@ export async function exportBookmarks(options: ExportOptions = {}): Promise<Expo
let offset = 0;

while (offset < total) {
const bookmarks = await listBookmarks({ limit: batchSize, offset, sort: 'desc' });
const bookmarks = await listBookmarks({
limit: batchSize,
offset,
sort: "desc",
});
if (bookmarks.length === 0) break;

for (const b of bookmarks) {
const filename = bookmarkFilename(b);
const filename = bookmarkFilename(b, options.filenameFormat);
Comment thread
MrDwarf7 marked this conversation as resolved.

if (!options.force && existingFiles.has(filename)) {
skipped++;
Expand Down