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.

Create OCaml Karakeep client library

- Remove Bushel references to make standalone library
- Create comprehensive OCamldoc documentation
- Implement full API bindings for Karakeep
- Add tests for fetching bookmarks, creating bookmarks, and handling assets
- Create documentation in doc/ directory explaining API usage

+1015 -390
+1
.gitignore
··· 1 1 _build 2 2 karakeep-src 3 + .karakeep-api 3 4 .*.swp
+70
README.md
··· 1 + # OCaml Karakeep API Client 2 + 3 + This library provides OCaml bindings to the Karakeep API for bookmark management. 4 + 5 + ## Features 6 + 7 + - Fetch bookmarks from Karakeep instances with pagination 8 + - Retrieve detailed bookmark information 9 + - Create new bookmarks 10 + - Access and download bookmark assets 11 + - Filter bookmarks by tags 12 + 13 + ## Installation 14 + 15 + To install the library, you can use opam: 16 + 17 + ```bash 18 + opam install karakeep 19 + ``` 20 + 21 + Or pin the development version: 22 + 23 + ```bash 24 + opam pin add karakeep.dev git+https://github.com/yourusername/ocaml-karakeep.git 25 + ``` 26 + 27 + ## Configuration 28 + 29 + The library requires an API key for authentication. You can obtain an API key from your Karakeep instance. 30 + 31 + ## Usage Example 32 + 33 + ```ocaml 34 + open Lwt.Infix 35 + open Karakeep 36 + 37 + (* Setup the Karakeep client *) 38 + let api_key = "your_api_key" 39 + let base_url = "https://hoard.recoil.org" 40 + 41 + (* Fetch recent bookmarks *) 42 + let fetch_recent_bookmarks () = 43 + fetch_bookmarks ~api_key ~limit:10 base_url >>= fun response -> 44 + 45 + (* Process bookmarks *) 46 + List.iter (fun bookmark -> 47 + let title = match bookmark.title with 48 + | Some t -> t 49 + | None -> "(No title)" 50 + in 51 + Printf.printf "- %s\n URL: %s\n\n" title bookmark.url 52 + ) response.data; 53 + 54 + Lwt.return_unit 55 + 56 + (* Run the function *) 57 + let () = Lwt_main.run (fetch_recent_bookmarks ()) 58 + ``` 59 + 60 + ## Documentation 61 + 62 + See the [documentation](doc/index.md) for more detailed usage instructions and API reference. 63 + 64 + ## License 65 + 66 + This library is licensed under the ISC license. 67 + 68 + ## Contact 69 + 70 + For issues and questions, please create an issue on the GitHub repository.
+62
doc/assets.md
··· 1 + # Asset Operations 2 + 3 + The Karakeep library provides functions for working with assets (images, documents, etc.) attached to bookmarks. 4 + 5 + ## Getting Asset URL 6 + 7 + ### `get_asset_url` 8 + 9 + Get the asset URL for a given asset ID. 10 + 11 + ```ocaml 12 + val get_asset_url : 13 + string -> 14 + string -> 15 + string 16 + ``` 17 + 18 + #### Parameters 19 + 20 + - `base_url`: Base URL of the Karakeep instance 21 + - `asset_id`: ID of the asset 22 + 23 + #### Example 24 + 25 + ```ocaml 26 + let asset_url = 27 + let base_url = "https://hoard.recoil.org" in 28 + let asset_id = "asset123" in 29 + Karakeep.get_asset_url base_url asset_id 30 + ``` 31 + 32 + ## Fetching Assets 33 + 34 + ### `fetch_asset` 35 + 36 + Fetch an asset from the Karakeep server as a binary string. 37 + 38 + ```ocaml 39 + val fetch_asset : 40 + api_key:string -> 41 + string -> 42 + string -> 43 + string Lwt.t 44 + ``` 45 + 46 + #### Parameters 47 + 48 + - `api_key`: API key for authentication 49 + - `base_url`: Base URL of the Karakeep instance 50 + - `asset_id`: ID of the asset to fetch 51 + 52 + #### Example 53 + 54 + ```ocaml 55 + let fetch_asset_content = 56 + let api_key = "your_api_key" in 57 + let base_url = "https://hoard.recoil.org" in 58 + let asset_id = "asset123" in 59 + let* binary_data = Karakeep.fetch_asset ~api_key base_url asset_id in 60 + (* Do something with the binary data *) 61 + Lwt.return_unit 62 + ```
+143
doc/bookmarks.md
··· 1 + # Bookmark Operations 2 + 3 + The Karakeep library provides several functions for working with bookmarks. 4 + 5 + ## Fetching Bookmarks 6 + 7 + ### `fetch_bookmarks` 8 + 9 + Fetch bookmarks from a Karakeep instance with pagination support. 10 + 11 + ```ocaml 12 + val fetch_bookmarks : 13 + api_key:string -> 14 + ?limit:int -> 15 + ?offset:int -> 16 + ?cursor:string -> 17 + ?include_content:bool -> 18 + ?filter_tags:string list -> 19 + string -> 20 + bookmark_response Lwt.t 21 + ``` 22 + 23 + #### Parameters 24 + 25 + - `api_key`: API key for authentication 26 + - `limit`: Number of bookmarks to fetch per page (default: 50) 27 + - `offset`: Starting index for pagination (0-based) (default: 0) 28 + - `cursor`: Optional pagination cursor for cursor-based pagination (overrides offset when provided) 29 + - `include_content`: Whether to include full content (default: true) 30 + - `filter_tags`: Optional list of tags to filter by 31 + - `base_url`: Base URL of the Karakeep instance 32 + 33 + #### Example 34 + 35 + ```ocaml 36 + let fetch_recent = 37 + let api_key = "your_api_key" in 38 + let base_url = "https://hoard.recoil.org" in 39 + Karakeep.fetch_bookmarks ~api_key ~limit:10 base_url 40 + ``` 41 + 42 + ### `fetch_all_bookmarks` 43 + 44 + Fetch all bookmarks from a Karakeep instance using pagination. 45 + 46 + ```ocaml 47 + val fetch_all_bookmarks : 48 + api_key:string -> 49 + ?page_size:int -> 50 + ?max_pages:int -> 51 + ?filter_tags:string list -> 52 + string -> 53 + bookmark list Lwt.t 54 + ``` 55 + 56 + #### Parameters 57 + 58 + - `api_key`: API key for authentication 59 + - `page_size`: Number of bookmarks to fetch per page (default: 50) 60 + - `max_pages`: Maximum number of pages to fetch (None for all pages) 61 + - `filter_tags`: Optional list of tags to filter by 62 + - `base_url`: Base URL of the Karakeep instance 63 + 64 + #### Example 65 + 66 + ```ocaml 67 + let fetch_all_with_tag_ocaml = 68 + let api_key = "your_api_key" in 69 + let base_url = "https://hoard.recoil.org" in 70 + Karakeep.fetch_all_bookmarks ~api_key ~filter_tags:["ocaml"] base_url 71 + ``` 72 + 73 + ### `fetch_bookmark_details` 74 + 75 + Fetch detailed information for a single bookmark by ID. 76 + 77 + ```ocaml 78 + val fetch_bookmark_details : 79 + api_key:string -> 80 + string -> 81 + string -> 82 + bookmark Lwt.t 83 + ``` 84 + 85 + #### Parameters 86 + 87 + - `api_key`: API key for authentication 88 + - `base_url`: Base URL of the Karakeep instance 89 + - `bookmark_id`: ID of the bookmark to fetch 90 + 91 + #### Example 92 + 93 + ```ocaml 94 + let fetch_specific_bookmark = 95 + let api_key = "your_api_key" in 96 + let base_url = "https://hoard.recoil.org" in 97 + let bookmark_id = "123456" in 98 + Karakeep.fetch_bookmark_details ~api_key base_url bookmark_id 99 + ``` 100 + 101 + ## Creating Bookmarks 102 + 103 + ### `create_bookmark` 104 + 105 + Create a new bookmark in Karakeep with optional tags. 106 + 107 + ```ocaml 108 + val create_bookmark : 109 + api_key:string -> 110 + url:string -> 111 + ?title:string -> 112 + ?note:string -> 113 + ?tags:string list -> 114 + ?favourited:bool -> 115 + ?archived:bool -> 116 + string -> 117 + bookmark Lwt.t 118 + ``` 119 + 120 + #### Parameters 121 + 122 + - `api_key`: API key for authentication 123 + - `url`: The URL to bookmark 124 + - `title`: Optional title for the bookmark 125 + - `note`: Optional note to add to the bookmark 126 + - `tags`: Optional list of tag names to add to the bookmark 127 + - `favourited`: Whether the bookmark should be marked as favourite (default: false) 128 + - `archived`: Whether the bookmark should be archived (default: false) 129 + - `base_url`: Base URL of the Karakeep instance 130 + 131 + #### Example 132 + 133 + ```ocaml 134 + let create_new_bookmark = 135 + let api_key = "your_api_key" in 136 + let base_url = "https://hoard.recoil.org" in 137 + Karakeep.create_bookmark 138 + ~api_key 139 + ~url:"https://ocaml.org" 140 + ~title:"OCaml Programming Language" 141 + ~tags:["programming"; "ocaml"; "functional"] 142 + base_url 143 + ```
+20
doc/index.md
··· 1 + # Karakeep OCaml Client 2 + 3 + This library provides OCaml bindings to the Karakeep API. 4 + 5 + ## Getting Started 6 + 7 + ```ocaml 8 + (* Setup the Karakeep client *) 9 + let api_key = "your_api_key" 10 + let base_url = "https://hoard.recoil.org" 11 + 12 + (* Fetch bookmarks *) 13 + let bookmarks = Karakeep.fetch_all_bookmarks ~api_key base_url 14 + ``` 15 + 16 + ## API Documentation 17 + 18 + - [Types](types.md) 19 + - [Bookmarks](bookmarks.md) 20 + - [Assets](assets.md)
+39
doc/types.md
··· 1 + # Karakeep Types 2 + 3 + This document describes the main types used in the Karakeep OCaml client. 4 + 5 + ## Bookmark 6 + 7 + Represents a single bookmark in Karakeep. 8 + 9 + ```ocaml 10 + type bookmark = { 11 + id: string; 12 + title: string option; 13 + url: string; 14 + note: string option; 15 + created_at: Ptime.t; 16 + updated_at: Ptime.t option; 17 + favourited: bool; 18 + archived: bool; 19 + tags: string list; 20 + tagging_status: string option; 21 + summary: string option; 22 + content: (string * string) list; 23 + assets: (string * string) list; 24 + } 25 + ``` 26 + 27 + ## Bookmark Response 28 + 29 + Represents a paginated response from the Karakeep API containing bookmarks. 30 + 31 + ```ocaml 32 + type bookmark_response = { 33 + total: int; 34 + data: bookmark list; 35 + next_cursor: string option; 36 + } 37 + ``` 38 + 39 + See [Bookmarks](bookmarks.md) for more information on how to use these types.
+2 -2
dune-project
··· 9 9 10 10 (package 11 11 (name karakeep) 12 - (synopsis "Karakeep API client for Bushel") 13 - (description "Karakeep API client to retrieve bookmarks from Karakeep instances") 12 + (synopsis "Karakeep API client") 13 + (description "OCaml client library for the Karakeep bookmark service API") 14 14 (depends 15 15 (ocaml (>= "5.2.0")) 16 16 ezjsonm
+2 -3
karakeep.opam
··· 1 1 # This file is generated by dune, edit dune-project instead 2 2 opam-version: "2.0" 3 - synopsis: "Karakeep API client for Bushel" 4 - description: 5 - "Karakeep API client to retrieve bookmarks from Karakeep instances" 3 + synopsis: "Karakeep API client" 4 + description: "OCaml client library for the Karakeep bookmark service API" 6 5 maintainer: ["anil@recoil.org"] 7 6 authors: ["Anil Madhavapeddy"] 8 7 license: "ISC"
+1 -1
lib/dune
··· 1 1 (library 2 - (name karakeep) 2 + (name karakeep) 3 3 (public_name karakeep) 4 4 (libraries lwt cohttp cohttp-lwt-unix ezjsonm fmt ptime))
+298 -312
lib/karakeep.ml
··· 1 1 (** Karakeep API client implementation *) 2 2 3 3 open Lwt.Infix 4 - 5 4 module J = Ezjsonm 6 5 7 - (** Type representing a Karakeep bookmark *) 8 6 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; 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; 22 20 } 21 + (** Type representing a Karakeep bookmark *) 23 22 24 - (** Type for Karakeep API response containing bookmarks *) 25 23 type bookmark_response = { 26 - total: int; 27 - data: bookmark list; 28 - next_cursor: string option; 24 + total : int; 25 + data : bookmark list; 26 + next_cursor : string option; 29 27 } 28 + (** Type for Karakeep API response containing bookmarks *) 30 29 31 30 (** Parse a date string to Ptime.t, defaulting to epoch if invalid *) 32 31 let parse_date str = 33 32 match Ptime.of_rfc3339 str with 34 33 | Ok (date, _, _) -> date 35 - | Error _ -> 34 + | Error _ -> ( 36 35 Fmt.epr "Warning: could not parse date '%s'\n" str; 37 36 (* Default to epoch time *) 38 37 let span_opt = Ptime.Span.of_d_ps (0, 0L) in 39 38 match span_opt with 40 39 | 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" 40 + | Some span -> ( 41 + match Ptime.of_span span with 42 + | Some t -> t 43 + | None -> failwith "Internal error: couldn't create epoch time")) 45 44 46 - (** Extract a string field from JSON, returns None if not present or not a string *) 45 + (** Extract a string field from JSON, returns None if not present or not a 46 + string *) 47 47 let get_string_opt json path = 48 - try Some (J.find json path |> J.get_string) 49 - with _ -> None 48 + try Some (J.find json path |> J.get_string) with _ -> None 50 49 51 50 (** Extract a string list field from JSON, returns empty list if not present *) 52 51 let get_string_list json path = 53 - try 52 + try 54 53 let items_json = J.find json path in 55 - J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json 54 + J.get_list (fun tag -> J.find tag [ "name" ] |> J.get_string) items_json 56 55 with _ -> [] 57 56 58 57 (** Extract a boolean field from JSON, with default value *) 59 58 let get_bool_def json path default = 60 - try J.find json path |> J.get_bool 61 - with _ -> default 59 + try J.find json path |> J.get_bool with _ -> default 62 60 63 61 (** Parse a single bookmark from Karakeep JSON *) 64 62 let parse_bookmark json = 65 63 (* Remove debug prints for production *) 66 64 (* 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 -> 65 + let id = 66 + try J.find json [ "id" ] |> J.get_string 67 + with e -> 71 68 Printf.eprintf "Error parsing bookmark ID: %s\n" (Printexc.to_string e); 72 69 Printf.eprintf "JSON: %s\n" (J.value_to_string json); 73 70 failwith "Unable to parse bookmark ID" 74 71 in 75 - 72 + 76 73 (* Title can be null *) 77 - let title = 78 - try Some (J.find json ["title"] |> J.get_string) 79 - with _ -> None 74 + let title = 75 + try Some (J.find json [ "title" ] |> J.get_string) with _ -> None 80 76 in 81 77 (* Remove debug prints for production *) 82 78 (* Printf.eprintf "%s -> %s\n%!" id (match title with None -> "???" | Some v -> v); *) 83 79 (* 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" 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"))) 104 105 in 105 - 106 - let note = get_string_opt json ["note"] in 107 - 106 + 107 + let note = get_string_opt json [ "note" ] in 108 + 108 109 (* 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" 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") 114 115 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 116 + 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) 121 122 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 - 123 + 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 127 + 127 128 (* Extract additional metadata *) 128 - let tagging_status = get_string_opt json ["taggingStatus"] in 129 - let summary = get_string_opt json ["summary"] in 130 - 129 + let tagging_status = get_string_opt json [ "taggingStatus" ] in 130 + let summary = get_string_opt json [ "summary" ] in 131 + 131 132 (* Extract content details *) 132 133 let content = 133 134 try 134 - let content_json = J.find json ["content"] in 135 + let content_json = J.find json [ "content" ] in 135 136 let rec extract_fields acc = function 136 137 | [] -> acc 137 138 | (k, v) :: rest -> 138 - let value = match v with 139 + let value = 140 + match v with 139 141 | `String s -> s 140 142 | `Bool b -> string_of_bool b 141 143 | `Float f -> string_of_float f ··· 144 146 in 145 147 extract_fields ((k, value) :: acc) rest 146 148 in 147 - match content_json with 148 - | `O fields -> extract_fields [] fields 149 - | _ -> [] 149 + match content_json with `O fields -> extract_fields [] fields | _ -> [] 150 150 with _ -> [] 151 151 in 152 - 152 + 153 153 (* Extract assets *) 154 154 let assets = 155 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 156 + 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 165 166 with _ -> [] 166 167 in 167 - 168 - { id; title; url; note; created_at; updated_at; favourited; archived; tags; 169 - tagging_status; summary; content; assets } 168 + 169 + { 170 + id; 171 + title; 172 + url; 173 + note; 174 + created_at; 175 + updated_at; 176 + favourited; 177 + archived; 178 + tags; 179 + tagging_status; 180 + summary; 181 + content; 182 + assets; 183 + } 170 184 171 185 (** Parse a Karakeep bookmark response *) 172 186 let parse_bookmark_response json = 173 187 (* The response format is different based on endpoint, need to handle both structures *) 174 188 (* Print the whole JSON structure for debugging *) 175 189 Printf.eprintf "Full response JSON: %s\n" (J.value_to_string json); 176 - 177 - try 190 + 191 + try 178 192 (* 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 193 + let total = J.find json [ "total" ] |> J.get_int in 194 + let bookmarks_json = J.find json [ "data" ] in 181 195 Printf.eprintf "Found bookmarks in data array\n"; 182 196 let data = J.get_list parse_bookmark bookmarks_json in 183 - 197 + 184 198 (* Try to extract nextCursor if available *) 185 - let next_cursor = 186 - try Some (J.find json ["nextCursor"] |> J.get_string) 187 - with _ -> None 199 + let next_cursor = 200 + try Some (J.find json [ "nextCursor" ] |> J.get_string) with _ -> None 188 201 in 189 - 202 + 190 203 { total; data; next_cursor } 191 - with e1 -> 204 + with e1 -> ( 192 205 Printf.eprintf "First format parse error: %s\n" (Printexc.to_string e1); 193 206 try 194 207 (* Format with bookmarks array *) 195 - let bookmarks_json = J.find json ["bookmarks"] in 208 + let bookmarks_json = J.find json [ "bookmarks" ] in 196 209 Printf.eprintf "Found bookmarks in bookmarks array\n"; 197 - let data = 198 - try J.get_list parse_bookmark bookmarks_json 210 + let data = 211 + try J.get_list parse_bookmark bookmarks_json 199 212 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)) 213 + Printf.eprintf "Error parsing bookmarks array: %s\n" 214 + (Printexc.to_string e); 215 + Printf.eprintf "First bookmark sample: %s\n" 216 + (try 217 + J.value_to_string 218 + (List.hd (J.get_list (fun x -> x) bookmarks_json)) 203 219 with _ -> "Could not extract sample"); 204 220 [] 205 221 in 206 - 222 + 207 223 (* Try to extract nextCursor if available *) 208 - let next_cursor = 209 - try Some (J.find json ["nextCursor"] |> J.get_string) 210 - with _ -> None 224 + let next_cursor = 225 + try Some (J.find json [ "nextCursor" ] |> J.get_string) with _ -> None 211 226 in 212 - 227 + 213 228 { total = List.length data; data; next_cursor } 214 - with e2 -> 229 + with e2 -> ( 215 230 Printf.eprintf "Second format parse error: %s\n" (Printexc.to_string e2); 216 231 try 217 232 (* 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 233 + let error = J.find json [ "error" ] |> J.get_string in 234 + let message = 235 + try J.find json [ "message" ] |> J.get_string 221 236 with _ -> "Unknown error" 222 237 in 223 238 Printf.eprintf "API Error: %s - %s\n" error message; 224 239 { total = 0; data = []; next_cursor = None } 225 - with _ -> 240 + with _ -> ( 226 241 try 227 242 (* Alternate format without total (for endpoints like /tags/<id>/bookmarks) *) 228 243 Printf.eprintf "Trying alternate array format\n"; 229 - 244 + 230 245 (* Debug the structure to identify the format *) 231 - Printf.eprintf "JSON structure keys: %s\n" 246 + Printf.eprintf "JSON structure keys: %s\n" 232 247 (match json with 233 - | `O fields -> 234 - String.concat ", " (List.map (fun (k, _) -> k) fields) 235 - | _ -> "not an object"); 236 - 248 + | `O fields -> 249 + String.concat ", " (List.map (fun (k, _) -> k) fields) 250 + | _ -> "not an object"); 251 + 237 252 (* Check if it has a nextCursor but bookmarks are nested differently *) 238 - if J.find_opt json ["nextCursor"] <> None then begin 253 + if J.find_opt json [ "nextCursor" ] <> None then ( 239 254 Printf.eprintf "Found nextCursor, checking alternate structures\n"; 240 - 255 + 241 256 (* Try different bookmark container paths *) 242 257 let bookmarks_json = 243 - try Some (J.find json ["data"]) 244 - with _ -> None 258 + try Some (J.find json [ "data" ]) with _ -> None 245 259 in 246 - 260 + 247 261 match bookmarks_json with 248 - | Some json_array -> 262 + | Some json_array -> ( 249 263 Printf.eprintf "Found bookmarks in data field\n"; 250 - begin try 264 + try 251 265 let data = J.get_list parse_bookmark json_array in 252 - let next_cursor = 253 - try Some (J.find json ["nextCursor"] |> J.get_string) 266 + let next_cursor = 267 + try Some (J.find json [ "nextCursor" ] |> J.get_string) 254 268 with _ -> None 255 269 in 256 270 { total = List.length data; data; next_cursor } 257 271 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 272 + Printf.eprintf "Error parsing bookmarks from data: %s\n" 273 + (Printexc.to_string e); 274 + { total = 0; data = []; next_cursor = None }) 261 275 | None -> 262 276 Printf.eprintf "No bookmarks found in alternate structure\n"; 263 - { total = 0; data = []; next_cursor = None } 264 - end 265 - else begin 277 + { total = 0; data = []; next_cursor = None }) 278 + else 266 279 (* Check if it's an array at root level *) 267 280 match json with 268 281 | `A _ -> 269 - let data = 282 + let data = 270 283 try J.get_list parse_bookmark json 271 284 with e -> 272 - Printf.eprintf "Error parsing root array: %s\n" (Printexc.to_string e); 285 + Printf.eprintf "Error parsing root array: %s\n" 286 + (Printexc.to_string e); 273 287 [] 274 288 in 275 289 { total = List.length data; data; next_cursor = None } 276 - | _ -> 290 + | _ -> 277 291 Printf.eprintf "Not an array at root level\n"; 278 292 { total = 0; data = []; next_cursor = None } 279 - end 280 293 with e3 -> 281 - Printf.eprintf "Third format parse error: %s\n" (Printexc.to_string e3); 282 - { total = 0; data = []; next_cursor = None } 294 + Printf.eprintf "Third format parse error: %s\n" 295 + (Printexc.to_string e3); 296 + { total = 0; data = []; next_cursor = None }))) 283 297 284 298 (** Helper function to consume and return response body data *) 285 299 let consume_body body = 286 - Cohttp_lwt.Body.to_string body >>= fun _ -> 287 - Lwt.return_unit 300 + Cohttp_lwt.Body.to_string body >>= fun _ -> Lwt.return_unit 288 301 289 302 (** 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 = 303 + let fetch_bookmarks ~api_key ?(limit = 50) ?(offset = 0) ?cursor 304 + ?(include_content = true) ?filter_tags base_url = 291 305 let open Cohttp_lwt_unix in 292 - 293 306 (* 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 - 307 + let url_base = 308 + Printf.sprintf "%s/api/v1/bookmarks?limit=%d&includeContent=%b" base_url 309 + limit include_content 310 + in 311 + 297 312 (* Add pagination parameter - either cursor or offset *) 298 - let url = 313 + let url = 299 314 match cursor with 300 - | Some cursor_value -> 301 - url_base ^ "&cursor=" ^ cursor_value 302 - | None -> 303 - url_base ^ "&offset=" ^ string_of_int offset 315 + | Some cursor_value -> url_base ^ "&cursor=" ^ cursor_value 316 + | None -> url_base ^ "&offset=" ^ string_of_int offset 304 317 in 305 - 318 + 306 319 (* Add tags filter if provided *) 307 - let url = match filter_tags with 308 - | Some tags when tags <> [] -> 320 + let url = 321 + match filter_tags with 322 + | Some tags when tags <> [] -> 309 323 (* 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 324 + let encoded_tags = 325 + List.map (fun tag -> Uri.pct_encode ~component:`Query_key tag) tags 314 326 in 315 327 let tags_param = String.concat "," encoded_tags in 316 328 Printf.eprintf "Adding tags filter: %s\n" tags_param; 317 329 url ^ "&tags=" ^ tags_param 318 330 | _ -> url 319 331 in 320 - 332 + 321 333 (* 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 - 334 + let headers = 335 + Cohttp.Header.init () |> fun h -> 336 + Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 337 + in 338 + 325 339 Printf.eprintf "Fetching bookmarks from: %s\n" url; 326 340 327 341 (* Make the request *) 328 342 Lwt.catch 329 343 (fun () -> 330 344 Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 331 - if resp.status = `OK then 345 + if resp.status = `OK then ( 332 346 Cohttp_lwt.Body.to_string body >>= fun body_str -> 333 - Printf.eprintf "Received %d bytes of response data\n" (String.length body_str); 334 - 347 + Printf.eprintf "Received %d bytes of response data\n" 348 + (String.length body_str); 349 + 335 350 Lwt.catch 336 351 (fun () -> 337 352 let json = J.from_string body_str in 338 - Lwt.return (parse_bookmark_response json) 339 - ) 353 + Lwt.return (parse_bookmark_response json)) 340 354 (fun e -> 341 355 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 - ) 356 + Printf.eprintf "Response body (first 200 chars): %s\n" 357 + (if String.length body_str > 200 then 358 + String.sub body_str 0 200 ^ "..." 359 + else body_str); 360 + Lwt.fail e)) 346 361 else 347 362 let status_code = Cohttp.Code.code_of_status resp.status in 348 363 consume_body body >>= fun _ -> 349 364 Printf.eprintf "HTTP error %d\n" status_code; 350 - Lwt.fail_with (Fmt.str "HTTP error: %d" status_code) 351 - ) 365 + Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)) 352 366 (fun e -> 353 367 Printf.eprintf "Network error: %s\n" (Printexc.to_string e); 354 - Lwt.fail e 355 - ) 368 + Lwt.fail e) 356 369 357 370 (** 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 = 371 + let fetch_all_bookmarks ~api_key ?(page_size = 50) ?max_pages ?filter_tags 372 + base_url = 359 373 let rec fetch_pages page_num cursor acc _total_count = 360 374 (* Use cursor if available, otherwise use offset-based pagination *) 361 375 (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) 376 + | Some cursor_str -> 377 + fetch_bookmarks ~api_key ~limit:page_size ~cursor:cursor_str 378 + ?filter_tags base_url 379 + | None -> 380 + fetch_bookmarks ~api_key ~limit:page_size ~offset:(page_num * page_size) 381 + ?filter_tags base_url) 364 382 >>= fun response -> 365 - 366 383 let all_bookmarks = acc @ response.data in 367 - 384 + 368 385 (* Determine if we need to fetch more pages *) 369 - let more_available = 386 + let more_available = 370 387 match response.next_cursor with 371 - | Some _ -> true (* We have a cursor, so there are more results *) 372 - | None -> 388 + | Some _ -> true (* We have a cursor, so there are more results *) 389 + | None -> 373 390 (* Fall back to offset-based check *) 374 - let fetched_count = (page_num * page_size) + List.length response.data in 391 + let fetched_count = 392 + (page_num * page_size) + List.length response.data 393 + in 375 394 fetched_count < response.total 376 395 in 377 - 378 - let under_max_pages = match max_pages with 379 - | None -> true 380 - | Some max -> page_num + 1 < max 396 + 397 + let under_max_pages = 398 + match max_pages with None -> true | Some max -> page_num + 1 < max 381 399 in 382 - 400 + 383 401 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 402 + fetch_pages (page_num + 1) response.next_cursor all_bookmarks 403 + response.total 404 + else Lwt.return all_bookmarks 387 405 in 388 406 fetch_pages 0 None [] 0 389 407 ··· 391 409 let fetch_bookmark_details ~api_key base_url bookmark_id = 392 410 let open Cohttp_lwt_unix in 393 411 let url = Printf.sprintf "%s/api/v1/bookmarks/%s" base_url bookmark_id in 394 - 412 + 395 413 (* 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 - 414 + let headers = 415 + Cohttp.Header.init () |> fun h -> 416 + Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 417 + in 418 + 399 419 Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 400 420 if resp.status = `OK then 401 421 Cohttp_lwt.Body.to_string body >>= fun body_str -> ··· 413 433 (** Fetch an asset from the Karakeep server as a binary string *) 414 434 let fetch_asset ~api_key base_url asset_id = 415 435 let open Cohttp_lwt_unix in 416 - 417 436 let url = get_asset_url base_url asset_id in 418 - 437 + 419 438 (* 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 - 439 + let headers = 440 + Cohttp.Header.init () |> fun h -> 441 + Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) 442 + in 443 + 423 444 Client.get ~headers (Uri.of_string url) >>= fun (resp, body) -> 424 - if resp.status = `OK then 425 - Cohttp_lwt.Body.to_string body 445 + if resp.status = `OK then Cohttp_lwt.Body.to_string body 426 446 else 427 447 let status_code = Cohttp.Code.code_of_status resp.status in 428 448 consume_body body >>= fun () -> 429 449 Lwt.fail_with (Fmt.str "Asset fetch error: %d" status_code) 430 450 431 451 (** 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 = 452 + let create_bookmark ~api_key ~url ?title ?note ?tags ?(favourited = false) 453 + ?(archived = false) base_url = 433 454 let open Cohttp_lwt_unix in 434 - 435 455 (* 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 - 456 + let body_obj = 457 + [ 458 + ("type", `String "link"); 459 + ("url", `String url); 460 + ("favourited", `Bool favourited); 461 + ("archived", `Bool archived); 462 + ] 463 + in 464 + 443 465 (* Add optional fields *) 444 - let body_obj = match title with 466 + let body_obj = 467 + match title with 445 468 | Some title_str -> ("title", `String title_str) :: body_obj 446 469 | None -> body_obj 447 470 in 448 - 449 - let body_obj = match note with 471 + 472 + let body_obj = 473 + match note with 450 474 | Some note_str -> ("note", `String note_str) :: body_obj 451 475 | None -> body_obj 452 476 in 453 - 477 + 454 478 (* Convert to JSON *) 455 479 let body_json = `O body_obj in 456 480 let body_str = J.to_string body_json in 457 - 481 + 458 482 (* 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" 483 + let headers = 484 + Cohttp.Header.init () |> fun h -> 485 + Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) |> fun h -> 486 + Cohttp.Header.add h "Content-Type" "application/json" 462 487 in 463 - 488 + 464 489 (* Helper function to ensure we consume all response body data *) 465 490 let consume_body body = 466 - Cohttp_lwt.Body.to_string body >>= fun _ -> 467 - Lwt.return_unit 491 + Cohttp_lwt.Body.to_string body >>= fun _ -> Lwt.return_unit 468 492 in 469 - 493 + 470 494 (* Create the bookmark *) 471 495 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 - 496 + Client.post ~headers 497 + ~body:(Cohttp_lwt.Body.of_string body_str) 498 + (Uri.of_string url_endpoint) 499 + >>= fun (resp, body) -> 474 500 if resp.status = `Created || resp.status = `OK then 475 501 Cohttp_lwt.Body.to_string body >>= fun body_str -> 476 502 let json = J.from_string body_str in 477 503 let bookmark = parse_bookmark json in 478 - 504 + 479 505 (* 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) 506 + match tags with 507 + | Some tag_list when tag_list <> [] -> 508 + (* Prepare the tags request body *) 509 + let tag_objects = 510 + List.map 511 + (fun tag_name -> `O [ ("tagName", `String tag_name) ]) 512 + tag_list 513 + in 514 + 515 + let tags_body = `O [ ("tags", `A tag_objects) ] in 516 + let tags_body_str = J.to_string tags_body in 517 + 518 + (* Add tags to the bookmark *) 519 + let tags_url = 520 + Printf.sprintf "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id 521 + in 522 + Client.post ~headers 523 + ~body:(Cohttp_lwt.Body.of_string tags_body_str) 524 + (Uri.of_string tags_url) 525 + >>= fun (resp, body) -> 526 + (* Always consume the response body *) 527 + consume_body body >>= fun () -> 528 + if resp.status = `OK then 529 + (* Fetch the bookmark again to get updated tags *) 530 + fetch_bookmark_details ~api_key base_url bookmark.id 531 + else 532 + (* Return the bookmark without tags if tag addition failed *) 533 + Lwt.return bookmark 534 + | _ -> Lwt.return bookmark 504 535 else 505 536 let status_code = Cohttp.Code.code_of_status resp.status in 506 537 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 } 538 + Lwt.fail_with 539 + (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" 540 + status_code error_body)
+176 -72
lib/karakeep.mli
··· 1 - (** Karakeep API client interface *) 1 + (** Karakeep API client interface 2 + 3 + This module provides a client for interacting with the Karakeep bookmark 4 + service API. It allows fetching, creating, and managing bookmarks stored in 5 + a Karakeep instance. 6 + 7 + {2 Basic Usage} 8 + 9 + {[ 10 + (* Setup the client *) 11 + let api_key = "your_api_key" 12 + let base_url = "https://hoard.recoil.org" 13 + 14 + (* Fetch recent bookmarks *) 15 + let recent_bookmarks = 16 + Karakeep.fetch_bookmarks ~api_key ~limit:10 base_url 17 + 18 + (* Fetch all bookmarks (handles pagination automatically) *) 19 + let all_bookmarks = Karakeep.fetch_all_bookmarks ~api_key base_url 20 + 21 + (* Get a specific bookmark by ID *) 22 + let specific_bookmark = 23 + Karakeep.fetch_bookmark_details ~api_key base_url "bookmark_id" 24 + 25 + (* Create a new bookmark *) 26 + let new_bookmark = 27 + Karakeep.create_bookmark ~api_key ~url:"https://ocaml.org" 28 + ~title:"OCaml Programming Language" base_url 29 + ]} 2 30 3 - (** Type representing a Karakeep bookmark *) 31 + {2 Pagination} 32 + 33 + The Karakeep API uses pagination to return large result sets. There are two 34 + ways to handle pagination: 35 + 36 + 1. Manually using {!fetch_bookmarks} with offset/cursor parameters for 37 + fine-grained control 2. Automatically using {!fetch_all_bookmarks} which 38 + handles pagination for you 39 + 40 + {2 API Key} 41 + 42 + All operations require an API key that can be obtained from your Karakeep 43 + instance. *) 44 + 4 45 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; 46 + id : string; (** Unique identifier for the bookmark *) 47 + title : string option; (** Optional title of the bookmarked page *) 48 + url : string; (** URL of the bookmarked page *) 49 + note : string option; (** Optional user note associated with the bookmark *) 50 + created_at : Ptime.t; (** Timestamp when the bookmark was created *) 51 + updated_at : Ptime.t option; (** Optional timestamp of the last update *) 52 + favourited : bool; (** Whether the bookmark is marked as a favorite *) 53 + archived : bool; (** Whether the bookmark is archived *) 54 + tags : string list; (** List of tags associated with the bookmark *) 55 + tagging_status : string option; (** Optional status of automatic tagging *) 56 + summary : string option; (** Optional AI-generated summary *) 57 + content : (string * string) list; 58 + (** List of key-value pairs with content metadata *) 59 + assets : (string * string) list; 60 + (** List of (id, type) pairs for associated assets *) 18 61 } 62 + (** Type representing a Karakeep bookmark. Each bookmark contains metadata and 63 + content information from the Karakeep service. *) 19 64 20 - (** Type for Karakeep API response containing bookmarks *) 21 65 type bookmark_response = { 22 - total: int; 23 - data: bookmark list; 24 - next_cursor: string option; 66 + total : int; (** Total number of bookmarks available *) 67 + data : bookmark list; (** List of bookmarks in the current page *) 68 + next_cursor : string option; (** Optional cursor for fetching the next page *) 25 69 } 70 + (** Type for Karakeep API response containing bookmarks. This represents a page 71 + of results from the Karakeep API. *) 26 72 27 - (** Parse a single bookmark from Karakeep JSON *) 28 73 val parse_bookmark : Ezjsonm.value -> bookmark 74 + (** [parse_bookmark json] parses a single bookmark from Karakeep JSON. 29 75 30 - (** Parse a Karakeep bookmark response *) 76 + This is an internal function used to parse bookmark data from the Karakeep 77 + API. Most users will not need to call this directly. 78 + 79 + @param json Ezjsonm value representing a bookmark 80 + @return The parsed bookmark record *) 81 + 31 82 val parse_bookmark_response : Ezjsonm.value -> bookmark_response 83 + (** [parse_bookmark_response json] parses a Karakeep API response containing 84 + bookmarks. 32 85 33 - (** Fetch bookmarks from a Karakeep instance with pagination support 86 + This is an internal function used to parse response data from the Karakeep 87 + API. Most users will not need to call this directly. 88 + 89 + @param json Ezjsonm value representing a bookmark response 90 + @return The parsed bookmark_response record *) 91 + 92 + val fetch_bookmarks : 93 + api_key:string -> 94 + ?limit:int -> 95 + ?offset:int -> 96 + ?cursor:string -> 97 + ?include_content:bool -> 98 + ?filter_tags:string list -> 99 + string -> 100 + bookmark_response Lwt.t 101 + (** [fetch_bookmarks ~api_key ?limit ?offset ?cursor ?include_content 102 + ?filter_tags base_url] fetches a single page of bookmarks from a Karakeep 103 + instance. 104 + 105 + This function provides fine-grained control over pagination with offset or 106 + cursor-based pagination. It returns a {!bookmark_response} that includes 107 + pagination information like total count and next cursor. 108 + 109 + Use this function when you need to: 110 + - Control pagination manually 111 + - Process results one page at a time 112 + - Access pagination metadata 113 + 34 114 @param api_key API key for authentication 35 115 @param limit Number of bookmarks to fetch per page (default: 50) 36 116 @param offset Starting index for pagination (0-based) (default: 0) 37 - @param cursor Optional pagination cursor for cursor-based pagination (overrides offset when provided) 117 + @param cursor 118 + Optional pagination cursor for cursor-based pagination (overrides offset 119 + when provided) 38 120 @param include_content Whether to include full content (default: true) 39 121 @param filter_tags Optional list of tags to filter by 40 122 @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 -> 123 + @return 124 + A Lwt promise with the bookmark response containing a single page of 125 + results *) 126 + 127 + val fetch_all_bookmarks : 128 + api_key:string -> 129 + ?page_size:int -> 130 + ?max_pages:int -> 48 131 ?filter_tags:string list -> 49 - string -> 50 - bookmark_response Lwt.t 132 + string -> 133 + bookmark list Lwt.t 134 + (** [fetch_all_bookmarks ~api_key ?page_size ?max_pages ?filter_tags base_url] 135 + fetches all bookmarks from a Karakeep instance, automatically handling 136 + pagination. 137 + 138 + This function handles pagination internally and returns a flattened list of 139 + all bookmarks. It will continue making API requests until all pages have 140 + been fetched or the max_pages limit is reached. 51 141 52 - (** Fetch all bookmarks from a Karakeep instance using pagination 142 + Use this function when you: 143 + - Want to retrieve all bookmarks with minimal code 144 + - Don't need pagination metadata 145 + - Are only interested in the bookmarks themselves 146 + 53 147 @param api_key API key for authentication 54 148 @param page_size Number of bookmarks to fetch per page (default: 50) 55 149 @param max_pages Maximum number of pages to fetch (None for all pages) 56 150 @param filter_tags Optional list of tags to filter by 57 151 @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 152 + @return A Lwt promise with all bookmarks combined into a single list *) 153 + 154 + val fetch_bookmark_details : 155 + api_key:string -> string -> string -> bookmark Lwt.t 156 + (** [fetch_bookmark_details ~api_key base_url bookmark_id] fetches detailed 157 + information for a single bookmark by ID. 158 + 159 + This function retrieves complete details for a specific bookmark identified 160 + by its ID. It provides a convenient way to access a single bookmark's 161 + detailed information. 66 162 67 - (** Fetch detailed information for a single bookmark by ID 68 163 @param api_key API key for authentication 69 164 @param base_url Base URL of the Karakeep instance 70 165 @param bookmark_id ID of the bookmark to fetch 71 166 @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 167 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 168 + val fetch_asset : api_key:string -> string -> string -> string Lwt.t 169 + (** [fetch_asset ~api_key base_url asset_id] fetches an asset from the Karakeep 170 + server as a binary string. 81 171 82 - (** Fetch an asset from the Karakeep server as a binary string 172 + Assets can include images, PDFs, or other files attached to bookmarks. This 173 + function retrieves the binary content of an asset by its ID. 174 + 83 175 @param api_key API key for authentication 84 176 @param base_url Base URL of the Karakeep instance 85 177 @param asset_id ID of the asset to fetch 86 178 @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 179 + 180 + val get_asset_url : string -> string -> string 181 + (** [get_asset_url base_url asset_id] returns the full URL for a given asset ID. 182 + 183 + This is a pure function that constructs the URL for an asset without making 184 + any API calls. The returned URL can be used to access the asset directly, 185 + assuming proper authentication. 92 186 93 - (** Get the asset URL for a given asset ID 94 187 @param base_url Base URL of the Karakeep instance 95 188 @param asset_id ID of the asset 96 189 @return The full URL to the asset *) 97 - val get_asset_url : 98 - string -> 99 - string -> 100 - string 101 190 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 191 val create_bookmark : 113 192 api_key:string -> 114 193 url:string -> ··· 118 197 ?favourited:bool -> 119 198 ?archived:bool -> 120 199 string -> 121 - bookmark Lwt.t 200 + bookmark Lwt.t 201 + (** [create_bookmark ~api_key ~url ?title ?note ?tags ?favourited ?archived 202 + base_url] creates a new bookmark in Karakeep. 203 + 204 + This function adds a new bookmark to the Karakeep instance for the given 205 + URL. It supports setting various bookmark attributes and adding tags. 206 + 207 + Example: 208 + {[ 209 + let new_bookmark = 210 + create_bookmark ~api_key ~url:"https://ocaml.org" 211 + ~title:"OCaml Programming Language" 212 + ~tags:[ "programming"; "language"; "functional" ] 213 + "https://hoard.recoil.org" 214 + ]} 215 + 216 + @param api_key API key for authentication 217 + @param url The URL to bookmark 218 + @param title Optional title for the bookmark 219 + @param note Optional note to add to the bookmark 220 + @param tags Optional list of tag names to add to the bookmark 221 + @param favourited 222 + Whether the bookmark should be marked as favourite (default: false) 223 + @param archived Whether the bookmark should be archived (default: false) 224 + @param base_url Base URL of the Karakeep instance 225 + @return A Lwt promise with the created bookmark *)
+16
run_tests.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + echo "Building project..." 5 + opam exec -- dune build --display=quiet 6 + 7 + echo -e "\n=== Running fetch bookmarks test ===\n" 8 + opam exec -- dune exec test/test.exe 9 + 10 + echo -e "\n=== Running create bookmark test ===\n" 11 + opam exec -- dune exec test/create_test.exe 12 + 13 + echo -e "\n=== Running asset test ===\n" 14 + opam exec -- dune exec test/asset_test.exe 15 + 16 + echo -e "\nAll tests passed!"
+62
test/asset_test.ml
··· 1 + open Lwt.Infix 2 + open Karakeep 3 + 4 + let () = 5 + (* Load API key from file *) 6 + let api_key = 7 + try 8 + let ic = open_in ".karakeep-api" in 9 + let key = input_line ic in 10 + close_in ic; 11 + key 12 + with _ -> 13 + Printf.eprintf "Error: Could not load API key from .karakeep-api file\n"; 14 + exit 1 15 + in 16 + 17 + (* Test configuration *) 18 + let base_url = "https://hoard.recoil.org" in 19 + 20 + (* Test asset URL and optionally fetch asset *) 21 + let run_test () = 22 + (* First get a bookmark with assets *) 23 + Printf.printf "Fetching bookmarks with assets...\n"; 24 + 25 + fetch_bookmarks ~api_key ~limit:5 base_url >>= fun response -> 26 + (* Find a bookmark with assets *) 27 + let bookmark_with_assets = 28 + List.find_opt (fun b -> List.length b.assets > 0) response.data 29 + in 30 + 31 + match bookmark_with_assets with 32 + | None -> 33 + Printf.printf "No bookmarks with assets found in the first 5 results.\n"; 34 + Lwt.return_unit 35 + | Some bookmark -> ( 36 + (* Print assets info *) 37 + Printf.printf "Found bookmark with %d assets: %s\n" 38 + (List.length bookmark.assets) 39 + bookmark.url; 40 + 41 + List.iter 42 + (fun (asset_id, asset_type) -> 43 + Printf.printf "- Asset ID: %s, Type: %s\n" asset_id asset_type; 44 + 45 + (* Get asset URL *) 46 + let asset_url = get_asset_url base_url asset_id in 47 + Printf.printf " URL: %s\n" asset_url) 48 + bookmark.assets; 49 + 50 + (* Optionally fetch one asset to verify it works *) 51 + 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 -> 55 + Printf.printf "Successfully fetched asset. Size: %d bytes\n" 56 + (String.length data); 57 + Lwt.return_unit 58 + | [] -> Lwt.return_unit) 59 + in 60 + 61 + (* Run test *) 62 + Lwt_main.run (run_test ())
+41
test/create_test.ml
··· 1 + open Lwt.Infix 2 + open Karakeep 3 + 4 + let () = 5 + (* Load API key from file *) 6 + let api_key = 7 + try 8 + let ic = open_in ".karakeep-api" in 9 + let key = input_line ic in 10 + close_in ic; 11 + key 12 + with _ -> 13 + Printf.eprintf "Error: Could not load API key from .karakeep-api file\n"; 14 + exit 1 15 + in 16 + 17 + (* Test configuration *) 18 + let base_url = "https://hoard.recoil.org" in 19 + 20 + (* Test creating a new bookmark *) 21 + let run_test () = 22 + Printf.printf "Creating a new bookmark...\n"; 23 + 24 + let url = "https://ocaml.org" in 25 + let title = "OCaml Programming Language" in 26 + let tags = [ "programming"; "ocaml"; "functional" ] in 27 + 28 + create_bookmark ~api_key ~url ~title ~tags base_url >>= fun bookmark -> 29 + Printf.printf "Successfully created bookmark:\n"; 30 + Printf.printf "- ID: %s\n" bookmark.id; 31 + Printf.printf "- Title: %s\n" 32 + (match bookmark.title with Some t -> t | None -> "(No title)"); 33 + Printf.printf "- URL: %s\n" bookmark.url; 34 + Printf.printf "- Created: %s\n" (Ptime.to_rfc3339 bookmark.created_at); 35 + Printf.printf "- Tags: %s\n" (String.concat ", " bookmark.tags); 36 + 37 + Lwt.return_unit 38 + in 39 + 40 + (* Run test *) 41 + Lwt_main.run (run_test ())
+11
test/dune
··· 1 + (executable 2 + (name test) 3 + (libraries karakeep lwt.unix)) 4 + 5 + (executable 6 + (name create_test) 7 + (libraries karakeep lwt.unix)) 8 + 9 + (executable 10 + (name asset_test) 11 + (libraries karakeep lwt.unix))
+71
test/test.ml
··· 1 + open Lwt.Infix 2 + open Karakeep 3 + 4 + let print_bookmark bookmark = 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" title 7 + bookmark.url 8 + (Ptime.to_rfc3339 bookmark.created_at) 9 + (String.concat ", " bookmark.tags) 10 + 11 + let () = 12 + (* Load API key from file *) 13 + let api_key = 14 + try 15 + let ic = open_in ".karakeep-api" in 16 + let key = input_line ic in 17 + close_in ic; 18 + key 19 + with _ -> 20 + Printf.eprintf "Error: Could not load API key from .karakeep-api file\n"; 21 + exit 1 22 + in 23 + 24 + (* Test configuration *) 25 + let base_url = "https://hoard.recoil.org" in 26 + 27 + (* Test both fetch methods *) 28 + let run_tests () = 29 + (* Test 1: fetch_bookmarks - get a single page with pagination info *) 30 + Printf.printf "=== Test 1: fetch_bookmarks (paginated) ===\n"; 31 + 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); 35 + Printf.printf "Next cursor: %s\n\n" 36 + (match response.next_cursor with Some c -> c | None -> "none"); 37 + 38 + List.iter print_bookmark response.data; 39 + 40 + (* Test 2: fetch_all_bookmarks - get multiple pages automatically *) 41 + Printf.printf "=== Test 2: fetch_all_bookmarks (with limit) ===\n"; 42 + fetch_all_bookmarks ~api_key ~page_size:2 ~max_pages:2 base_url 43 + >>= fun all_bookmarks -> 44 + Printf.printf "Fetched %d bookmarks from up to 2 pages\n\n" 45 + (List.length all_bookmarks); 46 + 47 + List.iter print_bookmark 48 + (List.fold_left 49 + (fun acc x -> if List.length acc < 4 then acc @ [ x ] else acc) 50 + [] all_bookmarks); 51 + Printf.printf "... and %d more bookmarks\n\n" 52 + (max 0 (List.length all_bookmarks - 4)); 53 + 54 + (* Test 3: fetch_bookmark_details - get a specific bookmark *) 55 + match response.data with 56 + | first_bookmark :: _ -> 57 + Printf.printf "=== Test 3: fetch_bookmark_details ===\n"; 58 + Printf.printf "Fetching details for bookmark ID: %s\n\n" 59 + first_bookmark.id; 60 + 61 + fetch_bookmark_details ~api_key base_url first_bookmark.id 62 + >>= fun bookmark -> 63 + print_bookmark bookmark; 64 + Lwt.return_unit 65 + | [] -> 66 + Printf.printf "No bookmarks found to test fetch_bookmark_details\n"; 67 + Lwt.return_unit 68 + in 69 + 70 + (* Run all tests *) 71 + Lwt_main.run (run_tests ())