My agentic slop goes here. Not intended for anyone else!
1(** Karakeep API client implementation (Eio version) *)
2
3let src = Logs.Src.create "karakeepe" ~doc:"Karakeep API client"
4module Log = (val Logs.src_log src : Logs.LOG)
5
6(** RFC 3339 timestamp support for JSON *)
7module Rfc3339 = struct
8 let parse s =
9 Ptime.of_rfc3339 s |> Result.to_option |> Option.map (fun (t, _, _) -> t)
10
11 let format t = Ptime.to_rfc3339 ~frac_s:6 ~tz_offset_s:0 t
12 let _pp ppf t = Format.pp_print_string ppf (format t)
13
14 let jsont =
15 let kind = "RFC 3339 timestamp" in
16 let dec meta s =
17 match parse s with
18 | Some t -> t
19 | None ->
20 Jsont.Error.msgf meta "invalid RFC 3339 timestamp: %S" s
21 in
22 let enc = Jsont.Base.enc format in
23 Jsont.Base.string (Jsont.Base.map ~kind ~dec ~enc ())
24end
25
26(** Unknown JSON fields - used when keeping unknown members *)
27let json_mems_empty = Jsont.Object ([], Jsont.Meta.none)
28
29(** Type representing a Karakeep client session *)
30type 'net t_internal = {
31 api_key: string;
32 base_url: string;
33 http_client: (float Eio.Time.clock_ty Eio.Resource.t, 'net Eio.Net.ty Eio.Resource.t) Requests.t;
34}
35
36type t = [`Generic | `Unix] t_internal
37
38(** Create a new Karakeep client *)
39let create ~sw ~env ~api_key ~base_url : t =
40 let http_client = Requests.create ~sw env in
41 { api_key; base_url; http_client }
42
43(** Tag type for bookmark tags *)
44module Tag = struct
45 type t = {
46 name: string;
47 unknown: Jsont.json;
48 }
49
50 let make name unknown = { name; unknown }
51 let name t = t.name
52 let unknown t = t.unknown
53
54 let jsont =
55 let kind = "Tag" in
56 Jsont.Object.map ~kind make
57 |> Jsont.Object.mem "name" Jsont.string ~enc:name
58 |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
59 |> Jsont.Object.finish
60end
61
62(** Content field pair (key-value from content object) *)
63module ContentField = struct
64 type _t = string * string
65
66 let _key (k, _) = k
67 let _value (_, v) = v
68
69 (* Helper to convert Jsont.json to string *)
70 let json_to_string = function
71 | Jsont.String (s, _) -> s
72 | Jsont.Bool (b, _) -> string_of_bool b
73 | Jsont.Number (n, _) -> string_of_float n
74 | Jsont.Null _ -> "null"
75 | _ -> "complex_value"
76
77 (* Decode from JSON object members *)
78 let of_json_mems mems =
79 List.map (fun ((k, _meta), v) -> (k, json_to_string v)) mems
80
81 (* Encode to JSON object members *)
82 let to_json_mems fields =
83 List.map (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) fields
84end
85
86(** Asset type *)
87module Asset = struct
88 type t = {
89 id: string;
90 asset_type: string;
91 unknown: Jsont.json;
92 }
93
94 let make id asset_type unknown = { id; asset_type; unknown }
95 let id t = t.id
96 let asset_type t = t.asset_type
97 let unknown t = t.unknown
98
99 let jsont =
100 let kind = "Asset" in
101 Jsont.Object.map ~kind make
102 |> Jsont.Object.mem "id" Jsont.string ~enc:id
103 |> Jsont.Object.mem "assetType" Jsont.string ~enc:asset_type
104 |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
105 |> Jsont.Object.finish
106end
107
108(** Karakeep bookmark *)
109module Bookmark = struct
110 type t = {
111 id: string;
112 title: string option;
113 url: string;
114 note: string option;
115 created_at: Ptime.t;
116 updated_at: Ptime.t option;
117 favourited: bool;
118 archived: bool;
119 tags: string list;
120 tagging_status: string option;
121 summary: string option;
122 content: (string * string) list;
123 assets: (string * string) list;
124 }
125
126 let id t = t.id
127 let title t = t.title
128 let url t = t.url
129 let note t = t.note
130 let created_at t = t.created_at
131 let updated_at t = t.updated_at
132 let favourited t = t.favourited
133 let archived t = t.archived
134 let tags t = t.tags
135 let tagging_status t = t.tagging_status
136 let summary t = t.summary
137 let content t = t.content
138 let assets t = t.assets
139
140 let jsont =
141 let kind = "Bookmark" in
142
143 (* Constructor for decoding *)
144 let make id title url note created_at updated_at favourited archived
145 tag_objs tagging_status summary content_obj assets_objs _unknown =
146
147 (* Extract tag names from tag objects *)
148 let tags = match tag_objs with
149 | Some tags -> List.map Tag.name tags
150 | None -> []
151 in
152
153 (* Extract content fields from JSON object *)
154 let content = match content_obj with
155 | Some (Jsont.Object (mems, _)) -> ContentField.of_json_mems mems
156 | _ -> []
157 in
158
159 (* Extract asset tuples *)
160 let assets = match assets_objs with
161 | Some asset_list -> List.map (fun a -> (Asset.id a, Asset.asset_type a)) asset_list
162 | None -> []
163 in
164
165 (* Handle URL extraction from content if main URL is missing *)
166 let url = match url with
167 | Some u -> u
168 | None ->
169 (* Try to find URL in content *)
170 (match List.assoc_opt "url" content with
171 | Some u -> u
172 | None ->
173 (match List.assoc_opt "sourceUrl" content with
174 | Some u -> u
175 | None ->
176 (* Check if it's an asset type *)
177 (match List.assoc_opt "type" content with
178 | Some "asset" ->
179 (match List.assoc_opt "sourceUrl" content with
180 | Some u -> u
181 | None -> "karakeep-asset://" ^ id)
182 | _ -> "unknown://no-url")))
183 in
184
185 {
186 id;
187 title;
188 url;
189 note;
190 created_at;
191 updated_at;
192 favourited = Option.value ~default:false favourited;
193 archived = Option.value ~default:false archived;
194 tags;
195 tagging_status;
196 summary;
197 content;
198 assets;
199 }
200 in
201
202 Jsont.Object.map ~kind make
203 |> Jsont.Object.mem "id" Jsont.string ~enc:id
204 |> Jsont.Object.opt_mem "title" Jsont.string ~enc:title
205 |> Jsont.Object.opt_mem "url" Jsont.string ~enc:(fun t -> Some t.url)
206 |> Jsont.Object.opt_mem "note" Jsont.string ~enc:note
207 |> Jsont.Object.mem "createdAt" Rfc3339.jsont ~enc:created_at
208 |> Jsont.Object.opt_mem "updatedAt" Rfc3339.jsont ~enc:updated_at
209 |> Jsont.Object.opt_mem "favourited" Jsont.bool ~enc:(fun t -> Some t.favourited)
210 |> Jsont.Object.opt_mem "archived" Jsont.bool ~enc:(fun t -> Some t.archived)
211 |> Jsont.Object.opt_mem "tags" (Jsont.list Tag.jsont)
212 ~enc:(fun t -> if t.tags = [] then None else
213 Some (List.map (fun name -> Tag.make name json_mems_empty) t.tags))
214 |> Jsont.Object.opt_mem "taggingStatus" Jsont.string ~enc:tagging_status
215 |> Jsont.Object.opt_mem "summary" Jsont.string ~enc:summary
216 |> Jsont.Object.opt_mem "content" Jsont.json
217 ~enc:(fun t -> if t.content = [] then None else
218 Some (Jsont.Object (ContentField.to_json_mems t.content, Jsont.Meta.none)))
219 |> Jsont.Object.opt_mem "assets" (Jsont.list Asset.jsont)
220 ~enc:(fun t -> if t.assets = [] then None else
221 Some (List.map (fun (id, asset_type) ->
222 Asset.make id asset_type json_mems_empty) t.assets))
223 |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
224 |> Jsont.Object.finish
225end
226
227(* Compatibility type aliases and accessors *)
228type bookmark = Bookmark.t
229let bookmark_id = Bookmark.id
230let bookmark_title = Bookmark.title
231let bookmark_url = Bookmark.url
232let bookmark_note = Bookmark.note
233let bookmark_created_at = Bookmark.created_at
234let bookmark_updated_at = Bookmark.updated_at
235let bookmark_favourited = Bookmark.favourited
236let bookmark_archived = Bookmark.archived
237let bookmark_tags = Bookmark.tags
238let bookmark_tagging_status = Bookmark.tagging_status
239let bookmark_summary = Bookmark.summary
240let bookmark_content = Bookmark.content
241let bookmark_assets = Bookmark.assets
242
243(** Karakeep API response containing bookmarks *)
244module BookmarkResponse = struct
245 type t = {
246 total: int;
247 data: bookmark list;
248 next_cursor: string option;
249 }
250
251 let make total data next_cursor = { total; data; next_cursor }
252 let total t = t.total
253 let data t = t.data
254 let next_cursor t = t.next_cursor
255
256 (* Format 1: {total, data, nextCursor} *)
257 let format1_jsont =
258 let kind = "BookmarkResponse" in
259 let make total data next_cursor _unknown =
260 { total; data; next_cursor }
261 in
262 Jsont.Object.map ~kind make
263 |> Jsont.Object.mem "total" Jsont.int ~enc:total
264 |> Jsont.Object.mem "data" (Jsont.list Bookmark.jsont) ~enc:data
265 |> Jsont.Object.opt_mem "nextCursor" Jsont.string ~enc:next_cursor
266 |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
267 |> Jsont.Object.finish
268
269 (* Format 2: {bookmarks, nextCursor} *)
270 let format2_jsont =
271 let kind = "BookmarkResponse" in
272 let make data next_cursor _unknown =
273 { total = List.length data; data; next_cursor }
274 in
275 Jsont.Object.map ~kind make
276 |> Jsont.Object.mem "bookmarks" (Jsont.list Bookmark.jsont) ~enc:data
277 |> Jsont.Object.opt_mem "nextCursor" Jsont.string ~enc:next_cursor
278 |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
279 |> Jsont.Object.finish
280end
281
282(* Compatibility type aliases and accessors *)
283type bookmark_response = BookmarkResponse.t
284let response_total = BookmarkResponse.total
285let response_data = BookmarkResponse.data
286let response_next_cursor = BookmarkResponse.next_cursor
287
288(** Parse a Karakeep bookmark response - handles multiple API response formats *)
289let parse_bookmark_response json_str =
290 Log.debug (fun m -> m "Parsing API response (%d bytes)" (String.length json_str));
291
292 (* Try format 1: {total: int, data: [...], nextCursor?: string} *)
293 let try_format1 () =
294 Log.debug (fun m -> m "Trying format 1: {total, data, nextCursor}");
295 match Jsont_bytesrw.decode_string' BookmarkResponse.format1_jsont json_str with
296 | Ok response ->
297 Log.debug (fun m -> m "Successfully parsed format 1: %d bookmarks" (List.length response.data));
298 response
299 | Error e ->
300 Log.debug (fun m -> m "Format 1 failed: %s" (Jsont.Error.to_string e));
301 raise Not_found
302 in
303
304 (* Try format 2: {bookmarks: [...], nextCursor?: string} *)
305 let try_format2 () =
306 Log.debug (fun m -> m "Trying format 2: {bookmarks, nextCursor}");
307 match Jsont_bytesrw.decode_string' BookmarkResponse.format2_jsont json_str with
308 | Ok response ->
309 Log.debug (fun m -> m "Successfully parsed format 2: %d bookmarks" (List.length response.data));
310 response
311 | Error e ->
312 Log.debug (fun m -> m "Format 2 failed: %s" (Jsont.Error.to_string e));
313 raise Not_found
314 in
315
316 (* Try format 3: Plain array at root level *)
317 let try_array_format () =
318 Log.debug (fun m -> m "Trying format 3: array at root");
319 let array_jsont = Jsont.list Bookmark.jsont in
320 match Jsont_bytesrw.decode_string' array_jsont json_str with
321 | Ok data ->
322 Log.debug (fun m -> m "Successfully parsed array format: %d bookmarks" (List.length data));
323 BookmarkResponse.make (List.length data) data None
324 | Error e ->
325 Log.debug (fun m -> m "Array format failed: %s" (Jsont.Error.to_string e));
326 raise Not_found
327 in
328
329 (* Try each format in order *)
330 try try_format1 ()
331 with Not_found -> (
332 try try_format2 ()
333 with Not_found -> (
334 try try_array_format ()
335 with Not_found ->
336 Log.err (fun m -> m "Failed to parse response in any known format");
337 Log.debug (fun m -> m "Response preview: %s"
338 (if String.length json_str > 200 then String.sub json_str 0 200 ^ "..." else json_str));
339 BookmarkResponse.make 0 [] None
340 )
341 )
342
343(** Fetch bookmarks from a Karakeep instance with pagination support *)
344let fetch_bookmarks client ?(limit=50) ?(offset=0) ?cursor ?(include_content=false) ?filter_tags () =
345 let url_base = Fmt.str "%s/api/v1/bookmarks?limit=%d&includeContent=%b"
346 client.base_url limit include_content in
347
348 let url =
349 match cursor with
350 | Some cursor_value -> url_base ^ "&cursor=" ^ cursor_value
351 | None -> url_base ^ "&offset=" ^ string_of_int offset
352 in
353
354 let url = match filter_tags with
355 | Some tags when tags <> [] ->
356 let encoded_tags =
357 List.map (fun tag -> Uri.pct_encode ~component:`Query_key tag) tags
358 in
359 let tags_param = String.concat "," encoded_tags in
360 Log.debug (fun m -> m "Adding tags filter: %s" tags_param);
361 url ^ "&tags=" ^ tags_param
362 | _ -> url
363 in
364
365 let headers = Requests.Headers.empty
366 |> Requests.Headers.set "Authorization" ("Bearer " ^ client.api_key) in
367
368 Log.debug (fun m -> m "Fetching bookmarks from: %s" url);
369
370 try
371 let response = Requests.get client.http_client ~headers url in
372 match Requests_json_api.check_ok response with
373 | Ok body_str ->
374 Log.debug (fun m -> m "Received %d bytes of response data" (String.length body_str));
375 parse_bookmark_response body_str
376 | Error (status_code, _) ->
377 Log.err (fun m -> m "HTTP error %d" status_code);
378 failwith (Fmt.str "HTTP error: %d" status_code)
379 with e ->
380 Log.err (fun m -> m "Network error: %s" (Printexc.to_string e));
381 raise e
382
383(** Fetch all bookmarks from a Karakeep instance using pagination *)
384let fetch_all_bookmarks client ?(page_size=50) ?max_pages ?max_bookmarks ?filter_tags ?(include_content=false) () =
385 let rec fetch_pages page_num cursor acc =
386 (* Check if we've reached the max_bookmarks limit *)
387 let reached_limit = match max_bookmarks with
388 | Some max when List.length acc >= max ->
389 Log.debug (fun m -> m "Reached max_bookmarks limit (%d)" max);
390 true
391 | _ -> false
392 in
393
394 if reached_limit then
395 acc
396 else begin
397 Log.debug (fun m -> m "Fetching page %d" page_num);
398 let response =
399 match cursor with
400 | Some cursor_str -> fetch_bookmarks client ~limit:page_size ~cursor:cursor_str ~include_content ?filter_tags ()
401 | None -> fetch_bookmarks client ~limit:page_size ~offset:(page_num * page_size) ~include_content ?filter_tags ()
402 in
403
404 let all_bookmarks = acc @ response.data in
405 Log.debug (fun m -> m "Fetched %d bookmarks this page, %d total so far"
406 (List.length response.data) (List.length all_bookmarks));
407
408 (* Truncate to max_bookmarks if needed *)
409 let all_bookmarks = match max_bookmarks with
410 | Some max when List.length all_bookmarks > max ->
411 Log.debug (fun m -> m "Truncating to max_bookmarks (%d)" max);
412 List.filteri (fun i _ -> i < max) all_bookmarks
413 | _ -> all_bookmarks
414 in
415
416 (* Determine if more pages are available *)
417 let more_available =
418 match response.next_cursor with
419 | Some _ ->
420 Log.debug (fun m -> m "More pages available (next_cursor present)");
421 true
422 | None ->
423 let current_count = List.length all_bookmarks in
424 let got_full_page = List.length response.data = page_size in
425 let total_indicates_more = response.total > current_count in
426 let has_more = got_full_page && total_indicates_more in
427 if has_more then
428 Log.debug (fun m -> m "More pages likely available (%d fetched < %d total)"
429 current_count response.total)
430 else
431 Log.debug (fun m -> m "No more pages (got %d items, total=%d)"
432 (List.length response.data) response.total);
433 has_more
434 in
435
436 let under_max_pages = match max_pages with
437 | None -> true
438 | Some max -> page_num + 1 < max
439 in
440
441 let under_max_bookmarks = match max_bookmarks with
442 | None -> true
443 | Some max -> List.length all_bookmarks < max
444 in
445
446 if more_available && under_max_pages && under_max_bookmarks then
447 fetch_pages (page_num + 1) response.next_cursor all_bookmarks
448 else begin
449 Log.debug (fun m -> m "Pagination complete: fetched %d total bookmarks" (List.length all_bookmarks));
450 all_bookmarks
451 end
452 end
453 in
454 fetch_pages 0 None []
455
456(** Fetch detailed information for a single bookmark by ID *)
457let fetch_bookmark_details client bookmark_id =
458 let open Requests_json_api in
459 let url = client.base_url / "api/v1/bookmarks" / bookmark_id in
460
461 let headers = Requests.Headers.empty
462 |> Requests.Headers.set "Authorization" ("Bearer " ^ client.api_key) in
463
464 let response = Requests.get client.http_client ~headers url in
465 match check_ok response with
466 | Ok body_str ->
467 (match Jsont_bytesrw.decode_string' Bookmark.jsont body_str with
468 | Ok bookmark -> bookmark
469 | Error e ->
470 failwith (Fmt.str "Failed to parse bookmark: %s" (Jsont.Error.to_string e)))
471 | Error (status_code, _) ->
472 failwith (Fmt.str "HTTP error: %d" status_code)
473
474(** Get the asset URL for a given asset ID *)
475let get_asset_url client asset_id =
476 Fmt.str "%s/api/assets/%s" client.base_url asset_id
477
478(** Fetch an asset from the Karakeep server as a binary string *)
479let fetch_asset client asset_id =
480 let open Requests_json_api in
481 let url = client.base_url / "api/assets" / asset_id in
482
483 let headers = Requests.Headers.empty
484 |> Requests.Headers.set "Authorization" ("Bearer " ^ client.api_key) in
485
486 let response = Requests.get client.http_client ~headers url in
487 match check_ok response with
488 | Ok body_str -> body_str
489 | Error (status_code, _) ->
490 failwith (Fmt.str "Asset fetch error: %d" status_code)
491
492(** Create a new bookmark in Karakeep with optional tags *)
493let create_bookmark client ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) () =
494 let meta = Jsont.Meta.none in
495 let body_obj = [
496 (("type", meta), Jsont.String ("link", meta));
497 (("url", meta), Jsont.String (url, meta));
498 (("favourited", meta), Jsont.Bool (favourited, meta));
499 (("archived", meta), Jsont.Bool (archived, meta));
500 ] in
501
502 let body_obj = match title with
503 | Some title_str -> (("title", meta), Jsont.String (title_str, meta)) :: body_obj
504 | None -> body_obj
505 in
506
507 let body_obj = match note with
508 | Some note_str -> (("note", meta), Jsont.String (note_str, meta)) :: body_obj
509 | None -> body_obj
510 in
511
512 let body_json = Jsont.Object (body_obj, meta) in
513 let body_str = match Jsont_bytesrw.encode_string' Jsont.json body_json with
514 | Ok s -> s
515 | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
516 in
517
518 let headers = Requests.Headers.empty
519 |> Requests.Headers.set "Authorization" ("Bearer " ^ client.api_key)
520 |> Requests.Headers.set "Content-Type" "application/json"
521 in
522
523 let open Requests_json_api in
524 let url_endpoint = client.base_url / "api/v1/bookmarks" in
525 let body = Requests.Body.of_string Requests.Mime.json body_str in
526 let response = Requests.post client.http_client ~headers ~body url_endpoint in
527
528 let status_code = Requests.Response.status_code response in
529 if status_code = 201 || status_code = 200 then begin
530 let body_str = read_body response in
531 let bookmark = match Jsont_bytesrw.decode_string' Bookmark.jsont body_str with
532 | Ok b -> b
533 | Error e -> failwith (Fmt.str "Failed to parse created bookmark: %s" (Jsont.Error.to_string e))
534 in
535
536 match tags with
537 | Some tag_list when tag_list <> [] ->
538 let tag_objects = List.map (fun tag_name ->
539 Jsont.Object ([(("tagName", meta), Jsont.String (tag_name, meta))], meta)
540 ) tag_list in
541
542 let tags_body = Jsont.Object ([(("tags", meta), Jsont.Array (tag_objects, meta))], meta) in
543 let tags_body_str = match Jsont_bytesrw.encode_string' Jsont.json tags_body with
544 | Ok s -> s
545 | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
546 in
547
548 let tags_url = client.base_url / "api/v1/bookmarks" / bookmark.id / "tags" in
549 let tags_body = Requests.Body.of_string Requests.Mime.json tags_body_str in
550 let tags_response = Requests.post client.http_client ~headers ~body:tags_body tags_url in
551
552 let tags_status = Requests.Response.status_code tags_response in
553 if tags_status = 200 then
554 fetch_bookmark_details client bookmark.id
555 else
556 bookmark
557 | _ -> bookmark
558 end else begin
559 let error_body = read_body response in
560 failwith (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
561 end