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