OCaml CLI and library to the Karakeep bookmarking app
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

init-vibe

+741
+3
.gitignore
··· 1 + _build 2 + karakeep-src 3 + .*.swp
+7
CLAUDE.md
··· 1 + I wish to write high quality OCaml library bindings to the Karakeep instance. 2 + You can assume while testing that an API key to the <https://hoard.recoil.org> 3 + instance is in .karakeep-api. 4 + 5 + Examine the source code for Karakeep in karakeep-src/ as a reference. 6 + Maintain helpful API documentation inside the doc/ directory as a set of 7 + Markdown files that reference each other.
+20
dune-project
··· 1 + (lang dune 3.17) 2 + (name karakeep) 3 + 4 + (license ISC) 5 + (authors "Anil Madhavapeddy") 6 + (maintainers "anil@recoil.org") 7 + 8 + (generate_opam_files true) 9 + 10 + (package 11 + (name karakeep) 12 + (synopsis "Karakeep API client for Bushel") 13 + (description "Karakeep API client to retrieve bookmarks from Karakeep instances") 14 + (depends 15 + (ocaml (>= "5.2.0")) 16 + ezjsonm 17 + lwt 18 + cohttp-lwt-unix 19 + ptime 20 + fmt))
+32
karakeep.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Karakeep API client for Bushel" 4 + description: 5 + "Karakeep API client to retrieve bookmarks from Karakeep instances" 6 + maintainer: ["anil@recoil.org"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + depends: [ 10 + "dune" {>= "3.17"} 11 + "ocaml" {>= "5.2.0"} 12 + "ezjsonm" 13 + "lwt" 14 + "cohttp-lwt-unix" 15 + "ptime" 16 + "fmt" 17 + "odoc" {with-doc} 18 + ] 19 + build: [ 20 + ["dune" "subst"] {dev} 21 + [ 22 + "dune" 23 + "build" 24 + "-p" 25 + name 26 + "-j" 27 + jobs 28 + "@install" 29 + "@runtest" {with-test} 30 + "@doc" {with-doc} 31 + ] 32 + ]
+4
lib/dune
··· 1 + (library 2 + (name karakeep) 3 + (public_name karakeep) 4 + (libraries lwt cohttp cohttp-lwt-unix ezjsonm fmt ptime))
+554
lib/karakeep.ml
··· 1 + (** Karakeep API client implementation *) 2 + 3 + open Lwt.Infix 4 + 5 + module J = Ezjsonm 6 + 7 + (** Type representing a Karakeep bookmark *) 8 + type bookmark = { 9 + id: string; 10 + title: string option; 11 + url: string; 12 + note: string option; 13 + created_at: Ptime.t; 14 + updated_at: Ptime.t option; 15 + favourited: bool; 16 + archived: bool; 17 + tags: string list; 18 + tagging_status: string option; 19 + summary: string option; 20 + content: (string * string) list; 21 + assets: (string * string) list; 22 + } 23 + 24 + (** Type for Karakeep API response containing bookmarks *) 25 + type bookmark_response = { 26 + total: int; 27 + data: bookmark list; 28 + next_cursor: string option; 29 + } 30 + 31 + (** Parse a date string to Ptime.t, defaulting to epoch if invalid *) 32 + let parse_date str = 33 + match Ptime.of_rfc3339 str with 34 + | Ok (date, _, _) -> date 35 + | Error _ -> 36 + Fmt.epr "Warning: could not parse date '%s'\n" str; 37 + (* Default to epoch time *) 38 + let span_opt = Ptime.Span.of_d_ps (0, 0L) in 39 + match span_opt with 40 + | None -> failwith "Internal error: couldn't create epoch time span" 41 + | Some span -> 42 + match Ptime.of_span span with 43 + | Some t -> t 44 + | None -> failwith "Internal error: couldn't create epoch time" 45 + 46 + (** Extract a string field from JSON, returns None if not present or not a string *) 47 + let get_string_opt json path = 48 + try Some (J.find json path |> J.get_string) 49 + with _ -> None 50 + 51 + (** Extract a string list field from JSON, returns empty list if not present *) 52 + let get_string_list json path = 53 + try 54 + let items_json = J.find json path in 55 + J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json 56 + with _ -> [] 57 + 58 + (** Extract a boolean field from JSON, with default value *) 59 + let get_bool_def json path default = 60 + try J.find json path |> J.get_bool 61 + with _ -> default 62 + 63 + (** Parse a single bookmark from Karakeep JSON *) 64 + let parse_bookmark json = 65 + (* Remove debug prints for production *) 66 + (* Printf.eprintf "%s\n%!" (J.value_to_string json); *) 67 + 68 + let id = 69 + try J.find json ["id"] |> J.get_string 70 + with e -> 71 + Printf.eprintf "Error parsing bookmark ID: %s\n" (Printexc.to_string e); 72 + Printf.eprintf "JSON: %s\n" (J.value_to_string json); 73 + failwith "Unable to parse bookmark ID" 74 + in 75 + 76 + (* Title can be null *) 77 + let title = 78 + try Some (J.find json ["title"] |> J.get_string) 79 + with _ -> None 80 + in 81 + (* Remove debug prints for production *) 82 + (* Printf.eprintf "%s -> %s\n%!" id (match title with None -> "???" | Some v -> v); *) 83 + (* Get URL - try all possible locations *) 84 + let url = 85 + try J.find json ["url"] |> J.get_string (* Direct url field *) 86 + with _ -> try 87 + J.find json ["content"; "url"] |> J.get_string (* Inside content.url *) 88 + with _ -> try 89 + J.find json ["content"; "sourceUrl"] |> J.get_string (* Inside content.sourceUrl *) 90 + with _ -> 91 + (* For assets/PDF type links *) 92 + match J.find_opt json ["content"; "type"] with 93 + | Some (`String "asset") -> 94 + (* Extract URL from sourceUrl in content *) 95 + (try J.find json ["content"; "sourceUrl"] |> J.get_string 96 + with _ -> 97 + (match J.find_opt json ["id"] with 98 + | Some (`String id) -> "karakeep-asset://" ^ id 99 + | _ -> failwith "No URL or asset ID found in bookmark")) 100 + | _ -> 101 + (* Debug output to understand what we're getting *) 102 + Printf.eprintf "Bookmark JSON structure: %s\n" (J.value_to_string json); 103 + failwith "No URL found in bookmark" 104 + in 105 + 106 + let note = get_string_opt json ["note"] in 107 + 108 + (* Parse dates *) 109 + let created_at = 110 + try J.find json ["createdAt"] |> J.get_string |> parse_date 111 + with _ -> 112 + try J.find json ["created_at"] |> J.get_string |> parse_date 113 + with _ -> failwith "No creation date found" 114 + in 115 + 116 + let updated_at = 117 + try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date) 118 + with _ -> 119 + try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date) 120 + with _ -> None 121 + in 122 + 123 + let favourited = get_bool_def json ["favourited"] false in 124 + let archived = get_bool_def json ["archived"] false in 125 + let tags = get_string_list json ["tags"] in 126 + 127 + (* Extract additional metadata *) 128 + let tagging_status = get_string_opt json ["taggingStatus"] in 129 + let summary = get_string_opt json ["summary"] in 130 + 131 + (* Extract content details *) 132 + let content = 133 + try 134 + let content_json = J.find json ["content"] in 135 + let rec extract_fields acc = function 136 + | [] -> acc 137 + | (k, v) :: rest -> 138 + let value = match v with 139 + | `String s -> s 140 + | `Bool b -> string_of_bool b 141 + | `Float f -> string_of_float f 142 + | `Null -> "null" 143 + | _ -> "complex_value" (* For objects and arrays *) 144 + in 145 + extract_fields ((k, value) :: acc) rest 146 + in 147 + match content_json with 148 + | `O fields -> extract_fields [] fields 149 + | _ -> [] 150 + with _ -> [] 151 + in 152 + 153 + (* Extract assets *) 154 + let assets = 155 + try 156 + let assets_json = J.find json ["assets"] in 157 + J.get_list (fun asset_json -> 158 + let id = J.find asset_json ["id"] |> J.get_string in 159 + let asset_type = 160 + try J.find asset_json ["assetType"] |> J.get_string 161 + with _ -> "unknown" 162 + in 163 + (id, asset_type) 164 + ) assets_json 165 + with _ -> [] 166 + in 167 + 168 + { id; title; url; note; created_at; updated_at; favourited; archived; tags; 169 + tagging_status; summary; content; assets } 170 + 171 + (** Parse a Karakeep bookmark response *) 172 + let parse_bookmark_response json = 173 + (* The response format is different based on endpoint, need to handle both structures *) 174 + (* Print the whole JSON structure for debugging *) 175 + Printf.eprintf "Full response JSON: %s\n" (J.value_to_string json); 176 + 177 + try 178 + (* Standard list format with total count *) 179 + let total = J.find json ["total"] |> J.get_int in 180 + let bookmarks_json = J.find json ["data"] in 181 + Printf.eprintf "Found bookmarks in data array\n"; 182 + let data = J.get_list parse_bookmark bookmarks_json in 183 + 184 + (* Try to extract nextCursor if available *) 185 + let next_cursor = 186 + try Some (J.find json ["nextCursor"] |> J.get_string) 187 + with _ -> None 188 + in 189 + 190 + { total; data; next_cursor } 191 + with e1 -> 192 + Printf.eprintf "First format parse error: %s\n" (Printexc.to_string e1); 193 + try 194 + (* Format with bookmarks array *) 195 + let bookmarks_json = J.find json ["bookmarks"] in 196 + Printf.eprintf "Found bookmarks in bookmarks array\n"; 197 + let data = 198 + try J.get_list parse_bookmark bookmarks_json 199 + with e -> 200 + Printf.eprintf "Error parsing bookmarks array: %s\n" (Printexc.to_string e); 201 + Printf.eprintf "First bookmark sample: %s\n" 202 + (try J.value_to_string (List.hd (J.get_list (fun x -> x) bookmarks_json)) 203 + with _ -> "Could not extract sample"); 204 + [] 205 + in 206 + 207 + (* Try to extract nextCursor if available *) 208 + let next_cursor = 209 + try Some (J.find json ["nextCursor"] |> J.get_string) 210 + with _ -> None 211 + in 212 + 213 + { total = List.length data; data; next_cursor } 214 + with e2 -> 215 + Printf.eprintf "Second format parse error: %s\n" (Printexc.to_string e2); 216 + try 217 + (* Check if it's an error response *) 218 + let error = J.find json ["error"] |> J.get_string in 219 + let message = 220 + try J.find json ["message"] |> J.get_string 221 + with _ -> "Unknown error" 222 + in 223 + Printf.eprintf "API Error: %s - %s\n" error message; 224 + { total = 0; data = []; next_cursor = None } 225 + with _ -> 226 + try 227 + (* Alternate format without total (for endpoints like /tags/<id>/bookmarks) *) 228 + Printf.eprintf "Trying alternate array format\n"; 229 + 230 + (* Debug the structure to identify the format *) 231 + Printf.eprintf "JSON structure keys: %s\n" 232 + (match json with 233 + | `O fields -> 234 + String.concat ", " (List.map (fun (k, _) -> k) fields) 235 + | _ -> "not an object"); 236 + 237 + (* Check if it has a nextCursor but bookmarks are nested differently *) 238 + if J.find_opt json ["nextCursor"] <> None then begin 239 + Printf.eprintf "Found nextCursor, checking alternate structures\n"; 240 + 241 + (* Try different bookmark container paths *) 242 + let bookmarks_json = 243 + try Some (J.find json ["data"]) 244 + with _ -> None 245 + in 246 + 247 + match bookmarks_json with 248 + | Some json_array -> 249 + Printf.eprintf "Found bookmarks in data field\n"; 250 + begin try 251 + let data = J.get_list parse_bookmark json_array in 252 + let next_cursor = 253 + try Some (J.find json ["nextCursor"] |> J.get_string) 254 + with _ -> None 255 + in 256 + { total = List.length data; data; next_cursor } 257 + with e -> 258 + Printf.eprintf "Error parsing bookmarks from data: %s\n" (Printexc.to_string e); 259 + { total = 0; data = []; next_cursor = None } 260 + end 261 + | None -> 262 + Printf.eprintf "No bookmarks found in alternate structure\n"; 263 + { total = 0; data = []; next_cursor = None } 264 + end 265 + else begin 266 + (* Check if it's an array at root level *) 267 + match json with 268 + | `A _ -> 269 + let data = 270 + try J.get_list parse_bookmark json 271 + with e -> 272 + Printf.eprintf "Error parsing root array: %s\n" (Printexc.to_string e); 273 + [] 274 + in 275 + { total = List.length data; data; next_cursor = None } 276 + | _ -> 277 + Printf.eprintf "Not an array at root level\n"; 278 + { total = 0; data = []; next_cursor = None } 279 + end 280 + with e3 -> 281 + Printf.eprintf "Third format parse error: %s\n" (Printexc.to_string e3); 282 + { total = 0; data = []; next_cursor = None } 283 + 284 + (** Helper function to consume and return response body data *) 285 + let consume_body body = 286 + Cohttp_lwt.Body.to_string body >>= fun _ -> 287 + Lwt.return_unit 288 + 289 + (** Fetch bookmarks from a Karakeep instance with pagination support *) 290 + let fetch_bookmarks ~api_key ?(limit=50) ?(offset=0) ?cursor ?(include_content=true) ?filter_tags base_url = 291 + let open Cohttp_lwt_unix in 292 + 293 + (* Base URL for bookmarks API *) 294 + let url_base = Printf.sprintf "%s/api/v1/bookmarks?limit=%d&includeContent=%b" 295 + base_url limit include_content in 296 + 297 + (* Add pagination parameter - either cursor or offset *) 298 + let url = 299 + match cursor with 300 + | Some cursor_value -> 301 + url_base ^ "&cursor=" ^ cursor_value 302 + | None -> 303 + url_base ^ "&offset=" ^ string_of_int offset 304 + in 305 + 306 + (* Add tags filter if provided *) 307 + let url = match filter_tags with 308 + | Some tags when tags <> [] -> 309 + (* URL encode each tag and join with commas *) 310 + let encoded_tags = 311 + List.map (fun tag -> 312 + Uri.pct_encode ~component:`Query_key tag 313 + ) tags 314 + in 315 + let tags_param = String.concat "," encoded_tags in 316 + Printf.eprintf "Adding tags filter: %s\n" tags_param; 317 + url ^ "&tags=" ^ tags_param 318 + | _ -> url 319 + in 320 + 321 + (* Set up headers with API key *) 322 + let headers = Cohttp.Header.init () 323 + |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in 324 + 325 + Printf.eprintf "Fetching bookmarks from: %s\n" url; 326 + 327 + (* Make the request *) 328 + Lwt.catch 329 + (fun () -> 330 + Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 331 + if resp.status = `OK then 332 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 333 + Printf.eprintf "Received %d bytes of response data\n" (String.length body_str); 334 + 335 + Lwt.catch 336 + (fun () -> 337 + let json = J.from_string body_str in 338 + Lwt.return (parse_bookmark_response json) 339 + ) 340 + (fun e -> 341 + Printf.eprintf "JSON parsing error: %s\n" (Printexc.to_string e); 342 + Printf.eprintf "Response body (first 200 chars): %s\n" 343 + (if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str); 344 + Lwt.fail e 345 + ) 346 + else 347 + let status_code = Cohttp.Code.code_of_status resp.status in 348 + consume_body body >>= fun _ -> 349 + Printf.eprintf "HTTP error %d\n" status_code; 350 + Lwt.fail_with (Fmt.str "HTTP error: %d" status_code) 351 + ) 352 + (fun e -> 353 + Printf.eprintf "Network error: %s\n" (Printexc.to_string e); 354 + Lwt.fail e 355 + ) 356 + 357 + (** Fetch all bookmarks from a Karakeep instance using pagination *) 358 + let fetch_all_bookmarks ~api_key ?(page_size=50) ?max_pages ?filter_tags base_url = 359 + let rec fetch_pages page_num cursor acc _total_count = 360 + (* Use cursor if available, otherwise use offset-based pagination *) 361 + (match cursor with 362 + | Some cursor_str -> fetch_bookmarks ~api_key ~limit:page_size ~cursor:cursor_str ?filter_tags base_url 363 + | None -> fetch_bookmarks ~api_key ~limit:page_size ~offset:(page_num * page_size) ?filter_tags base_url) 364 + >>= fun response -> 365 + 366 + let all_bookmarks = acc @ response.data in 367 + 368 + (* Determine if we need to fetch more pages *) 369 + let more_available = 370 + match response.next_cursor with 371 + | Some _ -> true (* We have a cursor, so there are more results *) 372 + | None -> 373 + (* Fall back to offset-based check *) 374 + let fetched_count = (page_num * page_size) + List.length response.data in 375 + fetched_count < response.total 376 + in 377 + 378 + let under_max_pages = match max_pages with 379 + | None -> true 380 + | Some max -> page_num + 1 < max 381 + in 382 + 383 + if more_available && under_max_pages then 384 + fetch_pages (page_num + 1) response.next_cursor all_bookmarks response.total 385 + else 386 + Lwt.return all_bookmarks 387 + in 388 + fetch_pages 0 None [] 0 389 + 390 + (** Fetch detailed information for a single bookmark by ID *) 391 + let fetch_bookmark_details ~api_key base_url bookmark_id = 392 + let open Cohttp_lwt_unix in 393 + let url = Printf.sprintf "%s/api/v1/bookmarks/%s" base_url bookmark_id in 394 + 395 + (* Set up headers with API key *) 396 + let headers = Cohttp.Header.init () 397 + |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in 398 + 399 + Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 400 + if resp.status = `OK then 401 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 402 + let json = J.from_string body_str in 403 + Lwt.return (parse_bookmark json) 404 + else 405 + let status_code = Cohttp.Code.code_of_status resp.status in 406 + consume_body body >>= fun () -> 407 + Lwt.fail_with (Fmt.str "HTTP error: %d" status_code) 408 + 409 + (** Get the asset URL for a given asset ID *) 410 + let get_asset_url base_url asset_id = 411 + Printf.sprintf "%s/api/assets/%s" base_url asset_id 412 + 413 + (** Fetch an asset from the Karakeep server as a binary string *) 414 + let fetch_asset ~api_key base_url asset_id = 415 + let open Cohttp_lwt_unix in 416 + 417 + let url = get_asset_url base_url asset_id in 418 + 419 + (* Set up headers with API key *) 420 + let headers = Cohttp.Header.init () 421 + |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in 422 + 423 + Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 424 + if resp.status = `OK then 425 + Cohttp_lwt.Body.to_string body 426 + else 427 + let status_code = Cohttp.Code.code_of_status resp.status in 428 + consume_body body >>= fun () -> 429 + Lwt.fail_with (Fmt.str "Asset fetch error: %d" status_code) 430 + 431 + (** Create a new bookmark in Karakeep with optional tags *) 432 + let create_bookmark ~api_key ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) base_url = 433 + let open Cohttp_lwt_unix in 434 + 435 + (* Prepare the bookmark request body *) 436 + let body_obj = [ 437 + ("type", `String "link"); 438 + ("url", `String url); 439 + ("favourited", `Bool favourited); 440 + ("archived", `Bool archived); 441 + ] in 442 + 443 + (* Add optional fields *) 444 + let body_obj = match title with 445 + | Some title_str -> ("title", `String title_str) :: body_obj 446 + | None -> body_obj 447 + in 448 + 449 + let body_obj = match note with 450 + | Some note_str -> ("note", `String note_str) :: body_obj 451 + | None -> body_obj 452 + in 453 + 454 + (* Convert to JSON *) 455 + let body_json = `O body_obj in 456 + let body_str = J.to_string body_json in 457 + 458 + (* Set up headers with API key *) 459 + let headers = Cohttp.Header.init () 460 + |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 461 + |> fun h -> Cohttp.Header.add h "Content-Type" "application/json" 462 + in 463 + 464 + (* Helper function to ensure we consume all response body data *) 465 + let consume_body body = 466 + Cohttp_lwt.Body.to_string body >>= fun _ -> 467 + Lwt.return_unit 468 + in 469 + 470 + (* Create the bookmark *) 471 + let url_endpoint = Printf.sprintf "%s/api/v1/bookmarks" base_url in 472 + Client.post ~headers ~body:(Cohttp_lwt.Body.of_string body_str) (Uri.of_string url_endpoint) >>= fun (resp, body) -> 473 + 474 + if resp.status = `Created || resp.status = `OK then 475 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 476 + let json = J.from_string body_str in 477 + let bookmark = parse_bookmark json in 478 + 479 + (* If tags are provided, add them to the bookmark *) 480 + (match tags with 481 + | Some tag_list when tag_list <> [] -> 482 + (* Prepare the tags request body *) 483 + let tag_objects = List.map (fun tag_name -> 484 + `O [("tagName", `String tag_name)] 485 + ) tag_list in 486 + 487 + let tags_body = `O [("tags", `A tag_objects)] in 488 + let tags_body_str = J.to_string tags_body in 489 + 490 + (* Add tags to the bookmark *) 491 + let tags_url = Printf.sprintf "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id in 492 + Client.post ~headers ~body:(Cohttp_lwt.Body.of_string tags_body_str) (Uri.of_string tags_url) >>= fun (resp, body) -> 493 + 494 + (* Always consume the response body *) 495 + consume_body body >>= fun () -> 496 + 497 + if resp.status = `OK then 498 + (* Fetch the bookmark again to get updated tags *) 499 + fetch_bookmark_details ~api_key base_url bookmark.id 500 + else 501 + (* Return the bookmark without tags if tag addition failed *) 502 + Lwt.return bookmark 503 + | _ -> Lwt.return bookmark) 504 + else 505 + let status_code = Cohttp.Code.code_of_status resp.status in 506 + Cohttp_lwt.Body.to_string body >>= fun error_body -> 507 + Lwt.fail_with (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body) 508 + 509 + (** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *) 510 + let to_bushel_link ?base_url bookmark = 511 + (* Try to find the best title from multiple possible sources *) 512 + let description = 513 + match bookmark.title with 514 + | Some title when title <> "" -> title 515 + | _ -> 516 + (* Check if there's a title in the content *) 517 + let content_title = List.assoc_opt "title" bookmark.content in 518 + match content_title with 519 + | Some title when title <> "" && title <> "null" -> title 520 + | _ -> bookmark.url 521 + in 522 + let date = Ptime.to_date bookmark.created_at in 523 + 524 + (* Build comprehensive metadata from all available fields *) 525 + let metadata = 526 + (match bookmark.summary with Some s -> [("summary", s)] | None -> []) @ 527 + (match bookmark.tagging_status with Some s -> [("tagging_status", s)] | None -> []) @ 528 + (List.map (fun (id, asset_type) -> ("asset_" ^ asset_type, id)) bookmark.assets) @ 529 + (List.filter_map (fun (k, v) -> 530 + if List.mem k ["type"; "url"; "title"; "screenshotAssetId"; "favicon"] 531 + then Some ("content_" ^ k, v) else None) bookmark.content) 532 + in 533 + 534 + (* Create karakeep_id if base_url is provided *) 535 + let karakeep_id = 536 + match base_url with 537 + | Some url -> Some { Bushel.Link.remote_url = url; id = bookmark.id } 538 + | None -> 539 + (* For backward compatibility, try to extract from metadata *) 540 + Some { Bushel.Link.remote_url = "unknown"; id = bookmark.id } 541 + in 542 + 543 + (* Extract any tags for additional context *) 544 + let bushel_slugs = 545 + (* If there are tags that start with "bushel:", use them as slugs *) 546 + List.filter_map (fun tag -> 547 + if String.starts_with ~prefix:"bushel:" tag then 548 + Some (String.sub tag 7 (String.length tag - 7)) 549 + else 550 + None 551 + ) bookmark.tags 552 + in 553 + 554 + { Bushel.Link.url = bookmark.url; date; description; metadata; karakeep_id; bushel_slugs }
+121
lib/karakeep.mli
··· 1 + (** Karakeep API client interface *) 2 + 3 + (** Type representing a Karakeep bookmark *) 4 + type bookmark = { 5 + id: string; 6 + title: string option; 7 + url: string; 8 + note: string option; 9 + created_at: Ptime.t; 10 + updated_at: Ptime.t option; 11 + favourited: bool; 12 + archived: bool; 13 + tags: string list; 14 + tagging_status: string option; 15 + summary: string option; 16 + content: (string * string) list; 17 + assets: (string * string) list; 18 + } 19 + 20 + (** Type for Karakeep API response containing bookmarks *) 21 + type bookmark_response = { 22 + total: int; 23 + data: bookmark list; 24 + next_cursor: string option; 25 + } 26 + 27 + (** Parse a single bookmark from Karakeep JSON *) 28 + val parse_bookmark : Ezjsonm.value -> bookmark 29 + 30 + (** Parse a Karakeep bookmark response *) 31 + val parse_bookmark_response : Ezjsonm.value -> bookmark_response 32 + 33 + (** Fetch bookmarks from a Karakeep instance with pagination support 34 + @param api_key API key for authentication 35 + @param limit Number of bookmarks to fetch per page (default: 50) 36 + @param offset Starting index for pagination (0-based) (default: 0) 37 + @param cursor Optional pagination cursor for cursor-based pagination (overrides offset when provided) 38 + @param include_content Whether to include full content (default: true) 39 + @param filter_tags Optional list of tags to filter by 40 + @param base_url Base URL of the Karakeep instance 41 + @return A Lwt promise with the bookmark response *) 42 + val fetch_bookmarks : 43 + api_key:string -> 44 + ?limit:int -> 45 + ?offset:int -> 46 + ?cursor:string -> 47 + ?include_content:bool -> 48 + ?filter_tags:string list -> 49 + string -> 50 + bookmark_response Lwt.t 51 + 52 + (** Fetch all bookmarks from a Karakeep instance using pagination 53 + @param api_key API key for authentication 54 + @param page_size Number of bookmarks to fetch per page (default: 50) 55 + @param max_pages Maximum number of pages to fetch (None for all pages) 56 + @param filter_tags Optional list of tags to filter by 57 + @param base_url Base URL of the Karakeep instance 58 + @return A Lwt promise with all bookmarks combined *) 59 + val fetch_all_bookmarks : 60 + api_key:string -> 61 + ?page_size:int -> 62 + ?max_pages:int -> 63 + ?filter_tags:string list -> 64 + string -> 65 + bookmark list Lwt.t 66 + 67 + (** Fetch detailed information for a single bookmark by ID 68 + @param api_key API key for authentication 69 + @param base_url Base URL of the Karakeep instance 70 + @param bookmark_id ID of the bookmark to fetch 71 + @return A Lwt promise with the complete bookmark details *) 72 + val fetch_bookmark_details : 73 + api_key:string -> 74 + string -> 75 + string -> 76 + bookmark Lwt.t 77 + 78 + (** Convert a Karakeep bookmark to Bushel.Link.t compatible structure 79 + @param base_url Optional base URL of the Karakeep instance (for karakeep_id) *) 80 + val to_bushel_link : ?base_url:string -> bookmark -> Bushel.Link.t 81 + 82 + (** Fetch an asset from the Karakeep server as a binary string 83 + @param api_key API key for authentication 84 + @param base_url Base URL of the Karakeep instance 85 + @param asset_id ID of the asset to fetch 86 + @return A Lwt promise with the binary asset data *) 87 + val fetch_asset : 88 + api_key:string -> 89 + string -> 90 + string -> 91 + string Lwt.t 92 + 93 + (** Get the asset URL for a given asset ID 94 + @param base_url Base URL of the Karakeep instance 95 + @param asset_id ID of the asset 96 + @return The full URL to the asset *) 97 + val get_asset_url : 98 + string -> 99 + string -> 100 + string 101 + 102 + (** Create a new bookmark in Karakeep with optional tags 103 + @param api_key API key for authentication 104 + @param url The URL to bookmark 105 + @param title Optional title for the bookmark 106 + @param note Optional note to add to the bookmark 107 + @param tags Optional list of tag names to add to the bookmark 108 + @param favourited Whether the bookmark should be marked as favourite (default: false) 109 + @param archived Whether the bookmark should be archived (default: false) 110 + @param base_url Base URL of the Karakeep instance 111 + @return A Lwt promise with the created bookmark *) 112 + val create_bookmark : 113 + api_key:string -> 114 + url:string -> 115 + ?title:string -> 116 + ?note:string -> 117 + ?tags:string list -> 118 + ?favourited:bool -> 119 + ?archived:bool -> 120 + string -> 121 + bookmark Lwt.t