OCaml CLI and library to the Karakeep bookmarking app

Implement full Karakeep API client according to interface

- Refactored the implementation to match the interface definition
- Added proper types for all API entities
- Added parsers for all response types
- Fixed all tests to work with the new type definitions
- Added OCaml formatting configuration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1325 -467
+1
.ocamlformat
··· 1 + version=0.27.0
+1219 -409
lib/karakeep.ml
··· 3 3 open Lwt.Infix 4 4 module J = Ezjsonm 5 5 6 + (** {1 Core Types} *) 7 + 8 + type asset_id = string 9 + (** Asset identifier type *) 10 + 11 + type bookmark_id = string 12 + (** Bookmark identifier type *) 13 + 14 + type list_id = string 15 + (** List identifier type *) 16 + 17 + type tag_id = string 18 + (** Tag identifier type *) 19 + 20 + type highlight_id = string 21 + (** Highlight identifier type *) 22 + 23 + (** Type of content a bookmark can have *) 24 + type bookmark_content_type = 25 + | Link (** A URL to a webpage *) 26 + | Text (** Plain text content *) 27 + | Asset (** An attached asset (image, PDF, etc.) *) 28 + | Unknown (** Unknown content type *) 29 + 30 + (** Type of asset *) 31 + type asset_type = 32 + | Screenshot (** Screenshot of a webpage *) 33 + | AssetScreenshot (** Screenshot of an asset *) 34 + | BannerImage (** Banner image *) 35 + | FullPageArchive (** Archive of a full webpage *) 36 + | Video (** Video asset *) 37 + | BookmarkAsset (** Generic bookmark asset *) 38 + | PrecrawledArchive (** Pre-crawled archive *) 39 + | Unknown (** Unknown asset type *) 40 + 41 + (** Type of tagging status *) 42 + type tagging_status = 43 + | Success (** Tagging was successful *) 44 + | Failure (** Tagging failed *) 45 + | Pending (** Tagging is pending *) 46 + 47 + (** Type of bookmark list *) 48 + type list_type = 49 + | Manual (** List is manually managed *) 50 + | Smart (** List is dynamically generated based on a query *) 51 + 52 + (** Highlight color *) 53 + type highlight_color = 54 + | Yellow (** Yellow highlight *) 55 + | Red (** Red highlight *) 56 + | Green (** Green highlight *) 57 + | Blue (** Blue highlight *) 58 + 59 + (** Type of how a tag was attached *) 60 + type tag_attachment_type = 61 + | AI (** Tag was attached by AI *) 62 + | Human (** Tag was attached by a human *) 63 + 64 + type link_content = { 65 + url : string; (** The URL of the bookmarked page *) 66 + title : string option; (** Title from the link *) 67 + description : string option; (** Description from the link *) 68 + image_url : string option; (** URL of an image from the link *) 69 + image_asset_id : asset_id option; (** ID of an image asset *) 70 + screenshot_asset_id : asset_id option; (** ID of a screenshot asset *) 71 + full_page_archive_asset_id : asset_id option; 72 + (** ID of a full page archive asset *) 73 + precrawled_archive_asset_id : asset_id option; 74 + (** ID of a pre-crawled archive asset *) 75 + video_asset_id : asset_id option; (** ID of a video asset *) 76 + favicon : string option; (** URL of the favicon *) 77 + html_content : string option; (** HTML content of the page *) 78 + crawled_at : Ptime.t option; (** When the page was crawled *) 79 + author : string option; (** Author of the content *) 80 + publisher : string option; (** Publisher of the content *) 81 + date_published : Ptime.t option; (** When the content was published *) 82 + date_modified : Ptime.t option; (** When the content was last modified *) 83 + } 84 + (** Link content for a bookmark *) 85 + 86 + type text_content = { 87 + text : string; (** The text content *) 88 + source_url : string option; (** Optional source URL for the text *) 89 + } 90 + (** Text content for a bookmark *) 91 + 92 + type asset_content = { 93 + asset_type : [ `Image | `PDF ]; (** Type of the asset *) 94 + asset_id : asset_id; (** ID of the asset *) 95 + file_name : string option; (** Name of the file *) 96 + source_url : string option; (** Source URL for the asset *) 97 + size : int option; (** Size of the asset in bytes *) 98 + content : string option; (** Extracted content from the asset *) 99 + } 100 + (** Asset content for a bookmark *) 101 + 102 + (** Content of a bookmark *) 103 + type content = 104 + | Link of link_content (** Link-type content *) 105 + | Text of text_content (** Text-type content *) 106 + | Asset of asset_content (** Asset-type content *) 107 + | Unknown (** Unknown content type *) 108 + 109 + type asset = { 110 + id : asset_id; (** ID of the asset *) 111 + asset_type : asset_type; (** Type of the asset *) 112 + } 113 + (** Asset attached to a bookmark *) 114 + 115 + type bookmark_tag = { 116 + id : tag_id; (** ID of the tag *) 117 + name : string; (** Name of the tag *) 118 + attached_by : tag_attachment_type; (** How the tag was attached *) 119 + } 120 + (** Tag with attachment information *) 121 + 6 122 type bookmark = { 7 - id : string; 8 - title : string option; 9 - url : string; 10 - note : string option; 11 - created_at : Ptime.t; 12 - updated_at : Ptime.t option; 13 - favourited : bool; 14 - archived : bool; 15 - tags : string list; 16 - tagging_status : string option; 17 - summary : string option; 18 - content : (string * string) list; 19 - assets : (string * string) list; 123 + id : bookmark_id; (** Unique identifier for the bookmark *) 124 + created_at : Ptime.t; (** Timestamp when the bookmark was created *) 125 + modified_at : Ptime.t option; (** Optional timestamp of the last update *) 126 + title : string option; (** Optional title of the bookmarked page *) 127 + archived : bool; (** Whether the bookmark is archived *) 128 + favourited : bool; (** Whether the bookmark is marked as a favorite *) 129 + tagging_status : tagging_status option; (** Status of automatic tagging *) 130 + note : string option; (** Optional user note associated with the bookmark *) 131 + summary : string option; (** Optional AI-generated summary *) 132 + tags : bookmark_tag list; (** Tags associated with the bookmark *) 133 + content : content; (** Content of the bookmark *) 134 + assets : asset list; (** Assets attached to the bookmark *) 135 + } 136 + (** A bookmark from the Karakeep service *) 137 + 138 + type paginated_bookmarks = { 139 + bookmarks : bookmark list; (** List of bookmarks in the current page *) 140 + next_cursor : string option; (** Optional cursor for fetching the next page *) 20 141 } 21 - (** Type representing a Karakeep bookmark *) 142 + (** Paginated response of bookmarks *) 143 + 144 + type _list = { 145 + id : list_id; (** ID of the list *) 146 + name : string; (** Name of the list *) 147 + description : string option; (** Optional description of the list *) 148 + icon : string; (** Icon for the list *) 149 + parent_id : list_id option; (** Optional parent list ID *) 150 + list_type : list_type; (** Type of the list *) 151 + query : string option; (** Optional query for smart lists *) 152 + } 153 + (** List in Karakeep *) 154 + 155 + type tag = { 156 + id : tag_id; (** ID of the tag *) 157 + name : string; (** Name of the tag *) 158 + num_bookmarks : int; (** Number of bookmarks with this tag *) 159 + num_bookmarks_by_attached_type : (tag_attachment_type * int) list; 160 + (** Number of bookmarks by attachment type *) 161 + } 162 + (** Tag in Karakeep *) 163 + 164 + type highlight = { 165 + bookmark_id : bookmark_id; (** ID of the bookmark *) 166 + start_offset : int; (** Start position of the highlight *) 167 + end_offset : int; (** End position of the highlight *) 168 + color : highlight_color; (** Color of the highlight *) 169 + text : string option; (** Text of the highlight *) 170 + note : string option; (** Note for the highlight *) 171 + id : highlight_id; (** ID of the highlight *) 172 + user_id : string; (** ID of the user who created the highlight *) 173 + created_at : Ptime.t; (** When the highlight was created *) 174 + } 175 + (** Highlight in Karakeep *) 176 + 177 + type paginated_highlights = { 178 + highlights : highlight list; (** List of highlights in the current page *) 179 + next_cursor : string option; (** Optional cursor for fetching the next page *) 180 + } 181 + (** Paginated response of highlights *) 182 + 183 + type user_info = { 184 + id : string; (** ID of the user *) 185 + name : string option; (** Name of the user *) 186 + email : string option; (** Email of the user *) 187 + } 188 + (** User information *) 189 + 190 + type user_stats = { 191 + num_bookmarks : int; (** Number of bookmarks *) 192 + num_favorites : int; (** Number of favorite bookmarks *) 193 + num_archived : int; (** Number of archived bookmarks *) 194 + num_tags : int; (** Number of tags *) 195 + num_lists : int; (** Number of lists *) 196 + num_highlights : int; (** Number of highlights *) 197 + } 198 + (** User statistics *) 22 199 23 - type bookmark_response = { 24 - total : int; 25 - data : bookmark list; 26 - next_cursor : string option; 200 + type error_response = { 201 + code : string; (** Error code *) 202 + message : string; (** Error message *) 27 203 } 28 - (** Type for Karakeep API response containing bookmarks *) 204 + (** Error response from the API *) 205 + 206 + (** {1 Helper Functions} *) 29 207 30 208 (** Parse a date string to Ptime.t, defaulting to epoch if invalid *) 31 209 let parse_date str = ··· 47 225 let get_string_opt json path = 48 226 try Some (J.find json path |> J.get_string) with _ -> None 49 227 50 - (** Extract a string list field from JSON, returns empty list if not present *) 51 - let get_string_list json path = 52 - try 53 - let items_json = J.find json path in 54 - J.get_list (fun tag -> J.find tag [ "name" ] |> J.get_string) items_json 55 - with _ -> [] 228 + (** Extract an int field from JSON, returns None if not present or not an int *) 229 + let get_int_opt json path = 230 + try Some (J.find json path |> J.get_int) with _ -> None 231 + 232 + (** Extract a date field from JSON, returns None if not present or invalid *) 233 + let get_date_opt json path = 234 + match get_string_opt json path with 235 + | Some date_str -> ( try Some (parse_date date_str) with _ -> None) 236 + | None -> None 56 237 57 238 (** Extract a boolean field from JSON, with default value *) 58 239 let get_bool_def json path default = 59 240 try J.find json path |> J.get_bool with _ -> default 60 241 61 - (** Parse a single bookmark from Karakeep JSON *) 62 - let parse_bookmark json = 63 - (* Remove debug prints for production *) 64 - (* Printf.eprintf "%s\n%!" (J.value_to_string json); *) 65 - let id = 66 - try J.find json [ "id" ] |> J.get_string 67 - with e -> 68 - Printf.eprintf "Error parsing bookmark ID: %s\n" (Printexc.to_string e); 69 - Printf.eprintf "JSON: %s\n" (J.value_to_string json); 70 - failwith "Unable to parse bookmark ID" 71 - in 242 + (** Convert string to asset_type *) 243 + let asset_type_of_string = function 244 + | "screenshot" -> Screenshot 245 + | "assetScreenshot" -> AssetScreenshot 246 + | "bannerImage" -> BannerImage 247 + | "fullPageArchive" -> FullPageArchive 248 + | "video" -> Video 249 + | "bookmarkAsset" -> BookmarkAsset 250 + | "precrawledArchive" -> PrecrawledArchive 251 + | _ -> Unknown 252 + 253 + (** Convert asset_type to string *) 254 + let string_of_asset_type = function 255 + | Screenshot -> "screenshot" 256 + | AssetScreenshot -> "assetScreenshot" 257 + | BannerImage -> "bannerImage" 258 + | FullPageArchive -> "fullPageArchive" 259 + | Video -> "video" 260 + | BookmarkAsset -> "bookmarkAsset" 261 + | PrecrawledArchive -> "precrawledArchive" 262 + | Unknown -> "unknown" 263 + 264 + (** Convert string to tagging_status *) 265 + let tagging_status_of_string = function 266 + | "success" -> Success 267 + | "failure" -> Failure 268 + | "pending" -> Pending 269 + | _ -> Success (* Default to success if unknown *) 270 + 271 + (** Convert tagging_status to string *) 272 + let string_of_tagging_status = function 273 + | Success -> "success" 274 + | Failure -> "failure" 275 + | Pending -> "pending" 276 + 277 + (** Convert string to list_type *) 278 + let list_type_of_string = function 279 + | "manual" -> Manual 280 + | "smart" -> Smart 281 + | _ -> Manual (* Default to manual if unknown *) 282 + 283 + (** Convert list_type to string *) 284 + let string_of_list_type = function Manual -> "manual" | Smart -> "smart" 285 + 286 + (** Convert string to highlight_color *) 287 + let highlight_color_of_string = function 288 + | "yellow" -> Yellow 289 + | "red" -> Red 290 + | "green" -> Green 291 + | "blue" -> Blue 292 + | _ -> Yellow (* Default to yellow if unknown *) 293 + 294 + (** Convert highlight_color to string *) 295 + let string_of_highlight_color = function 296 + | Yellow -> "yellow" 297 + | Red -> "red" 298 + | Green -> "green" 299 + | Blue -> "blue" 300 + 301 + (** Convert string to tag_attachment_type *) 302 + let tag_attachment_type_of_string = function 303 + | "ai" -> AI 304 + | "human" -> Human 305 + | _ -> Human (* Default to human if unknown *) 72 306 73 - (* Title can be null *) 74 - let title = 75 - try Some (J.find json [ "title" ] |> J.get_string) with _ -> None 76 - in 77 - (* Remove debug prints for production *) 78 - (* Printf.eprintf "%s -> %s\n%!" id (match title with None -> "???" | Some v -> v); *) 79 - (* Get URL - try all possible locations *) 80 - let url = 81 - try J.find json [ "url" ] |> J.get_string (* Direct url field *) 82 - with _ -> ( 83 - try 84 - J.find json [ "content"; "url" ] 85 - |> J.get_string (* Inside content.url *) 86 - with _ -> ( 87 - try 88 - J.find json [ "content"; "sourceUrl" ] 89 - |> 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" 103 - (J.value_to_string json); 104 - failwith "No URL found in bookmark"))) 105 - in 307 + (** Convert tag_attachment_type to string *) 308 + let string_of_tag_attachment_type = function AI -> "ai" | Human -> "human" 106 309 107 - let note = get_string_opt json [ "note" ] in 310 + (** Parse link content from JSON *) 311 + let parse_link_content json = 312 + { 313 + url = J.find json [ "url" ] |> J.get_string; 314 + title = get_string_opt json [ "title" ]; 315 + description = get_string_opt json [ "description" ]; 316 + image_url = get_string_opt json [ "imageUrl" ]; 317 + image_asset_id = get_string_opt json [ "imageAssetId" ]; 318 + screenshot_asset_id = get_string_opt json [ "screenshotAssetId" ]; 319 + full_page_archive_asset_id = 320 + get_string_opt json [ "fullPageArchiveAssetId" ]; 321 + precrawled_archive_asset_id = 322 + get_string_opt json [ "precrawledArchiveAssetId" ]; 323 + video_asset_id = get_string_opt json [ "videoAssetId" ]; 324 + favicon = get_string_opt json [ "favicon" ]; 325 + html_content = get_string_opt json [ "htmlContent" ]; 326 + crawled_at = get_date_opt json [ "crawledAt" ]; 327 + author = get_string_opt json [ "author" ]; 328 + publisher = get_string_opt json [ "publisher" ]; 329 + date_published = get_date_opt json [ "datePublished" ]; 330 + date_modified = get_date_opt json [ "dateModified" ]; 331 + } 108 332 109 - (* Parse dates *) 110 - let created_at = 111 - try J.find json [ "createdAt" ] |> J.get_string |> parse_date 112 - with _ -> ( 113 - try J.find json [ "created_at" ] |> J.get_string |> parse_date 114 - with _ -> failwith "No creation date found") 115 - in 333 + (** Parse text content from JSON *) 334 + let parse_text_content json = 335 + { 336 + text = J.find json [ "text" ] |> J.get_string; 337 + source_url = get_string_opt json [ "sourceUrl" ]; 338 + } 116 339 117 - let updated_at = 118 - try Some (J.find json [ "updatedAt" ] |> J.get_string |> parse_date) 119 - with _ -> ( 120 - try Some (J.find json [ "modifiedAt" ] |> J.get_string |> parse_date) 121 - with _ -> None) 340 + (** Parse asset content from JSON *) 341 + let parse_asset_content json = 342 + let asset_type_str = get_string_opt json [ "assetType" ] in 343 + let asset_type = 344 + match asset_type_str with 345 + | Some "image" -> `Image 346 + | Some "pdf" -> `PDF 347 + | _ -> `Image (* Default to image if unknown *) 122 348 in 349 + { 350 + asset_type; 351 + asset_id = J.find json [ "assetId" ] |> J.get_string; 352 + file_name = get_string_opt json [ "fileName" ]; 353 + source_url = get_string_opt json [ "sourceUrl" ]; 354 + size = get_int_opt json [ "size" ]; 355 + content = get_string_opt json [ "content" ]; 356 + } 123 357 124 - let favourited = get_bool_def json [ "favourited" ] false in 125 - let archived = get_bool_def json [ "archived" ] false in 126 - let tags = get_string_list json [ "tags" ] in 358 + (** Parse content from JSON *) 359 + let parse_content json = 360 + let content_type = get_string_opt json [ "type" ] in 361 + match content_type with 362 + | Some "link" -> Link (parse_link_content json) 363 + | Some "text" -> Text (parse_text_content json) 364 + | Some "asset" -> Asset (parse_asset_content json) 365 + | _ -> Unknown 127 366 128 - (* Extract additional metadata *) 129 - let tagging_status = get_string_opt json [ "taggingStatus" ] in 130 - let summary = get_string_opt json [ "summary" ] in 367 + (** Parse asset from JSON *) 368 + let parse_asset json = 369 + { 370 + id = J.find json [ "id" ] |> J.get_string; 371 + asset_type = 372 + J.find json [ "assetType" ] |> J.get_string |> asset_type_of_string; 373 + } 131 374 132 - (* Extract content details *) 133 - let content = 375 + (** Parse bookmark tag from JSON *) 376 + let parse_bookmark_tag json = 377 + { 378 + id = J.find json [ "id" ] |> J.get_string; 379 + name = J.find json [ "name" ] |> J.get_string; 380 + attached_by = 381 + get_string_opt json [ "attachedBy" ] 382 + |> Option.value ~default:"human" 383 + |> tag_attachment_type_of_string; 384 + } 385 + 386 + (** Parse a bookmark from JSON *) 387 + let parse_bookmark json = 388 + let id = J.find json [ "id" ] |> J.get_string in 389 + let created_at = J.find json [ "createdAt" ] |> J.get_string |> parse_date in 390 + let tags = 134 391 try 135 - let content_json = J.find json [ "content" ] in 136 - let rec extract_fields acc = function 137 - | [] -> acc 138 - | (k, v) :: rest -> 139 - let value = 140 - match v with 141 - | `String s -> s 142 - | `Bool b -> string_of_bool b 143 - | `Float f -> string_of_float f 144 - | `Null -> "null" 145 - | _ -> "complex_value" (* For objects and arrays *) 146 - in 147 - extract_fields ((k, value) :: acc) rest 148 - in 149 - match content_json with `O fields -> extract_fields [] fields | _ -> [] 392 + let tags_json = J.find json [ "tags" ] in 393 + J.get_list parse_bookmark_tag tags_json 150 394 with _ -> [] 151 395 in 152 - 153 - (* Extract assets *) 154 396 let assets = 155 397 try 156 398 let assets_json = J.find json [ "assets" ] in 157 - J.get_list 158 - (fun asset_json -> 159 - let id = J.find asset_json [ "id" ] |> J.get_string in 160 - let asset_type = 161 - try J.find asset_json [ "assetType" ] |> J.get_string 162 - with _ -> "unknown" 163 - in 164 - (id, asset_type)) 165 - assets_json 399 + J.get_list parse_asset assets_json 166 400 with _ -> [] 167 401 in 168 - 402 + let tagging_status = 403 + get_string_opt json [ "taggingStatus" ] 404 + |> Option.map tagging_status_of_string 405 + in 169 406 { 170 407 id; 171 - title; 172 - url; 173 - note; 174 408 created_at; 175 - updated_at; 176 - favourited; 177 - archived; 409 + modified_at = get_date_opt json [ "modifiedAt" ]; 410 + title = get_string_opt json [ "title" ]; 411 + archived = get_bool_def json [ "archived" ] false; 412 + favourited = get_bool_def json [ "favourited" ] false; 413 + tagging_status; 414 + note = get_string_opt json [ "note" ]; 415 + summary = get_string_opt json [ "summary" ]; 178 416 tags; 179 - tagging_status; 180 - summary; 181 - content; 417 + content = 418 + (try parse_content (J.find json [ "content" ]) with _ -> Unknown); 182 419 assets; 183 420 } 184 421 185 - (** Parse a Karakeep bookmark response *) 186 - let parse_bookmark_response json = 187 - (* The response format is different based on endpoint, need to handle both structures *) 188 - (* Print the whole JSON structure for debugging *) 189 - 190 - try 191 - (* Standard list format with total count *) 192 - let total = J.find json [ "total" ] |> J.get_int in 193 - let bookmarks_json = J.find json [ "data" ] in 194 - Printf.eprintf "Found bookmarks in data array\n"; 195 - let data = J.get_list parse_bookmark bookmarks_json in 196 - 197 - (* Try to extract nextCursor if available *) 198 - let next_cursor = 199 - try Some (J.find json [ "nextCursor" ] |> J.get_string) with _ -> None 200 - in 201 - 202 - { total; data; next_cursor } 203 - with e1 -> ( 204 - Printf.eprintf "First format parse error: %s\n" (Printexc.to_string e1); 422 + (** Parse paginated bookmarks from JSON *) 423 + let parse_paginated_bookmarks json = 424 + let bookmarks = 205 425 try 206 - (* Format with bookmarks array *) 207 426 let bookmarks_json = J.find json [ "bookmarks" ] in 208 - Printf.eprintf "Found bookmarks in bookmarks array\n"; 209 - let data = 210 - try J.get_list parse_bookmark bookmarks_json 211 - with e -> 212 - Printf.eprintf "Error parsing bookmarks array: %s\n" 213 - (Printexc.to_string e); 214 - Printf.eprintf "First bookmark sample: %s\n" 215 - (try 216 - J.value_to_string 217 - (List.hd (J.get_list (fun x -> x) bookmarks_json)) 218 - with _ -> "Could not extract sample"); 219 - [] 220 - in 427 + J.get_list parse_bookmark bookmarks_json 428 + with _ -> ( 429 + try 430 + let bookmarks_json = J.find json [ "data" ] in 431 + J.get_list parse_bookmark bookmarks_json 432 + with _ -> []) 433 + in 434 + let next_cursor = get_string_opt json [ "nextCursor" ] in 435 + { bookmarks; next_cursor } 221 436 222 - (* Try to extract nextCursor if available *) 223 - let next_cursor = 224 - try Some (J.find json [ "nextCursor" ] |> J.get_string) with _ -> None 225 - in 437 + (** Parse tag from JSON *) 438 + let parse_tag json = 439 + let attachment_types = 440 + try 441 + let stats_json = J.find json [ "numBookmarksByAttachedType" ] in 442 + match stats_json with 443 + | `O fields -> 444 + List.map 445 + (fun (k, v) -> (tag_attachment_type_of_string k, J.get_int v)) 446 + fields 447 + | _ -> [] 448 + with _ -> [] 449 + in 450 + { 451 + id = J.find json [ "id" ] |> J.get_string; 452 + name = J.find json [ "name" ] |> J.get_string; 453 + num_bookmarks = 454 + get_int_opt json [ "numBookmarks" ] |> Option.value ~default:0; 455 + num_bookmarks_by_attached_type = attachment_types; 456 + } 226 457 227 - { total = List.length data; data; next_cursor } 228 - with e2 -> ( 229 - Printf.eprintf "Second format parse error: %s\n" (Printexc.to_string e2); 230 - try 231 - (* Check if it's an error response *) 232 - let error = J.find json [ "error" ] |> J.get_string in 233 - let message = 234 - try J.find json [ "message" ] |> J.get_string 235 - with _ -> "Unknown error" 236 - in 237 - Printf.eprintf "API Error: %s - %s\n" error message; 238 - { total = 0; data = []; next_cursor = None } 239 - with _ -> ( 240 - try 241 - (* Alternate format without total (for endpoints like /tags/<id>/bookmarks) *) 242 - Printf.eprintf "Trying alternate array format\n"; 458 + (** Parse list from JSON *) 459 + let parse_list json = 460 + { 461 + id = J.find json [ "id" ] |> J.get_string; 462 + name = J.find json [ "name" ] |> J.get_string; 463 + description = get_string_opt json [ "description" ]; 464 + icon = get_string_opt json [ "icon" ] |> Option.value ~default:"default"; 465 + parent_id = get_string_opt json [ "parentId" ]; 466 + list_type = 467 + get_string_opt json [ "listType" ] 468 + |> Option.value ~default:"manual" 469 + |> list_type_of_string; 470 + query = get_string_opt json [ "query" ]; 471 + } 472 + 473 + (** Parse highlight from JSON *) 474 + let parse_highlight json = 475 + { 476 + bookmark_id = J.find json [ "bookmarkId" ] |> J.get_string; 477 + start_offset = J.find json [ "startOffset" ] |> J.get_int; 478 + end_offset = J.find json [ "endOffset" ] |> J.get_int; 479 + color = 480 + get_string_opt json [ "color" ] 481 + |> Option.value ~default:"yellow" 482 + |> highlight_color_of_string; 483 + text = get_string_opt json [ "text" ]; 484 + note = get_string_opt json [ "note" ]; 485 + id = J.find json [ "id" ] |> J.get_string; 486 + user_id = J.find json [ "userId" ] |> J.get_string; 487 + created_at = J.find json [ "createdAt" ] |> J.get_string |> parse_date; 488 + } 243 489 244 - (* Debug the structure to identify the format *) 245 - Printf.eprintf "JSON structure keys: %s\n" 246 - (match json with 247 - | `O fields -> 248 - String.concat ", " (List.map (fun (k, _) -> k) fields) 249 - | _ -> "not an object"); 490 + (** Parse paginated highlights from JSON *) 491 + let parse_paginated_highlights json = 492 + let highlights = 493 + try 494 + let highlights_json = J.find json [ "highlights" ] in 495 + J.get_list parse_highlight highlights_json 496 + with _ -> [] 497 + in 498 + let next_cursor = get_string_opt json [ "nextCursor" ] in 499 + { highlights; next_cursor } 250 500 251 - (* Check if it has a nextCursor but bookmarks are nested differently *) 252 - if J.find_opt json [ "nextCursor" ] <> None then ( 253 - Printf.eprintf "Found nextCursor, checking alternate structures\n"; 501 + (** Parse user info from JSON *) 502 + let parse_user_info json = 503 + { 504 + id = J.find json [ "id" ] |> J.get_string; 505 + name = get_string_opt json [ "name" ]; 506 + email = get_string_opt json [ "email" ]; 507 + } 254 508 255 - (* Try different bookmark container paths *) 256 - let bookmarks_json = 257 - try Some (J.find json [ "data" ]) with _ -> None 258 - in 509 + (** Parse user stats from JSON *) 510 + let parse_user_stats json = 511 + { 512 + num_bookmarks = 513 + get_int_opt json [ "numBookmarks" ] |> Option.value ~default:0; 514 + num_favorites = 515 + get_int_opt json [ "numFavorites" ] |> Option.value ~default:0; 516 + num_archived = get_int_opt json [ "numArchived" ] |> Option.value ~default:0; 517 + num_tags = get_int_opt json [ "numTags" ] |> Option.value ~default:0; 518 + num_lists = get_int_opt json [ "numLists" ] |> Option.value ~default:0; 519 + num_highlights = 520 + get_int_opt json [ "numHighlights" ] |> Option.value ~default:0; 521 + } 259 522 260 - match bookmarks_json with 261 - | Some json_array -> ( 262 - Printf.eprintf "Found bookmarks in data field\n"; 263 - try 264 - let data = J.get_list parse_bookmark json_array in 265 - let next_cursor = 266 - try Some (J.find json [ "nextCursor" ] |> J.get_string) 267 - with _ -> None 268 - in 269 - { total = List.length data; data; next_cursor } 270 - with e -> 271 - Printf.eprintf "Error parsing bookmarks from data: %s\n" 272 - (Printexc.to_string e); 273 - { total = 0; data = []; next_cursor = None }) 274 - | None -> 275 - Printf.eprintf "No bookmarks found in alternate structure\n"; 276 - { total = 0; data = []; next_cursor = None }) 277 - else 278 - (* Check if it's an array at root level *) 279 - match json with 280 - | `A _ -> 281 - let data = 282 - try J.get_list parse_bookmark json 283 - with e -> 284 - Printf.eprintf "Error parsing root array: %s\n" 285 - (Printexc.to_string e); 286 - [] 287 - in 288 - { total = List.length data; data; next_cursor = None } 289 - | _ -> 290 - Printf.eprintf "Not an array at root level\n"; 291 - { total = 0; data = []; next_cursor = None } 292 - with e3 -> 293 - Printf.eprintf "Third format parse error: %s\n" 294 - (Printexc.to_string e3); 295 - { total = 0; data = []; next_cursor = None }))) 523 + (** Parse error response from JSON *) 524 + let parse_error_response json = 525 + { 526 + code = 527 + get_string_opt json [ "code" ] |> Option.value ~default:"unknown_error"; 528 + message = 529 + get_string_opt json [ "message" ] |> Option.value ~default:"Unknown error"; 530 + } 296 531 297 532 (** Helper function to consume and return response body data *) 298 533 let consume_body body = 299 534 Cohttp_lwt.Body.to_string body >>= fun _ -> Lwt.return_unit 300 535 301 - (** Fetch bookmarks from a Karakeep instance with pagination support *) 302 - let fetch_bookmarks ~api_key ?(limit = 50) ?(offset = 0) ?cursor 303 - ?(include_content = true) ?filter_tags base_url = 536 + (** Helper function to make API requests *) 537 + let make_request ~api_key ~method_ ?(headers = []) ?(body = None) url = 304 538 let open Cohttp_lwt_unix in 305 - (* Base URL for bookmarks API *) 306 - let url_base = 307 - Printf.sprintf "%s/api/v1/bookmarks?limit=%d&includeContent=%b" base_url 308 - limit include_content 539 + let uri = Uri.of_string url in 540 + 541 + (* Set up headers with API key *) 542 + let base_headers = [ ("Authorization", "Bearer " ^ api_key) ] in 543 + let all_headers = base_headers @ headers in 544 + let headers = Cohttp.Header.of_list all_headers in 545 + 546 + let body_opt = 547 + match body with 548 + | Some content -> Some (Cohttp_lwt.Body.of_string content) 549 + | None -> None 309 550 in 310 551 311 - (* Add pagination parameter - either cursor or offset *) 312 - let url = 313 - match cursor with 314 - | Some cursor_value -> url_base ^ "&cursor=" ^ cursor_value 315 - | None -> url_base ^ "&offset=" ^ string_of_int offset 552 + match method_ with 553 + | `GET -> Client.get ~headers uri 554 + | `POST -> Client.post ~headers ?body:body_opt uri 555 + | `PUT -> Client.put ~headers ?body:body_opt uri 556 + | `DELETE -> Client.delete ~headers ?body:body_opt uri 557 + 558 + (** Process API response *) 559 + let process_response resp body parse_fn = 560 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 561 + if status_code >= 200 && status_code < 300 then 562 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 563 + try 564 + let json = J.from_string body_str in 565 + Lwt.return (parse_fn json) 566 + with e -> 567 + Lwt.fail_with 568 + ("Failed to parse response: " ^ Printexc.to_string e 569 + ^ "\nResponse body: " ^ body_str) 570 + else 571 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 572 + let error_msg = 573 + try 574 + let json = J.from_string body_str in 575 + let error_resp = parse_error_response json in 576 + Printf.sprintf "API Error (%s): %s" error_resp.code error_resp.message 577 + with _ -> Printf.sprintf "HTTP error %d: %s" status_code body_str 578 + in 579 + Lwt.fail_with error_msg 580 + 581 + (** {1 Bookmark Operations} *) 582 + 583 + let fetch_bookmarks ~api_key ?limit ?cursor ?include_content ?archived 584 + ?favourited base_url = 585 + (* Build query parameters *) 586 + let params = [] in 587 + let params = 588 + match limit with 589 + | Some l -> ("limit", string_of_int l) :: params 590 + | None -> params 591 + in 592 + let params = 593 + match cursor with Some c -> ("cursor", c) :: params | None -> params 594 + in 595 + let params = 596 + match include_content with 597 + | Some ic -> ("includeContent", string_of_bool ic) :: params 598 + | None -> params 599 + in 600 + let params = 601 + match archived with 602 + | Some a -> ("archived", string_of_bool a) :: params 603 + | None -> params 316 604 in 317 - 318 - (* Add tags filter if provided *) 319 - let url = 320 - match filter_tags with 321 - | Some tags when tags <> [] -> 322 - (* URL encode each tag and join with commas *) 323 - let encoded_tags = 324 - List.map (fun tag -> Uri.pct_encode ~component:`Query_key tag) tags 325 - in 326 - let tags_param = String.concat "," encoded_tags in 327 - Printf.eprintf "Adding tags filter: %s\n" tags_param; 328 - url ^ "&tags=" ^ tags_param 329 - | _ -> url 605 + let params = 606 + match favourited with 607 + | Some f -> ("favourited", string_of_bool f) :: params 608 + | None -> params 330 609 in 331 610 332 - (* Set up headers with API key *) 333 - let headers = 334 - Cohttp.Header.init () |> fun h -> 335 - Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 611 + (* Construct URL with query parameters *) 612 + let query_str = 613 + if params = [] then "" 614 + else 615 + "?" 616 + ^ String.concat "&" 617 + (List.map (fun (k, v) -> k ^ "=" ^ Uri.pct_encode v) params) 336 618 in 337 - 338 - Printf.eprintf "Fetching bookmarks from: %s\n" url; 619 + let url = base_url ^ "/api/v1/bookmarks" ^ query_str in 339 620 340 621 (* Make the request *) 341 - Lwt.catch 342 - (fun () -> 343 - Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 344 - if resp.status = `OK then ( 345 - Cohttp_lwt.Body.to_string body >>= fun body_str -> 346 - Printf.eprintf "Received %d bytes of response data\n" 347 - (String.length body_str); 348 - 349 - Lwt.catch 350 - (fun () -> 351 - let json = J.from_string body_str in 352 - Lwt.return (parse_bookmark_response json)) 353 - (fun e -> 354 - Printf.eprintf "JSON parsing error: %s\n" (Printexc.to_string e); 355 - Printf.eprintf "Response body (first 200 chars): %s\n" 356 - (if String.length body_str > 200 then 357 - String.sub body_str 0 200 ^ "..." 358 - else body_str); 359 - Lwt.fail e)) 360 - else 361 - let status_code = Cohttp.Code.code_of_status resp.status in 362 - consume_body body >>= fun _ -> 363 - Printf.eprintf "HTTP error %d\n" status_code; 364 - Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)) 365 - (fun e -> 366 - Printf.eprintf "Network error: %s\n" (Printexc.to_string e); 367 - Lwt.fail e) 622 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 623 + process_response resp body parse_paginated_bookmarks 368 624 369 - (** Fetch all bookmarks from a Karakeep instance using pagination *) 370 - let fetch_all_bookmarks ~api_key ?(page_size = 50) ?max_pages ?filter_tags 625 + let fetch_all_bookmarks ~api_key ?page_size ?max_pages ?archived ?favourited 371 626 base_url = 372 - let rec fetch_pages page_num cursor acc _total_count = 373 - (* Use cursor if available, otherwise use offset-based pagination *) 374 - (match cursor with 375 - | Some cursor_str -> 376 - fetch_bookmarks ~api_key ~limit:page_size ~cursor:cursor_str 377 - ?filter_tags base_url 378 - | None -> 379 - fetch_bookmarks ~api_key ~limit:page_size ~offset:(page_num * page_size) 380 - ?filter_tags base_url) 627 + let rec fetch_pages cursor acc page_num = 628 + let limit = Option.value page_size ~default:50 in 629 + fetch_bookmarks ~api_key ~limit ?cursor ?archived ?favourited base_url 381 630 >>= fun response -> 382 - let all_bookmarks = acc @ response.data in 631 + let all_bookmarks = acc @ response.bookmarks in 383 632 384 - (* Determine if we need to fetch more pages *) 385 - let more_available = 386 - match response.next_cursor with 387 - | Some _ -> true (* We have a cursor, so there are more results *) 388 - | None -> 389 - (* Fall back to offset-based check *) 390 - let fetched_count = 391 - (page_num * page_size) + List.length response.data 392 - in 393 - fetched_count < response.total 633 + let more_pages = response.next_cursor <> None in 634 + let under_max = 635 + match max_pages with Some max -> page_num < max | None -> true 394 636 in 395 637 396 - let under_max_pages = 397 - match max_pages with None -> true | Some max -> page_num + 1 < max 398 - in 399 - 400 - if more_available && under_max_pages then 401 - fetch_pages (page_num + 1) response.next_cursor all_bookmarks 402 - response.total 638 + if more_pages && under_max then 639 + fetch_pages response.next_cursor all_bookmarks (page_num + 1) 403 640 else Lwt.return all_bookmarks 404 641 in 405 - fetch_pages 0 None [] 0 642 + 643 + fetch_pages None [] 0 644 + 645 + let search_bookmarks ~api_key ~query ?limit ?cursor ?include_content base_url = 646 + (* Build query parameters *) 647 + let params = [ ("q", query) ] in 648 + let params = 649 + match limit with 650 + | Some l -> ("limit", string_of_int l) :: params 651 + | None -> params 652 + in 653 + let params = 654 + match cursor with Some c -> ("cursor", c) :: params | None -> params 655 + in 656 + let params = 657 + match include_content with 658 + | Some ic -> ("includeContent", string_of_bool ic) :: params 659 + | None -> params 660 + in 661 + 662 + (* Construct URL with query parameters *) 663 + let query_str = 664 + "?" 665 + ^ String.concat "&" 666 + (List.map (fun (k, v) -> k ^ "=" ^ Uri.pct_encode v) params) 667 + in 668 + let url = base_url ^ "/api/v1/search" ^ query_str in 669 + 670 + (* Make the request *) 671 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 672 + process_response resp body parse_paginated_bookmarks 406 673 407 - (** Fetch detailed information for a single bookmark by ID *) 408 674 let fetch_bookmark_details ~api_key base_url bookmark_id = 409 - let open Cohttp_lwt_unix in 410 - let url = Printf.sprintf "%s/api/v1/bookmarks/%s" base_url bookmark_id in 675 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id in 676 + 677 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 678 + process_response resp body parse_bookmark 411 679 412 - (* Set up headers with API key *) 413 - let headers = 414 - Cohttp.Header.init () |> fun h -> 415 - Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 680 + let create_bookmark ~api_key ~url ?title ?note ?summary ?favourited ?archived 681 + ?created_at ?tags base_url = 682 + (* Prepare the bookmark request body *) 683 + let body_obj = [ ("url", `String url) ] in 684 + 685 + (* Add optional fields *) 686 + let body_obj = 687 + match title with 688 + | Some t -> ("title", `String t) :: body_obj 689 + | None -> body_obj 690 + in 691 + let body_obj = 692 + match note with 693 + | Some n -> ("note", `String n) :: body_obj 694 + | None -> body_obj 695 + in 696 + let body_obj = 697 + match summary with 698 + | Some s -> ("summary", `String s) :: body_obj 699 + | None -> body_obj 700 + in 701 + let body_obj = 702 + match favourited with 703 + | Some f -> ("favourited", `Bool f) :: body_obj 704 + | None -> body_obj 705 + in 706 + let body_obj = 707 + match archived with 708 + | Some a -> ("archived", `Bool a) :: body_obj 709 + | None -> body_obj 710 + in 711 + let body_obj = 712 + match created_at with 713 + | Some date -> 714 + let iso_date = Ptime.to_rfc3339 date in 715 + ("createdAt", `String iso_date) :: body_obj 716 + | None -> body_obj 717 + in 718 + let body_obj = 719 + match tags with 720 + | Some tag_list when tag_list <> [] -> 721 + let tag_objs = 722 + List.map (fun t -> `O [ ("name", `String t) ]) tag_list 723 + in 724 + ("tags", `A tag_objs) :: body_obj 725 + | _ -> body_obj 726 + in 727 + 728 + (* Convert to JSON *) 729 + let body_json = `O body_obj in 730 + let body_str = J.to_string body_json in 731 + 732 + (* Make the request *) 733 + let headers = [ ("Content-Type", "application/json") ] in 734 + let url = base_url ^ "/api/v1/bookmarks" in 735 + 736 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 737 + >>= fun (resp, body) -> process_response resp body parse_bookmark 738 + 739 + let update_bookmark ~api_key ?title ?note ?summary ?favourited ?archived ?url 740 + ?description ?author ?publisher ?date_published ?date_modified ?text 741 + ?asset_content base_url bookmark_id = 742 + (* Prepare the update request body *) 743 + let body_obj = [] in 744 + 745 + (* Add optional fields *) 746 + let body_obj = 747 + match title with 748 + | Some t -> ("title", `String t) :: body_obj 749 + | None -> body_obj 750 + in 751 + let body_obj = 752 + match note with 753 + | Some n -> ("note", `String n) :: body_obj 754 + | None -> body_obj 755 + in 756 + let body_obj = 757 + match summary with 758 + | Some s -> ("summary", `String s) :: body_obj 759 + | None -> body_obj 760 + in 761 + let body_obj = 762 + match favourited with 763 + | Some f -> ("favourited", `Bool f) :: body_obj 764 + | None -> body_obj 765 + in 766 + let body_obj = 767 + match archived with 768 + | Some a -> ("archived", `Bool a) :: body_obj 769 + | None -> body_obj 770 + in 771 + let body_obj = 772 + match url with Some u -> ("url", `String u) :: body_obj | None -> body_obj 773 + in 774 + let body_obj = 775 + match description with 776 + | Some d -> ("description", `String d) :: body_obj 777 + | None -> body_obj 778 + in 779 + let body_obj = 780 + match author with 781 + | Some a -> ("author", `String a) :: body_obj 782 + | None -> body_obj 783 + in 784 + let body_obj = 785 + match publisher with 786 + | Some p -> ("publisher", `String p) :: body_obj 787 + | None -> body_obj 788 + in 789 + let body_obj = 790 + match date_published with 791 + | Some date -> 792 + let iso_date = Ptime.to_rfc3339 date in 793 + ("datePublished", `String iso_date) :: body_obj 794 + | None -> body_obj 795 + in 796 + let body_obj = 797 + match date_modified with 798 + | Some date -> 799 + let iso_date = Ptime.to_rfc3339 date in 800 + ("dateModified", `String iso_date) :: body_obj 801 + | None -> body_obj 802 + in 803 + let body_obj = 804 + match text with 805 + | Some t -> ("text", `String t) :: body_obj 806 + | None -> body_obj 807 + in 808 + let body_obj = 809 + match asset_content with 810 + | Some c -> ("assetContent", `String c) :: body_obj 811 + | None -> body_obj 416 812 in 417 813 418 - Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 419 - if resp.status = `OK then 814 + (* Only proceed if there are updates to make *) 815 + if body_obj = [] then 816 + (* No updates, just fetch the current bookmark *) 817 + fetch_bookmark_details ~api_key base_url bookmark_id 818 + else 819 + (* Convert to JSON *) 820 + let body_json = `O body_obj in 821 + let body_str = J.to_string body_json in 822 + 823 + (* Make the request *) 824 + let headers = [ ("Content-Type", "application/json") ] in 825 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id in 826 + 827 + make_request ~api_key ~method_:`PUT ~headers ~body:(Some body_str) url 828 + >>= fun (resp, body) -> process_response resp body parse_bookmark 829 + 830 + let delete_bookmark ~api_key base_url bookmark_id = 831 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id in 832 + 833 + make_request ~api_key ~method_:`DELETE url >>= fun (resp, body) -> 834 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 835 + if status_code >= 200 && status_code < 300 then 836 + consume_body body >>= fun () -> Lwt.return_unit 837 + else 420 838 Cohttp_lwt.Body.to_string body >>= fun body_str -> 421 - let json = J.from_string body_str in 422 - Lwt.return (parse_bookmark json) 839 + Lwt.fail_with ("Failed to delete bookmark: " ^ body_str) 840 + 841 + let summarize_bookmark ~api_key base_url bookmark_id = 842 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/summarize" in 843 + 844 + make_request ~api_key ~method_:`POST url >>= fun (resp, body) -> 845 + process_response resp body parse_bookmark 846 + 847 + (** {1 Tag Operations} *) 848 + 849 + let attach_tags ~api_key ~tag_refs base_url bookmark_id = 850 + (* Prepare the tag request body *) 851 + let tag_objs = 852 + List.map 853 + (function 854 + | `TagId id -> `O [ ("tagId", `String id) ] 855 + | `TagName name -> `O [ ("tagName", `String name) ]) 856 + tag_refs 857 + in 858 + 859 + let body_json = `O [ ("tags", `A tag_objs) ] in 860 + let body_str = J.to_string body_json in 861 + 862 + (* Make the request *) 863 + let headers = [ ("Content-Type", "application/json") ] in 864 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/tags" in 865 + 866 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 867 + >>= fun (resp, body) -> 868 + process_response resp body (fun json -> 869 + try 870 + let tags_json = J.find json [ "tags" ] in 871 + J.get_list 872 + (fun tag_json -> J.find tag_json [ "id" ] |> J.get_string) 873 + tags_json 874 + with _ -> []) 875 + 876 + let detach_tags ~api_key ~tag_refs base_url bookmark_id = 877 + (* Prepare the tag request body *) 878 + let tag_objs = 879 + List.map 880 + (function 881 + | `TagId id -> `O [ ("tagId", `String id) ] 882 + | `TagName name -> `O [ ("tagName", `String name) ]) 883 + tag_refs 884 + in 885 + 886 + let body_json = `O [ ("tags", `A tag_objs) ] in 887 + let body_str = J.to_string body_json in 888 + 889 + (* Make the request *) 890 + let headers = [ ("Content-Type", "application/json") ] in 891 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/tags/detach" in 892 + 893 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 894 + >>= fun (resp, body) -> 895 + process_response resp body (fun json -> 896 + try 897 + let tags_json = J.find json [ "tags" ] in 898 + J.get_list 899 + (fun tag_json -> J.find tag_json [ "id" ] |> J.get_string) 900 + tags_json 901 + with _ -> []) 902 + 903 + let fetch_all_tags ~api_key base_url = 904 + let url = base_url ^ "/api/v1/tags" in 905 + 906 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 907 + process_response resp body (fun json -> 908 + try 909 + let tags_json = J.find json [ "tags" ] in 910 + J.get_list parse_tag tags_json 911 + with _ -> []) 912 + 913 + let fetch_tag_details ~api_key base_url tag_id = 914 + let url = base_url ^ "/api/v1/tags/" ^ tag_id in 915 + 916 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 917 + process_response resp body parse_tag 918 + 919 + let fetch_bookmarks_with_tag ~api_key ?limit ?cursor ?include_content base_url 920 + tag_id = 921 + (* Build query parameters *) 922 + let params = [] in 923 + let params = 924 + match limit with 925 + | Some l -> ("limit", string_of_int l) :: params 926 + | None -> params 927 + in 928 + let params = 929 + match cursor with Some c -> ("cursor", c) :: params | None -> params 930 + in 931 + let params = 932 + match include_content with 933 + | Some ic -> ("includeContent", string_of_bool ic) :: params 934 + | None -> params 935 + in 936 + 937 + (* Construct URL with query parameters *) 938 + let query_str = 939 + if params = [] then "" 940 + else 941 + "?" 942 + ^ String.concat "&" 943 + (List.map (fun (k, v) -> k ^ "=" ^ Uri.pct_encode v) params) 944 + in 945 + let url = base_url ^ "/api/v1/tags/" ^ tag_id ^ "/bookmarks" ^ query_str in 946 + 947 + (* Make the request *) 948 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 949 + process_response resp body parse_paginated_bookmarks 950 + 951 + let update_tag ~api_key ~name base_url tag_id = 952 + let body_json = `O [ ("name", `String name) ] in 953 + let body_str = J.to_string body_json in 954 + 955 + (* Make the request *) 956 + let headers = [ ("Content-Type", "application/json") ] in 957 + let url = base_url ^ "/api/v1/tags/" ^ tag_id in 958 + 959 + make_request ~api_key ~method_:`PUT ~headers ~body:(Some body_str) url 960 + >>= fun (resp, body) -> process_response resp body parse_tag 961 + 962 + let delete_tag ~api_key base_url tag_id = 963 + let url = base_url ^ "/api/v1/tags/" ^ tag_id in 964 + 965 + make_request ~api_key ~method_:`DELETE url >>= fun (resp, body) -> 966 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 967 + if status_code >= 200 && status_code < 300 then 968 + consume_body body >>= fun () -> Lwt.return_unit 423 969 else 424 - let status_code = Cohttp.Code.code_of_status resp.status in 425 - consume_body body >>= fun () -> 426 - Lwt.fail_with (Fmt.str "HTTP error: %d" status_code) 970 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 971 + Lwt.fail_with ("Failed to delete tag: " ^ body_str) 427 972 428 - (** Get the asset URL for a given asset ID *) 429 - let get_asset_url base_url asset_id = 430 - Printf.sprintf "%s/api/assets/%s" base_url asset_id 973 + (** {1 List Operations} *) 431 974 432 - (** Fetch an asset from the Karakeep server as a binary string *) 433 - let fetch_asset ~api_key base_url asset_id = 434 - let open Cohttp_lwt_unix in 435 - let url = get_asset_url base_url asset_id in 975 + let fetch_all_lists ~api_key base_url = 976 + let url = base_url ^ "/api/v1/lists" in 436 977 437 - (* Set up headers with API key *) 438 - let headers = 439 - Cohttp.Header.init () |> fun h -> 440 - Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 978 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 979 + process_response resp body (fun json -> 980 + try 981 + let lists_json = J.find json [ "lists" ] in 982 + J.get_list parse_list lists_json 983 + with _ -> []) 984 + 985 + let fetch_list_details ~api_key base_url list_id = 986 + let url = base_url ^ "/api/v1/lists/" ^ list_id in 987 + 988 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 989 + process_response resp body parse_list 990 + 991 + let create_list ~api_key ~name ~icon ?description ?parent_id ?list_type ?query 992 + base_url = 993 + (* Prepare the list request body *) 994 + let body_obj = [ ("name", `String name); ("icon", `String icon) ] in 995 + 996 + (* Add optional fields *) 997 + let body_obj = 998 + match description with 999 + | Some d -> ("description", `String d) :: body_obj 1000 + | None -> body_obj 1001 + in 1002 + let body_obj = 1003 + match parent_id with 1004 + | Some id -> ("parentId", `String id) :: body_obj 1005 + | None -> body_obj 1006 + in 1007 + let body_obj = 1008 + match list_type with 1009 + | Some lt -> ("listType", `String (string_of_list_type lt)) :: body_obj 1010 + | None -> body_obj 1011 + in 1012 + let body_obj = 1013 + match query with 1014 + | Some q -> ("query", `String q) :: body_obj 1015 + | None -> body_obj 441 1016 in 442 1017 443 - Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 444 - if resp.status = `OK then Cohttp_lwt.Body.to_string body 1018 + (* Convert to JSON *) 1019 + let body_json = `O body_obj in 1020 + let body_str = J.to_string body_json in 1021 + 1022 + (* Make the request *) 1023 + let headers = [ ("Content-Type", "application/json") ] in 1024 + let url = base_url ^ "/api/v1/lists" in 1025 + 1026 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 1027 + >>= fun (resp, body) -> process_response resp body parse_list 1028 + 1029 + let update_list ~api_key ?name ?description ?icon ?parent_id ?query base_url 1030 + list_id = 1031 + (* Prepare the update request body *) 1032 + let body_obj = [] in 1033 + 1034 + (* Add optional fields *) 1035 + let body_obj = 1036 + match name with 1037 + | Some n -> ("name", `String n) :: body_obj 1038 + | None -> body_obj 1039 + in 1040 + let body_obj = 1041 + match description with 1042 + | Some d -> ("description", `String d) :: body_obj 1043 + | None -> body_obj 1044 + in 1045 + let body_obj = 1046 + match icon with 1047 + | Some i -> ("icon", `String i) :: body_obj 1048 + | None -> body_obj 1049 + in 1050 + let body_obj = 1051 + match parent_id with 1052 + | Some (Some id) -> ("parentId", `String id) :: body_obj 1053 + | Some None -> ("parentId", `Null) :: body_obj 1054 + | None -> body_obj 1055 + in 1056 + let body_obj = 1057 + match query with 1058 + | Some q -> ("query", `String q) :: body_obj 1059 + | None -> body_obj 1060 + in 1061 + 1062 + (* Only proceed if there are updates to make *) 1063 + if body_obj = [] then 1064 + (* No updates, just fetch the current list *) 1065 + fetch_list_details ~api_key base_url list_id 445 1066 else 446 - let status_code = Cohttp.Code.code_of_status resp.status in 447 - consume_body body >>= fun () -> 448 - Lwt.fail_with (Fmt.str "Asset fetch error: %d" status_code) 1067 + (* Convert to JSON *) 1068 + let body_json = `O body_obj in 1069 + let body_str = J.to_string body_json in 1070 + 1071 + (* Make the request *) 1072 + let headers = [ ("Content-Type", "application/json") ] in 1073 + let url = base_url ^ "/api/v1/lists/" ^ list_id in 1074 + 1075 + make_request ~api_key ~method_:`PUT ~headers ~body:(Some body_str) url 1076 + >>= fun (resp, body) -> process_response resp body parse_list 1077 + 1078 + let delete_list ~api_key base_url list_id = 1079 + let url = base_url ^ "/api/v1/lists/" ^ list_id in 1080 + 1081 + make_request ~api_key ~method_:`DELETE url >>= fun (resp, body) -> 1082 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 1083 + if status_code >= 200 && status_code < 300 then 1084 + consume_body body >>= fun () -> Lwt.return_unit 1085 + else 1086 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 1087 + Lwt.fail_with ("Failed to delete list: " ^ body_str) 1088 + 1089 + let fetch_bookmarks_in_list ~api_key ?limit ?cursor ?include_content base_url 1090 + list_id = 1091 + (* Build query parameters *) 1092 + let params = [] in 1093 + let params = 1094 + match limit with 1095 + | Some l -> ("limit", string_of_int l) :: params 1096 + | None -> params 1097 + in 1098 + let params = 1099 + match cursor with Some c -> ("cursor", c) :: params | None -> params 1100 + in 1101 + let params = 1102 + match include_content with 1103 + | Some ic -> ("includeContent", string_of_bool ic) :: params 1104 + | None -> params 1105 + in 1106 + 1107 + (* Construct URL with query parameters *) 1108 + let query_str = 1109 + if params = [] then "" 1110 + else 1111 + "?" 1112 + ^ String.concat "&" 1113 + (List.map (fun (k, v) -> k ^ "=" ^ Uri.pct_encode v) params) 1114 + in 1115 + let url = base_url ^ "/api/v1/lists/" ^ list_id ^ "/bookmarks" ^ query_str in 1116 + 1117 + (* Make the request *) 1118 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 1119 + process_response resp body parse_paginated_bookmarks 1120 + 1121 + let add_bookmark_to_list ~api_key base_url list_id bookmark_id = 1122 + let url = 1123 + base_url ^ "/api/v1/lists/" ^ list_id ^ "/bookmarks/" ^ bookmark_id 1124 + in 1125 + 1126 + make_request ~api_key ~method_:`POST url >>= fun (resp, body) -> 1127 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 1128 + if status_code >= 200 && status_code < 300 then 1129 + consume_body body >>= fun () -> Lwt.return_unit 1130 + else 1131 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 1132 + Lwt.fail_with ("Failed to add bookmark to list: " ^ body_str) 1133 + 1134 + let remove_bookmark_from_list ~api_key base_url list_id bookmark_id = 1135 + let url = 1136 + base_url ^ "/api/v1/lists/" ^ list_id ^ "/bookmarks/" ^ bookmark_id 1137 + in 1138 + 1139 + make_request ~api_key ~method_:`DELETE url >>= fun (resp, body) -> 1140 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 1141 + if status_code >= 200 && status_code < 300 then 1142 + consume_body body >>= fun () -> Lwt.return_unit 1143 + else 1144 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 1145 + Lwt.fail_with ("Failed to remove bookmark from list: " ^ body_str) 1146 + 1147 + (** {1 Highlight Operations} *) 1148 + 1149 + let fetch_all_highlights ~api_key ?limit ?cursor base_url = 1150 + (* Build query parameters *) 1151 + let params = [] in 1152 + let params = 1153 + match limit with 1154 + | Some l -> ("limit", string_of_int l) :: params 1155 + | None -> params 1156 + in 1157 + let params = 1158 + match cursor with Some c -> ("cursor", c) :: params | None -> params 1159 + in 1160 + 1161 + (* Construct URL with query parameters *) 1162 + let query_str = 1163 + if params = [] then "" 1164 + else 1165 + "?" 1166 + ^ String.concat "&" 1167 + (List.map (fun (k, v) -> k ^ "=" ^ Uri.pct_encode v) params) 1168 + in 1169 + let url = base_url ^ "/api/v1/highlights" ^ query_str in 1170 + 1171 + (* Make the request *) 1172 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 1173 + process_response resp body parse_paginated_highlights 1174 + 1175 + let fetch_bookmark_highlights ~api_key base_url bookmark_id = 1176 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/highlights" in 1177 + 1178 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 1179 + process_response resp body (fun json -> 1180 + try 1181 + let highlights_json = J.find json [ "highlights" ] in 1182 + J.get_list parse_highlight highlights_json 1183 + with _ -> []) 1184 + 1185 + let fetch_highlight_details ~api_key base_url highlight_id = 1186 + let url = base_url ^ "/api/v1/highlights/" ^ highlight_id in 449 1187 450 - (** Create a new bookmark in Karakeep with optional tags *) 451 - let create_bookmark ~api_key ~url ?title ?note ?tags ?(favourited = false) 452 - ?(archived = false) base_url = 453 - let open Cohttp_lwt_unix in 454 - (* Prepare the bookmark request body *) 1188 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 1189 + process_response resp body parse_highlight 1190 + 1191 + let create_highlight ~api_key ~bookmark_id ~start_offset ~end_offset ~text ?note 1192 + ?color base_url = 1193 + (* Prepare the highlight request body *) 455 1194 let body_obj = 456 1195 [ 457 - ("type", `String "link"); 458 - ("url", `String url); 459 - ("favourited", `Bool favourited); 460 - ("archived", `Bool archived); 1196 + ("bookmarkId", `String bookmark_id); 1197 + ("startOffset", `Float (float_of_int start_offset)); 1198 + ("endOffset", `Float (float_of_int end_offset)); 1199 + ("text", `String text); 461 1200 ] 462 1201 in 463 1202 464 1203 (* Add optional fields *) 465 1204 let body_obj = 466 - match title with 467 - | Some title_str -> ("title", `String title_str) :: body_obj 1205 + match note with 1206 + | Some n -> ("note", `String n) :: body_obj 468 1207 | None -> body_obj 469 1208 in 470 - 471 1209 let body_obj = 472 - match note with 473 - | Some note_str -> ("note", `String note_str) :: body_obj 1210 + match color with 1211 + | Some c -> ("color", `String (string_of_highlight_color c)) :: body_obj 474 1212 | None -> body_obj 475 1213 in 476 1214 ··· 478 1216 let body_json = `O body_obj in 479 1217 let body_str = J.to_string body_json in 480 1218 481 - (* Set up headers with API key *) 1219 + (* Make the request *) 1220 + let headers = [ ("Content-Type", "application/json") ] in 1221 + let url = base_url ^ "/api/v1/highlights" in 1222 + 1223 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 1224 + >>= fun (resp, body) -> process_response resp body parse_highlight 1225 + 1226 + let update_highlight ~api_key ?color base_url highlight_id = 1227 + (* Prepare the update request body *) 1228 + let body_obj = [] in 1229 + 1230 + (* Add optional fields *) 1231 + let body_obj = 1232 + match color with 1233 + | Some c -> ("color", `String (string_of_highlight_color c)) :: body_obj 1234 + | None -> body_obj 1235 + in 1236 + 1237 + (* Only proceed if there are updates to make *) 1238 + if body_obj = [] then 1239 + (* No updates, just fetch the current highlight *) 1240 + fetch_highlight_details ~api_key base_url highlight_id 1241 + else 1242 + (* Convert to JSON *) 1243 + let body_json = `O body_obj in 1244 + let body_str = J.to_string body_json in 1245 + 1246 + (* Make the request *) 1247 + let headers = [ ("Content-Type", "application/json") ] in 1248 + let url = base_url ^ "/api/v1/highlights/" ^ highlight_id in 1249 + 1250 + make_request ~api_key ~method_:`PUT ~headers ~body:(Some body_str) url 1251 + >>= fun (resp, body) -> process_response resp body parse_highlight 1252 + 1253 + let delete_highlight ~api_key base_url highlight_id = 1254 + let url = base_url ^ "/api/v1/highlights/" ^ highlight_id in 1255 + 1256 + make_request ~api_key ~method_:`DELETE url >>= fun (resp, body) -> 1257 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 1258 + if status_code >= 200 && status_code < 300 then 1259 + consume_body body >>= fun () -> Lwt.return_unit 1260 + else 1261 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 1262 + Lwt.fail_with ("Failed to delete highlight: " ^ body_str) 1263 + 1264 + (** {1 Asset Operations} *) 1265 + 1266 + let get_asset_url base_url asset_id = 1267 + Printf.sprintf "%s/api/assets/%s" base_url asset_id 1268 + 1269 + let fetch_asset ~api_key base_url asset_id = 1270 + let url = get_asset_url base_url asset_id in 1271 + 1272 + let open Cohttp_lwt_unix in 482 1273 let headers = 483 1274 Cohttp.Header.init () |> fun h -> 484 - Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) |> fun h -> 485 - Cohttp.Header.add h "Content-Type" "application/json" 1275 + Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 1276 + in 1277 + 1278 + Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 1279 + let status_code = Cohttp.Code.code_of_status resp.status in 1280 + if status_code >= 200 && status_code < 300 then Cohttp_lwt.Body.to_string body 1281 + else 1282 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 1283 + Lwt.fail_with ("Failed to fetch asset: " ^ body_str) 1284 + 1285 + let attach_asset ~api_key ~asset_id ~asset_type base_url bookmark_id = 1286 + (* Prepare the asset request body *) 1287 + let body_json = 1288 + `O 1289 + [ 1290 + ("assetId", `String asset_id); 1291 + ("assetType", `String (string_of_asset_type asset_type)); 1292 + ] 486 1293 in 1294 + let body_str = J.to_string body_json in 1295 + 1296 + (* Make the request *) 1297 + let headers = [ ("Content-Type", "application/json") ] in 1298 + let url = base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/assets" in 487 1299 488 - (* Helper function to ensure we consume all response body data *) 489 - let consume_body body = 490 - Cohttp_lwt.Body.to_string body >>= fun _ -> Lwt.return_unit 1300 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 1301 + >>= fun (resp, body) -> process_response resp body parse_asset 1302 + 1303 + let replace_asset ~api_key ~new_asset_id base_url bookmark_id asset_id = 1304 + (* Prepare the asset request body *) 1305 + let body_json = `O [ ("newAssetId", `String new_asset_id) ] in 1306 + let body_str = J.to_string body_json in 1307 + 1308 + (* Make the request *) 1309 + let headers = [ ("Content-Type", "application/json") ] in 1310 + let url = 1311 + base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/assets/" ^ asset_id 1312 + ^ "/replace" 491 1313 in 492 1314 493 - (* Create the bookmark *) 494 - let url_endpoint = Printf.sprintf "%s/api/v1/bookmarks" base_url in 495 - Client.post ~headers 496 - ~body:(Cohttp_lwt.Body.of_string body_str) 497 - (Uri.of_string url_endpoint) 1315 + make_request ~api_key ~method_:`POST ~headers ~body:(Some body_str) url 498 1316 >>= fun (resp, body) -> 499 - if resp.status = `Created || resp.status = `OK then 1317 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 1318 + if status_code >= 200 && status_code < 300 then 1319 + consume_body body >>= fun () -> Lwt.return_unit 1320 + else 1321 + Cohttp_lwt.Body.to_string body >>= fun body_str -> 1322 + Lwt.fail_with ("Failed to replace asset: " ^ body_str) 1323 + 1324 + let detach_asset ~api_key base_url bookmark_id asset_id = 1325 + let url = 1326 + base_url ^ "/api/v1/bookmarks/" ^ bookmark_id ^ "/assets/" ^ asset_id 1327 + in 1328 + 1329 + make_request ~api_key ~method_:`DELETE url >>= fun (resp, body) -> 1330 + let status_code = Cohttp.Code.code_of_status resp.Cohttp.Response.status in 1331 + if status_code >= 200 && status_code < 300 then 1332 + consume_body body >>= fun () -> Lwt.return_unit 1333 + else 500 1334 Cohttp_lwt.Body.to_string body >>= fun body_str -> 501 - let json = J.from_string body_str in 502 - let bookmark = parse_bookmark json in 1335 + Lwt.fail_with ("Failed to detach asset: " ^ body_str) 503 1336 504 - (* If tags are provided, add them to the bookmark *) 505 - match tags with 506 - | Some tag_list when tag_list <> [] -> 507 - (* Prepare the tags request body *) 508 - let tag_objects = 509 - List.map 510 - (fun tag_name -> `O [ ("tagName", `String tag_name) ]) 511 - tag_list 512 - in 1337 + (** {1 User Operations} *) 513 1338 514 - let tags_body = `O [ ("tags", `A tag_objects) ] in 515 - let tags_body_str = J.to_string tags_body in 1339 + let get_current_user ~api_key base_url = 1340 + let url = base_url ^ "/api/v1/user" in 516 1341 517 - (* Add tags to the bookmark *) 518 - let tags_url = 519 - Printf.sprintf "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id 520 - in 521 - Client.post ~headers 522 - ~body:(Cohttp_lwt.Body.of_string tags_body_str) 523 - (Uri.of_string tags_url) 524 - >>= fun (resp, body) -> 525 - (* Always consume the response body *) 526 - consume_body body >>= fun () -> 527 - if resp.status = `OK then 528 - (* Fetch the bookmark again to get updated tags *) 529 - fetch_bookmark_details ~api_key base_url bookmark.id 530 - else 531 - (* Return the bookmark without tags if tag addition failed *) 532 - Lwt.return bookmark 533 - | _ -> Lwt.return bookmark 534 - else 535 - let status_code = Cohttp.Code.code_of_status resp.status in 536 - Cohttp_lwt.Body.to_string body >>= fun error_body -> 537 - Lwt.fail_with 538 - (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" 539 - status_code error_body) 1342 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 1343 + process_response resp body parse_user_info 1344 + 1345 + let get_user_stats ~api_key base_url = 1346 + let url = base_url ^ "/api/v1/user/stats" in 1347 + 1348 + make_request ~api_key ~method_:`GET url >>= fun (resp, body) -> 1349 + process_response resp body parse_user_stats
+50 -40
lib/karakeep.mli
··· 44 44 45 45 (** {1 Core Types} *) 46 46 47 - (** Asset identifier type *) 48 47 type asset_id = string 48 + (** Asset identifier type *) 49 49 50 + type bookmark_id = string 50 51 (** Bookmark identifier type *) 51 - type bookmark_id = string 52 52 53 - (** List identifier type *) 54 53 type list_id = string 54 + (** List identifier type *) 55 55 56 + type tag_id = string 56 57 (** Tag identifier type *) 57 - type tag_id = string 58 58 59 - (** Highlight identifier type *) 60 59 type highlight_id = string 60 + (** Highlight identifier type *) 61 61 62 62 (** Type of content a bookmark can have *) 63 63 type bookmark_content_type = ··· 82 82 | Success (** Tagging was successful *) 83 83 | Failure (** Tagging failed *) 84 84 | Pending (** Tagging is pending *) 85 + 86 + val string_of_tagging_status : tagging_status -> string 85 87 86 88 (** Type of bookmark list *) 87 89 type list_type = ··· 100 102 | AI (** Tag was attached by AI *) 101 103 | Human (** Tag was attached by a human *) 102 104 103 - (** Link content for a bookmark *) 105 + val string_of_tag_attachment_type : tag_attachment_type -> string 106 + 104 107 type link_content = { 105 108 url : string; (** The URL of the bookmarked page *) 106 109 title : string option; (** Title from the link *) ··· 121 124 date_published : Ptime.t option; (** When the content was published *) 122 125 date_modified : Ptime.t option; (** When the content was last modified *) 123 126 } 127 + (** Link content for a bookmark *) 124 128 125 - (** Text content for a bookmark *) 126 129 type text_content = { 127 130 text : string; (** The text content *) 128 131 source_url : string option; (** Optional source URL for the text *) 129 132 } 133 + (** Text content for a bookmark *) 130 134 131 - (** Asset content for a bookmark *) 132 135 type asset_content = { 133 136 asset_type : [ `Image | `PDF ]; (** Type of the asset *) 134 137 asset_id : asset_id; (** ID of the asset *) ··· 137 140 size : int option; (** Size of the asset in bytes *) 138 141 content : string option; (** Extracted content from the asset *) 139 142 } 143 + (** Asset content for a bookmark *) 140 144 141 145 (** Content of a bookmark *) 142 146 type content = ··· 145 149 | Asset of asset_content (** Asset-type content *) 146 150 | Unknown (** Unknown content type *) 147 151 148 - (** Asset attached to a bookmark *) 149 152 type asset = { 150 153 id : asset_id; (** ID of the asset *) 151 154 asset_type : asset_type; (** Type of the asset *) 152 155 } 156 + (** Asset attached to a bookmark *) 153 157 154 - (** Tag with attachment information *) 155 158 type bookmark_tag = { 156 159 id : tag_id; (** ID of the tag *) 157 160 name : string; (** Name of the tag *) 158 161 attached_by : tag_attachment_type; (** How the tag was attached *) 159 162 } 163 + (** Tag with attachment information *) 160 164 161 - (** A bookmark from the Karakeep service *) 162 165 type bookmark = { 163 166 id : bookmark_id; (** Unique identifier for the bookmark *) 164 167 created_at : Ptime.t; (** Timestamp when the bookmark was created *) ··· 173 176 content : content; (** Content of the bookmark *) 174 177 assets : asset list; (** Assets attached to the bookmark *) 175 178 } 179 + (** A bookmark from the Karakeep service *) 176 180 177 - (** Paginated response of bookmarks *) 178 181 type paginated_bookmarks = { 179 182 bookmarks : bookmark list; (** List of bookmarks in the current page *) 180 183 next_cursor : string option; (** Optional cursor for fetching the next page *) 181 184 } 185 + (** Paginated response of bookmarks *) 182 186 183 - (** List in Karakeep *) 184 187 type _list = { 185 188 id : list_id; (** ID of the list *) 186 189 name : string; (** Name of the list *) ··· 190 193 list_type : list_type; (** Type of the list *) 191 194 query : string option; (** Optional query for smart lists *) 192 195 } 196 + (** List in Karakeep *) 193 197 194 - (** Tag in Karakeep *) 195 198 type tag = { 196 199 id : tag_id; (** ID of the tag *) 197 200 name : string; (** Name of the tag *) 198 201 num_bookmarks : int; (** Number of bookmarks with this tag *) 199 - num_bookmarks_by_attached_type : (tag_attachment_type * int) list; (** Number of bookmarks by attachment type *) 202 + num_bookmarks_by_attached_type : (tag_attachment_type * int) list; 203 + (** Number of bookmarks by attachment type *) 200 204 } 205 + (** Tag in Karakeep *) 201 206 202 - (** Highlight in Karakeep *) 203 207 type highlight = { 204 208 bookmark_id : bookmark_id; (** ID of the bookmark *) 205 209 start_offset : int; (** Start position of the highlight *) ··· 211 215 user_id : string; (** ID of the user who created the highlight *) 212 216 created_at : Ptime.t; (** When the highlight was created *) 213 217 } 218 + (** Highlight in Karakeep *) 214 219 215 - (** Paginated response of highlights *) 216 220 type paginated_highlights = { 217 221 highlights : highlight list; (** List of highlights in the current page *) 218 222 next_cursor : string option; (** Optional cursor for fetching the next page *) 219 223 } 224 + (** Paginated response of highlights *) 220 225 221 - (** User information *) 222 226 type user_info = { 223 227 id : string; (** ID of the user *) 224 228 name : string option; (** Name of the user *) 225 229 email : string option; (** Email of the user *) 226 230 } 231 + (** User information *) 227 232 228 - (** User statistics *) 229 233 type user_stats = { 230 234 num_bookmarks : int; (** Number of bookmarks *) 231 235 num_favorites : int; (** Number of favorite bookmarks *) ··· 234 238 num_lists : int; (** Number of lists *) 235 239 num_highlights : int; (** Number of highlights *) 236 240 } 241 + (** User statistics *) 237 242 238 - (** Error response from the API *) 239 243 type error_response = { 240 244 code : string; (** Error code *) 241 245 message : string; (** Error message *) 242 246 } 247 + (** Error response from the API *) 243 248 244 249 (** {1 Bookmark Operations} *) 245 250 ··· 253 258 string -> 254 259 paginated_bookmarks Lwt.t 255 260 (** [fetch_bookmarks ~api_key ?limit ?cursor ?include_content ?archived 256 - ?favourited base_url] fetches a page of bookmarks from a Karakeep instance. 261 + ?favourited base_url] fetches a page of bookmarks from a Karakeep instance. 257 262 258 263 This function provides fine-grained control over pagination with 259 264 cursor-based pagination. It returns a {!paginated_bookmarks} that includes ··· 279 284 string -> 280 285 bookmark list Lwt.t 281 286 (** [fetch_all_bookmarks ~api_key ?page_size ?max_pages ?archived ?favourited 282 - base_url] fetches all bookmarks from a Karakeep instance, automatically 287 + base_url] fetches all bookmarks from a Karakeep instance, automatically 283 288 handling pagination. 284 289 285 290 This function handles pagination internally and returns a flattened list of ··· 316 321 @param cursor Optional pagination cursor 317 322 @param include_content Whether to include full content (default: true) 318 323 @param base_url Base URL of the Karakeep instance 319 - @return 320 - A Lwt promise with the bookmark response containing search results *) 324 + @return A Lwt promise with the bookmark response containing search results 325 + *) 321 326 322 327 val fetch_bookmark_details : 323 328 api_key:string -> string -> bookmark_id -> bookmark Lwt.t ··· 346 351 string -> 347 352 bookmark Lwt.t 348 353 (** [create_bookmark ~api_key ~url ?title ?note ?summary ?favourited ?archived 349 - ?created_at ?tags base_url] creates a new bookmark in Karakeep. 354 + ?created_at ?tags base_url] creates a new bookmark in Karakeep. 350 355 351 356 This function adds a new bookmark to the Karakeep instance for the given 352 357 URL. It supports setting various bookmark attributes and adding tags. ··· 392 397 bookmark_id -> 393 398 bookmark Lwt.t 394 399 (** [update_bookmark ~api_key ?title ?note ?summary ?favourited ?archived ?url 395 - ?description ?author ?publisher ?date_published ?date_modified ?text 396 - ?asset_content base_url bookmark_id] updates a bookmark by its ID. 400 + ?description ?author ?publisher ?date_published ?date_modified ?text 401 + ?asset_content base_url bookmark_id] updates a bookmark by its ID. 397 402 398 403 This function updates various attributes of an existing bookmark. Only the 399 404 fields provided will be updated. ··· 509 514 tag_id -> 510 515 paginated_bookmarks Lwt.t 511 516 (** [fetch_bookmarks_with_tag ~api_key ?limit ?cursor ?include_content base_url 512 - tag_id] fetches bookmarks with a specific tag. 517 + tag_id] fetches bookmarks with a specific tag. 513 518 514 519 This function retrieves bookmarks that have been tagged with a specific tag. 515 520 ··· 520 525 @param base_url Base URL of the Karakeep instance 521 526 @param tag_id ID of the tag to filter by 522 527 @return 523 - A Lwt promise with the bookmark response containing bookmarks with the tag *) 528 + A Lwt promise with the bookmark response containing bookmarks with the tag 529 + *) 524 530 525 - val update_tag : 526 - api_key:string -> name:string -> string -> tag_id -> tag Lwt.t 531 + val update_tag : api_key:string -> name:string -> string -> tag_id -> tag Lwt.t 527 532 (** [update_tag ~api_key ~name base_url tag_id] updates a tag's name. 528 533 529 534 This function changes the name of an existing tag. ··· 579 584 string -> 580 585 _list Lwt.t 581 586 (** [create_list ~api_key ~name ~icon ?description ?parent_id ?list_type ?query 582 - base_url] creates a new list in Karakeep. 587 + base_url] creates a new list in Karakeep. 583 588 584 589 This function adds a new list to the Karakeep instance. Lists can be 585 590 hierarchical with parent-child relationships, and can be either manual or ··· 606 611 list_id -> 607 612 _list Lwt.t 608 613 (** [update_list ~api_key ?name ?description ?icon ?parent_id ?query base_url 609 - list_id] updates a list by its ID. 614 + list_id] updates a list by its ID. 610 615 611 616 This function updates various attributes of an existing list. Only the 612 617 fields provided will be updated. ··· 615 620 @param name Optional new name for the list 616 621 @param description Optional new description for the list 617 622 @param icon Optional new icon for the list 618 - @param parent_id 619 - Optional new parent list ID (use None to remove the parent) 623 + @param parent_id Optional new parent list ID (use None to remove the parent) 620 624 @param query Optional new query for smart lists 621 625 @param base_url Base URL of the Karakeep instance 622 626 @param list_id ID of the list to update ··· 642 646 list_id -> 643 647 paginated_bookmarks Lwt.t 644 648 (** [fetch_bookmarks_in_list ~api_key ?limit ?cursor ?include_content base_url 645 - list_id] fetches bookmarks in a specific list. 649 + list_id] fetches bookmarks in a specific list. 646 650 647 651 This function retrieves bookmarks that have been added to a specific list. 648 652 ··· 653 657 @param base_url Base URL of the Karakeep instance 654 658 @param list_id ID of the list to get bookmarks from 655 659 @return 656 - A Lwt promise with the bookmark response containing bookmarks in the list *) 660 + A Lwt promise with the bookmark response containing bookmarks in the list 661 + *) 657 662 658 663 val add_bookmark_to_list : 659 664 api_key:string -> string -> list_id -> bookmark_id -> unit Lwt.t ··· 667 672 @param base_url Base URL of the Karakeep instance 668 673 @param list_id ID of the list to add the bookmark to 669 674 @param bookmark_id ID of the bookmark to add 670 - @return A Lwt promise that completes when the bookmark is added to the list *) 675 + @return A Lwt promise that completes when the bookmark is added to the list 676 + *) 671 677 672 678 val remove_bookmark_from_list : 673 679 api_key:string -> string -> list_id -> bookmark_id -> unit Lwt.t ··· 687 693 (** {1 Highlight Operations} *) 688 694 689 695 val fetch_all_highlights : 690 - api_key:string -> ?limit:int -> ?cursor:string -> string -> paginated_highlights Lwt.t 696 + api_key:string -> 697 + ?limit:int -> 698 + ?cursor:string -> 699 + string -> 700 + paginated_highlights Lwt.t 691 701 (** [fetch_all_highlights ~api_key ?limit ?cursor base_url] fetches all 692 702 highlights. 693 703 ··· 737 747 string -> 738 748 highlight Lwt.t 739 749 (** [create_highlight ~api_key ~bookmark_id ~start_offset ~end_offset ~text 740 - ?note ?color base_url] creates a new highlight in Karakeep. 750 + ?note ?color base_url] creates a new highlight in Karakeep. 741 751 742 752 This function adds a new highlight to a bookmark in the Karakeep instance. 743 753 Highlights mark specific portions of the bookmark's content.
+26 -8
test/asset_test.ml
··· 25 25 fetch_bookmarks ~api_key ~limit:5 base_url >>= fun response -> 26 26 (* Find a bookmark with assets *) 27 27 let bookmark_with_assets = 28 - List.find_opt (fun b -> List.length b.assets > 0) response.data 28 + List.find_opt (fun b -> List.length b.assets > 0) response.bookmarks 29 29 in 30 30 31 31 match bookmark_with_assets with ··· 34 34 Lwt.return_unit 35 35 | Some bookmark -> ( 36 36 (* Print assets info *) 37 + let url = 38 + match bookmark.content with 39 + | Link lc -> lc.url 40 + | Text tc -> Option.value tc.source_url ~default:"(text content)" 41 + | Asset ac -> Option.value ac.source_url ~default:"(asset content)" 42 + | Unknown -> "(unknown content)" 43 + in 37 44 Printf.printf "Found bookmark with %d assets: %s\n" 38 45 (List.length bookmark.assets) 39 - bookmark.url; 46 + url; 40 47 41 48 List.iter 42 - (fun (asset_id, asset_type) -> 43 - Printf.printf "- Asset ID: %s, Type: %s\n" asset_id asset_type; 49 + (fun asset -> 50 + let asset_type_str = 51 + match asset.asset_type with 52 + | Screenshot -> "screenshot" 53 + | AssetScreenshot -> "assetScreenshot" 54 + | BannerImage -> "bannerImage" 55 + | FullPageArchive -> "fullPageArchive" 56 + | Video -> "video" 57 + | BookmarkAsset -> "bookmarkAsset" 58 + | PrecrawledArchive -> "precrawledArchive" 59 + | Unknown -> "unknown" 60 + in 61 + Printf.printf "- Asset ID: %s, Type: %s\n" asset.id asset_type_str; 44 62 45 63 (* Get asset URL *) 46 - let asset_url = get_asset_url base_url asset_id in 64 + let asset_url = get_asset_url base_url asset.id in 47 65 Printf.printf " URL: %s\n" asset_url) 48 66 bookmark.assets; 49 67 50 68 (* Optionally fetch one asset to verify it works *) 51 69 match bookmark.assets with 52 - | (asset_id, _) :: _ -> 53 - Printf.printf "\nFetching asset %s...\n" asset_id; 54 - fetch_asset ~api_key base_url asset_id >>= fun data -> 70 + | asset :: _ -> 71 + Printf.printf "\nFetching asset %s...\n" asset.id; 72 + fetch_asset ~api_key base_url asset.id >>= fun data -> 55 73 Printf.printf "Successfully fetched asset. Size: %d bytes\n" 56 74 (String.length data); 57 75 Lwt.return_unit
+12 -2
test/create_test.ml
··· 30 30 Printf.printf "- ID: %s\n" bookmark.id; 31 31 Printf.printf "- Title: %s\n" 32 32 (match bookmark.title with Some t -> t | None -> "(No title)"); 33 - Printf.printf "- URL: %s\n" bookmark.url; 33 + 34 + let url = 35 + match bookmark.content with 36 + | Link lc -> lc.url 37 + | Text tc -> Option.value tc.source_url ~default:"(text content)" 38 + | Asset ac -> Option.value ac.source_url ~default:"(asset content)" 39 + | Unknown -> "(unknown content)" 40 + in 41 + Printf.printf "- URL: %s\n" url; 34 42 Printf.printf "- Created: %s\n" (Ptime.to_rfc3339 bookmark.created_at); 35 - Printf.printf "- Tags: %s\n" (String.concat ", " bookmark.tags); 43 + Printf.printf "- Tags: %s\n" 44 + (String.concat ", " 45 + (List.map (fun (tag : bookmark_tag) -> tag.name) bookmark.tags)); 36 46 37 47 Lwt.return_unit 38 48 in
+17 -8
test/test.ml
··· 3 3 4 4 let print_bookmark bookmark = 5 5 let title = match bookmark.title with Some t -> t | None -> "(No title)" in 6 - Printf.printf "- %s\n URL: %s\n Created: %s\n Tags: %s\n---\n\n" title 7 - bookmark.url 6 + let url = 7 + match bookmark.content with 8 + | Link lc -> lc.url 9 + | Text tc -> Option.value tc.source_url ~default:"(text content)" 10 + | Asset ac -> Option.value ac.source_url ~default:"(asset content)" 11 + | Unknown -> "(unknown content)" 12 + in 13 + let tags_str = 14 + String.concat ", " 15 + (List.map (fun (tag : bookmark_tag) -> tag.name) bookmark.tags) 16 + in 17 + Printf.printf "- %s\n URL: %s\n Created: %s\n Tags: %s\n---\n\n" title url 8 18 (Ptime.to_rfc3339 bookmark.created_at) 9 - (String.concat ", " bookmark.tags) 19 + tags_str 10 20 11 21 let () = 12 22 (* Load API key from file *) ··· 29 39 (* Test 1: fetch_bookmarks - get a single page with pagination info *) 30 40 Printf.printf "=== Test 1: fetch_bookmarks (paginated) ===\n"; 31 41 fetch_bookmarks ~api_key ~limit:3 base_url >>= fun response -> 32 - Printf.printf "Found %d total bookmarks, showing %d (page 1)\n" 33 - response.total 34 - (List.length response.data); 42 + Printf.printf "Found bookmarks, showing %d (page 1)\n" 43 + (List.length response.bookmarks); 35 44 Printf.printf "Next cursor: %s\n\n" 36 45 (match response.next_cursor with Some c -> c | None -> "none"); 37 46 38 - List.iter print_bookmark response.data; 47 + List.iter print_bookmark response.bookmarks; 39 48 40 49 (* Test 2: fetch_all_bookmarks - get multiple pages automatically *) 41 50 Printf.printf "=== Test 2: fetch_all_bookmarks (with limit) ===\n"; ··· 52 61 (max 0 (List.length all_bookmarks - 4)); 53 62 54 63 (* Test 3: fetch_bookmark_details - get a specific bookmark *) 55 - match response.data with 64 + match response.bookmarks with 56 65 | first_bookmark :: _ -> 57 66 Printf.printf "=== Test 3: fetch_bookmark_details ===\n"; 58 67 Printf.printf "Fetching details for bookmark ID: %s\n\n"