diff --git a/migrate/sources/karakeep.js b/migrate/sources/karakeep.js new file mode 100644 index 0000000..584433a --- /dev/null +++ b/migrate/sources/karakeep.js @@ -0,0 +1,22 @@ +import * as refs from '../../lib/db/repos/refs.js'; + +const SYS = { kind: 'system', id: null }; + +// Karakeep REST: GET /api/v1/bookmarks?cursor=... → { bookmarks:[{id,content:{url,title}}], nextCursor }. +export async function importKarakeep({ apiUrl = process.env.KARAKEEP_URL, token = process.env.KARAKEEP_TOKEN, spaceId, dryRun = false, fetchImpl = fetch }) { + let cursor = null, created = 0; + do { + const u = `${apiUrl}/api/v1/bookmarks?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`; + const res = await fetchImpl(u, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`karakeep ${res.status}`); + const body = await res.json(); + for (const b of (body.bookmarks || [])) { + const url = b.content?.url; if (!url) continue; + created++; + if (dryRun) continue; + await refs.upsertByExternal({ space_id: spaceId, kind: 'url', source_url: url, title: b.content?.title || url, source_kind: 'karakeep', external_id: String(b.id) }, SYS); + } + cursor = body.nextCursor || null; + } while (cursor); + return { created }; +} diff --git a/tests/migrate/karakeep.test.js b/tests/migrate/karakeep.test.js new file mode 100644 index 0000000..9854530 --- /dev/null +++ b/tests/migrate/karakeep.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { pool } from '../../lib/db/pool.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { ensureSpace } from '../../migrate/spaces.js'; +import { importKarakeep } from '../../migrate/sources/karakeep.js'; + +let spaceId; +beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('bookmarks', 'Bookmarks'); }); + +describe('karakeep importer', () => { + it('imports bookmarks as url refs, idempotently', async () => { + const page = { bookmarks: [ + { id: 'b1', content: { type: 'link', url: 'https://a.test', title: 'A' } }, + { id: 'b2', content: { type: 'link', url: 'https://b.test', title: 'B' } } + ], nextCursor: null }; + const fetchImpl = vi.fn(async () => ({ ok: true, json: async () => page })); + const r1 = await importKarakeep({ apiUrl: 'http://k', token: 't', spaceId, fetchImpl }); + expect(r1.created).toBe(2); + const { rows } = await pool.query(`SELECT source_url FROM refs WHERE space_id=$1 ORDER BY source_url`, [spaceId]); + expect(rows.map(r => r.source_url)).toEqual(['https://a.test', 'https://b.test']); + await importKarakeep({ apiUrl: 'http://k', token: 't', spaceId, fetchImpl }); // upsert, no dupes + const { rows: again } = await pool.query(`SELECT count(*)::int n FROM refs WHERE space_id=$1`, [spaceId]); + expect(again[0].n).toBe(2); + }); +});