My agentic slop goes here. Not intended for anyone else!

more

-2
CLAUDE.md
···
-
This is a monorepo of a collection of OCaml libraries that should all work together, but they are in different stages of porting. Ultimately, the goal is to use Eio for everything and move away from Lwt entirely. For HTTP(S) requests the idea is to use River as the main high evel 'http requests' API, so anything using Cohttp or other HTTP fetchers will need to be ported.
-
+232
stack/TODO.md
···
+
# Production Quality TODO
+
+
This document tracks remaining tasks to make the immutable Requests.t and refactored library patterns production-ready.
+
+
## High Priority
+
+
### 1. Update Immiche README.md ⚠️
+
**Status:** Outdated
+
**Location:** `immiche/README.md`
+
+
The README still documents the old API (passing `~sw ~clock ~net ~base_url ~api_key` on every call) instead of the new session-based pattern.
+
+
**Tasks:**
+
- [ ] Update API signatures to show `create` function
+
- [ ] Update example code to show session creation
+
- [ ] Document connection pooling benefits
+
- [ ] Update "Implementation Notes" to mention `Requests.t` instead of `Requests.One`
+
- [ ] Add example of sharing sessions across multiple libraries
+
+
**Example of needed change:**
+
```ocaml
+
(* OLD - documented in README *)
+
let people = Immiche.fetch_people ~sw ~clock ~net ~base_url ~api_key
+
+
(* NEW - what actually works now *)
+
let client = Immiche.create ~sw ~env ~base_url ~api_key () in
+
let people = Immiche.fetch_people client
+
```
+
+
### 2. Improve Error Handling in Immiche
+
**Status:** Inconsistent
+
**Location:** `immiche/immiche.ml:79,92,133`
+
+
Currently uses `failwith` for HTTP errors instead of returning Result types consistently.
+
+
**Issues:**
+
- `fetch_people`, `fetch_person`, `search_person` use `failwith` for HTTP errors
+
- `download_thumbnail` correctly returns `Result`
+
- Inconsistent error handling makes it hard to gracefully handle failures
+
+
**Tasks:**
+
- [ ] Change `fetch_people` to return `(people_response, [> `Msg of string]) result`
+
- [ ] Change `fetch_person` to return `(person, [> `Msg of string]) result`
+
- [ ] Change `search_person` to return `(person list, [> `Msg of string]) result`
+
- [ ] Update `immiche/immiche.mli` with new signatures
+
- [ ] Update `bushel/bin/bushel_faces.ml` to handle Result types
+
- [ ] Document error cases in docstrings
+
+
### 3. Add Type Aliases for Verbose Signatures
+
**Status:** Verbose
+
**Location:** `immiche/immiche.mli`, `zotero-translation/zotero_translation.mli`
+
+
Type signatures like `([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t` are extremely verbose.
+
+
**Tasks:**
+
- [ ] Consider adding type aliases:
+
```ocaml
+
type client =
+
([> float Eio.Time.clock_ty ] Eio.Resource.t,
+
[> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t
+
+
val fetch_people : client -> people_response
+
```
+
- [ ] Evaluate if this improves or harms the API (might hide important type info)
+
- [ ] If beneficial, apply to both Immiche and zotero-translation
+
+
## Medium Priority
+
+
### 4. Add Tests for New Immiche Pattern
+
**Status:** No tests
+
**Location:** None - needs creation
+
+
The Immiche library was refactored but has no tests.
+
+
**Tasks:**
+
- [ ] Create `immiche/test/test_immiche.ml`
+
- [ ] Add test for `create` function
+
- [ ] Add tests for all API functions with mocked responses
+
- [ ] Test connection pooling behavior (multiple requests reuse connections)
+
- [ ] Test session sharing (derived sessions share pools)
+
- [ ] Add to dune test suite
+
+
### 5. Verify Zotero-translation with Immutable Requests.t
+
**Status:** Unknown compatibility
+
**Location:** `zotero-translation/`
+
+
Zotero-translation was already using session pattern before we made Requests.t immutable. Need to verify it still works correctly.
+
+
**Tasks:**
+
- [ ] Review if zotero-translation needs updates for immutable Requests.t
+
- [ ] Check if any derived sessions are created (and if pools are shared correctly)
+
- [ ] Add tests if missing
+
- [ ] Document session sharing pattern if used
+
+
### 6. Document Thread Safety / Concurrency Model
+
**Status:** Unclear
+
**Location:** Documentation
+
+
With immutable sessions that share mutable cookie_jar and statistics, the concurrency model needs documentation.
+
+
**Tasks:**
+
- [ ] Document that derived sessions share:
+
- Connection pools (intentional - for performance)
+
- Cookie jar (intentional - session state)
+
- Statistics (intentional - unified monitoring)
+
- [ ] Document that cookie_mutex protects concurrent cookie access
+
- [ ] Clarify if derived sessions can be used safely across Eio fibers
+
- [ ] Add concurrency examples to documentation
+
+
### 7. Enhanced API Documentation
+
**Status:** Basic
+
**Location:** All `.mli` files
+
+
Current documentation is minimal. Could add more examples and edge case documentation.
+
+
**Tasks:**
+
- [ ] Add usage examples to `requests/lib/requests.mli` showing:
+
- Creating base session
+
- Deriving sessions with different configs
+
- Sharing pools across derived sessions
+
- [ ] Document what happens when you derive from derived sessions
+
- [ ] Add performance notes about connection pooling
+
- [ ] Document cookie handling behavior
+
+
## Low Priority
+
+
### 8. Consider Immutable Statistics
+
**Status:** Design decision
+
**Location:** `requests/lib/requests.ml:51-53`
+
+
Statistics (requests_made, total_time, retries_count) are still mutable and shared across derived sessions.
+
+
**Trade-offs:**
+
- **Current (mutable):** Simple, tracks total usage across all derived sessions
+
- **Alternative (immutable):** Would require returning `(Response.t * t)` from all request functions
+
+
**Tasks:**
+
- [ ] Evaluate if immutable statistics would be valuable
+
- [ ] If yes, design API that returns updated sessions from request functions
+
- [ ] If no, document the design decision and rationale
+
+
### 9. Add Examples Directory
+
**Status:** No standalone examples
+
**Location:** None
+
+
Would be helpful to have example code showing common patterns.
+
+
**Tasks:**
+
- [ ] Create `examples/` directory
+
- [ ] Add `examples/immiche_basic.ml` - basic Immiche usage
+
- [ ] Add `examples/immiche_advanced.ml` - session sharing across APIs
+
- [ ] Add `examples/requests_sessions.ml` - derived sessions with shared pools
+
- [ ] Add `examples/zotero_immiche_shared.ml` - sharing pools between libraries
+
- [ ] Ensure examples build and run
+
+
### 10. Performance Benchmarks
+
**Status:** No benchmarks
+
**Location:** None
+
+
Would be valuable to have benchmarks showing connection pooling benefits.
+
+
**Tasks:**
+
- [ ] Create benchmark comparing:
+
- Requests.One (no pooling) vs Requests.t (with pooling)
+
- Single session vs derived sessions (verify pool sharing)
+
- Sequential vs parallel requests
+
- [ ] Document performance characteristics
+
- [ ] Add to CI if significant
+
+
## Breaking Changes Documented
+
+
### Immiche API (Breaking)
+
**Old API:**
+
```ocaml
+
val fetch_people :
+
sw:Eio.Switch.t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
+
base_url:string ->
+
api_key:string ->
+
people_response
+
```
+
+
**New API:**
+
```ocaml
+
val create :
+
sw:Eio.Switch.t ->
+
env:< clock:_; net:_; fs:_; .. > ->
+
?requests_session:_ Requests.t ->
+
base_url:string ->
+
api_key:string ->
+
unit -> t
+
+
val fetch_people : t -> people_response
+
```
+
+
**Migration Required:**
+
- All Immiche users must update to create a client first
+
- Only known usage: `bushel/bin/bushel_faces.ml` (already updated)
+
+
### Requests Configuration Functions (Breaking)
+
**Old API:**
+
```ocaml
+
val set_auth : t -> Auth.t -> unit
+
```
+
+
**New API:**
+
```ocaml
+
val set_auth : t -> Auth.t -> t
+
```
+
+
**Migration Required:**
+
- Must capture returned session: `let req = Requests.set_auth req auth`
+
- All usages updated: ocurl.ml, test_requests.ml, jmap_client.ml
+
+
## Notes
+
+
- **Cookie handling:** Still mutable via shared cookie_jar - this is intentional for session state
+
- **Statistics:** Still mutable and shared - tracks total usage across derived sessions
+
- **Connection pools:** Shared across all derived sessions - major performance benefit
+
- **No backward compatibility:** Breaking changes are acceptable for this monorepo
+
+
## Completion Checklist
+
+
Before considering this production-ready:
+
+
- [ ] High priority items completed
+
- [ ] README.md updated for Immiche
+
- [ ] Error handling consistent across Immiche
+
- [ ] At least basic tests added for Immiche
+
- [ ] Documentation covers concurrency model
+
- [ ] All examples in docs are verified to work
+121 -132
stack/bushel/bin/bushel_doi.ml
···
module ZT = Zotero_translation
-
open Lwt.Infix
module J = Ezjsonm
open Cmdliner
···
let resolve_doi zt ~verbose doi =
Printf.printf "Resolving DOI: %s\n%!" doi;
let doi_url = Printf.sprintf "https://doi.org/%s" doi in
-
Lwt.catch
-
(fun () ->
-
ZT.json_of_doi zt ~slug:"temp" doi >>= fun json ->
-
if verbose then begin
-
Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string json)
-
end;
-
try
-
let keys = J.get_dict (json :> J.value) in
-
let title = J.find json ["title"] |> J.get_string in
-
let authors = J.find json ["author"] |> J.get_list J.get_string in
-
let year = J.find json ["year"] |> J.get_string |> int_of_string in
-
let bibtype = J.find json ["bibtype"] |> J.get_string in
-
let publisher =
-
try
-
(* Try journal first, then booktitle, then publisher *)
-
match List.assoc_opt "journal" keys with
-
| Some j -> J.get_string j
+
try
+
let json = ZT.json_of_doi zt ~slug:"temp" doi in
+
if verbose then begin
+
Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string json)
+
end;
+
try
+
let keys = J.get_dict (json :> J.value) in
+
let title = J.find json ["title"] |> J.get_string in
+
let authors = J.find json ["author"] |> J.get_list J.get_string in
+
let year = J.find json ["year"] |> J.get_string |> int_of_string in
+
let bibtype = J.find json ["bibtype"] |> J.get_string in
+
let publisher =
+
try
+
(* Try journal first, then booktitle, then publisher *)
+
match List.assoc_opt "journal" keys with
+
| Some j -> J.get_string j
+
| None ->
+
match List.assoc_opt "booktitle" keys with
+
| Some b -> J.get_string b
| None ->
-
match List.assoc_opt "booktitle" keys with
-
| Some b -> J.get_string b
-
| None ->
-
match List.assoc_opt "publisher" keys with
-
| Some p -> J.get_string p
-
| None -> ""
-
with _ -> ""
-
in
-
let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls:[doi_url] () in
-
Printf.printf " ✓ Resolved: %s (%d)\n%!" title year;
-
Lwt.return entry
-
with e ->
-
Printf.eprintf " ✗ Failed to parse response for %s: %s\n%!" doi (Printexc.to_string e);
-
Lwt.return (Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string e) ~source_urls:[doi_url] ())
-
)
-
(fun exn ->
-
Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" doi (Printexc.to_string exn);
-
Lwt.return (Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string exn) ~source_urls:[doi_url] ())
-
)
+
match List.assoc_opt "publisher" keys with
+
| Some p -> J.get_string p
+
| None -> ""
+
with _ -> ""
+
in
+
let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls:[doi_url] () in
+
Printf.printf " ✓ Resolved: %s (%d)\n%!" title year;
+
entry
+
with e ->
+
Printf.eprintf " ✗ Failed to parse response for %s: %s\n%!" doi (Printexc.to_string e);
+
Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string e) ~source_urls:[doi_url] ()
+
with exn ->
+
Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" doi (Printexc.to_string exn);
+
Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string exn) ~source_urls:[doi_url] ()
(* Resolve a publisher URL via Zotero /web endpoint *)
let resolve_url zt ~verbose url =
Printf.printf "Resolving URL: %s\n%!" url;
-
Lwt.catch
-
(fun () ->
-
(* Use Zotero's resolve_url which calls /web endpoint with the URL directly *)
-
ZT.resolve_url zt url >>= function
-
| Error (`Msg err) ->
-
Printf.eprintf " ✗ Failed to resolve URL: %s\n%!" err;
-
Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:err ~source_urls:[url] ())
-
| Ok json ->
-
if verbose then begin
-
Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string json)
-
end;
-
try
-
(* Extract metadata from the JSON response *)
-
let json_list = match json with
-
| `A lst -> lst
-
| single -> [single]
+
try
+
(* Use Zotero's resolve_url which calls /web endpoint with the URL directly *)
+
match ZT.resolve_url zt url with
+
| Error (`Msg err) ->
+
Printf.eprintf " ✗ Failed to resolve URL: %s\n%!" err;
+
Bushel.Doi_entry.create_failed ~doi:url ~error:err ~source_urls:[url] ()
+
| Ok json ->
+
if verbose then begin
+
Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string (json :> J.value))
+
end;
+
try
+
(* Extract metadata from the JSON response *)
+
let json_list = match json with
+
| `A lst -> lst
+
| single -> [(single :> J.value)]
+
in
+
match json_list with
+
| [] ->
+
Printf.eprintf " ✗ Empty response\n%!";
+
Bushel.Doi_entry.create_failed ~doi:url ~error:"Empty response" ~source_urls:[url] ()
+
| item :: _ ->
+
(* Extract DOI if present, otherwise use URL *)
+
let doi = try J.find item ["DOI"] |> J.get_string with _ ->
+
try J.find item ["doi"] |> J.get_string with _ -> url
+
in
+
let title = try J.find item ["title"] |> J.get_string with _ ->
+
"Unknown Title"
+
in
+
(* Extract authors from Zotero's "creators" field *)
+
let authors = try
+
J.find item ["creators"] |> J.get_list (fun creator_obj ->
+
try
+
let last_name = J.find creator_obj ["lastName"] |> J.get_string in
+
let first_name = try J.find creator_obj ["firstName"] |> J.get_string with _ -> "" in
+
if first_name = "" then last_name else first_name ^ " " ^ last_name
+
with _ -> "Unknown Author"
+
)
+
with _ -> []
+
in
+
(* Extract year from Zotero's "date" field *)
+
(* Handles both ISO format "2025-07" and text format "November 28, 2023" *)
+
let year = try
+
let date_str = J.find item ["date"] |> J.get_string in
+
(* First try splitting on '-' for ISO dates like "2025-07" or "2024-11-04" *)
+
let parts = String.split_on_char '-' date_str in
+
match parts with
+
| year_str :: _ when String.length year_str = 4 ->
+
(try int_of_string year_str with _ -> 0)
+
| _ ->
+
(* Try splitting on space and comma for dates like "November 28, 2023" *)
+
let space_parts = String.split_on_char ' ' date_str in
+
let year_candidate = List.find_opt (fun s ->
+
let s = String.trim (String.map (fun c -> if c = ',' then ' ' else c) s) in
+
String.length s = 4 && String.for_all (function '0'..'9' -> true | _ -> false) s
+
) space_parts in
+
(match year_candidate with
+
| Some year_str -> int_of_string (String.trim year_str)
+
| None -> 0)
+
with _ -> 0
in
-
match json_list with
-
| [] ->
-
Printf.eprintf " ✗ Empty response\n%!";
-
Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:"Empty response" ~source_urls:[url] ())
-
| item :: _ ->
-
(* Extract DOI if present, otherwise use URL *)
-
let doi = try J.find item ["DOI"] |> J.get_string with _ ->
-
try J.find item ["doi"] |> J.get_string with _ -> url
-
in
-
let title = try J.find item ["title"] |> J.get_string with _ ->
-
"Unknown Title"
-
in
-
(* Extract authors from Zotero's "creators" field *)
-
let authors = try
-
J.find item ["creators"] |> J.get_list (fun creator_obj ->
-
try
-
let last_name = J.find creator_obj ["lastName"] |> J.get_string in
-
let first_name = try J.find creator_obj ["firstName"] |> J.get_string with _ -> "" in
-
if first_name = "" then last_name else first_name ^ " " ^ last_name
-
with _ -> "Unknown Author"
-
)
-
with _ -> []
-
in
-
(* Extract year from Zotero's "date" field *)
-
(* Handles both ISO format "2025-07" and text format "November 28, 2023" *)
-
let year = try
-
let date_str = J.find item ["date"] |> J.get_string in
-
(* First try splitting on '-' for ISO dates like "2025-07" or "2024-11-04" *)
-
let parts = String.split_on_char '-' date_str in
-
match parts with
-
| year_str :: _ when String.length year_str = 4 ->
-
(try int_of_string year_str with _ -> 0)
-
| _ ->
-
(* Try splitting on space and comma for dates like "November 28, 2023" *)
-
let space_parts = String.split_on_char ' ' date_str in
-
let year_candidate = List.find_opt (fun s ->
-
let s = String.trim (String.map (fun c -> if c = ',' then ' ' else c) s) in
-
String.length s = 4 && String.for_all (function '0'..'9' -> true | _ -> false) s
-
) space_parts in
-
(match year_candidate with
-
| Some year_str -> int_of_string (String.trim year_str)
-
| None -> 0)
-
with _ -> 0
-
in
-
(* Extract type/bibtype from Zotero's "itemType" field *)
-
let bibtype = try J.find item ["itemType"] |> J.get_string with _ -> "article" in
-
(* Extract publisher/journal from Zotero's "publicationTitle" field *)
-
let publisher = try
-
J.find item ["publicationTitle"] |> J.get_string
-
with _ -> ""
-
in
-
(* Include both the original URL and the DOI URL in source_urls *)
-
let doi_url = if doi = url then [] else [Printf.sprintf "https://doi.org/%s" doi] in
-
let source_urls = url :: doi_url in
-
let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls () in
-
Printf.printf " ✓ Resolved: %s (%d) [DOI: %s]\n%!" title year doi;
-
Lwt.return entry
-
with e ->
-
Printf.eprintf " ✗ Failed to parse response: %s\n%!" (Printexc.to_string e);
-
Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string e) ~source_urls:[url] ())
-
)
-
(fun exn ->
-
Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" url (Printexc.to_string exn);
-
Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string exn) ~source_urls:[url] ())
-
)
+
(* Extract type/bibtype from Zotero's "itemType" field *)
+
let bibtype = try J.find item ["itemType"] |> J.get_string with _ -> "article" in
+
(* Extract publisher/journal from Zotero's "publicationTitle" field *)
+
let publisher = try
+
J.find item ["publicationTitle"] |> J.get_string
+
with _ -> ""
+
in
+
(* Include both the original URL and the DOI URL in source_urls *)
+
let doi_url = if doi = url then [] else [Printf.sprintf "https://doi.org/%s" doi] in
+
let source_urls = url :: doi_url in
+
let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls () in
+
Printf.printf " ✓ Resolved: %s (%d) [DOI: %s]\n%!" title year doi;
+
entry
+
with e ->
+
Printf.eprintf " ✗ Failed to parse response: %s\n%!" (Printexc.to_string e);
+
Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string e) ~source_urls:[url] ()
+
with exn ->
+
Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" url (Printexc.to_string exn);
+
Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string exn) ~source_urls:[url] ()
let run base_dir force verbose =
Printf.printf "Loading bushel database...\n%!";
···
end else begin
Printf.printf "Resolving %d DOI(s) and %d URL(s)...\n%!" (List.length dois_to_resolve) (List.length urls_to_resolve);
-
let zt = ZT.v "http://svr-avsm2-eeg-ce:1969" in
-
-
(* Resolve all DOIs *)
-
let resolved_doi_entries_lwt =
-
Lwt_list.map_s (resolve_doi zt ~verbose) dois_to_resolve
-
in
-
-
(* Resolve all publisher URLs *)
-
let resolved_url_entries_lwt =
-
Lwt_list.map_s (resolve_url zt ~verbose) urls_to_resolve
+
(* Resolve all DOIs and URLs using Eio *)
+
let new_entries = Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
(* Create Zotero Translation client with connection pooling *)
+
let zt = ZT.create ~sw ~env "http://svr-avsm2-eeg-ce:1969" in
+
(* Resolve all DOIs *)
+
let new_doi_entries = List.map (resolve_doi zt ~verbose) dois_to_resolve in
+
(* Resolve all publisher URLs *)
+
let new_url_entries = List.map (resolve_url zt ~verbose) urls_to_resolve in
+
new_doi_entries @ new_url_entries
in
-
-
let new_doi_entries = Lwt_main.run resolved_doi_entries_lwt in
-
let new_url_entries = Lwt_main.run resolved_url_entries_lwt in
-
let new_entries = new_doi_entries @ new_url_entries in
(* Merge with existing entries, combining source_urls for entries with the same DOI *)
let all_entries =
+38 -95
stack/bushel/bin/bushel_faces.ml
···
open Cmdliner
-
open Lwt.Infix
open Printf
-
(* Type for person response *)
-
type person = {
-
id: string;
-
name: string;
-
thumbnailPath: string option;
-
}
-
-
(* Parse a person from JSON *)
-
let parse_person json =
-
let open Ezjsonm in
-
let id = find json ["id"] |> get_string in
-
let name = find json ["name"] |> get_string in
-
let thumbnailPath =
-
try Some (find json ["thumbnailPath"] |> get_string)
-
with _ -> None
-
in
-
{ id; name; thumbnailPath }
-
-
(* Parse a list of people from JSON response *)
-
let parse_people_response json =
-
let open Ezjsonm in
-
get_list parse_person json
-
(* Read API key from file *)
let read_api_key file =
let ic = open_in file in
···
close_in ic;
key
-
(* Search for a person by name *)
-
let search_person base_url api_key name =
-
let open Cohttp_lwt_unix in
-
let headers = Cohttp.Header.init_with "X-Api-Key" api_key in
-
let encoded_name = Uri.pct_encode name in
-
let url = Printf.sprintf "%s/api/search/person?name=%s" base_url encoded_name in
-
-
Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
let json = Ezjsonm.from_string body_str in
-
Lwt.return (parse_people_response json)
-
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
Lwt.fail_with (Printf.sprintf "HTTP error: %d" status_code)
-
-
(* Download thumbnail for a person *)
-
let download_thumbnail base_url api_key person_id output_path =
-
let open Cohttp_lwt_unix in
-
let headers = Cohttp.Header.init_with "X-Api-Key" api_key in
-
let url = Printf.sprintf "%s/api/people/%s/thumbnail" base_url person_id in
-
-
Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
-
match resp.status with
-
| `OK ->
-
Cohttp_lwt.Body.to_string body >>= fun img_data ->
-
(* Ensure output directory exists *)
-
(try
-
let dir = Filename.dirname output_path in
-
if not (Sys.file_exists dir) then Unix.mkdir dir 0o755;
-
Lwt.return_unit
-
with _ -> Lwt.return_unit) >>= fun () ->
-
Lwt_io.with_file ~mode:Lwt_io.output output_path
-
(fun oc -> Lwt_io.write oc img_data) >>= fun () ->
-
Lwt.return_ok output_path
-
| _ ->
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
Lwt.return_error (Printf.sprintf "HTTP error: %d" status_code)
-
(* Get face for a single contact *)
-
(* TODO:claude *)
-
let get_face_for_contact base_url api_key output_dir contact =
+
let get_face_for_contact immiche_client ~fs output_dir contact =
let names = Bushel.Contact.names contact in
let handle = Bushel.Contact.handle contact in
let output_path = Filename.concat output_dir (handle ^ ".jpg") in
(* Skip if file already exists *)
if Sys.file_exists output_path then
-
Lwt.return (`Skipped (sprintf "Thumbnail for '%s' already exists at %s" (List.hd names) output_path))
+
`Skipped (sprintf "Thumbnail for '%s' already exists at %s" (List.hd names) output_path)
else begin
printf "Processing contact: %s (handle: %s)\n%!" (List.hd names) handle;
(* Try each name in the list until we find a match *)
let rec try_names = function
| [] ->
-
Lwt.return (`Error (sprintf "No person found with any name for contact '%s'" handle))
+
`Error (sprintf "No person found with any name for contact '%s'" handle)
| name :: rest_names ->
printf " Trying name: %s\n%!" name;
-
search_person base_url api_key name >>= function
+
let people = Immiche.search_person immiche_client ~name in
+
(match people with
| [] ->
printf " No results for '%s', trying next name...\n%!" name;
try_names rest_names
| person :: _ ->
printf " Found match for '%s'\n%!" name;
-
download_thumbnail base_url api_key person.id output_path >>= function
-
| Ok path ->
-
Lwt.return (`Ok (sprintf "Saved thumbnail for '%s' to %s" name path))
-
| Error err ->
-
Lwt.return (`Error (sprintf "Error for '%s': %s" name err))
+
let result = Immiche.download_thumbnail immiche_client
+
~fs ~person_id:person.id ~output_path in
+
(match result with
+
| Ok _ ->
+
`Ok (sprintf "Saved thumbnail for '%s' to %s" name output_path)
+
| Error (`Msg err) ->
+
`Error (sprintf "Error for '%s': %s" name err)))
in
try_names names
end
(* Process all contacts or a specific one *)
-
let process_contacts base_dir output_dir specific_handle api_key base_url =
+
let process_contacts ~sw ~env base_dir output_dir specific_handle api_key base_url =
printf "Loading Bushel database from %s\n%!" base_dir;
let db = Bushel.load base_dir in
let contacts = Bushel.Entry.contacts db in
printf "Found %d contacts\n%!" (List.length contacts);
-
+
+
(* Create Immiche client for connection pooling *)
+
let immiche_client = Immiche.create ~sw ~env ~base_url ~api_key () in
+
(* Ensure output directory exists *)
if not (Sys.file_exists output_dir) then Unix.mkdir output_dir 0o755;
-
+
(* Filter contacts based on specific_handle if provided *)
-
let contacts_to_process =
+
let contacts_to_process =
match specific_handle with
-
| Some handle ->
+
| Some handle ->
begin match Bushel.Contact.find_by_handle contacts handle with
| Some contact -> [contact]
-
| None ->
+
| None ->
eprintf "No contact found with handle '%s'\n%!" handle;
[]
end
| None -> contacts
in
-
+
(* Process each contact *)
-
let results = Lwt_main.run begin
-
Lwt_list.map_s
-
(fun contact ->
-
get_face_for_contact base_url api_key output_dir contact >>= fun result ->
-
Lwt.return (Bushel.Contact.handle contact, result))
-
contacts_to_process
-
end in
-
+
let results = List.map
+
(fun contact ->
+
let result = get_face_for_contact immiche_client ~fs:env#fs output_dir contact in
+
(Bushel.Contact.handle contact, result))
+
contacts_to_process
+
in
+
(* Print summary *)
let ok_count = List.length (List.filter (fun (_, r) -> match r with `Ok _ -> true | _ -> false) results) in
let error_count = List.length (List.filter (fun (_, r) -> match r with `Error _ -> true | _ -> false) results) in
let skipped_count = List.length (List.filter (fun (_, r) -> match r with `Skipped _ -> true | _ -> false) results) in
-
+
printf "\nSummary:\n";
printf " Successfully processed: %d\n" ok_count;
printf " Errors: %d\n" error_count;
printf " Skipped (already exist): %d\n" skipped_count;
-
+
(* Print detailed results *)
if error_count > 0 then begin
printf "\nError details:\n";
···
| _ -> ())
results;
end;
-
+
if ok_count > 0 || skipped_count > 0 then 0 else 1
(* Command line interface *)
···
const (fun base_dir output_dir handle api_key_file base_url ->
try
let api_key = read_api_key api_key_file in
-
process_contacts base_dir output_dir handle api_key base_url
-
with e ->
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
process_contacts ~sw ~env base_dir output_dir handle api_key base_url
+
with e ->
eprintf "Error: %s\n%!" (Printexc.to_string e);
1
-
) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $
-
Bushel_common.api_key_file ~default:".photos-api" $
+
) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $
+
Bushel_common.api_key_file ~default:".photos-api" $
Bushel_common.url_term ~default:"https://photos.recoil.org" ~doc:"Base URL of the Immich instance")
let cmd =
+187 -189
stack/bushel/bin/bushel_links.ml
···
open Cmdliner
-
open Lwt.Infix
(* Helper function for logging with proper flushing *)
let log fmt = Fmt.kstr (fun s -> prerr_string s; flush stderr) fmt
-
let log_verbose verbose fmt =
-
if verbose then Fmt.kstr (fun s -> prerr_string s; flush stderr) fmt
+
let log_verbose verbose fmt =
+
if verbose then Fmt.kstr (fun s -> prerr_string s; flush stderr) fmt
else Fmt.kstr (fun _ -> ()) fmt
(* Initialize a new links.yml file or ensure it exists *)
···
0
(* Update links.yml from Karakeep *)
-
let update_from_karakeep base_url api_key_opt tag links_file download_assets =
+
let update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets =
match api_key_opt with
| None ->
prerr_endline "Error: API key is required.";
···
1
| Some api_key ->
let assets_dir = "data/assets" in
-
-
(* Run the Lwt program *)
-
Lwt_main.run (
+
+
try
print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag);
-
+
(* Prepare tag filter *)
let filter_tags = if tag = "" then [] else [tag] in
-
-
(* Fetch bookmarks from Karakeep with error handling *)
-
Lwt.catch
-
(fun () ->
-
Karakeep.fetch_all_bookmarks ~api_key ~filter_tags base_url >>= fun bookmarks ->
-
-
print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
-
-
(* Read existing links if file exists *)
-
let existing_links = Bushel.Link.load_links_file links_file in
-
-
(* Convert bookmarks to bushel links *)
-
let new_links = List.map (fun bookmark ->
-
Karakeep.to_bushel_link ~base_url bookmark
-
) bookmarks in
-
-
(* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
-
let merged_links = Bushel.Link.merge_links existing_links new_links in
-
-
(* Save the updated links file *)
-
Bushel.Link.save_links_file links_file merged_links;
-
-
print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
-
-
(* Download assets if requested *)
-
if download_assets then begin
-
print_endline "Downloading assets for bookmarks...";
-
-
(* Ensure the assets directory exists *)
-
(try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
-
-
(* Process each bookmark with assets *)
-
Lwt_list.iter_s (fun bookmark ->
-
(* Extract asset IDs from bookmark *)
-
let assets = bookmark.Karakeep.assets in
-
-
(* Skip if no assets *)
-
if assets = [] then
-
Lwt.return_unit
-
else
-
(* Process each asset *)
-
Lwt_list.iter_s (fun (asset_id, asset_type) ->
-
let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
-
let asset_file = Fmt.str "%s/asset.bin" asset_dir in
-
let meta_file = Fmt.str "%s/metadata.json" asset_dir in
-
-
(* Skip if the asset already exists *)
-
if Sys.file_exists asset_file then
-
Lwt.return_unit
-
else begin
-
(* Create the asset directory *)
-
(try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
-
-
(* Download the asset *)
-
print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
-
Karakeep.fetch_asset ~api_key base_url asset_id >>= fun data ->
-
-
(* Guess content type based on first bytes *)
-
let content_type =
-
if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
-
"image/png"
-
else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
-
"image/jpeg"
-
else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
-
"application/pdf"
-
else
-
"application/octet-stream"
-
in
-
-
(* Write the asset data *)
-
Lwt_io.with_file ~mode:Lwt_io.Output asset_file (fun oc ->
-
Lwt_io.write oc data
-
) >>= fun () ->
-
-
(* Write metadata file *)
-
let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
-
content_type asset_type in
-
Lwt_io.with_file ~mode:Lwt_io.Output meta_file (fun oc ->
-
Lwt_io.write oc metadata
-
)
-
end
-
) assets
-
) bookmarks >>= fun () ->
-
-
print_endline "Asset download completed.";
-
Lwt.return 0
-
end else
-
Lwt.return 0
-
)
-
(fun exn ->
-
prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
-
Lwt.return 1
-
)
-
)
+
+
(* Fetch bookmarks from Karakeep *)
+
let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in
+
+
print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
+
+
(* Read existing links if file exists *)
+
let existing_links = Bushel.Link.load_links_file links_file in
+
+
(* Convert bookmarks to bushel links *)
+
let new_links = List.map (fun bookmark ->
+
Karakeepe.to_bushel_link ~base_url bookmark
+
) bookmarks in
+
+
(* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
+
let merged_links = Bushel.Link.merge_links existing_links new_links in
+
+
(* Save the updated links file *)
+
Bushel.Link.save_links_file links_file merged_links;
+
+
print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
+
+
(* Download assets if requested *)
+
if download_assets then begin
+
print_endline "Downloading assets for bookmarks...";
+
+
(* Ensure the assets directory exists *)
+
(try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
+
+
(* Process each bookmark with assets *)
+
List.iter (fun bookmark ->
+
(* Extract asset IDs from bookmark *)
+
let assets = bookmark.Karakeepe.assets in
+
+
(* Skip if no assets *)
+
if assets <> [] then
+
(* Process each asset *)
+
List.iter (fun (asset_id, asset_type) ->
+
let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
+
let asset_file = Fmt.str "%s/asset.bin" asset_dir in
+
let meta_file = Fmt.str "%s/metadata.json" asset_dir in
+
+
(* Skip if the asset already exists *)
+
if not (Sys.file_exists asset_file) then begin
+
(* Create the asset directory *)
+
(try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
+
+
(* Download the asset *)
+
print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
+
let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in
+
+
(* Guess content type based on first bytes *)
+
let content_type =
+
if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
+
"image/png"
+
else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
+
"image/jpeg"
+
else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
+
"application/pdf"
+
else
+
"application/octet-stream"
+
in
+
+
(* Write the asset data *)
+
let oc = open_out_bin asset_file in
+
output_string oc data;
+
close_out oc;
+
+
(* Write metadata file *)
+
let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
+
content_type asset_type in
+
let oc = open_out meta_file in
+
output_string oc metadata;
+
close_out oc
+
end
+
) assets
+
) bookmarks;
+
+
print_endline "Asset download completed.";
+
0
+
end else
+
0
+
with exn ->
+
prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
+
1
(* Extract outgoing links from Bushel entries *)
let update_from_bushel bushel_dir links_file include_domains exclude_domains =
···
create_batches_aux links []
(* Helper function to upload a single link to Karakeep *)
-
let upload_single_link api_key base_url tag verbose updated_links link =
+
let upload_single_link ~sw ~env api_key base_url tag verbose updated_links link =
let url = link.Bushel.Link.url in
-
let title =
-
if link.Bushel.Link.description <> "" then
-
Some link.Bushel.Link.description
-
else None
+
let title =
+
if link.Bushel.Link.description <> "" then
+
Some link.Bushel.Link.description
+
else None
in
let tags = prepare_tags_for_link tag link in
-
+
if verbose then begin
log " Uploading: %s\n" url;
-
if tags <> [] then
+
if tags <> [] then
log " Tags: %s\n" (String.concat ", " tags);
-
if title <> None then
+
if title <> None then
log " Title: %s\n" (Option.get title);
end else begin
log "Uploading: %s\n" url;
end;
-
+
(* Create the bookmark with tags *)
-
Lwt.catch
-
(fun () ->
-
Karakeep.create_bookmark
-
~api_key
-
~url
-
?title
-
~tags
-
base_url
-
>>= fun bookmark ->
-
-
(* Create updated link with karakeep data *)
-
let updated_link = {
-
link with
-
Bushel.Link.karakeep =
-
Some {
-
Bushel.Link.remote_url = base_url;
-
id = bookmark.id;
-
tags = bookmark.tags;
-
metadata = []; (* Will be populated on next sync *)
-
}
-
} in
-
updated_links := updated_link :: !updated_links;
-
-
if verbose then
-
log " ✓ Added to Karakeep with ID: %s\n" bookmark.id
-
else
-
log " - Added to Karakeep with ID: %s\n" bookmark.id;
-
Lwt.return 1 (* Success *)
-
)
-
(fun exn ->
-
if verbose then
-
log " ✗ Error uploading %s: %s\n" url (Printexc.to_string exn)
-
else
-
log " - Error uploading %s: %s\n" url (Printexc.to_string exn);
-
Lwt.return 0 (* Failure *)
-
)
+
try
+
let bookmark = Karakeepe.create_bookmark
+
~sw ~env
+
~api_key
+
~url
+
?title
+
~tags
+
base_url
+
in
+
+
(* Create updated link with karakeep data *)
+
let updated_link = {
+
link with
+
Bushel.Link.karakeep =
+
Some {
+
Bushel.Link.remote_url = base_url;
+
id = bookmark.id;
+
tags = bookmark.tags;
+
metadata = []; (* Will be populated on next sync *)
+
}
+
} in
+
updated_links := updated_link :: !updated_links;
+
+
if verbose then
+
log " ✓ Added to Karakeep with ID: %s\n" bookmark.id
+
else
+
log " - Added to Karakeep with ID: %s\n" bookmark.id;
+
1 (* Success *)
+
with exn ->
+
if verbose then
+
log " ✗ Error uploading %s: %s\n" url (Printexc.to_string exn)
+
else
+
log " - Error uploading %s: %s\n" url (Printexc.to_string exn);
+
0 (* Failure *)
(* Helper function to process a batch of links *)
-
let process_batch api_key base_url tag verbose updated_links batch_num total_batches batch =
-
log_verbose verbose "\nProcessing batch %d/%d (%d links)...\n"
+
let process_batch ~sw ~env api_key base_url tag verbose updated_links batch_num total_batches batch =
+
log_verbose verbose "\nProcessing batch %d/%d (%d links)...\n"
(batch_num + 1) total_batches (List.length batch);
-
-
(* Process links in this batch concurrently *)
-
Lwt_list.map_p (upload_single_link api_key base_url tag verbose updated_links) batch
+
+
(* Process links in this batch in parallel *)
+
let results = ref [] in
+
Eio.Fiber.all (List.map (fun link ->
+
fun () ->
+
let count = upload_single_link ~sw ~env api_key base_url tag verbose updated_links link in
+
results := count :: !results
+
) batch);
+
List.rev !results
(* Helper function to update links file with new karakeep data *)
let update_links_file links_file original_links updated_links =
···
end
(* Upload links to Karakeep that don't already have karakeep data *)
-
let upload_to_karakeep base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose =
+
let upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose =
match api_key_opt with
| None ->
log "Error: API key is required.\n";
···
log_verbose verbose "Loading links from %s...\n" links_file;
let links = Bushel.Link.load_links_file links_file in
log_verbose verbose "Loaded %d total links\n" (List.length links);
-
+
(* Filter links that don't have karakeep data for this remote *)
log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url;
let filtered_links = filter_links_without_karakeep base_url links in
log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links);
-
+
(* Apply limit if specified *)
let links_to_upload = apply_limit_to_links limit filtered_links in
-
+
if links_to_upload = [] then begin
log "No links to upload to %s (all links already have karakeep data)\n" base_url;
0
end else begin
log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url;
-
+
(* Split links into batches for parallel processing *)
let batches = create_batches max_concurrent links_to_upload in
-
log_verbose verbose "Processing in %d batches of up to %d links each...\n"
+
log_verbose verbose "Processing in %d batches of up to %d links each...\n"
(List.length batches) max_concurrent;
log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds;
-
+
(* Process batches and accumulate updated links *)
let updated_links = ref [] in
-
-
let result = Lwt_main.run (
-
Lwt.catch
-
(fun () ->
-
Lwt_list.fold_left_s (fun (total_count, batch_num) batch ->
-
process_batch api_key base_url tag verbose updated_links
-
batch_num (List.length batches) batch >>= fun results ->
-
+
+
let result = try
+
let rec process_batches total_count batch_num = function
+
| [] -> total_count
+
| batch :: rest ->
+
let results = process_batch ~sw ~env api_key base_url tag verbose updated_links
+
batch_num (List.length batches) batch in
+
(* Count successes in this batch *)
let batch_successes = List.fold_left (+) 0 results in
let new_total = total_count + batch_successes in
-
-
log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
+
+
log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
(batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total));
-
+
(* Add a delay before processing the next batch *)
-
if batch_num + 1 < List.length batches then begin
+
if rest <> [] then begin
log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds;
-
Lwt_unix.sleep delay_seconds >>= fun () ->
-
Lwt.return (new_total, batch_num + 1)
-
end else
-
Lwt.return (new_total, batch_num + 1)
-
) (0, 0) batches >>= fun (final_count, _) ->
-
Lwt.return final_count
-
)
-
(fun exn ->
-
log "Error during upload operation: %s\n" (Printexc.to_string exn);
-
Lwt.return 0
-
)
-
) in
-
+
Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds;
+
end;
+
process_batches new_total (batch_num + 1) rest
+
in
+
process_batches 0 0 batches
+
with exn ->
+
log "Error during upload operation: %s\n" (Printexc.to_string exn);
+
0
+
in
+
(* Update the links file with the new karakeep_ids *)
update_links_file links_file links updated_links;
-
-
log "Upload complete. %d/%d links uploaded successfully.\n"
+
+
log "Upload complete. %d/%d links uploaded successfully.\n"
result (List.length links_to_upload);
-
+
0
end
···
let karakeep_cmd =
let doc = "Update links.yml with links from Karakeep" in
let info = Cmd.info "karakeep" ~doc in
-
Cmd.v info Term.(const update_from_karakeep $ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg)
+
Cmd.v info Term.(const (fun base_url api_key_opt tag links_file download_assets ->
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets)
+
$ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg)
let bushel_cmd =
let doc = "Update links.yml with outgoing links from Bushel entries" in
···
let upload_cmd =
let doc = "Upload links without karakeep data to Karakeep" in
let info = Cmd.info "upload" ~doc in
-
Cmd.v info Term.(const upload_to_karakeep $ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
+
Cmd.v info Term.(const (fun base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose ->
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose)
+
$ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
(* Export the term and cmd for use in main bushel.ml *)
let cmd =
+12 -10
stack/bushel/bin/bushel_paper.ml
···
module ZT = Zotero_translation
-
open Lwt.Infix
open Printf
module J = Ezjsonm
open Cmdliner
···
J.update j ["author"] (Some (`A a))
let of_doi zt ~base_dir ~slug ~version doi =
-
ZT.json_of_doi zt ~slug doi >>= fun j ->
+
let j = ZT.json_of_doi zt ~slug doi in
let papers_dir = Printf.sprintf "%s/papers/%s" base_dir slug in
(* Ensure papers directory exists *)
(try Unix.mkdir papers_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
-
+
(* Extract abstract from JSON data *)
let abstract = try
let keys = Ezjsonm.get_dict (j :> Ezjsonm.value) in
···
| Some abstract_json -> Some (Ezjsonm.get_string abstract_json)
| None -> None
with _ -> None in
-
+
(* Remove abstract from frontmatter - it goes in body *)
let keys = Ezjsonm.get_dict (j :> Ezjsonm.value) in
let filtered_keys = List.filter (fun (k, _) -> k <> "abstract") keys in
let json_without_abstract = `O filtered_keys in
-
+
(* Use library function to generate YAML with abstract in body *)
let content = Bushel.Paper.to_yaml ?abstract ~ver:version json_without_abstract in
-
+
let filename = Printf.sprintf "%s.md" version in
let filepath = Filename.concat papers_dir filename in
let oc = open_out filepath in
output_string oc content;
close_out oc;
-
Printf.printf "Created paper file: %s\n" filepath;
-
Lwt.return ()
+
Printf.printf "Created paper file: %s\n" filepath
let slug_arg =
let doc = "Slug for the entry." in
···
(* Export the term for use in main bushel.ml *)
let term =
Term.(const (fun base slug version doi ->
-
let zt = ZT.v "http://svr-avsm2-eeg-ce:1969" in
-
Lwt_main.run @@ of_doi zt ~base_dir:base ~slug ~version doi; 0
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
(* Create Zotero Translation client with connection pooling *)
+
let zt = ZT.create ~sw ~env "http://svr-avsm2-eeg-ce:1969" in
+
of_doi zt ~base_dir:base ~slug ~version doi;
+
0
) $ Bushel_common.base_dir $ slug_arg $ version_arg $ doi_arg)
let cmd =
+20 -22
stack/bushel/bin/bushel_search.ml
···
open Cmdliner
-
open Lwt.Syntax
(** TODO:claude Bushel search command for integration with main CLI *)
···
Printf.printf "Query: \"%s\"\n" query_text;
Printf.printf "Limit: %d, Offset: %d\n" limit offset;
Printf.printf "\n";
-
-
Lwt_main.run (
-
Lwt.catch (fun () ->
-
let* result = Bushel.Typesense.multisearch config query_text ~limit:50 () in
-
match result with
-
| Ok multisearch_resp ->
-
let combined_response = Bushel.Typesense.combine_multisearch_results multisearch_resp ~limit ~offset () in
-
Printf.printf "Found %d results (%.2fms)\n\n" combined_response.total combined_response.query_time;
-
-
List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
-
Printf.printf "%d. %s (score: %.2f)\n" (i + 1) (Bushel.Typesense.pp_search_result_oneline hit) hit.Bushel.Typesense.score
-
) combined_response.hits;
-
Lwt.return_unit
-
| Error err ->
-
Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
-
exit 1
-
) (fun exn ->
-
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
(try
+
let result = Bushel.Typesense.multisearch ~sw ~env config query_text ~limit:50 () in
+
match result with
+
| Ok multisearch_resp ->
+
let combined_response = Bushel.Typesense.combine_multisearch_results multisearch_resp ~limit ~offset () in
+
Printf.printf "Found %d results (%.2fms)\n\n" combined_response.total combined_response.query_time;
+
+
List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
+
Printf.printf "%d. %s (score: %.2f)\n" (i + 1) (Bushel.Typesense.pp_search_result_oneline hit) hit.Bushel.Typesense.score
+
) combined_response.hits;
+
0
+
| Error err ->
+
Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
exit 1
-
)
-
);
-
0
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
exit 1
+
)
(** TODO:claude Command line term *)
let term = Term.(const search $ endpoint $ api_key $ query_text $ limit $ offset)
+53 -57
stack/bushel/bin/bushel_typesense.ml
···
open Cmdliner
-
open Lwt.Syntax
(** TODO:claude Bushel Typesense binary with upload and query functionality *)
···
Printf.printf "Uploading bushel data to Typesense at %s\n" endpoint;
-
Lwt_main.run (
-
Lwt.catch (fun () ->
-
Bushel.Typesense.upload_all config entries
-
) (fun exn ->
-
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
-
exit 1
-
)
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
(try
+
Bushel.Typesense.upload_all ~sw ~env config entries
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
exit 1
)
···
if collection <> "" then Printf.printf "Collection: %s\n" collection;
Printf.printf "Limit: %d, Offset: %d\n" limit offset;
Printf.printf "\n";
-
-
Lwt_main.run (
-
Lwt.catch (fun () ->
-
let search_fn = if collection = "" then
-
Bushel.Typesense.search_all config query_text ~limit ~offset
-
else
-
Bushel.Typesense.search_collection config collection query_text ~limit ~offset
-
in
-
let* result = search_fn () in
-
match result with
-
| Ok response ->
-
Printf.printf "Found %d results (%.2fms)\n\n" response.total response.query_time;
-
List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
-
Printf.printf "%d. [%s] %s (score: %.2f)\n" (i + 1) hit.collection hit.title hit.score;
-
if hit.content <> "" then Printf.printf " %s\n" hit.content;
-
if hit.highlights <> [] then (
-
Printf.printf " Highlights:\n";
-
List.iter (fun (field, snippets) ->
-
List.iter (fun snippet ->
-
Printf.printf " %s: %s\n" field snippet
-
) snippets
-
) hit.highlights
-
);
-
Printf.printf "\n"
-
) response.hits;
-
Lwt.return_unit
-
| Error err ->
-
Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
-
exit 1
-
) (fun exn ->
-
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
(try
+
let result = if collection = "" then
+
Bushel.Typesense.search_all ~sw ~env config query_text ~limit ~offset ()
+
else
+
Bushel.Typesense.search_collection ~sw ~env config collection query_text ~limit ~offset ()
+
in
+
match result with
+
| Ok response ->
+
Printf.printf "Found %d results (%.2fms)\n\n" response.total response.query_time;
+
List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
+
Printf.printf "%d. [%s] %s (score: %.2f)\n" (i + 1) hit.collection hit.title hit.score;
+
if hit.content <> "" then Printf.printf " %s\n" hit.content;
+
if hit.highlights <> [] then (
+
Printf.printf " Highlights:\n";
+
List.iter (fun (field, snippets) ->
+
List.iter (fun snippet ->
+
Printf.printf " %s: %s\n" field snippet
+
) snippets
+
) hit.highlights
+
);
+
Printf.printf "\n"
+
) response.hits
+
| Error err ->
+
Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
exit 1
-
)
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
exit 1
)
(** TODO:claude List collections function *)
···
);
Printf.printf "Listing collections at %s\n\n" config.endpoint;
-
-
Lwt_main.run (
-
Lwt.catch (fun () ->
-
let* result = Bushel.Typesense.list_collections config in
-
match result with
-
| Ok collections ->
-
Printf.printf "Collections:\n";
-
List.iter (fun (name, count) ->
-
Printf.printf " %s (%d documents)\n" name count
-
) collections;
-
Lwt.return_unit
-
| Error err ->
-
Format.eprintf "List error: %a\n" Bushel.Typesense.pp_error err;
-
exit 1
-
) (fun exn ->
-
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
(try
+
let result = Bushel.Typesense.list_collections ~sw ~env config in
+
match result with
+
| Ok collections ->
+
Printf.printf "Collections:\n";
+
List.iter (fun (name, count) ->
+
Printf.printf " %s (%d documents)\n" name count
+
) collections
+
| Error err ->
+
Format.eprintf "List error: %a\n" Bushel.Typesense.pp_error err;
exit 1
-
)
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
exit 1
)
(** TODO:claude Command line arguments for query *)
+43 -55
stack/bushel/bin/bushel_video.ml
···
[@@@warning "-26-27-32"]
-
open Lwt.Infix
open Cmdliner
let setup_log style_renderer level =
···
Logs.set_reporter (Logs_fmt.reporter ());
()
-
let process_videos output_dir overwrite base_url channel fetch_thumbs thumbs_dir =
-
Peertube.fetch_all_channel_videos base_url channel >>= fun all_videos ->
+
let process_videos ~sw ~env output_dir overwrite base_url channel fetch_thumbs thumbs_dir =
+
let all_videos = Peertubee.fetch_all_channel_videos ~sw ~env base_url channel in
Logs.info (fun f -> f "Total videos: %d" (List.length all_videos));
(* Create thumbnails directory if needed *)
···
Unix.mkdir thumbs_dir 0o755);
(* Process each video, fetching full details for complete descriptions *)
-
Lwt_list.map_s (fun video ->
+
let vids = List.map (fun video ->
(* Fetch complete video details to get full description *)
-
Peertube.fetch_video_details base_url video.Peertube.uuid >>= fun full_video ->
+
let full_video = Peertubee.fetch_video_details ~sw ~env base_url video.Peertubee.uuid in
let (description, published_date, title, url, uuid, slug) =
-
Peertube.to_bushel_video full_video
+
Peertubee.to_bushel_video full_video
in
Logs.info (fun f -> f "Title: %s, URL: %s" title url);
(* Download thumbnail if requested *)
(if fetch_thumbs then
let thumb_path = Filename.concat thumbs_dir (uuid ^ ".jpg") in
-
Peertube.download_thumbnail base_url full_video thumb_path >>= fun result ->
+
let result = Peertubee.download_thumbnail ~sw ~env base_url full_video thumb_path in
match result with
| Ok () ->
-
Logs.info (fun f -> f "Downloaded thumbnail for %s to %s" title thumb_path);
-
Lwt.return_unit
+
Logs.info (fun f -> f "Downloaded thumbnail for %s to %s" title thumb_path)
| Error (`Msg e) ->
-
Logs.warn (fun f -> f "Failed to download thumbnail for %s: %s" title e);
-
Lwt.return_unit
-
else
-
Lwt.return_unit) >>= fun () ->
+
Logs.warn (fun f -> f "Failed to download thumbnail for %s: %s" title e)
+
);
+
+
{Bushel.Video.description; published_date; title; url; uuid; slug;
+
talk=false; paper=None; project=None; tags=full_video.tags}
+
) all_videos in
-
Lwt.return {Bushel.Video.description; published_date; title; url; uuid; slug;
-
talk=false; paper=None; project=None; tags=full_video.tags}
-
) all_videos >>= fun vids ->
-
(* Write video files *)
-
Lwt_list.iter_s (fun video ->
+
List.iter (fun video ->
let file_path = Filename.concat output_dir (video.Bushel.Video.uuid ^ ".md") in
let file_exists = Sys.file_exists file_path in
-
+
if file_exists then
try
(* If file exists, load it to preserve specific fields *)
···
project = existing_video.project; (* Preserve project field *)
talk = existing_video.talk; (* Preserve talk field *)
} in
-
+
(* Write the merged video data *)
if overwrite then
match Bushel.Video.to_file output_dir merged_video with
-
| Ok () ->
-
Logs.info (fun f -> f "Updated video %s with preserved fields in %s"
-
merged_video.Bushel.Video.title file_path);
-
Lwt.return_unit
-
| Error (`Msg e) ->
-
Logs.err (fun f -> f "Failed to update video %s: %s"
-
merged_video.Bushel.Video.title e);
-
Lwt.return_unit
-
else begin
-
Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
-
video.Bushel.Video.title);
-
Lwt.return_unit
-
end
+
| Ok () ->
+
Logs.info (fun f -> f "Updated video %s with preserved fields in %s"
+
merged_video.Bushel.Video.title file_path)
+
| Error (`Msg e) ->
+
Logs.err (fun f -> f "Failed to update video %s: %s"
+
merged_video.Bushel.Video.title e)
+
else
+
Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
+
video.Bushel.Video.title)
with _ ->
(* If reading existing file fails, proceed with new data *)
if overwrite then
match Bushel.Video.to_file output_dir video with
-
| Ok () ->
-
Logs.info (fun f -> f "Wrote video %s to %s (existing file could not be read)"
-
video.Bushel.Video.title file_path);
-
Lwt.return_unit
-
| Error (`Msg e) ->
-
Logs.err (fun f -> f "Failed to write video %s: %s"
-
video.Bushel.Video.title e);
-
Lwt.return_unit
-
else begin
-
Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
-
video.Bushel.Video.title);
-
Lwt.return_unit
-
end
+
| Ok () ->
+
Logs.info (fun f -> f "Wrote video %s to %s (existing file could not be read)"
+
video.Bushel.Video.title file_path)
+
| Error (`Msg e) ->
+
Logs.err (fun f -> f "Failed to write video %s: %s"
+
video.Bushel.Video.title e)
+
else
+
Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
+
video.Bushel.Video.title)
else
(* If file doesn't exist, just write new data *)
match Bushel.Video.to_file output_dir video with
-
| Ok () ->
-
Logs.info (fun f -> f "Wrote new video %s to %s"
-
video.Bushel.Video.title file_path);
-
Lwt.return_unit
-
| Error (`Msg e) ->
-
Logs.err (fun f -> f "Failed to write video %s: %s"
-
video.Bushel.Video.title e);
-
Lwt.return_unit
+
| Ok () ->
+
Logs.info (fun f -> f "Wrote new video %s to %s"
+
video.Bushel.Video.title file_path)
+
| Error (`Msg e) ->
+
Logs.err (fun f -> f "Failed to write video %s: %s"
+
video.Bushel.Video.title e)
) vids
(* Command line arguments are now imported from Bushel_common *)
···
Arg.(value & opt string "images/videos" & info ["thumbs-dir"] ~docv:"DIR" ~doc)
in
Term.(const (fun output_dir overwrite base_url channel fetch_thumbs thumbs_dir () ->
-
Lwt_main.run (process_videos output_dir overwrite base_url channel fetch_thumbs thumbs_dir); 0)
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
process_videos ~sw ~env output_dir overwrite base_url channel fetch_thumbs thumbs_dir; 0)
$ Bushel_common.output_dir ~default:"." $
Bushel_common.overwrite $
Bushel_common.url_term ~default:"https://crank.recoil.org" ~doc:"PeerTube base URL" $
+12 -15
stack/bushel/bin/bushel_video_thumbs.ml
···
[@@@warning "-26-27-32"]
-
open Lwt.Infix
open Cmdliner
let setup_log style_renderer level =
···
Logs.set_reporter (Logs_fmt.reporter ());
()
-
let process_video_thumbs videos_dir thumbs_dir base_url =
+
let process_video_thumbs ~sw ~env videos_dir thumbs_dir base_url =
(* Ensure thumbnail directory exists *)
(if not (Sys.file_exists thumbs_dir) then
Unix.mkdir thumbs_dir 0o755);
···
Logs.info (fun f -> f "Found %d video files to process" (List.length video_files));
(* Process each video file *)
-
Lwt_list.iter_s (fun video_file ->
+
List.iter (fun video_file ->
try
(* Load existing video *)
let video = Bushel.Video.of_md video_file in
···
Logs.info (fun f -> f "Processing video: %s (UUID: %s)" video.title uuid);
(* Fetch video details from PeerTube to get thumbnail info *)
-
Peertube.fetch_video_details base_url uuid >>= fun peertube_video ->
+
let peertube_video = Peertubee.fetch_video_details ~sw ~env base_url uuid in
(* Download thumbnail *)
let thumb_path = Filename.concat thumbs_dir (uuid ^ ".jpg") in
-
Peertube.download_thumbnail base_url peertube_video thumb_path >>= fun result ->
+
let result = Peertubee.download_thumbnail ~sw ~env base_url peertube_video thumb_path in
match result with
| Ok () ->
Logs.info (fun f -> f "Downloaded thumbnail for %s to %s" video.title thumb_path);
(* Update video file with thumbnail_url field *)
-
(match Peertube.thumbnail_url base_url peertube_video with
+
(match Peertubee.thumbnail_url base_url peertube_video with
| Some url ->
-
Logs.info (fun f -> f "Thumbnail URL: %s" url);
-
Lwt.return_unit
+
Logs.info (fun f -> f "Thumbnail URL: %s" url)
| None ->
-
Logs.warn (fun f -> f "No thumbnail URL for video %s" video.title);
-
Lwt.return_unit)
+
Logs.warn (fun f -> f "No thumbnail URL for video %s" video.title))
| Error (`Msg e) ->
-
Logs.err (fun f -> f "Failed to download thumbnail for %s: %s" video.title e);
-
Lwt.return_unit
+
Logs.err (fun f -> f "Failed to download thumbnail for %s: %s" video.title e)
with exn ->
-
Logs.err (fun f -> f "Error processing %s: %s" video_file (Printexc.to_string exn));
-
Lwt.return_unit
+
Logs.err (fun f -> f "Error processing %s: %s" video_file (Printexc.to_string exn))
) video_files
let term =
···
Arg.(value & opt string "images/videos" & info ["thumbs-dir"; "t"] ~docv:"DIR" ~doc)
in
Term.(const (fun videos_dir thumbs_dir base_url () ->
-
Lwt_main.run (process_video_thumbs videos_dir thumbs_dir base_url); 0)
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
process_video_thumbs ~sw ~env videos_dir thumbs_dir base_url; 0)
$ videos_dir $
thumbs_dir $
Bushel_common.url_term ~default:"https://crank.recoil.org" ~doc:"PeerTube base URL" $
+2 -2
stack/bushel/bin/dune
···
(package bushel)
(modules bushel_main bushel_bibtex bushel_doi bushel_ideas bushel_info bushel_missing bushel_note_doi bushel_obsidian bushel_paper bushel_paper_classify bushel_paper_tex bushel_video bushel_video_thumbs bushel_thumbs bushel_faces bushel_links bushel_search)
(flags (:standard -w -69))
-
(libraries bushel bushel_common cmdliner cohttp-lwt-unix lwt.unix yaml ezjsonm zotero-translation peertube fmt fmt.cli fmt.tty logs logs.cli logs.fmt cmarkit karakeep uri unix ptime.clock.os crockford))
+
(libraries bushel bushel_common cmdliner eio eio_main yaml ezjsonm zotero-translation peertubee fmt fmt.cli fmt.tty logs logs.cli logs.fmt cmarkit karakeepe uri unix ptime.clock.os crockford immiche))
(executable
(name bushel_typesense)
···
(package bushel)
(modules bushel_typesense)
(flags (:standard -w -69))
-
(libraries bushel bushel_common cmdliner lwt.unix))
+
(libraries bushel bushel_common cmdliner eio eio_main))
+7 -5
stack/bushel/bushel.opam
···
"bytesrw"
"jekyll-format"
"yaml"
-
"lwt"
-
"cohttp-lwt-unix"
+
"eio"
+
"eio_main"
+
"requests"
"fmt"
-
"peertube"
-
"karakeep"
-
"typesense-client"
+
"peertubee"
+
"karakeepe"
+
"typesense-cliente"
+
"immiche"
"cmdliner" {>= "2.0.0"}
"odoc" {with-doc}
]
+7 -42
stack/bushel/dune-project
···
bytesrw
jekyll-format
yaml
-
lwt
-
cohttp-lwt-unix
+
eio
+
eio_main
+
requests
fmt
-
peertube
-
karakeep
-
typesense-client
+
peertubee
+
karakeepe
+
typesense-cliente
+
immiche
(cmdliner (>= 2.0.0))))
-
-
(package
-
(name peertube)
-
(synopsis "PeerTube API client")
-
(description "Client for interacting with PeerTube instances")
-
(depends
-
(ocaml (>= "5.2.0"))
-
ezjsonm
-
lwt
-
cohttp-lwt-unix
-
ptime
-
fmt))
-
-
(package
-
(name karakeep)
-
(synopsis "Karakeep API client for Bushel")
-
(description "Karakeep API client to retrieve bookmarks from Karakeep instances")
-
(depends
-
(ocaml (>= "5.2.0"))
-
ezjsonm
-
lwt
-
cohttp-lwt-unix
-
ptime
-
fmt))
-
-
(package
-
(name typesense-client)
-
(synopsis "Standalone Typesense client for OCaml")
-
(description "A standalone Typesense client that can be compiled to JavaScript")
-
(depends
-
(ocaml (>= "5.2.0"))
-
ezjsonm
-
lwt
-
cohttp-lwt-unix
-
ptime
-
fmt
-
uri))
-35
stack/bushel/karakeep.opam
···
-
# This file is generated by dune, edit dune-project instead
-
opam-version: "2.0"
-
synopsis: "Karakeep API client for Bushel"
-
description:
-
"Karakeep API client to retrieve bookmarks from Karakeep instances"
-
maintainer: ["anil@recoil.org"]
-
authors: ["Anil Madhavapeddy"]
-
license: "ISC"
-
homepage: "https://github.com/avsm/bushel"
-
bug-reports: "https://github.com/avsm/bushel/issues"
-
depends: [
-
"dune" {>= "3.17"}
-
"ocaml" {>= "5.2.0"}
-
"ezjsonm"
-
"lwt"
-
"cohttp-lwt-unix"
-
"ptime"
-
"fmt"
-
"odoc" {with-doc}
-
]
-
build: [
-
["dune" "subst"] {dev}
-
[
-
"dune"
-
"build"
-
"-p"
-
name
-
"-j"
-
jobs
-
"@install"
-
"@runtest" {with-test}
-
"@doc" {with-doc}
-
]
-
]
-
dev-repo: "git+https://github.com/avsm/bushel.git"
-4
stack/bushel/karakeep/dune
···
-
(library
-
(name karakeep)
-
(public_name karakeep)
-
(libraries bushel lwt cohttp cohttp-lwt-unix ezjsonm fmt ptime))
-568
stack/bushel/karakeep/karakeep.ml
···
-
(** Karakeep API client implementation *)
-
-
open Lwt.Infix
-
-
module J = Ezjsonm
-
-
(** Type representing a Karakeep bookmark *)
-
type bookmark = {
-
id: string;
-
title: string option;
-
url: string;
-
note: string option;
-
created_at: Ptime.t;
-
updated_at: Ptime.t option;
-
favourited: bool;
-
archived: bool;
-
tags: string list;
-
tagging_status: string option;
-
summary: string option;
-
content: (string * string) list;
-
assets: (string * string) list;
-
}
-
-
(** Type for Karakeep API response containing bookmarks *)
-
type bookmark_response = {
-
total: int;
-
data: bookmark list;
-
next_cursor: string option;
-
}
-
-
(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
-
let parse_date str =
-
match Ptime.of_rfc3339 str with
-
| Ok (date, _, _) -> date
-
| Error _ ->
-
Fmt.epr "Warning: could not parse date '%s'\n" str;
-
(* Default to epoch time *)
-
let span_opt = Ptime.Span.of_d_ps (0, 0L) in
-
match span_opt with
-
| None -> failwith "Internal error: couldn't create epoch time span"
-
| Some span ->
-
match Ptime.of_span span with
-
| Some t -> t
-
| None -> failwith "Internal error: couldn't create epoch time"
-
-
(** Extract a string field from JSON, returns None if not present or not a string *)
-
let get_string_opt json path =
-
try Some (J.find json path |> J.get_string)
-
with _ -> None
-
-
(** Extract a string list field from JSON, returns empty list if not present *)
-
let get_string_list json path =
-
try
-
let items_json = J.find json path in
-
J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json
-
with _ -> []
-
-
(** Extract a boolean field from JSON, with default value *)
-
let get_bool_def json path default =
-
try J.find json path |> J.get_bool
-
with _ -> default
-
-
(** Parse a single bookmark from Karakeep JSON *)
-
let parse_bookmark json =
-
(* Remove debug prints for production *)
-
(* Printf.eprintf "%s\n%!" (J.value_to_string json); *)
-
-
let id =
-
try J.find json ["id"] |> J.get_string
-
with e ->
-
prerr_endline (Fmt.str "Error parsing bookmark ID: %s" (Printexc.to_string e));
-
prerr_endline (Fmt.str "JSON: %s" (J.value_to_string json));
-
failwith "Unable to parse bookmark ID"
-
in
-
-
(* Title can be null *)
-
let title =
-
try Some (J.find json ["title"] |> J.get_string)
-
with _ -> None
-
in
-
(* Remove debug prints for production *)
-
(* Printf.eprintf "%s -> %s\n%!" id (match title with None -> "???" | Some v -> v); *)
-
(* Get URL - try all possible locations *)
-
let url =
-
try J.find json ["url"] |> J.get_string (* Direct url field *)
-
with _ -> try
-
J.find json ["content"; "url"] |> J.get_string (* Inside content.url *)
-
with _ -> try
-
J.find json ["content"; "sourceUrl"] |> J.get_string (* Inside content.sourceUrl *)
-
with _ ->
-
(* For assets/PDF type links *)
-
match J.find_opt json ["content"; "type"] with
-
| Some (`String "asset") ->
-
(* Extract URL from sourceUrl in content *)
-
(try J.find json ["content"; "sourceUrl"] |> J.get_string
-
with _ ->
-
(match J.find_opt json ["id"] with
-
| Some (`String id) -> "karakeep-asset://" ^ id
-
| _ -> failwith "No URL or asset ID found in bookmark"))
-
| _ ->
-
(* Debug output to understand what we're getting *)
-
prerr_endline (Fmt.str "Bookmark JSON structure: %s" (J.value_to_string json));
-
failwith "No URL found in bookmark"
-
in
-
-
let note = get_string_opt json ["note"] in
-
-
(* Parse dates *)
-
let created_at =
-
try J.find json ["createdAt"] |> J.get_string |> parse_date
-
with _ ->
-
try J.find json ["created_at"] |> J.get_string |> parse_date
-
with _ -> failwith "No creation date found"
-
in
-
-
let updated_at =
-
try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date)
-
with _ ->
-
try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date)
-
with _ -> None
-
in
-
-
let favourited = get_bool_def json ["favourited"] false in
-
let archived = get_bool_def json ["archived"] false in
-
let tags = get_string_list json ["tags"] in
-
-
(* Extract additional metadata *)
-
let tagging_status = get_string_opt json ["taggingStatus"] in
-
let summary = get_string_opt json ["summary"] in
-
-
(* Extract content details *)
-
let content =
-
try
-
let content_json = J.find json ["content"] in
-
let rec extract_fields acc = function
-
| [] -> acc
-
| (k, v) :: rest ->
-
let value = match v with
-
| `String s -> s
-
| `Bool b -> string_of_bool b
-
| `Float f -> string_of_float f
-
| `Null -> "null"
-
| _ -> "complex_value" (* For objects and arrays *)
-
in
-
extract_fields ((k, value) :: acc) rest
-
in
-
match content_json with
-
| `O fields -> extract_fields [] fields
-
| _ -> []
-
with _ -> []
-
in
-
-
(* Extract assets *)
-
let assets =
-
try
-
let assets_json = J.find json ["assets"] in
-
J.get_list (fun asset_json ->
-
let id = J.find asset_json ["id"] |> J.get_string in
-
let asset_type =
-
try J.find asset_json ["assetType"] |> J.get_string
-
with _ -> "unknown"
-
in
-
(id, asset_type)
-
) assets_json
-
with _ -> []
-
in
-
-
{ id; title; url; note; created_at; updated_at; favourited; archived; tags;
-
tagging_status; summary; content; assets }
-
-
(** Parse a Karakeep bookmark response *)
-
let parse_bookmark_response json =
-
(* The response format is different based on endpoint, need to handle both structures *)
-
(* Print the whole JSON structure for debugging *)
-
prerr_endline (Fmt.str "Full response JSON: %s" (J.value_to_string json));
-
-
try
-
(* Standard list format with total count *)
-
let total = J.find json ["total"] |> J.get_int in
-
let bookmarks_json = J.find json ["data"] in
-
prerr_endline "Found bookmarks in data array";
-
let data = J.get_list parse_bookmark bookmarks_json in
-
-
(* Try to extract nextCursor if available *)
-
let next_cursor =
-
try Some (J.find json ["nextCursor"] |> J.get_string)
-
with _ -> None
-
in
-
-
{ total; data; next_cursor }
-
with e1 ->
-
prerr_endline (Fmt.str "First format parse error: %s" (Printexc.to_string e1));
-
try
-
(* Format with bookmarks array *)
-
let bookmarks_json = J.find json ["bookmarks"] in
-
prerr_endline "Found bookmarks in bookmarks array";
-
let data =
-
try J.get_list parse_bookmark bookmarks_json
-
with e ->
-
prerr_endline (Fmt.str "Error parsing bookmarks array: %s" (Printexc.to_string e));
-
prerr_endline (Fmt.str "First bookmark sample: %s"
-
(try J.value_to_string (List.hd (J.get_list (fun x -> x) bookmarks_json))
-
with _ -> "Could not extract sample"));
-
[]
-
in
-
-
(* Try to extract nextCursor if available *)
-
let next_cursor =
-
try Some (J.find json ["nextCursor"] |> J.get_string)
-
with _ -> None
-
in
-
-
{ total = List.length data; data; next_cursor }
-
with e2 ->
-
prerr_endline (Fmt.str "Second format parse error: %s" (Printexc.to_string e2));
-
try
-
(* Check if it's an error response *)
-
let error = J.find json ["error"] |> J.get_string in
-
let message =
-
try J.find json ["message"] |> J.get_string
-
with _ -> "Unknown error"
-
in
-
prerr_endline (Fmt.str "API Error: %s - %s" error message);
-
{ total = 0; data = []; next_cursor = None }
-
with _ ->
-
try
-
(* Alternate format without total (for endpoints like /tags/<id>/bookmarks) *)
-
prerr_endline "Trying alternate array format";
-
-
(* Debug the structure to identify the format *)
-
prerr_endline (Fmt.str "JSON structure keys: %s"
-
(match json with
-
| `O fields ->
-
String.concat ", " (List.map (fun (k, _) -> k) fields)
-
| _ -> "not an object"));
-
-
(* Check if it has a nextCursor but bookmarks are nested differently *)
-
if J.find_opt json ["nextCursor"] <> None then begin
-
prerr_endline "Found nextCursor, checking alternate structures";
-
-
(* Try different bookmark container paths *)
-
let bookmarks_json =
-
try Some (J.find json ["data"])
-
with _ -> None
-
in
-
-
match bookmarks_json with
-
| Some json_array ->
-
prerr_endline "Found bookmarks in data field";
-
begin try
-
let data = J.get_list parse_bookmark json_array in
-
let next_cursor =
-
try Some (J.find json ["nextCursor"] |> J.get_string)
-
with _ -> None
-
in
-
{ total = List.length data; data; next_cursor }
-
with e ->
-
prerr_endline (Fmt.str "Error parsing bookmarks from data: %s" (Printexc.to_string e));
-
{ total = 0; data = []; next_cursor = None }
-
end
-
| None ->
-
prerr_endline "No bookmarks found in alternate structure";
-
{ total = 0; data = []; next_cursor = None }
-
end
-
else begin
-
(* Check if it's an array at root level *)
-
match json with
-
| `A _ ->
-
let data =
-
try J.get_list parse_bookmark json
-
with e ->
-
prerr_endline (Fmt.str "Error parsing root array: %s" (Printexc.to_string e));
-
[]
-
in
-
{ total = List.length data; data; next_cursor = None }
-
| _ ->
-
prerr_endline "Not an array at root level";
-
{ total = 0; data = []; next_cursor = None }
-
end
-
with e3 ->
-
prerr_endline (Fmt.str "Third format parse error: %s" (Printexc.to_string e3));
-
{ total = 0; data = []; next_cursor = None }
-
-
(** Helper function to consume and return response body data *)
-
let consume_body body =
-
Cohttp_lwt.Body.to_string body >>= fun _ ->
-
Lwt.return_unit
-
-
(** Fetch bookmarks from a Karakeep instance with pagination support *)
-
let fetch_bookmarks ~api_key ?(limit=50) ?(offset=0) ?cursor ?(include_content=false) ?filter_tags base_url =
-
let open Cohttp_lwt_unix in
-
-
(* Base URL for bookmarks API *)
-
let url_base = Fmt.str "%s/api/v1/bookmarks?limit=%d&includeContent=%b"
-
base_url limit include_content in
-
-
(* Add pagination parameter - either cursor or offset *)
-
let url =
-
match cursor with
-
| Some cursor_value ->
-
url_base ^ "&cursor=" ^ cursor_value
-
| None ->
-
url_base ^ "&offset=" ^ string_of_int offset
-
in
-
-
(* Add tags filter if provided *)
-
let url = match filter_tags with
-
| Some tags when tags <> [] ->
-
(* URL encode each tag and join with commas *)
-
let encoded_tags =
-
List.map (fun tag ->
-
Uri.pct_encode ~component:`Query_key tag
-
) tags
-
in
-
let tags_param = String.concat "," encoded_tags in
-
prerr_endline (Fmt.str "Adding tags filter: %s" tags_param);
-
url ^ "&tags=" ^ tags_param
-
| _ -> url
-
in
-
-
(* Set up headers with API key *)
-
let headers = Cohttp.Header.init ()
-
|> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in
-
-
prerr_endline (Fmt.str "Fetching bookmarks from: %s" url);
-
-
(* Make the request *)
-
Lwt.catch
-
(fun () ->
-
Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
prerr_endline (Fmt.str "Received %d bytes of response data" (String.length body_str));
-
-
Lwt.catch
-
(fun () ->
-
let json = J.from_string body_str in
-
Lwt.return (parse_bookmark_response json)
-
)
-
(fun e ->
-
prerr_endline (Fmt.str "JSON parsing error: %s" (Printexc.to_string e));
-
prerr_endline (Fmt.str "Response body (first 200 chars): %s"
-
(if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str));
-
Lwt.fail e
-
)
-
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
consume_body body >>= fun _ ->
-
prerr_endline (Fmt.str "HTTP error %d" status_code);
-
Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
-
)
-
(fun e ->
-
prerr_endline (Fmt.str "Network error: %s" (Printexc.to_string e));
-
Lwt.fail e
-
)
-
-
(** Fetch all bookmarks from a Karakeep instance using pagination *)
-
let fetch_all_bookmarks ~api_key ?(page_size=50) ?max_pages ?filter_tags ?(include_content=false) base_url =
-
let rec fetch_pages page_num cursor acc _total_count =
-
(* Use cursor if available, otherwise use offset-based pagination *)
-
(match cursor with
-
| Some cursor_str -> fetch_bookmarks ~api_key ~limit:page_size ~cursor:cursor_str ~include_content ?filter_tags base_url
-
| None -> fetch_bookmarks ~api_key ~limit:page_size ~offset:(page_num * page_size) ~include_content ?filter_tags base_url)
-
>>= fun response ->
-
-
let all_bookmarks = acc @ response.data in
-
-
(* Determine if we need to fetch more pages *)
-
let more_available =
-
match response.next_cursor with
-
| Some _ -> true (* We have a cursor, so there are more results *)
-
| None ->
-
(* Fall back to offset-based check *)
-
let fetched_count = (page_num * page_size) + List.length response.data in
-
fetched_count < response.total
-
in
-
-
let under_max_pages = match max_pages with
-
| None -> true
-
| Some max -> page_num + 1 < max
-
in
-
-
if more_available && under_max_pages then
-
fetch_pages (page_num + 1) response.next_cursor all_bookmarks response.total
-
else
-
Lwt.return all_bookmarks
-
in
-
fetch_pages 0 None [] 0
-
-
(** Fetch detailed information for a single bookmark by ID *)
-
let fetch_bookmark_details ~api_key base_url bookmark_id =
-
let open Cohttp_lwt_unix in
-
let url = Fmt.str "%s/api/v1/bookmarks/%s" base_url bookmark_id in
-
-
(* Set up headers with API key *)
-
let headers = Cohttp.Header.init ()
-
|> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in
-
-
Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
let json = J.from_string body_str in
-
Lwt.return (parse_bookmark json)
-
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
consume_body body >>= fun () ->
-
Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
-
-
(** Get the asset URL for a given asset ID *)
-
let get_asset_url base_url asset_id =
-
Fmt.str "%s/api/assets/%s" base_url asset_id
-
-
(** Fetch an asset from the Karakeep server as a binary string *)
-
let fetch_asset ~api_key base_url asset_id =
-
let open Cohttp_lwt_unix in
-
-
let url = get_asset_url base_url asset_id in
-
-
(* Set up headers with API key *)
-
let headers = Cohttp.Header.init ()
-
|> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in
-
-
Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body
-
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
consume_body body >>= fun () ->
-
Lwt.fail_with (Fmt.str "Asset fetch error: %d" status_code)
-
-
(** Create a new bookmark in Karakeep with optional tags *)
-
let create_bookmark ~api_key ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) base_url =
-
let open Cohttp_lwt_unix in
-
-
(* Prepare the bookmark request body *)
-
let body_obj = [
-
("type", `String "link");
-
("url", `String url);
-
("favourited", `Bool favourited);
-
("archived", `Bool archived);
-
] in
-
-
(* Add optional fields *)
-
let body_obj = match title with
-
| Some title_str -> ("title", `String title_str) :: body_obj
-
| None -> body_obj
-
in
-
-
let body_obj = match note with
-
| Some note_str -> ("note", `String note_str) :: body_obj
-
| None -> body_obj
-
in
-
-
(* Convert to JSON *)
-
let body_json = `O body_obj in
-
let body_str = J.to_string body_json in
-
-
(* Set up headers with API key *)
-
let headers = Cohttp.Header.init ()
-
|> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key)
-
|> fun h -> Cohttp.Header.add h "Content-Type" "application/json"
-
in
-
-
(* Helper function to ensure we consume all response body data *)
-
let consume_body body =
-
Cohttp_lwt.Body.to_string body >>= fun _ ->
-
Lwt.return_unit
-
in
-
-
(* Create the bookmark *)
-
let url_endpoint = Fmt.str "%s/api/v1/bookmarks" base_url in
-
Client.post ~headers ~body:(Cohttp_lwt.Body.of_string body_str) (Uri.of_string url_endpoint) >>= fun (resp, body) ->
-
-
if resp.status = `Created || resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
let json = J.from_string body_str in
-
let bookmark = parse_bookmark json in
-
-
(* If tags are provided, add them to the bookmark *)
-
(match tags with
-
| Some tag_list when tag_list <> [] ->
-
(* Prepare the tags request body *)
-
let tag_objects = List.map (fun tag_name ->
-
`O [("tagName", `String tag_name)]
-
) tag_list in
-
-
let tags_body = `O [("tags", `A tag_objects)] in
-
let tags_body_str = J.to_string tags_body in
-
-
(* Add tags to the bookmark *)
-
let tags_url = Fmt.str "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id in
-
Client.post ~headers ~body:(Cohttp_lwt.Body.of_string tags_body_str) (Uri.of_string tags_url) >>= fun (resp, body) ->
-
-
(* Always consume the response body *)
-
consume_body body >>= fun () ->
-
-
if resp.status = `OK then
-
(* Fetch the bookmark again to get updated tags *)
-
fetch_bookmark_details ~api_key base_url bookmark.id
-
else
-
(* Return the bookmark without tags if tag addition failed *)
-
Lwt.return bookmark
-
| _ -> Lwt.return bookmark)
-
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
Cohttp_lwt.Body.to_string body >>= fun error_body ->
-
Lwt.fail_with (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
-
-
(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *)
-
let to_bushel_link ?base_url bookmark =
-
(* Try to find the best title from multiple possible sources *)
-
let description =
-
match bookmark.title with
-
| Some title when title <> "" -> title
-
| _ ->
-
(* Check if there's a title in the content *)
-
let content_title = List.assoc_opt "title" bookmark.content in
-
match content_title with
-
| Some title when title <> "" && title <> "null" -> title
-
| _ -> bookmark.url
-
in
-
let date = Ptime.to_date bookmark.created_at in
-
-
(* Build selective metadata - only include useful fields *)
-
let metadata =
-
(match bookmark.summary with Some s -> [("summary", s)] | None -> []) @
-
(* Extract key asset IDs *)
-
(List.filter_map (fun (id, asset_type) ->
-
match asset_type with
-
| "screenshot" | "bannerImage" -> Some (asset_type, id)
-
| _ -> None
-
) bookmark.assets) @
-
(* Extract only the favicon from content *)
-
(List.filter_map (fun (k, v) ->
-
if k = "favicon" && v <> "" && v <> "null" then Some ("favicon", v) else None
-
) bookmark.content)
-
in
-
-
(* Create karakeep data if base_url is provided *)
-
let karakeep =
-
match base_url with
-
| Some url ->
-
Some {
-
Bushel.Link.remote_url = url;
-
id = bookmark.id;
-
tags = bookmark.tags;
-
metadata = metadata;
-
}
-
| None -> None
-
in
-
-
(* Extract bushel slugs from tags *)
-
let bushel_slugs =
-
List.filter_map (fun tag ->
-
if String.starts_with ~prefix:"bushel:" tag then
-
Some (String.sub tag 7 (String.length tag - 7))
-
else
-
None
-
) bookmark.tags
-
in
-
-
(* Create bushel data if we have bushel-related information *)
-
let bushel =
-
if bushel_slugs = [] then None
-
else Some { Bushel.Link.slugs = bushel_slugs; tags = [] }
-
in
-
-
{ Bushel.Link.url = bookmark.url; date; description; karakeep; bushel }
+44 -24
stack/bushel/karakeep/karakeep.mli stack/karakeepe/karakeepe.mli
···
-
(** Karakeep API client interface *)
+
(** Karakeepe API client interface (Eio version) *)
(** Type representing a Karakeep bookmark *)
type bookmark = {
···
val parse_bookmark_response : Ezjsonm.value -> bookmark_response
(** Fetch bookmarks from a Karakeep instance with pagination support
+
@param sw Eio switch for resource management
+
@param env Eio environment (provides clock and network)
@param api_key API key for authentication
@param limit Number of bookmarks to fetch per page (default: 50)
@param offset Starting index for pagination (0-based) (default: 0)
···
@param include_content Whether to include full content (default: false)
@param filter_tags Optional list of tags to filter by
@param base_url Base URL of the Karakeep instance
-
@return A Lwt promise with the bookmark response *)
-
val fetch_bookmarks :
-
api_key:string ->
-
?limit:int ->
-
?offset:int ->
+
@return The bookmark response *)
+
val fetch_bookmarks :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
api_key:string ->
+
?limit:int ->
+
?offset:int ->
?cursor:string ->
?include_content:bool ->
?filter_tags:string list ->
-
string ->
-
bookmark_response Lwt.t
+
string ->
+
bookmark_response
(** Fetch all bookmarks from a Karakeep instance using pagination
+
@param sw Eio switch for resource management
+
@param env Eio environment (provides clock and network)
@param api_key API key for authentication
@param page_size Number of bookmarks to fetch per page (default: 50)
@param max_pages Maximum number of pages to fetch (None for all pages)
@param filter_tags Optional list of tags to filter by
@param include_content Whether to include full content (default: false)
@param base_url Base URL of the Karakeep instance
-
@return A Lwt promise with all bookmarks combined *)
-
val fetch_all_bookmarks :
-
api_key:string ->
-
?page_size:int ->
-
?max_pages:int ->
+
@return All bookmarks combined *)
+
val fetch_all_bookmarks :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
api_key:string ->
+
?page_size:int ->
+
?max_pages:int ->
?filter_tags:string list ->
?include_content:bool ->
-
string ->
-
bookmark list Lwt.t
+
string ->
+
bookmark list
(** Fetch detailed information for a single bookmark by ID
+
@param sw Eio switch for resource management
+
@param env Eio environment (provides clock and network)
@param api_key API key for authentication
@param base_url Base URL of the Karakeep instance
@param bookmark_id ID of the bookmark to fetch
-
@return A Lwt promise with the complete bookmark details *)
-
val fetch_bookmark_details :
+
@return The complete bookmark details *)
+
val fetch_bookmark_details :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic] Eio.Net.ty ] Eio.Resource.t; .. > ->
api_key:string ->
-
string ->
-
string ->
-
bookmark Lwt.t
+
string ->
+
string ->
+
bookmark
(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure
@param base_url Optional base URL of the Karakeep instance (for karakeep_id) *)
val to_bushel_link : ?base_url:string -> bookmark -> Bushel.Link.t
(** Fetch an asset from the Karakeep server as a binary string
+
@param sw Eio switch for resource management
+
@param env Eio environment (provides clock and network)
@param api_key API key for authentication
@param base_url Base URL of the Karakeep instance
@param asset_id ID of the asset to fetch
-
@return A Lwt promise with the binary asset data *)
+
@return The binary asset data *)
val fetch_asset :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic] Eio.Net.ty ] Eio.Resource.t; .. > ->
api_key:string ->
string ->
string ->
-
string Lwt.t
+
string
(** Get the asset URL for a given asset ID
@param base_url Base URL of the Karakeep instance
···
string
(** Create a new bookmark in Karakeep with optional tags
+
@param sw Eio switch for resource management
+
@param env Eio environment (provides clock and network)
@param api_key API key for authentication
@param url The URL to bookmark
@param title Optional title for the bookmark
···
@param favourited Whether the bookmark should be marked as favourite (default: false)
@param archived Whether the bookmark should be archived (default: false)
@param base_url Base URL of the Karakeep instance
-
@return A Lwt promise with the created bookmark *)
+
@return The created bookmark *)
val create_bookmark :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic] Eio.Net.ty ] Eio.Resource.t; .. > ->
api_key:string ->
url:string ->
?title:string ->
···
?favourited:bool ->
?archived:bool ->
string ->
-
bookmark Lwt.t
+
bookmark
+4 -3
stack/bushel/lib/dune
···
ptime
yaml.unix
jekyll-format
-
lwt
-
cohttp-lwt-unix
+
eio
+
eio_main
+
requests
fmt
re
ptime.clock
ptime.clock.os
-
typesense-client))
+
typesense-cliente))
+100 -99
stack/bushel/lib/typesense.ml
···
-
open Lwt.Syntax
-
open Cohttp_lwt_unix
-
(** TODO:claude Typesense API client for Bushel *)
type config = {
···
openai_key : string;
}
-
type error =
+
type error =
| Http_error of int * string
| Json_error of string
| Connection_error of string
···
(** TODO:claude Create authentication headers for Typesense API *)
let auth_headers api_key =
-
Cohttp.Header.of_list [
-
("X-TYPESENSE-API-KEY", api_key);
-
("Content-Type", "application/json");
-
]
+
Requests.Headers.empty
+
|> Requests.Headers.set "X-TYPESENSE-API-KEY" api_key
+
|> Requests.Headers.set "Content-Type" "application/json"
(** TODO:claude Make HTTP request to Typesense API *)
-
let make_request ?(meth=`GET) ?(body="") config path =
+
let make_request ~sw ~env ?(meth=`GET) ?(body="") config path =
let uri = Uri.of_string (config.endpoint ^ path) in
let headers = auth_headers config.api_key in
-
let body = if body = "" then `Empty else `String body in
-
Lwt.catch (fun () ->
-
let* resp, body = Client.call ~headers ~body meth uri in
-
let status = Cohttp.Code.code_of_status (Response.status resp) in
-
let* body_str = Cohttp_lwt.Body.to_string body in
+
let body = if body = "" then None else Some (Requests.Body.of_string Requests.Mime.json body) in
+
+
try
+
let response = Requests.One.request ~sw
+
~clock:env#clock ~net:env#net
+
?body
+
~headers
+
~method_:meth
+
(Uri.to_string uri)
+
in
+
+
let status = Requests.Response.status_code response in
+
let body_flow = Requests.Response.body response in
+
let body_str = Eio.Flow.read_all body_flow in
+
if status >= 200 && status < 300 then
-
Lwt.return_ok body_str
+
Ok body_str
else
-
Lwt.return_error (Http_error (status, body_str))
-
) (fun exn ->
-
Lwt.return_error (Connection_error (Printexc.to_string exn))
-
)
+
Error (Http_error (status, body_str))
+
with exn ->
+
Error (Connection_error (Printexc.to_string exn))
(** TODO:claude Create a collection with given schema *)
-
let create_collection config (schema : Ezjsonm.value) =
+
let create_collection ~sw ~env config (schema : Ezjsonm.value) =
let body = Ezjsonm.value_to_string schema in
-
make_request ~meth:`POST ~body config "/collections"
+
make_request ~sw ~env ~meth:`POST ~body config "/collections"
(** TODO:claude Check if collection exists *)
-
let collection_exists config name =
-
let* result = make_request config ("/collections/" ^ name) in
+
let collection_exists ~sw ~env config name =
+
let result = make_request ~sw ~env config ("/collections/" ^ name) in
match result with
-
| Ok _ -> Lwt.return true
-
| Error (Http_error (404, _)) -> Lwt.return false
-
| Error _ -> Lwt.return false
+
| Ok _ -> true
+
| Error (Http_error (404, _)) -> false
+
| Error _ -> false
(** TODO:claude Delete a collection *)
-
let delete_collection config name =
-
make_request ~meth:`DELETE config ("/collections/" ^ name)
+
let delete_collection ~sw ~env config name =
+
make_request ~sw ~env ~meth:`DELETE config ("/collections/" ^ name)
(** TODO:claude Upload documents to a collection in batch *)
-
let upload_documents config collection_name (documents : Ezjsonm.value list) =
+
let upload_documents ~sw ~env config collection_name (documents : Ezjsonm.value list) =
let jsonl_lines = List.map (fun doc -> Ezjsonm.value_to_string doc) documents in
let body = String.concat "\n" jsonl_lines in
-
make_request ~meth:`POST ~body config
+
make_request ~sw ~env ~meth:`POST ~body config
(Printf.sprintf "/collections/%s/documents/import?action=upsert" collection_name)
···
dict updated_schema
(** TODO:claude Upload all bushel objects to their respective collections *)
-
let upload_all config entries =
-
let* () = Lwt_io.write Lwt_io.stdout "Uploading bushel data to Typesense\n" in
+
let upload_all ~sw ~env config entries =
+
print_string "Uploading bushel data to Typesense\n";
let contacts = Entry.contacts entries in
let papers = Entry.papers entries in
···
] in
let upload_collection ((name, schema, documents) : string * Ezjsonm.value * Ezjsonm.value list) =
-
let* () = Lwt_io.write Lwt_io.stdout (Fmt.str "Processing collection: %s\n" name) in
-
let* exists = collection_exists config name in
-
let* () =
-
if exists then (
-
let* () = Lwt_io.write Lwt_io.stdout (Fmt.str "Collection %s exists, deleting...\n" name) in
-
let* result = delete_collection config name in
-
match result with
-
| Ok _ -> Lwt_io.write Lwt_io.stdout (Fmt.str "Deleted collection %s\n" name)
-
| Error err ->
-
let err_str = Fmt.str "%a" pp_error err in
-
Lwt_io.write Lwt_io.stdout (Fmt.str "Failed to delete collection %s: %s\n" name err_str)
-
) else
-
Lwt.return_unit
-
in
-
let* () = Lwt_io.write Lwt_io.stdout (Fmt.str "Creating collection %s with %d documents\n" name (List.length documents)) in
-
let* result = create_collection config schema in
+
Printf.printf "Processing collection: %s\n%!" name;
+
let exists = collection_exists ~sw ~env config name in
+
(if exists then (
+
Printf.printf "Collection %s exists, deleting...\n%!" name;
+
let result = delete_collection ~sw ~env config name in
+
match result with
+
| Ok _ -> Printf.printf "Deleted collection %s\n%!" name
+
| Error err ->
+
let err_str = Fmt.str "%a" pp_error err in
+
Printf.printf "Failed to delete collection %s: %s\n%!" name err_str
+
));
+
Printf.printf "Creating collection %s with %d documents\n%!" name (List.length documents);
+
let result = create_collection ~sw ~env config schema in
match result with
| Ok _ ->
-
let* () = Lwt_io.write Lwt_io.stdout (Fmt.str "Created collection %s\n" name) in
+
Printf.printf "Created collection %s\n%!" name;
if documents = [] then
-
Lwt_io.write Lwt_io.stdout (Fmt.str "No documents to upload for %s\n" name)
+
Printf.printf "No documents to upload for %s\n%!" name
else (
-
let* result = upload_documents config name documents in
+
let result = upload_documents ~sw ~env config name documents in
match result with
-
| Ok response ->
+
| Ok response ->
(* Count successes and failures *)
let lines = String.split_on_char '\n' response in
-
let successes = List.fold_left (fun acc line ->
+
let successes = List.fold_left (fun acc line ->
if String.contains line ':' && Str.string_match (Str.regexp ".*success.*true.*") line 0 then acc + 1 else acc) 0 lines in
-
let failures = List.fold_left (fun acc line ->
+
let failures = List.fold_left (fun acc line ->
if String.contains line ':' && Str.string_match (Str.regexp ".*success.*false.*") line 0 then acc + 1 else acc) 0 lines in
-
let* () = Lwt_io.write Lwt_io.stdout (Fmt.str "Upload results for %s: %d successful, %d failed out of %d total\n"
-
name successes failures (List.length documents)) in
-
if failures > 0 then
-
let* () = Lwt_io.write Lwt_io.stdout (Fmt.str "Failed documents in %s:\n" name) in
+
Printf.printf "Upload results for %s: %d successful, %d failed out of %d total\n%!"
+
name successes failures (List.length documents);
+
if failures > 0 then (
+
Printf.printf "Failed documents in %s:\n%!" name;
let failed_lines = List.filter (fun line -> Str.string_match (Str.regexp ".*success.*false.*") line 0) lines in
-
Lwt_list.iter_s (fun line -> Lwt_io.write Lwt_io.stdout (line ^ "\n")) failed_lines
-
else
-
Lwt.return_unit
-
| Error err ->
+
List.iter (fun line -> Printf.printf "%s\n%!" line) failed_lines
+
)
+
| Error err ->
let err_str = Fmt.str "%a" pp_error err in
-
Lwt_io.write Lwt_io.stdout (Fmt.str "Failed to upload documents to %s: %s\n" name err_str)
+
Printf.printf "Failed to upload documents to %s: %s\n%!" name err_str
)
| Error err ->
let err_str = Fmt.str "%a" pp_error err in
-
Lwt_io.write Lwt_io.stdout (Fmt.str "Failed to create collection %s: %s\n" name err_str)
+
Printf.printf "Failed to create collection %s: %s\n%!" name err_str
in
-
Lwt_list.iter_s upload_collection collections
+
List.iter upload_collection collections
-
(** TODO:claude Re-export search types from Typesense_client *)
-
type search_result = Typesense_client.search_result = {
+
(** TODO:claude Re-export search types from Typesense_cliente *)
+
type search_result = Typesense_cliente.search_result = {
id: string;
title: string;
content: string;
···
document: Ezjsonm.value;
}
-
type search_response = Typesense_client.search_response = {
+
type search_response = Typesense_cliente.search_response = {
hits: search_result list;
total: int;
query_time: float;
···
(** TODO:claude Convert bushel config to client config *)
let to_client_config (config : config) =
-
Typesense_client.{ endpoint = config.endpoint; api_key = config.api_key }
+
Typesense_cliente.{ endpoint = config.endpoint; api_key = config.api_key }
(** TODO:claude Search a single collection *)
-
let search_collection (config : config) collection_name query ?(limit=10) ?(offset=0) () =
+
let search_collection ~sw ~env (config : config) collection_name query ?(limit=10) ?(offset=0) () =
let client_config = to_client_config config in
-
let* result = Typesense_client.search_collection client_config collection_name query ~limit ~offset () in
+
let result = Typesense_cliente.search_collection ~sw ~env client_config collection_name query ~limit ~offset () in
match result with
-
| Ok response -> Lwt.return_ok response
-
| Error (Typesense_client.Http_error (code, msg)) -> Lwt.return_error (Http_error (code, msg))
-
| Error (Typesense_client.Json_error msg) -> Lwt.return_error (Json_error msg)
-
| Error (Typesense_client.Connection_error msg) -> Lwt.return_error (Connection_error msg)
+
| Ok response -> Ok response
+
| Error (Typesense_cliente.Http_error (code, msg)) -> Error (Http_error (code, msg))
+
| Error (Typesense_cliente.Json_error msg) -> Error (Json_error msg)
+
| Error (Typesense_cliente.Connection_error msg) -> Error (Connection_error msg)
(** TODO:claude Search across all collections - use client multisearch *)
-
let search_all (config : config) query ?(limit=10) ?(offset=0) () =
+
let search_all ~sw ~env (config : config) query ?(limit=10) ?(offset=0) () =
let client_config = to_client_config config in
-
let* result = Typesense_client.multisearch client_config query ~limit:50 () in
+
let result = Typesense_cliente.multisearch ~sw ~env client_config query ~limit:50 () in
match result with
| Ok multisearch_resp ->
-
let combined_response = Typesense_client.combine_multisearch_results multisearch_resp ~limit ~offset () in
-
Lwt.return_ok combined_response
-
| Error (Typesense_client.Http_error (code, msg)) -> Lwt.return_error (Http_error (code, msg))
-
| Error (Typesense_client.Json_error msg) -> Lwt.return_error (Json_error msg)
-
| Error (Typesense_client.Connection_error msg) -> Lwt.return_error (Connection_error msg)
+
let combined_response = Typesense_cliente.combine_multisearch_results multisearch_resp ~limit ~offset () in
+
Ok combined_response
+
| Error (Typesense_cliente.Http_error (code, msg)) -> Error (Http_error (code, msg))
+
| Error (Typesense_cliente.Json_error msg) -> Error (Json_error msg)
+
| Error (Typesense_cliente.Connection_error msg) -> Error (Connection_error msg)
(** TODO:claude List all collections *)
-
let list_collections (config : config) =
+
let list_collections ~sw ~env (config : config) =
let client_config = to_client_config config in
-
let* result = Typesense_client.list_collections client_config in
+
let result = Typesense_cliente.list_collections ~sw ~env client_config in
match result with
-
| Ok collections -> Lwt.return_ok collections
-
| Error (Typesense_client.Http_error (code, msg)) -> Lwt.return_error (Http_error (code, msg))
-
| Error (Typesense_client.Json_error msg) -> Lwt.return_error (Json_error msg)
-
| Error (Typesense_client.Connection_error msg) -> Lwt.return_error (Connection_error msg)
+
| Ok collections -> Ok collections
+
| Error (Typesense_cliente.Http_error (code, msg)) -> Error (Http_error (code, msg))
+
| Error (Typesense_cliente.Json_error msg) -> Error (Json_error msg)
+
| Error (Typesense_cliente.Connection_error msg) -> Error (Connection_error msg)
-
(** TODO:claude Re-export multisearch types from Typesense_client *)
-
type multisearch_response = Typesense_client.multisearch_response = {
+
(** TODO:claude Re-export multisearch types from Typesense_cliente *)
+
type multisearch_response = Typesense_cliente.multisearch_response = {
results: search_response list;
}
(** TODO:claude Perform multisearch across all collections *)
-
let multisearch (config : config) query ?(limit=10) () =
+
let multisearch ~sw ~env (config : config) query ?(limit=10) () =
let client_config = to_client_config config in
-
let* result = Typesense_client.multisearch client_config query ~limit () in
+
let result = Typesense_cliente.multisearch ~sw ~env client_config query ~limit () in
match result with
-
| Ok multisearch_resp -> Lwt.return_ok multisearch_resp
-
| Error (Typesense_client.Http_error (code, msg)) -> Lwt.return_error (Http_error (code, msg))
-
| Error (Typesense_client.Json_error msg) -> Lwt.return_error (Json_error msg)
-
| Error (Typesense_client.Connection_error msg) -> Lwt.return_error (Connection_error msg)
+
| Ok multisearch_resp -> Ok multisearch_resp
+
| Error (Typesense_cliente.Http_error (code, msg)) -> Error (Http_error (code, msg))
+
| Error (Typesense_cliente.Json_error msg) -> Error (Json_error msg)
+
| Error (Typesense_cliente.Connection_error msg) -> Error (Connection_error msg)
(** TODO:claude Combine multisearch results into single result set *)
let combine_multisearch_results (multisearch_resp : multisearch_response) ?(limit=10) ?(offset=0) () =
-
Typesense_client.combine_multisearch_results multisearch_resp ~limit ~offset ()
+
Typesense_cliente.combine_multisearch_results multisearch_resp ~limit ~offset ()
(** TODO:claude Load configuration from files *)
let load_config_from_files () =
···
{ endpoint; api_key; openai_key }
-
(** TODO:claude Re-export pretty printer from Typesense_client *)
-
let pp_search_result_oneline = Typesense_client.pp_search_result_oneline
+
(** TODO:claude Re-export pretty printer from Typesense_cliente *)
+
let pp_search_result_oneline = Typesense_cliente.pp_search_result_oneline
+73 -17
stack/bushel/lib/typesense.mli
···
(** Typesense API client for Bushel
-
+
This module provides an OCaml client for the Typesense search engine API.
It handles collection management and document indexing for all Bushel object
types including contacts, papers, projects, news, videos, notes, and ideas.
-
+
Example usage:
{[
-
let config = { endpoint = "https://search.example.com"; api_key = "xyz123" } in
-
Lwt_main.run (Typesense.upload_all config "/path/to/bushel/data")
+
let config = { endpoint = "https://search.example.com"; api_key = "xyz123"; openai_key = "sk-..." } in
+
Eio_main.run (fun env ->
+
Eio.Switch.run (fun sw ->
+
Typesense.upload_all ~sw ~env config entries))
]}
-
+
TODO:claude *)
(** Configuration for connecting to a Typesense server *)
···
}
(** Possible errors that can occur during Typesense operations *)
-
type error =
+
type error =
| Http_error of int * string (** HTTP error with status code and message *)
| Json_error of string (** JSON parsing or encoding error *)
| Connection_error of string (** Network connection error *)
···
(** Pretty-printer for error types *)
val pp_error : Format.formatter -> error -> unit
-
(** Create a collection with the given schema.
+
(** Create a collection with the given schema.
The schema should follow Typesense's collection schema format.
TODO:claude *)
-
val create_collection : config -> Ezjsonm.value -> (string, error) result Lwt.t
+
val create_collection :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
Ezjsonm.value ->
+
(string, error) result
-
(** Check if a collection exists by name.
+
(** Check if a collection exists by name.
Returns true if the collection exists, false otherwise.
TODO:claude *)
-
val collection_exists : config -> string -> bool Lwt.t
+
val collection_exists :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
bool
(** Delete a collection by name.
TODO:claude *)
-
val delete_collection : config -> string -> (string, error) result Lwt.t
+
val delete_collection :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
(string, error) result
(** Upload documents to a collection in batch using JSONL format.
More efficient than uploading documents one by one.
TODO:claude *)
-
val upload_documents : config -> string -> Ezjsonm.value list -> (string, error) result Lwt.t
+
val upload_documents :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
Ezjsonm.value list ->
+
(string, error) result
(** Upload all bushel objects to Typesense.
This function will:
···
- Upload all documents in batches
- Report progress to stdout
TODO:claude *)
-
val upload_all : config -> Entry.t -> unit Lwt.t
+
val upload_all :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
Entry.t ->
+
unit
(** Search result structure containing document information and relevance score *)
type search_result = {
···
(** Search a specific collection.
TODO:claude *)
-
val search_collection : config -> string -> string -> ?limit:int -> ?offset:int -> unit -> (search_response, error) result Lwt.t
+
val search_collection :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
string ->
+
?limit:int ->
+
?offset:int ->
+
unit ->
+
(search_response, error) result
(** Search across all bushel collections.
Results are sorted by relevance score and paginated.
TODO:claude *)
-
val search_all : config -> string -> ?limit:int -> ?offset:int -> unit -> (search_response, error) result Lwt.t
+
val search_all :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
?limit:int ->
+
?offset:int ->
+
unit ->
+
(search_response, error) result
(** Multisearch response containing results from multiple collections *)
type multisearch_response = {
···
(** Perform multisearch across all collections using Typesense's multi_search endpoint.
More efficient than individual searches as it's done in a single request.
TODO:claude *)
-
val multisearch : config -> string -> ?limit:int -> unit -> (multisearch_response, error) result Lwt.t
+
val multisearch :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
?limit:int ->
+
unit ->
+
(multisearch_response, error) result
(** Combine multisearch results into a single result set.
Results are sorted by relevance score and paginated.
···
(** List all collections with document counts.
Returns a list of (collection_name, document_count) pairs.
TODO:claude *)
-
val list_collections : config -> ((string * int) list, error) result Lwt.t
+
val list_collections :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
((string * int) list, error) result
(** Load configuration from .typesense-url and .typesense-api files.
Falls back to environment variables and defaults.
-34
stack/bushel/peertube.opam
···
-
# This file is generated by dune, edit dune-project instead
-
opam-version: "2.0"
-
synopsis: "PeerTube API client"
-
description: "Client for interacting with PeerTube instances"
-
maintainer: ["anil@recoil.org"]
-
authors: ["Anil Madhavapeddy"]
-
license: "ISC"
-
homepage: "https://github.com/avsm/bushel"
-
bug-reports: "https://github.com/avsm/bushel/issues"
-
depends: [
-
"dune" {>= "3.17"}
-
"ocaml" {>= "5.2.0"}
-
"ezjsonm"
-
"lwt"
-
"cohttp-lwt-unix"
-
"ptime"
-
"fmt"
-
"odoc" {with-doc}
-
]
-
build: [
-
["dune" "subst"] {dev}
-
[
-
"dune"
-
"build"
-
"-p"
-
name
-
"-j"
-
jobs
-
"@install"
-
"@runtest" {with-test}
-
"@doc" {with-doc}
-
]
-
]
-
dev-repo: "git+https://github.com/avsm/bushel.git"
-4
stack/bushel/peertube/dune
···
-
(library
-
(name peertube)
-
(public_name peertube)
-
(libraries ezjsonm lwt cohttp-lwt-unix ptime fmt))
+57 -63
stack/bushel/peertube/peertube.ml stack/peertubee/peertubee.ml
···
-
(** PeerTube API client implementation
-
TODO:claude *)
-
-
open Lwt.Infix
+
(** PeerTube API client implementation (Eio version) *)
module J = Ezjsonm
···
let parse_date str =
match Ptime.of_rfc3339 str with
| Ok (date, _, _) -> date
-
| Error _ ->
+
| Error _ ->
Fmt.epr "Warning: could not parse date '%s'\n" str;
(* Default to epoch time *)
let span_opt = Ptime.Span.of_d_ps (0, 0L) in
···
(** Extract a string list field from JSON, returns empty list if not present *)
let get_string_list json path =
-
try
+
try
let tags_json = J.find json path in
J.get_list J.get_string tags_json
with _ -> []
···
let description = get_string_opt json ["description"] in
let url = J.find json ["url"] |> J.get_string in
let embed_path = J.find json ["embedPath"] |> J.get_string in
-
+
(* Parse dates *)
-
let published_at =
-
J.find json ["publishedAt"] |> J.get_string |> parse_date
+
let published_at =
+
J.find json ["publishedAt"] |> J.get_string |> parse_date
in
-
+
let originally_published_at =
match get_string_opt json ["originallyPublishedAt"] with
| Some date -> Some (parse_date date)
| None -> None
in
-
+
let thumbnail_path = get_string_opt json ["thumbnailPath"] in
let tags = get_string_list json ["tags"] in
-
-
{ id; uuid; name; description; url; embed_path;
-
published_at; originally_published_at;
+
+
{ id; uuid; name; description; url; embed_path;
+
published_at; originally_published_at;
thumbnail_path; tags }
(** Parse a PeerTube video response *)
···
@param start Starting index for pagination (0-based)
@param base_url Base URL of the PeerTube instance
@param channel Channel name to fetch videos from
-
@return A Lwt promise with the video response
-
TODO:claude *)
-
let fetch_channel_videos ?(count=20) ?(start=0) base_url channel =
-
let open Cohttp_lwt_unix in
-
let url = Printf.sprintf "%s/api/v1/video-channels/%s/videos?count=%d&start=%d"
+
@return The video response *)
+
let fetch_channel_videos ~sw ~env ?(count=20) ?(start=0) base_url channel =
+
let url = Printf.sprintf "%s/api/v1/video-channels/%s/videos?count=%d&start=%d"
base_url channel count start in
-
Client.get (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
let json = J.from_string body_str in
-
Lwt.return (parse_video_response json)
+
let response = Requests.One.get ~sw ~clock:(Eio.Stdenv.clock env) ~net:(Eio.Stdenv.net env) url in
+
let status_code = Requests.Response.status_code response in
+
if status_code = 200 then
+
let s = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = J.from_string s in
+
parse_video_response json
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
+
failwith (Fmt.str "HTTP error: %d" status_code)
(** Fetch all videos from a PeerTube instance channel using pagination
@param page_size Number of videos to fetch per page
@param max_pages Maximum number of pages to fetch (None for all pages)
@param base_url Base URL of the PeerTube instance
@param channel Channel name to fetch videos from
-
@return A Lwt promise with all videos combined
-
TODO:claude *)
-
let fetch_all_channel_videos ?(page_size=20) ?max_pages base_url channel =
+
@return All videos combined *)
+
let fetch_all_channel_videos ~sw ~env ?(page_size=20) ?max_pages base_url channel =
let rec fetch_pages start acc _total_count =
-
fetch_channel_videos ~count:page_size ~start base_url channel >>= fun response ->
+
let response = fetch_channel_videos ~sw ~env ~count:page_size ~start base_url channel in
let all_videos = acc @ response.data in
-
+
(* Determine if we need to fetch more pages *)
let fetched_count = start + List.length response.data in
let more_available = fetched_count < response.total in
···
| None -> true
| Some max -> (start / page_size) + 1 < max
in
-
+
if more_available && under_max_pages then
fetch_pages fetched_count all_videos response.total
else
-
Lwt.return all_videos
+
all_videos
in
fetch_pages 0 [] 0
(** Fetch detailed information for a single video by UUID
@param base_url Base URL of the PeerTube instance
@param uuid UUID of the video to fetch
-
@return A Lwt promise with the complete video details
-
TODO:claude *)
-
let fetch_video_details base_url uuid =
-
let open Cohttp_lwt_unix in
+
@return The complete video details *)
+
let fetch_video_details ~sw ~env base_url uuid =
let url = Printf.sprintf "%s/api/v1/videos/%s" base_url uuid in
-
Client.get (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
let json = J.from_string body_str in
+
let response = Requests.One.get ~sw ~clock:(Eio.Stdenv.clock env) ~net:(Eio.Stdenv.net env) url in
+
let status_code = Requests.Response.status_code response in
+
if status_code = 200 then
+
let s = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = J.from_string s in
(* Parse the single video details *)
-
Lwt.return (parse_video json)
+
parse_video json
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
+
failwith (Fmt.str "HTTP error: %d" status_code)
(** Convert a PeerTube video to Bushel.Video.t compatible structure *)
let to_bushel_video video =
···
@param base_url Base URL of the PeerTube instance
@param video The video to download the thumbnail for
@param output_path Path where to save the thumbnail
-
@return A Lwt promise with unit on success *)
-
let download_thumbnail base_url video output_path =
+
@return Ok () on success or Error with message *)
+
let download_thumbnail ~sw ~env base_url video output_path =
match thumbnail_url base_url video with
| None ->
-
Lwt.return (Error (`Msg (Printf.sprintf "No thumbnail available for video %s" video.uuid)))
+
Error (`Msg (Printf.sprintf "No thumbnail available for video %s" video.uuid))
| Some url ->
-
let open Cohttp_lwt_unix in
-
Client.get (Uri.of_string url) >>= fun (resp, body) ->
-
if resp.status = `OK then
-
Cohttp_lwt.Body.to_string body >>= fun body_str ->
-
Lwt.catch
-
(fun () ->
-
let oc = open_out_bin output_path in
-
output_string oc body_str;
-
close_out oc;
-
Lwt.return (Ok ()))
-
(fun exn ->
-
Lwt.return (Error (`Msg (Printf.sprintf "Failed to write thumbnail: %s"
-
(Printexc.to_string exn)))))
-
else
-
let status_code = Cohttp.Code.code_of_status resp.status in
-
Lwt.return (Error (`Msg (Printf.sprintf "HTTP error downloading thumbnail: %d" status_code)))
+
try
+
let response = Requests.One.get ~sw ~clock:(Eio.Stdenv.clock env) ~net:(Eio.Stdenv.net env) url in
+
let status_code = Requests.Response.status_code response in
+
if status_code = 200 then
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
try
+
let fs = Eio.Stdenv.fs env in
+
let output_eio_path = Eio.Path.(fs / output_path) in
+
Eio.Path.save ~create:(`Or_truncate 0o644) output_eio_path body_str;
+
Ok ()
+
with exn ->
+
Error (`Msg (Printf.sprintf "Failed to write thumbnail: %s"
+
(Printexc.to_string exn)))
+
else
+
Error (`Msg (Printf.sprintf "HTTP error downloading thumbnail: %d" status_code))
+
with exn ->
+
Error (`Msg (Printf.sprintf "Failed to download thumbnail: %s"
+
(Printexc.to_string exn)))
+13 -6
stack/bushel/peertube/peertube.mli stack/peertubee/peertubee.mli
···
-
(** PeerTube API client interface
-
TODO:claude *)
+
(** PeerTube API client interface (Eio version) *)
(** Type representing a PeerTube video *)
type video = {
···
val parse_video_response : Ezjsonm.value -> video_response
(** Fetch videos from a PeerTube instance channel with pagination support
+
@param sw Eio switch for resource management
+
@param env Eio environment for network and clock access
@param count Number of videos to fetch per page (default: 20)
@param start Starting index for pagination (0-based) (default: 0)
@param base_url Base URL of the PeerTube instance
@param channel Channel name to fetch videos from *)
-
val fetch_channel_videos : ?count:int -> ?start:int -> string -> string -> video_response Lwt.t
+
val fetch_channel_videos : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; .. > -> ?count:int -> ?start:int -> string -> string -> video_response
(** Fetch all videos from a PeerTube instance channel using pagination
+
@param sw Eio switch for resource management
+
@param env Eio environment for network and clock access
@param page_size Number of videos to fetch per page (default: 20)
@param max_pages Maximum number of pages to fetch (None for all pages)
@param base_url Base URL of the PeerTube instance
@param channel Channel name to fetch videos from *)
-
val fetch_all_channel_videos : ?page_size:int -> ?max_pages:int -> string -> string -> video list Lwt.t
+
val fetch_all_channel_videos : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; .. > -> ?page_size:int -> ?max_pages:int -> string -> string -> video list
(** Fetch detailed information for a single video by UUID
+
@param sw Eio switch for resource management
+
@param env Eio environment for network and clock access
@param base_url Base URL of the PeerTube instance
@param uuid UUID of the video to fetch *)
-
val fetch_video_details : string -> string -> video Lwt.t
+
val fetch_video_details : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; .. > -> string -> string -> video
(** Convert a PeerTube video to Bushel.Video.t compatible structure
Returns (description, published_date, title, url, uuid, slug) *)
···
val thumbnail_url : string -> video -> string option
(** Download a thumbnail to a file
+
@param sw Eio switch for resource management
+
@param env Eio environment for network and filesystem access
@param base_url Base URL of the PeerTube instance
@param video The video to download the thumbnail for
@param output_path Path where to save the thumbnail *)
-
val download_thumbnail : string -> video -> string -> (unit, [> `Msg of string]) result Lwt.t
+
val download_thumbnail : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; fs : 'c Eio.Path.t; .. > -> string -> video -> string -> (unit, [> `Msg of string]) result
-36
stack/bushel/typesense-client.opam
···
-
# This file is generated by dune, edit dune-project instead
-
opam-version: "2.0"
-
synopsis: "Standalone Typesense client for OCaml"
-
description:
-
"A standalone Typesense client that can be compiled to JavaScript"
-
maintainer: ["anil@recoil.org"]
-
authors: ["Anil Madhavapeddy"]
-
license: "ISC"
-
homepage: "https://github.com/avsm/bushel"
-
bug-reports: "https://github.com/avsm/bushel/issues"
-
depends: [
-
"dune" {>= "3.17"}
-
"ocaml" {>= "5.2.0"}
-
"ezjsonm"
-
"lwt"
-
"cohttp-lwt-unix"
-
"ptime"
-
"fmt"
-
"uri"
-
"odoc" {with-doc}
-
]
-
build: [
-
["dune" "subst"] {dev}
-
[
-
"dune"
-
"build"
-
"-p"
-
name
-
"-j"
-
jobs
-
"@install"
-
"@runtest" {with-test}
-
"@doc" {with-doc}
-
]
-
]
-
dev-repo: "git+https://github.com/avsm/bushel.git"
-5
stack/bushel/typesense-client/dune
···
-
(library
-
(public_name typesense-client)
-
(name typesense_client)
-
(libraries lwt cohttp-lwt-unix ezjsonm fmt uri ptime)
-
(preprocess (pps lwt_ppx)))
+89 -87
stack/bushel/typesense-client/typesense_client.ml stack/typesense-cliente/typesense_cliente.ml
···
-
open Lwt.Syntax
-
open Cohttp_lwt_unix
-
-
(** TODO:claude Standalone Typesense client for OCaml *)
+
(** Typesense client for OCaml using Eio and Requests *)
(** Configuration for Typesense client *)
type config = {
···
}
(** Error types for Typesense operations *)
-
type error =
+
type error =
| Http_error of int * string
| Json_error of string
| Connection_error of string
···
| Json_error msg -> Fmt.pf fmt "JSON error: %s" msg
| Connection_error msg -> Fmt.pf fmt "Connection error: %s" msg
-
(** TODO:claude Create authentication headers for Typesense API *)
+
(** Create authentication headers for Typesense API *)
let auth_headers api_key =
-
Cohttp.Header.of_list [
-
("X-TYPESENSE-API-KEY", api_key);
-
("Content-Type", "application/json");
-
]
+
Requests.Headers.empty
+
|> Requests.Headers.set "X-TYPESENSE-API-KEY" api_key
+
|> Requests.Headers.set "Content-Type" "application/json"
-
(** TODO:claude Make HTTP request to Typesense API *)
-
let make_request ?(meth=`GET) ?(body="") config path =
+
(** Make HTTP request to Typesense API *)
+
let make_request ~sw ~env ?(meth=`GET) ?(body="") config path =
let uri = Uri.of_string (config.endpoint ^ path) in
let headers = auth_headers config.api_key in
-
let body = if body = "" then `Empty else `String body in
-
Lwt.catch (fun () ->
-
let* resp, body = Client.call ~headers ~body meth uri in
-
let status = Cohttp.Code.code_of_status (Response.status resp) in
-
let* body_str = Cohttp_lwt.Body.to_string body in
+
let body = if body = "" then None else Some (Requests.Body.of_string Requests.Mime.json body) in
+
+
try
+
let response = Requests.One.request ~sw ~clock:env#clock ~net:env#net
+
?body
+
~headers
+
~method_:meth
+
(Uri.to_string uri)
+
in
+
+
let status = Requests.Response.status_code response in
+
let body_flow = Requests.Response.body response in
+
let body_str = Eio.Flow.read_all body_flow in
+
if status >= 200 && status < 300 then
-
Lwt.return_ok body_str
+
Ok body_str
else
-
Lwt.return_error (Http_error (status, body_str))
-
) (fun exn ->
-
Lwt.return_error (Connection_error (Printexc.to_string exn))
-
)
+
Error (Http_error (status, body_str))
+
with exn ->
+
Error (Connection_error (Printexc.to_string exn))
-
(** TODO:claude Search result types *)
+
(** Search result types *)
type search_result = {
id: string;
title: string;
···
query_time: float;
}
-
(** TODO:claude Parse search result from JSON *)
+
(** Parse search result from JSON *)
let parse_search_result collection json =
let open Ezjsonm in
let document = get_dict json |> List.assoc "document" in
let highlights = try get_dict json |> List.assoc "highlights" with _ -> `A [] in
let score = try get_dict json |> List.assoc "text_match" |> get_float with _ -> 0.0 in
-
+
let id = get_dict document |> List.assoc "id" |> get_string in
let title = try get_dict document |> List.assoc "title" |> get_string with _ -> "" in
let content = try
···
| "contacts" -> get_dict document |> List.assoc "name" |> get_string
| _ -> ""
with _ -> "" in
-
+
let parse_highlights highlights =
try
get_list (fun h ->
···
) highlights
with _ -> []
in
-
+
{ id; title; content; score; collection; highlights = parse_highlights highlights; document }
-
(** TODO:claude Parse search response from JSON *)
+
(** Parse search response from JSON *)
let parse_search_response collection json =
let open Ezjsonm in
let hits = get_dict json |> List.assoc "hits" |> get_list (parse_search_result collection) in
···
let query_time = get_dict json |> List.assoc "search_time_ms" |> get_float in
{ hits; total; query_time }
-
(** TODO:claude Search a single collection *)
-
let search_collection config collection_name query ?(limit=10) ?(offset=0) () =
+
(** Search a single collection *)
+
let search_collection ~sw ~env config collection_name query ?(limit=10) ?(offset=0) () =
let escaped_query = Uri.pct_encode query in
let query_fields = match collection_name with
| "papers" -> "title,abstract,authors"
···
in
let path = Printf.sprintf "/collections/%s/documents/search?q=%s&query_by=%s&per_page=%d&page=%d&highlight_full_fields=%s"
collection_name escaped_query query_fields limit ((offset / limit) + 1) query_fields in
-
let* result = make_request config path in
-
match result with
+
+
match make_request ~sw ~env config path with
| Ok response_str ->
(try
let json = Ezjsonm.from_string response_str in
let search_response = parse_search_response collection_name json in
-
Lwt.return_ok search_response
+
Ok search_response
with exn ->
-
Lwt.return_error (Json_error (Printexc.to_string exn)))
-
| Error err -> Lwt.return_error err
+
Error (Json_error (Printexc.to_string exn)))
+
| Error err -> Error err
-
(** TODO:claude Helper function to drop n elements from list *)
+
(** Helper function to drop n elements from list *)
let rec drop n lst =
if n <= 0 then lst
else match lst with
| [] -> []
| _ :: tl -> drop (n - 1) tl
-
(** TODO:claude Helper function to take n elements from list *)
+
(** Helper function to take n elements from list *)
let rec take n lst =
if n <= 0 then []
else match lst with
| [] -> []
| hd :: tl -> hd :: take (n - 1) tl
-
(** TODO:claude Multisearch result types *)
+
(** Multisearch result types *)
type multisearch_response = {
results: search_response list;
}
-
(** TODO:claude Parse multisearch response from JSON *)
+
(** Parse multisearch response from JSON *)
let parse_multisearch_response json =
let open Ezjsonm in
let results_json = get_dict json |> List.assoc "results" |> get_list (fun r -> r) in
let results = List.mapi (fun i result_json ->
let collection_name = match i with
| 0 -> "contacts"
-
| 1 -> "news"
+
| 1 -> "news"
| 2 -> "notes"
| 3 -> "papers"
| 4 -> "projects"
···
) results_json in
{ results }
-
(** TODO:claude Perform multisearch across all collections *)
-
let multisearch config query ?(limit=10) () =
+
(** Perform multisearch across all collections *)
+
let multisearch ~sw ~env config query ?(limit=10) () =
let collections = ["contacts"; "news"; "notes"; "papers"; "projects"; "ideas"; "videos"] in
let query_by_collection = [
("contacts", "name,names,email,handle,github,twitter,url");
···
("ideas", "title,description,level,status,project,supervisors,tags");
("videos", "title,description,channel,platform,tags");
] in
-
+
let searches = List.map (fun collection ->
let query_by = List.assoc collection query_by_collection in
Ezjsonm.dict [
···
("per_page", Ezjsonm.int limit);
]
) collections in
-
+
let body = Ezjsonm.dict [("searches", Ezjsonm.list (fun x -> x) searches)] |> Ezjsonm.value_to_string in
-
let* result = make_request ~meth:`POST ~body config "/multi_search" in
-
-
match result with
+
+
match make_request ~sw ~env ~meth:`POST ~body config "/multi_search" with
| Ok response_str ->
(try
let json = Ezjsonm.from_string response_str in
let multisearch_resp = parse_multisearch_response json in
-
Lwt.return_ok multisearch_resp
+
Ok multisearch_resp
with exn ->
-
Lwt.return_error (Json_error (Printexc.to_string exn)))
-
| Error err -> Lwt.return_error err
+
Error (Json_error (Printexc.to_string exn)))
+
| Error err -> Error err
-
(** TODO:claude Combine multisearch results into single result set *)
+
(** Combine multisearch results into single result set *)
let combine_multisearch_results (multisearch_resp : multisearch_response) ?(limit=10) ?(offset=0) () =
(* Collect all hits from all collections *)
let all_hits = List.fold_left (fun acc response ->
response.hits @ acc
) [] multisearch_resp.results in
-
+
(* Sort by score descending *)
let sorted_hits = List.sort (fun a b -> Float.compare b.score a.score) all_hits in
-
+
(* Apply offset and limit *)
let dropped_hits = drop offset sorted_hits in
let final_hits = take limit dropped_hits in
-
+
(* Calculate totals *)
let total = List.fold_left (fun acc response -> acc + response.total) 0 multisearch_resp.results in
let query_time = List.fold_left (fun acc response -> acc +. response.query_time) 0.0 multisearch_resp.results in
-
+
{ hits = final_hits; total; query_time }
-
(** TODO:claude List all collections *)
-
let list_collections config =
-
let* result = make_request config "/collections" in
-
match result with
+
(** List all collections *)
+
let list_collections ~sw ~env config =
+
match make_request ~sw ~env config "/collections" with
| Ok response_str ->
(try
let json = Ezjsonm.from_string response_str in
···
let num_docs = Ezjsonm.get_dict c |> List.assoc "num_documents" |> Ezjsonm.get_int in
(name, num_docs)
) json in
-
Lwt.return_ok collections
+
Ok collections
with exn ->
-
Lwt.return_error (Json_error (Printexc.to_string exn)))
-
| Error err -> Lwt.return_error err
+
Error (Json_error (Printexc.to_string exn)))
+
| Error err -> Error err
-
(** TODO:claude Pretty printer utilities *)
+
(** Pretty printer utilities *)
(** Extract field value from JSON document or return empty string if not found *)
let extract_field_string document field =
···
| ts when List.length ts <= 3 -> String.concat ", " ts
| ts -> Printf.sprintf "%s (+%d more)" (String.concat ", " (take 2 ts)) (List.length ts - 2)
-
(** TODO:claude One-line pretty printer for search results *)
+
(** One-line pretty printer for search results *)
let pp_search_result_oneline (result : search_result) =
let document = result.document in
-
+
match result.collection with
| "papers" ->
let authors = extract_field_string_list document "authors" in
let date = extract_field_string document "date" in
let journal = extract_field_string_list document "journal" in
let journal_str = match journal with [] -> "" | j :: _ -> Printf.sprintf " (%s)" j in
-
Printf.sprintf "📄 %s — %s%s %s"
-
result.title
+
Printf.sprintf "📄 %s — %s%s %s"
+
result.title
(format_authors authors)
journal_str
(format_date date)
-
+
| "videos" ->
let date = extract_field_string document "published_date" in
let uuid = extract_field_string document "uuid" in
···
let talk_indicator = if is_talk then "🎤" else "🎬" in
let url = extract_field_string document "url" in
let url_display = if url = "" then "" else Printf.sprintf " <%s>" url in
-
Printf.sprintf "%s %s — %s [%s]%s"
+
Printf.sprintf "%s %s — %s [%s]%s"
talk_indicator
-
result.title
+
result.title
(format_date date)
(if uuid = "" then result.id else uuid)
url_display
-
+
| "projects" ->
let start_year = extract_field_string document "start_year" in
let tags = extract_field_string_list document "tags" in
let tags_str = match tags with [] -> "" | ts -> Printf.sprintf " #%s" (format_tags ts) in
-
Printf.sprintf "🚀 %s — %s%s"
-
result.title
+
Printf.sprintf "🚀 %s — %s%s"
+
result.title
(if start_year = "" then "" else Printf.sprintf "(%s) " start_year)
tags_str
-
+
| "news" ->
let date = extract_field_string document "date" in
let url = extract_field_string document "url" in
let url_display = if url = "" then "" else Printf.sprintf " <%s>" url in
-
Printf.sprintf "📰 %s — %s%s"
-
result.title
+
Printf.sprintf "📰 %s — %s%s"
+
result.title
(format_date date)
url_display
-
+
| "notes" ->
let date = extract_field_string document "date" in
let tags = extract_field_string_list document "tags" in
let tags_str = match tags with [] -> "" | ts -> Printf.sprintf " #%s" (format_tags ts) in
-
Printf.sprintf "📝 %s — %s%s"
-
result.title
+
Printf.sprintf "📝 %s — %s%s"
+
result.title
(format_date date)
tags_str
-
+
| "ideas" ->
let project = extract_field_string document "project" in
let level = extract_field_string document "level" in
let status = extract_field_string document "status" in
let year = extract_field_string document "year" in
-
Printf.sprintf "💡 %s — %s%s%s %s"
-
result.title
+
Printf.sprintf "💡 %s — %s%s%s %s"
+
result.title
(if project = "" then "" else Printf.sprintf "[%s] " project)
(if level = "" then "" else Printf.sprintf "(%s) " level)
(if status = "" then "" else Printf.sprintf "%s " status)
year
-
+
| "contacts" ->
let names = extract_field_string_list document "names" in
let handle = extract_field_string document "handle" in
···
(if email = "" then "" else email);
(if github = "" then "" else Printf.sprintf "github:%s" github);
] |> List.filter (fun s -> s <> "") |> String.concat " " in
-
Printf.sprintf "👤 %s — %s"
-
name_str
+
Printf.sprintf "👤 %s — %s"
+
name_str
contact_info
-
-
| _ -> Printf.sprintf "[%s] %s" result.collection result.title
+
+
| _ -> Printf.sprintf "[%s] %s" result.collection result.title
-60
stack/bushel/typesense-client/typesense_client.mli
···
-
(** Standalone Typesense client for OCaml *)
-
-
(** Configuration for Typesense client *)
-
type config = {
-
endpoint : string;
-
api_key : string;
-
}
-
-
(** Error types for Typesense operations *)
-
type error =
-
| Http_error of int * string
-
| Json_error of string
-
| Connection_error of string
-
-
val pp_error : Format.formatter -> error -> unit
-
-
(** Search result types *)
-
type search_result = {
-
id: string;
-
title: string;
-
content: string;
-
score: float;
-
collection: string;
-
highlights: (string * string list) list;
-
document: Ezjsonm.value; (* Store raw document for flexible field access *)
-
}
-
-
type search_response = {
-
hits: search_result list;
-
total: int;
-
query_time: float;
-
}
-
-
(** Multisearch result types *)
-
type multisearch_response = {
-
results: search_response list;
-
}
-
-
(** Search a single collection *)
-
val search_collection : config -> string -> string -> ?limit:int -> ?offset:int -> unit -> (search_response, error) result Lwt.t
-
-
(** Perform multisearch across all collections *)
-
val multisearch : config -> string -> ?limit:int -> unit -> (multisearch_response, error) result Lwt.t
-
-
(** Combine multisearch results into single result set *)
-
val combine_multisearch_results : multisearch_response -> ?limit:int -> ?offset:int -> unit -> search_response
-
-
(** List all collections *)
-
val list_collections : config -> ((string * int) list, error) result Lwt.t
-
-
(** Pretty printer utilities *)
-
val extract_field_string : Ezjsonm.value -> string -> string
-
val extract_field_string_list : Ezjsonm.value -> string -> string list
-
val extract_field_bool : Ezjsonm.value -> string -> bool
-
val format_authors : string list -> string
-
val format_date : string -> string
-
val format_tags : string list -> string
-
-
(** One-line pretty printer for search results *)
-
val pp_search_result_oneline : search_result -> string
+195
stack/immiche/README.md
···
+
# Immiche - Immich API Client Library
+
+
A clean Eio-based OCaml library for interacting with Immich instances, focusing on people and face recognition data.
+
+
## Overview
+
+
Immiche provides a straightforward API for interacting with Immich's people management endpoints. It uses the Requests library for HTTP operations and follows Eio patterns for concurrency and resource management.
+
+
## Features
+
+
- Fetch all people from an Immich instance
+
- Search for people by name
+
- Fetch individual person details
+
- Download person thumbnails
+
- Full Eio integration (no Lwt dependency)
+
- Type-safe API with result types for error handling
+
+
## API
+
+
### Types
+
+
```ocaml
+
(* Client type - encapsulates session with connection pooling *)
+
type ('clock, 'net) t
+
+
type person = {
+
id: string;
+
name: string;
+
birth_date: string option;
+
thumbnail_path: string;
+
is_hidden: bool;
+
}
+
+
type people_response = {
+
total: int;
+
visible: int;
+
people: person list;
+
}
+
```
+
+
### Client Creation
+
+
#### `create`
+
Create an Immich client with connection pooling.
+
+
```ocaml
+
val create :
+
sw:Eio.Switch.t ->
+
env:< clock: _ ; net: _ ; fs: _ ; .. > ->
+
?requests_session:('clock, 'net) Requests.t ->
+
base_url:string ->
+
api_key:string ->
+
unit -> ('clock, 'net) t
+
```
+
+
**Parameters:**
+
- `sw` - Eio switch for resource management
+
- `env` - Eio environment (provides clock, net, fs)
+
- `requests_session` - Optional Requests session for connection pooling. If not provided, a new session is created.
+
- `base_url` - Base URL of the Immich instance (e.g., "https://photos.example.com")
+
- `api_key` - API key for authentication
+
+
**Returns:** An Immich client configured for the specified instance
+
+
### API Functions
+
+
All API functions take a client as their first parameter. The client automatically handles:
+
- Connection pooling (reuses TCP connections)
+
- Authentication (API key set as default header)
+
- Base URL configuration
+
+
#### `fetch_people`
+
Fetch all people from an Immich instance.
+
+
```ocaml
+
val fetch_people : ('clock, 'net) t -> people_response
+
```
+
+
#### `search_person`
+
Search for people by name.
+
+
```ocaml
+
val search_person : ('clock, 'net) t -> name:string -> person list
+
```
+
+
#### `fetch_person`
+
Fetch details for a specific person.
+
+
```ocaml
+
val fetch_person : ('clock, 'net) t -> person_id:string -> person
+
```
+
+
#### `download_thumbnail`
+
Download a person's thumbnail image.
+
+
```ocaml
+
val download_thumbnail :
+
('clock, 'net) t ->
+
fs:_ Eio.Path.t ->
+
person_id:string ->
+
output_path:string ->
+
(unit, [> `Msg of string]) result
+
```
+
+
## Example Usage
+
+
### Basic Usage
+
+
```ocaml
+
open Eio.Std
+
+
let () =
+
Eio_main.run @@ fun env ->
+
Switch.run @@ fun sw ->
+
+
(* Create client once with connection pooling *)
+
let client = Immiche.create ~sw ~env
+
~base_url:"https://photos.example.com"
+
~api_key:"your-api-key" () in
+
+
(* Fetch all people - connection pooling automatic *)
+
let response = Immiche.fetch_people client in
+
Printf.printf "Total people: %d\n" response.total;
+
+
(* Search for a person - reuses connections *)
+
let results = Immiche.search_person client ~name:"John" in
+
+
(* Download first result's thumbnail *)
+
match results with
+
| person :: _ ->
+
let result = Immiche.download_thumbnail client
+
~fs:(Eio.Stdenv.fs env)
+
~person_id:person.id
+
~output_path:"thumbnail.jpg" in
+
begin match result with
+
| Ok () -> print_endline "Thumbnail downloaded!"
+
| Error (`Msg err) -> Printf.eprintf "Error: %s\n" err
+
end
+
| [] -> print_endline "No results found"
+
```
+
+
### Sharing Connection Pools
+
+
You can share a `Requests.t` session across multiple API clients for maximum connection reuse:
+
+
```ocaml
+
Eio_main.run @@ fun env ->
+
Switch.run @@ fun sw ->
+
+
(* Create shared Requests session *)
+
let shared_session = Requests.create ~sw env in
+
+
(* Create multiple clients sharing the same connection pools *)
+
let immich1 = Immiche.create ~sw ~env ~requests_session:shared_session
+
~base_url:"https://photos1.example.com"
+
~api_key:"key1" () in
+
+
let immich2 = Immiche.create ~sw ~env ~requests_session:shared_session
+
~base_url:"https://photos2.example.com"
+
~api_key:"key2" () in
+
+
(* Both clients share the same connection pools! *)
+
let people1 = Immiche.fetch_people immich1 in
+
let people2 = Immiche.fetch_people immich2 in
+
()
+
```
+
+
## Dependencies
+
+
- `eio` - Concurrent I/O library
+
- `requests` - HTTP client library
+
- `ezjsonm` - JSON parsing
+
- `uri` - URI encoding
+
- `fmt` - Formatting
+
- `ptime` - Time handling
+
+
## Implementation Notes
+
+
- Uses `Requests.t` for session-based HTTP requests with connection pooling
+
- Authentication via `x-api-key` header (set as default on session creation)
+
- Direct Eio style - no Lwt dependencies
+
- Connection pools shared across derived sessions
+
- Extracted and adapted from `bushel/bin/bushel_faces.ml`
+
+
## Benefits
+
+
✅ **Connection Pooling** - Automatic TCP connection reuse across requests
+
✅ **Clean API** - Session-level configuration passed once, not per-request
+
✅ **Injectable Sessions** - Can share `Requests.t` across multiple libraries
+
✅ **Type Safety** - Client parameterized by clock/net types
+
✅ **Immutable Configuration** - Derived sessions don't affect original
+
+
## License
+
+
ISC
+4
stack/immiche/dune
···
+
(library
+
(name immiche)
+
(public_name immiche)
+
(libraries eio eio.core requests ezjsonm fmt ptime uri))
+19
stack/immiche/dune-project
···
+
(lang dune 3.0)
+
(name immiche)
+
(version 0.1.0)
+
+
(generate_opam_files true)
+
+
(package
+
(name immiche)
+
(synopsis "Immich API client for OCaml using Eio")
+
(description "An Eio-based OCaml client library for Immich photo service API")
+
(depends
+
(ocaml (>= 4.14))
+
eio
+
(eio_main (>= 1.0))
+
requests
+
ezjsonm
+
fmt
+
ptime
+
uri))
+137
stack/immiche/immiche.ml
···
+
(** Immiche - Immich API client library *)
+
+
open Printf
+
+
(** {1 Types} *)
+
+
type ('clock, 'net) t = {
+
base_url: string;
+
api_key: string;
+
requests_session: ('clock, 'net) Requests.t;
+
}
+
+
type person = {
+
id: string;
+
name: string;
+
birth_date: string option;
+
thumbnail_path: string;
+
is_hidden: bool;
+
}
+
+
type people_response = {
+
total: int;
+
visible: int;
+
people: person list;
+
}
+
+
(** {1 Client Creation} *)
+
+
let create ~sw ~env ?requests_session ~base_url ~api_key () =
+
let requests_session = match requests_session with
+
| Some session -> session
+
| None -> Requests.create ~sw env
+
in
+
(* Set API key header on the session *)
+
let requests_session = Requests.set_default_header requests_session "x-api-key" api_key in
+
{ base_url; api_key; requests_session }
+
+
(** {1 JSON Parsing} *)
+
+
(* Parse a single person from JSON *)
+
let parse_person json =
+
let open Ezjsonm in
+
let id = find json ["id"] |> get_string in
+
let name = find json ["name"] |> get_string in
+
let birth_date =
+
try Some (find json ["birthDate"] |> get_string)
+
with _ -> None
+
in
+
let thumbnail_path = find json ["thumbnailPath"] |> get_string in
+
let is_hidden =
+
try find json ["isHidden"] |> get_bool
+
with _ -> false
+
in
+
{ id; name; birth_date; thumbnail_path; is_hidden }
+
+
(* Parse people response from JSON *)
+
let parse_people_response json =
+
let open Ezjsonm in
+
let total = find json ["total"] |> get_int in
+
let visible = find json ["visible"] |> get_int in
+
let people_json = find json ["people"] in
+
let people = get_list parse_person people_json in
+
{ total; visible; people }
+
+
(* Parse a list of people from search results *)
+
let parse_person_list json =
+
let open Ezjsonm in
+
get_list parse_person json
+
+
(** {1 API Functions} *)
+
+
let fetch_people { base_url; requests_session; _ } =
+
let url = sprintf "%s/api/people" base_url in
+
+
let response = Requests.get requests_session url in
+
let status = Requests.Response.status_code response in
+
+
if status <> 200 then
+
failwith (sprintf "HTTP error: %d" status)
+
else
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = Ezjsonm.from_string body_str in
+
parse_people_response json
+
+
let fetch_person { base_url; requests_session; _ } ~person_id =
+
let url = sprintf "%s/api/people/%s" base_url person_id in
+
+
let response = Requests.get requests_session url in
+
let status = Requests.Response.status_code response in
+
+
if status <> 200 then
+
failwith (sprintf "HTTP error: %d" status)
+
else
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = Ezjsonm.from_string body_str in
+
parse_person json
+
+
let download_thumbnail { base_url; requests_session; _ } ~fs ~person_id ~output_path =
+
try
+
let url = sprintf "%s/api/people/%s/thumbnail" base_url person_id in
+
+
let response = Requests.get requests_session url in
+
let status = Requests.Response.status_code response in
+
+
if status <> 200 then
+
Error (`Msg (sprintf "HTTP error: %d" status))
+
else begin
+
let img_data = Requests.Response.body response |> Eio.Flow.read_all in
+
+
(* Ensure output directory exists *)
+
let dir = Filename.dirname output_path in
+
if not (Sys.file_exists dir) then
+
Unix.mkdir dir 0o755;
+
+
(* Write the image data to file *)
+
let path = Eio.Path.(fs / output_path) in
+
Eio.Path.save ~create:(`Or_truncate 0o644) path img_data;
+
+
Ok ()
+
end
+
with
+
| Failure msg -> Error (`Msg msg)
+
| exn -> Error (`Msg (Printexc.to_string exn))
+
+
let search_person { base_url; requests_session; _ } ~name =
+
let encoded_name = Uri.pct_encode name in
+
let url = sprintf "%s/api/search/person?name=%s" base_url encoded_name in
+
+
let response = Requests.get requests_session url in
+
let status = Requests.Response.status_code response in
+
+
if status <> 200 then
+
failwith (sprintf "HTTP error: %d" status)
+
else
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = Ezjsonm.from_string body_str in
+
parse_person_list json
+100
stack/immiche/immiche.mli
···
+
(** Immiche - Immich API client library
+
+
This library provides a clean Eio-based interface to interact with Immich
+
instances for managing people and face recognition data.
+
*)
+
+
(** {1 Types} *)
+
+
(** Type representing an Immich client with connection pooling *)
+
type ('clock, 'net) t
+
+
(** Type representing a person in Immich *)
+
type person = {
+
id: string;
+
name: string;
+
birth_date: string option;
+
thumbnail_path: string;
+
is_hidden: bool;
+
}
+
+
(** Type for the people API response *)
+
type people_response = {
+
total: int;
+
visible: int;
+
people: person list;
+
}
+
+
(** {1 Client Creation} *)
+
+
(** [create ~sw ~env ?requests_session ~base_url ~api_key ()] creates a new Immich client.
+
+
@param sw The Eio switch for resource management
+
@param env The Eio environment (provides clock, net, fs)
+
@param requests_session Optional Requests session for connection pooling.
+
If not provided, a new session is created.
+
@param base_url The base URL of the Immich instance (e.g., "https://photos.example.com")
+
@param api_key The API key for authentication
+
@return An Immich client configured for the specified instance
+
*)
+
val create :
+
sw:Eio.Switch.t ->
+
env:< clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t;
+
net: ([> [> `Generic ] Eio.Net.ty ] as 'net) Eio.Resource.t;
+
fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
+
?requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
+
base_url:string ->
+
api_key:string ->
+
unit -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
+
+
(** {1 API Functions} *)
+
+
(** [fetch_people client] fetches all people from an Immich instance.
+
+
@param client The Immich client
+
@return A people_response containing all people and metadata
+
@raise Failure if the API request fails
+
*)
+
val fetch_people :
+
([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
people_response
+
+
(** [fetch_person client ~person_id] fetches details for a single person.
+
+
@param client The Immich client
+
@param person_id The ID of the person to fetch
+
@return The person details
+
@raise Failure if the API request fails or person not found
+
*)
+
val fetch_person :
+
([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
person_id:string ->
+
person
+
+
(** [download_thumbnail client ~fs ~person_id ~output_path] downloads
+
a person's thumbnail image to a file.
+
+
@param client The Immich client
+
@param fs The Eio filesystem capability
+
@param person_id The ID of the person whose thumbnail to download
+
@param output_path The file path where the thumbnail should be saved
+
@return Ok () on success, or Error with a message on failure
+
*)
+
val download_thumbnail :
+
([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
fs:_ Eio.Path.t ->
+
person_id:string ->
+
output_path:string ->
+
(unit, [> `Msg of string]) result
+
+
(** [search_person client ~name] searches for people by name.
+
+
@param client The Immich client
+
@param name The name to search for
+
@return A list of matching people
+
@raise Failure if the API request fails
+
*)
+
val search_person :
+
([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
name:string ->
+
person list
+31
stack/immiche/immiche.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
version: "0.1.0"
+
synopsis: "Immich API client for OCaml using Eio"
+
description: "An Eio-based OCaml client library for Immich photo service API"
+
depends: [
+
"dune" {>= "3.0"}
+
"ocaml" {>= "4.14"}
+
"eio"
+
"eio_main" {>= "1.0"}
+
"requests"
+
"ezjsonm"
+
"fmt"
+
"ptime"
+
"uri"
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+5 -4
stack/jmap/jmap-client/jmap_client.ml
···
let requests_session = Requests.create ~sw env in
(* Set authentication if configured *)
-
(match Jmap_connection.auth conn with
+
let requests_session = match Jmap_connection.auth conn with
| Some (Jmap_connection.Bearer token) ->
Requests.set_auth requests_session (Requests.Auth.bearer ~token)
| Some (Jmap_connection.Basic (user, pass)) ->
Requests.set_auth requests_session (Requests.Auth.basic ~username:user ~password:pass)
-
| None -> ());
+
| None -> requests_session
+
in
(* Set user agent *)
let config = Jmap_connection.config conn in
-
Requests.set_default_header requests_session "User-Agent"
-
(Jmap_connection.user_agent config);
+
let requests_session = Requests.set_default_header requests_session "User-Agent"
+
(Jmap_connection.user_agent config) in
{ session_url;
get_request = (fun ~timeout url -> Requests.get requests_session ~timeout url);
+4
stack/karakeepe/dune
···
+
(library
+
(name karakeepe)
+
(public_name karakeepe)
+
(libraries bushel eio eio.core requests ezjsonm fmt ptime uri))
+19
stack/karakeepe/dune-project
···
+
(lang dune 3.0)
+
(name karakeepe)
+
(version 0.1.0)
+
+
(generate_opam_files true)
+
+
(package
+
(name karakeepe)
+
(synopsis "Karakeep API client for OCaml using Eio")
+
(description "An Eio-based OCaml client library for the Karakeep bookmark management service API")
+
(depends
+
(ocaml (>= 4.14))
+
eio
+
(eio_main (>= 1.0))
+
requests
+
ezjsonm
+
fmt
+
ptime
+
uri))
+472
stack/karakeepe/karakeepe.ml
···
+
(** Karakeepe API client implementation (Eio version) *)
+
+
module J = Ezjsonm
+
+
(** Type representing a Karakeep bookmark *)
+
type bookmark = {
+
id: string;
+
title: string option;
+
url: string;
+
note: string option;
+
created_at: Ptime.t;
+
updated_at: Ptime.t option;
+
favourited: bool;
+
archived: bool;
+
tags: string list;
+
tagging_status: string option;
+
summary: string option;
+
content: (string * string) list;
+
assets: (string * string) list;
+
}
+
+
(** Type for Karakeep API response containing bookmarks *)
+
type bookmark_response = {
+
total: int;
+
data: bookmark list;
+
next_cursor: string option;
+
}
+
+
(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
+
let parse_date str =
+
match Ptime.of_rfc3339 str with
+
| Ok (date, _, _) -> date
+
| Error _ ->
+
Fmt.epr "Warning: could not parse date '%s'\n" str;
+
(* Default to epoch time *)
+
let span_opt = Ptime.Span.of_d_ps (0, 0L) in
+
match span_opt with
+
| None -> failwith "Internal error: couldn't create epoch time span"
+
| Some span ->
+
match Ptime.of_span span with
+
| Some t -> t
+
| None -> failwith "Internal error: couldn't create epoch time"
+
+
(** Extract a string field from JSON, returns None if not present or not a string *)
+
let get_string_opt json path =
+
try Some (J.find json path |> J.get_string)
+
with _ -> None
+
+
(** Extract a string list field from JSON, returns empty list if not present *)
+
let get_string_list json path =
+
try
+
let items_json = J.find json path in
+
J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json
+
with _ -> []
+
+
(** Extract a boolean field from JSON, with default value *)
+
let get_bool_def json path default =
+
try J.find json path |> J.get_bool
+
with _ -> default
+
+
(** Parse a single bookmark from Karakeep JSON *)
+
let parse_bookmark json =
+
let id =
+
try J.find json ["id"] |> J.get_string
+
with e ->
+
prerr_endline (Fmt.str "Error parsing bookmark ID: %s" (Printexc.to_string e));
+
prerr_endline (Fmt.str "JSON: %s" (J.value_to_string json));
+
failwith "Unable to parse bookmark ID"
+
in
+
+
let title =
+
try Some (J.find json ["title"] |> J.get_string)
+
with _ -> None
+
in
+
+
let url =
+
try J.find json ["url"] |> J.get_string
+
with _ -> try
+
J.find json ["content"; "url"] |> J.get_string
+
with _ -> try
+
J.find json ["content"; "sourceUrl"] |> J.get_string
+
with _ ->
+
match J.find_opt json ["content"; "type"] with
+
| Some (`String "asset") ->
+
(try J.find json ["content"; "sourceUrl"] |> J.get_string
+
with _ ->
+
(match J.find_opt json ["id"] with
+
| Some (`String id) -> "karakeep-asset://" ^ id
+
| _ -> failwith "No URL or asset ID found in bookmark"))
+
| _ ->
+
prerr_endline (Fmt.str "Bookmark JSON structure: %s" (J.value_to_string json));
+
failwith "No URL found in bookmark"
+
in
+
+
let note = get_string_opt json ["note"] in
+
+
let created_at =
+
try J.find json ["createdAt"] |> J.get_string |> parse_date
+
with _ ->
+
try J.find json ["created_at"] |> J.get_string |> parse_date
+
with _ -> failwith "No creation date found"
+
in
+
+
let updated_at =
+
try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date)
+
with _ ->
+
try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date)
+
with _ -> None
+
in
+
+
let favourited = get_bool_def json ["favourited"] false in
+
let archived = get_bool_def json ["archived"] false in
+
let tags = get_string_list json ["tags"] in
+
let tagging_status = get_string_opt json ["taggingStatus"] in
+
let summary = get_string_opt json ["summary"] in
+
+
let content =
+
try
+
let content_json = J.find json ["content"] in
+
let rec extract_fields acc = function
+
| [] -> acc
+
| (k, v) :: rest ->
+
let value = match v with
+
| `String s -> s
+
| `Bool b -> string_of_bool b
+
| `Float f -> string_of_float f
+
| `Null -> "null"
+
| _ -> "complex_value"
+
in
+
extract_fields ((k, value) :: acc) rest
+
in
+
match content_json with
+
| `O fields -> extract_fields [] fields
+
| _ -> []
+
with _ -> []
+
in
+
+
let assets =
+
try
+
let assets_json = J.find json ["assets"] in
+
J.get_list (fun asset_json ->
+
let id = J.find asset_json ["id"] |> J.get_string in
+
let asset_type =
+
try J.find asset_json ["assetType"] |> J.get_string
+
with _ -> "unknown"
+
in
+
(id, asset_type)
+
) assets_json
+
with _ -> []
+
in
+
+
{ id; title; url; note; created_at; updated_at; favourited; archived; tags;
+
tagging_status; summary; content; assets }
+
+
(** Parse a Karakeep bookmark response *)
+
let parse_bookmark_response json =
+
prerr_endline (Fmt.str "Full response JSON: %s" (J.value_to_string json));
+
+
try
+
let total = J.find json ["total"] |> J.get_int in
+
let bookmarks_json = J.find json ["data"] in
+
prerr_endline "Found bookmarks in data array";
+
let data = J.get_list parse_bookmark bookmarks_json in
+
let next_cursor =
+
try Some (J.find json ["nextCursor"] |> J.get_string)
+
with _ -> None
+
in
+
{ total; data; next_cursor }
+
with e1 ->
+
prerr_endline (Fmt.str "First format parse error: %s" (Printexc.to_string e1));
+
try
+
let bookmarks_json = J.find json ["bookmarks"] in
+
prerr_endline "Found bookmarks in bookmarks array";
+
let data =
+
try J.get_list parse_bookmark bookmarks_json
+
with e ->
+
prerr_endline (Fmt.str "Error parsing bookmarks array: %s" (Printexc.to_string e));
+
[]
+
in
+
let next_cursor =
+
try Some (J.find json ["nextCursor"] |> J.get_string)
+
with _ -> None
+
in
+
{ total = List.length data; data; next_cursor }
+
with e2 ->
+
prerr_endline (Fmt.str "Second format parse error: %s" (Printexc.to_string e2));
+
try
+
let error = J.find json ["error"] |> J.get_string in
+
let message =
+
try J.find json ["message"] |> J.get_string
+
with _ -> "Unknown error"
+
in
+
prerr_endline (Fmt.str "API Error: %s - %s" error message);
+
{ total = 0; data = []; next_cursor = None }
+
with _ ->
+
try
+
prerr_endline "Trying alternate array format";
+
prerr_endline (Fmt.str "JSON structure keys: %s"
+
(match json with
+
| `O fields -> String.concat ", " (List.map (fun (k, _) -> k) fields)
+
| _ -> "not an object"));
+
+
if J.find_opt json ["nextCursor"] <> None then begin
+
prerr_endline "Found nextCursor, checking alternate structures";
+
let bookmarks_json =
+
try Some (J.find json ["data"])
+
with _ -> None
+
in
+
match bookmarks_json with
+
| Some json_array ->
+
prerr_endline "Found bookmarks in data field";
+
begin try
+
let data = J.get_list parse_bookmark json_array in
+
let next_cursor =
+
try Some (J.find json ["nextCursor"] |> J.get_string)
+
with _ -> None
+
in
+
{ total = List.length data; data; next_cursor }
+
with e ->
+
prerr_endline (Fmt.str "Error parsing bookmarks from data: %s" (Printexc.to_string e));
+
{ total = 0; data = []; next_cursor = None }
+
end
+
| None ->
+
prerr_endline "No bookmarks found in alternate structure";
+
{ total = 0; data = []; next_cursor = None }
+
end
+
else begin
+
match json with
+
| `A _ ->
+
let data =
+
try J.get_list parse_bookmark json
+
with e ->
+
prerr_endline (Fmt.str "Error parsing root array: %s" (Printexc.to_string e));
+
[]
+
in
+
{ total = List.length data; data; next_cursor = None }
+
| _ ->
+
prerr_endline "Not an array at root level";
+
{ total = 0; data = []; next_cursor = None }
+
end
+
with e3 ->
+
prerr_endline (Fmt.str "Third format parse error: %s" (Printexc.to_string e3));
+
{ total = 0; data = []; next_cursor = None }
+
+
(** Fetch bookmarks from a Karakeep instance with pagination support *)
+
let fetch_bookmarks ~sw ~env ~api_key ?(limit=50) ?(offset=0) ?cursor ?(include_content=false) ?filter_tags base_url =
+
let url_base = Fmt.str "%s/api/v1/bookmarks?limit=%d&includeContent=%b"
+
base_url limit include_content in
+
+
let url =
+
match cursor with
+
| Some cursor_value -> url_base ^ "&cursor=" ^ cursor_value
+
| None -> url_base ^ "&offset=" ^ string_of_int offset
+
in
+
+
let url = match filter_tags with
+
| Some tags when tags <> [] ->
+
let encoded_tags =
+
List.map (fun tag -> Uri.pct_encode ~component:`Query_key tag) tags
+
in
+
let tags_param = String.concat "," encoded_tags in
+
prerr_endline (Fmt.str "Adding tags filter: %s" tags_param);
+
url ^ "&tags=" ^ tags_param
+
| _ -> url
+
in
+
+
let headers = Requests.Headers.empty
+
|> Requests.Headers.set "Authorization" ("Bearer " ^ api_key) in
+
+
prerr_endline (Fmt.str "Fetching bookmarks from: %s" url);
+
+
try
+
let response = Requests.One.get ~sw ~clock:env#clock ~net:env#net ~headers url in
+
let status_code = Requests.Response.status_code response in
+
if status_code = 200 then begin
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
prerr_endline (Fmt.str "Received %d bytes of response data" (String.length body_str));
+
+
try
+
let json = J.from_string body_str in
+
parse_bookmark_response json
+
with e ->
+
prerr_endline (Fmt.str "JSON parsing error: %s" (Printexc.to_string e));
+
prerr_endline (Fmt.str "Response body (first 200 chars): %s"
+
(if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str));
+
raise e
+
end else begin
+
prerr_endline (Fmt.str "HTTP error %d" status_code);
+
failwith (Fmt.str "HTTP error: %d" status_code)
+
end
+
with e ->
+
prerr_endline (Fmt.str "Network error: %s" (Printexc.to_string e));
+
raise e
+
+
(** Fetch all bookmarks from a Karakeep instance using pagination *)
+
let fetch_all_bookmarks ~sw ~env ~api_key ?(page_size=50) ?max_pages ?filter_tags ?(include_content=false) base_url =
+
let rec fetch_pages page_num cursor acc _total_count =
+
let response =
+
match cursor with
+
| Some cursor_str -> fetch_bookmarks ~sw ~env ~api_key ~limit:page_size ~cursor:cursor_str ~include_content ?filter_tags base_url
+
| None -> fetch_bookmarks ~sw ~env ~api_key ~limit:page_size ~offset:(page_num * page_size) ~include_content ?filter_tags base_url
+
in
+
+
let all_bookmarks = acc @ response.data in
+
+
let more_available =
+
match response.next_cursor with
+
| Some _ -> true
+
| None ->
+
let fetched_count = (page_num * page_size) + List.length response.data in
+
fetched_count < response.total
+
in
+
+
let under_max_pages = match max_pages with
+
| None -> true
+
| Some max -> page_num + 1 < max
+
in
+
+
if more_available && under_max_pages then
+
fetch_pages (page_num + 1) response.next_cursor all_bookmarks response.total
+
else
+
all_bookmarks
+
in
+
fetch_pages 0 None [] 0
+
+
(** Fetch detailed information for a single bookmark by ID *)
+
let fetch_bookmark_details ~sw ~env ~api_key base_url bookmark_id =
+
let url = Fmt.str "%s/api/v1/bookmarks/%s" base_url bookmark_id in
+
+
let headers = Requests.Headers.empty
+
|> Requests.Headers.set "Authorization" ("Bearer " ^ api_key) in
+
+
let response = Requests.One.get ~sw ~clock:env#clock ~net:env#net ~headers url in
+
let status_code = Requests.Response.status_code response in
+
if status_code = 200 then begin
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = J.from_string body_str in
+
parse_bookmark json
+
end else
+
failwith (Fmt.str "HTTP error: %d" status_code)
+
+
(** Get the asset URL for a given asset ID *)
+
let get_asset_url base_url asset_id =
+
Fmt.str "%s/api/assets/%s" base_url asset_id
+
+
(** Fetch an asset from the Karakeep server as a binary string *)
+
let fetch_asset ~sw ~env ~api_key base_url asset_id =
+
let url = get_asset_url base_url asset_id in
+
+
let headers = Requests.Headers.empty
+
|> Requests.Headers.set "Authorization" ("Bearer " ^ api_key) in
+
+
let response = Requests.One.get ~sw ~clock:env#clock ~net:env#net ~headers url in
+
let status_code = Requests.Response.status_code response in
+
if status_code = 200 then
+
Requests.Response.body response |> Eio.Flow.read_all
+
else
+
failwith (Fmt.str "Asset fetch error: %d" status_code)
+
+
(** Create a new bookmark in Karakeep with optional tags *)
+
let create_bookmark ~sw ~env ~api_key ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) base_url =
+
let body_obj = [
+
("type", `String "link");
+
("url", `String url);
+
("favourited", `Bool favourited);
+
("archived", `Bool archived);
+
] in
+
+
let body_obj = match title with
+
| Some title_str -> ("title", `String title_str) :: body_obj
+
| None -> body_obj
+
in
+
+
let body_obj = match note with
+
| Some note_str -> ("note", `String note_str) :: body_obj
+
| None -> body_obj
+
in
+
+
let body_json = `O body_obj in
+
let body_str = J.to_string body_json in
+
+
let headers = Requests.Headers.empty
+
|> Requests.Headers.set "Authorization" ("Bearer " ^ api_key)
+
|> Requests.Headers.set "Content-Type" "application/json"
+
in
+
+
let url_endpoint = Fmt.str "%s/api/v1/bookmarks" base_url in
+
let body = Requests.Body.of_string Requests.Mime.json body_str in
+
let response = Requests.One.post ~sw ~clock:env#clock ~net:env#net ~headers ~body url_endpoint in
+
+
let status_code = Requests.Response.status_code response in
+
if status_code = 201 || status_code = 200 then begin
+
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
+
let json = J.from_string body_str in
+
let bookmark = parse_bookmark json in
+
+
match tags with
+
| Some tag_list when tag_list <> [] ->
+
let tag_objects = List.map (fun tag_name ->
+
`O [("tagName", `String tag_name)]
+
) tag_list in
+
+
let tags_body = `O [("tags", `A tag_objects)] in
+
let tags_body_str = J.to_string tags_body in
+
+
let tags_url = Fmt.str "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id in
+
let tags_body = Requests.Body.of_string Requests.Mime.json tags_body_str in
+
let tags_response = Requests.One.post ~sw ~clock:env#clock ~net:env#net ~headers ~body:tags_body tags_url in
+
+
let tags_status = Requests.Response.status_code tags_response in
+
if tags_status = 200 then
+
fetch_bookmark_details ~sw ~env ~api_key base_url bookmark.id
+
else
+
bookmark
+
| _ -> bookmark
+
end else begin
+
let error_body = Requests.Response.body response |> Eio.Flow.read_all in
+
failwith (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
+
end
+
+
(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *)
+
let to_bushel_link ?base_url bookmark =
+
let description =
+
match bookmark.title with
+
| Some title when title <> "" -> title
+
| _ ->
+
let content_title = List.assoc_opt "title" bookmark.content in
+
match content_title with
+
| Some title when title <> "" && title <> "null" -> title
+
| _ -> bookmark.url
+
in
+
let date = Ptime.to_date bookmark.created_at in
+
+
let metadata =
+
(match bookmark.summary with Some s -> [("summary", s)] | None -> []) @
+
(List.filter_map (fun (id, asset_type) ->
+
match asset_type with
+
| "screenshot" | "bannerImage" -> Some (asset_type, id)
+
| _ -> None
+
) bookmark.assets) @
+
(List.filter_map (fun (k, v) ->
+
if k = "favicon" && v <> "" && v <> "null" then Some ("favicon", v) else None
+
) bookmark.content)
+
in
+
+
let karakeep =
+
match base_url with
+
| Some url ->
+
Some {
+
Bushel.Link.remote_url = url;
+
id = bookmark.id;
+
tags = bookmark.tags;
+
metadata = metadata;
+
}
+
| None -> None
+
in
+
+
let bushel_slugs =
+
List.filter_map (fun tag ->
+
if String.starts_with ~prefix:"bushel:" tag then
+
Some (String.sub tag 7 (String.length tag - 7))
+
else
+
None
+
) bookmark.tags
+
in
+
+
let bushel =
+
if bushel_slugs = [] then None
+
else Some { Bushel.Link.slugs = bushel_slugs; tags = [] }
+
in
+
+
{ Bushel.Link.url = bookmark.url; date; description; karakeep; bushel }
+32
stack/karakeepe/karakeepe.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
version: "0.1.0"
+
synopsis: "Karakeep API client for OCaml using Eio"
+
description:
+
"An Eio-based OCaml client library for the Karakeep bookmark management service API"
+
depends: [
+
"dune" {>= "3.0"}
+
"ocaml" {>= "4.14"}
+
"eio"
+
"eio_main" {>= "1.0"}
+
"requests"
+
"ezjsonm"
+
"fmt"
+
"ptime"
+
"uri"
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+39
stack/peertubee/README.md
···
+
# PeerTubee - Eio-based PeerTube API Client
+
+
This is a port of the PeerTube library from Lwt/Cohttp to Eio/Requests.
+
+
## Changes from Original
+
+
- **Removed** `open Lwt.Infix`
+
- **Replaced** all `Lwt.t` return types with direct return types (direct-style)
+
- **Added** `~sw:Eio.Switch.t` and `~env:<...>` parameters to all public functions
+
- **Replaced** `Cohttp_lwt_unix.Client.get` with `Requests.One.get`
+
- **Replaced** `Cohttp_lwt.Body.to_string body >>= fun s ->` with `let s = Requests.Response.body response |> Eio.Flow.read_all in`
+
- **Replaced** `>>=` (Lwt.bind) with direct let bindings
+
- **Replaced** `Lwt.return` with direct values
+
- **Replaced** `Lwt.return_ok`/`Lwt.return_error` with `Ok`/`Error`
+
- **Replaced** `open_out_bin` with `Eio.Path.save` for file writing
+
- **Used** `Requests.One.create` with `Eio.Stdenv.clock` and `Eio.Stdenv.net`
+
+
## Statistics
+
+
- Original: 191 lines
+
- Ported: 188 lines
+
- Functions: 8 public functions
+
- All JSON parsing logic preserved
+
- All type definitions preserved
+
+
## Files Created
+
+
- `/workspace/stack/bushel/peertubee/peertubee.ml` - Implementation (188 lines)
+
- `/workspace/stack/bushel/peertubee/peertubee.mli` - Interface (69 lines)
+
- `/workspace/stack/bushel/peertubee/dune` - Build configuration
+
- `/workspace/stack/bushel/peertubee.opam` - Package metadata
+
+
## Dependencies
+
+
- ezjsonm - JSON parsing
+
- eio + eio.core - Effects-based concurrency
+
- requests - HTTP client
+
- ptime - Time handling
+
- fmt - Formatted output
+4
stack/peertubee/dune
···
+
(library
+
(name peertubee)
+
(public_name peertubee)
+
(libraries ezjsonm eio eio.core requests ptime fmt))
+18
stack/peertubee/dune-project
···
+
(lang dune 3.0)
+
(name peertubee)
+
(version 0.1.0)
+
+
(generate_opam_files true)
+
+
(package
+
(name peertubee)
+
(synopsis "PeerTube API client for OCaml using Eio")
+
(description "An Eio-based OCaml client library for PeerTube video platform instances")
+
(depends
+
(ocaml (>= 4.14))
+
eio
+
(eio_main (>= 1.0))
+
requests
+
ezjsonm
+
fmt
+
ptime))
+31
stack/peertubee/peertubee.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
version: "0.1.0"
+
synopsis: "PeerTube API client for OCaml using Eio"
+
description:
+
"An Eio-based OCaml client library for PeerTube video platform instances"
+
depends: [
+
"dune" {>= "3.0"}
+
"ocaml" {>= "4.14"}
+
"eio"
+
"eio_main" {>= "1.0"}
+
"requests"
+
"ezjsonm"
+
"fmt"
+
"ptime"
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+5 -3
stack/requests/bin/ocurl.ml
···
~follow_redirects ~max_redirects ?timeout:timeout_obj env in
(* Set authentication if provided *)
-
(match auth with
+
let req = match auth with
| Some auth_str ->
(match parse_auth auth_str with
| Some (user, pass) ->
Requests.set_auth req
(Requests.Auth.basic ~username:user ~password:pass)
| None ->
-
Logs.warn (fun m -> m "Invalid auth format, ignoring"))
-
| None -> ());
+
Logs.warn (fun m -> m "Invalid auth format, ignoring");
+
req)
+
| None -> req
+
in
(* Build headers from command line *)
let cmd_headers = List.fold_left (fun hdrs header_str ->
+112 -167
stack/requests/lib/one.ml
···
let src = Logs.Src.create "requests.one" ~doc:"One-shot HTTP Requests"
module Log = (val Logs.src_log src : Logs.LOG)
-
type ('clock,'net) t = {
-
clock : 'clock;
-
net : 'net;
-
default_headers : Headers.t;
-
timeout : Timeout.t;
-
max_retries : int;
-
retry_backoff : float;
-
verify_tls : bool;
-
tls_config : Tls.Config.client option;
-
http_pool : ('clock, 'net) Conpool.t; (* For HTTP connections *)
-
https_pool : ('clock, 'net) Conpool.t; (* For HTTPS connections *)
-
}
+
(* Helper to create TCP connection to host:port *)
+
let connect_tcp ~sw ~net ~host ~port =
+
Log.debug (fun m -> m "Connecting to %s:%d" host port);
+
(* Resolve hostname to IP address *)
+
let addrs = Eio.Net.getaddrinfo_stream net host ~service:(string_of_int port) in
+
match addrs with
+
| addr :: _ ->
+
Log.debug (fun m -> m "Resolved %s, connecting..." host);
+
Eio.Net.connect ~sw net addr
+
| [] ->
+
let msg = Printf.sprintf "Failed to resolve hostname: %s" host in
+
Log.err (fun m -> m "%s" msg);
+
failwith msg
-
let create
-
~sw
-
?(default_headers = Headers.empty)
-
?(timeout = Timeout.default)
-
?(max_retries = 3)
-
?(retry_backoff = 2.0)
-
?(verify_tls = true)
-
?tls_config
-
?(max_connections_per_host = 10)
-
?(connection_idle_timeout = 60.0)
-
?(connection_lifetime = 300.0)
-
~clock
-
~net
-
() =
-
(* Create default TLS config if verify_tls is true and no custom config provided *)
-
let tls_config =
-
match tls_config, verify_tls with
-
| Some config, _ -> Some config
+
(* Helper to wrap connection with TLS if needed *)
+
let wrap_tls flow ~host ~verify_tls ~tls_config =
+
Log.debug (fun m -> m "Wrapping connection with TLS for %s (verify=%b)" host verify_tls);
+
+
(* Get or create TLS config *)
+
let tls_cfg = match tls_config, verify_tls with
+
| Some cfg, _ -> cfg
| None, true ->
(* Use CA certificates for verification *)
(match Ca_certs.authenticator () with
| Ok authenticator ->
(match Tls.Config.client ~authenticator () with
-
| Ok cfg -> Some cfg
+
| Ok cfg -> cfg
| Error (`Msg msg) ->
-
Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
-
None)
+
Log.err (fun m -> m "Failed to create TLS config: %s" msg);
+
failwith ("TLS config error: " ^ msg))
| Error (`Msg msg) ->
-
Log.warn (fun m -> m "Failed to load CA certificates: %s" msg);
-
None)
-
| None, false -> None
+
Log.err (fun m -> m "Failed to load CA certificates: %s" msg);
+
failwith ("CA certificates error: " ^ msg))
+
| None, false ->
+
(* No verification *)
+
match Tls.Config.client ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None) () with
+
| Ok cfg -> cfg
+
| Error (`Msg msg) -> failwith ("TLS config error: " ^ msg)
in
-
(* Create connection pools - one for HTTP, one for HTTPS *)
-
let pool_config = Conpool.Config.make
-
~max_connections_per_endpoint:max_connections_per_host
-
~max_idle_time:connection_idle_timeout
-
~max_connection_lifetime:connection_lifetime
-
()
+
(* Get domain name for SNI *)
+
let domain = match Domain_name.of_string host with
+
| Ok dn -> (match Domain_name.host dn with
+
| Ok d -> d
+
| Error (`Msg msg) ->
+
Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg);
+
failwith ("Invalid hostname: " ^ msg))
+
| Error (`Msg msg) ->
+
Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg);
+
failwith ("Invalid hostname: " ^ msg)
in
-
(* HTTP pool - plain TCP connections *)
-
let http_pool = Conpool.create
-
~sw
-
~net
-
~clock
-
~config:pool_config
-
()
-
in
+
(Tls_eio.client_of_flow ~host:domain tls_cfg flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
-
(* HTTPS pool - TLS-wrapped connections *)
-
let https_tls_config = Option.map (fun cfg ->
-
Conpool.Tls_config.make ~config:cfg ()
-
) tls_config in
+
(* Parse URL and connect directly (no pooling) *)
+
let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config =
+
let uri = Uri.of_string url in
-
let https_pool = Conpool.create
-
~sw
-
~net
-
~clock
-
?tls:https_tls_config
-
~config:pool_config
-
()
+
(* Extract host and port *)
+
let host = match Uri.host uri with
+
| Some h -> h
+
| None -> failwith ("URL must contain a host: " ^ url)
in
-
Log.info (fun m -> m "Created HTTP client with connection pools (max_per_host=%d, TLS=%b)"
-
max_connections_per_host (Option.is_some https_tls_config));
+
let is_https = Uri.scheme uri = Some "https" in
+
let default_port = if is_https then 443 else 80 in
+
let port = Option.value (Uri.port uri) ~default:default_port in
-
{
-
clock;
-
net;
-
default_headers;
-
timeout;
-
max_retries;
-
retry_backoff;
-
verify_tls;
-
tls_config;
-
http_pool;
-
https_pool;
-
}
+
(* Apply connection timeout if specified *)
+
let connect_fn () =
+
let tcp_flow = connect_tcp ~sw ~net ~host ~port in
+
if is_https then
+
wrap_tls tcp_flow ~host ~verify_tls ~tls_config
+
else
+
(tcp_flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
+
in
-
let default ~sw ~clock ~net =
-
create ~sw ~clock ~net ()
+
match timeout with
+
| Some t ->
+
let timeout_seconds = Timeout.total t in
+
(match timeout_seconds with
+
| Some seconds ->
+
Log.debug (fun m -> m "Setting connection timeout: %.2f seconds" seconds);
+
Eio.Time.with_timeout_exn clock seconds connect_fn
+
| None -> connect_fn ())
+
| None -> connect_fn ()
-
(* Accessors *)
-
let clock t = t.clock
-
let net t = t.net
-
let default_headers t = t.default_headers
-
let timeout t = t.timeout
-
let max_retries t = t.max_retries
-
let retry_backoff t = t.retry_backoff
-
let verify_tls t = t.verify_tls
-
let tls_config t = t.tls_config
+
(* Main request implementation - completely stateless *)
+
let request ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?(follow_redirects = true) ?(max_redirects = 10)
+
?(verify_tls = true) ?tls_config ~method_ url =
-
(* HTTP Request Methods *)
-
-
(* Helper to get client or use default *)
-
let get_client client =
-
match client with
-
| Some c -> c
-
| None -> failwith "No client provided"
-
-
(* Main request implementation with connection pooling *)
-
let request ~sw ?client ?headers ?body ?auth ?timeout ?follow_redirects
-
?max_redirects ~method_ url =
-
let client = get_client client in
let start_time = Unix.gettimeofday () in
-
let method_str = Method.to_string method_ in
Log.debug (fun m -> m "[One] Executing %s request to %s" method_str url);
(* Prepare headers *)
-
let headers = match headers with
-
| Some h -> h
-
| None -> default_headers client
-
in
+
let headers = Option.value headers ~default:Headers.empty in
(* Apply auth *)
let headers = match auth with
···
| Some b -> Body.Private.to_string b
in
-
(* Execute request with pooled connection *)
+
(* Execute request with redirects *)
let rec make_with_redirects url_to_fetch redirects_left =
let uri_to_fetch = Uri.of_string url_to_fetch in
-
(* Parse the redirect URL to get correct host and port *)
-
let redirect_host = match Uri.host uri_to_fetch with
-
| Some h -> h
-
| None -> failwith "Redirect URL must contain a host"
-
in
-
let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with
-
| Some "https", None -> 443
-
| Some "https", Some p -> p
-
| Some "http", None -> 80
-
| Some "http", Some p -> p
-
| _, Some p -> p
-
| _ -> 80
-
in
+
(* Connect to URL (opens new TCP connection) *)
+
let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
+
~timeout ~verify_tls ~tls_config in
-
(* Create endpoint for this specific URL *)
-
let endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in
-
-
(* Determine if we need TLS based on this URL's scheme *)
-
let is_https = match Uri.scheme uri_to_fetch with
-
| Some "https" -> true
-
| _ -> false
-
in
-
-
(* Choose the appropriate connection pool for this URL *)
-
let pool = if is_https then client.https_pool else client.http_pool in
-
-
let make_request_fn () =
-
Conpool.with_connection pool endpoint (fun flow ->
-
(* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *)
-
(* Use our low-level HTTP client *)
-
Http_client.make_request ~method_:method_str ~uri:uri_to_fetch ~headers ~body_str:request_body_str flow
-
)
-
in
-
-
(* Apply timeout if specified *)
+
(* Make HTTP request using low-level client *)
let status, resp_headers, response_body_str =
-
match timeout with
-
| Some t ->
-
let timeout_seconds = Timeout.total t in
-
(match timeout_seconds with
-
| Some seconds ->
-
Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
-
Eio.Time.with_timeout_exn (clock client) seconds make_request_fn
-
| None -> make_request_fn ())
-
| None -> make_request_fn ()
+
Http_client.make_request ~method_:method_str ~uri:uri_to_fetch
+
~headers ~body_str:request_body_str flow
in
Log.info (fun m -> m "Received response: status=%d" status);
(* Handle redirects if enabled *)
-
let follow_redirects = Option.value follow_redirects ~default:true in
if follow_redirects && (status >= 300 && status < 400) then begin
if redirects_left <= 0 then begin
-
let max_redirects = Option.value max_redirects ~default:10 in
Log.err (fun m -> m "Too many redirects (%d) for %s" max_redirects url);
raise (Error.TooManyRedirects { url; count = max_redirects; max = max_redirects })
end;
···
(status, resp_headers, response_body_str, url_to_fetch)
in
-
let max_redirects = Option.value max_redirects ~default:10 in
let final_status, final_headers, final_body_str, final_url =
make_with_redirects url max_redirects
in
···
~elapsed
(* Convenience methods *)
-
let get ~sw ?client ?headers ?auth ?timeout ?follow_redirects ?max_redirects url =
-
request ~sw ?client ?headers ?auth ?timeout ?follow_redirects ?max_redirects
+
let get ~sw ~clock ~net ?headers ?auth ?timeout
+
?follow_redirects ?max_redirects ?verify_tls ?tls_config url =
+
request ~sw ~clock ~net ?headers ?auth ?timeout
+
?follow_redirects ?max_redirects ?verify_tls ?tls_config
~method_:`GET url
-
let post ~sw ?client ?headers ?body ?auth ?timeout url =
-
request ~sw ?client ?headers ?body ?auth ?timeout ~method_:`POST url
+
let post ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?verify_tls ?tls_config url =
+
request ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?verify_tls ?tls_config ~method_:`POST url
-
let put ~sw ?client ?headers ?body ?auth ?timeout url =
-
request ~sw ?client ?headers ?body ?auth ?timeout ~method_:`PUT url
+
let put ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?verify_tls ?tls_config url =
+
request ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?verify_tls ?tls_config ~method_:`PUT url
-
let delete ~sw ?client ?headers ?auth ?timeout url =
-
request ~sw ?client ?headers ?auth ?timeout ~method_:`DELETE url
+
let delete ~sw ~clock ~net ?headers ?auth ?timeout
+
?verify_tls ?tls_config url =
+
request ~sw ~clock ~net ?headers ?auth ?timeout
+
?verify_tls ?tls_config ~method_:`DELETE url
-
let head ~sw ?client ?headers ?auth ?timeout url =
-
request ~sw ?client ?headers ?auth ?timeout ~method_:`HEAD url
+
let head ~sw ~clock ~net ?headers ?auth ?timeout
+
?verify_tls ?tls_config url =
+
request ~sw ~clock ~net ?headers ?auth ?timeout
+
?verify_tls ?tls_config ~method_:`HEAD url
-
let patch ~sw ?client ?headers ?body ?auth ?timeout url =
-
request ~sw ?client ?headers ?body ?auth ?timeout ~method_:`PATCH url
+
let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?verify_tls ?tls_config url =
+
request ~sw ~clock ~net ?headers ?body ?auth ?timeout
+
?verify_tls ?tls_config ~method_:`PATCH url
-
let upload ~sw ?client ?headers ?auth ?timeout ?method_ ?mime ?length
-
?on_progress ~source url =
+
let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length
+
?on_progress ?verify_tls ?tls_config ~source url =
let method_ = Option.value method_ ~default:`POST in
let mime = Option.value mime ~default:Mime.octet_stream in
···
in
let body = Body.of_stream ?length mime tracked_source in
-
request ~sw ?client ?headers ~body ?auth ?timeout ~method_ url
+
request ~sw ~clock ~net ?headers ~body ?auth ?timeout
+
?verify_tls ?tls_config ~method_ url
-
let download ~sw ?client ?headers ?auth ?timeout ?on_progress url ~sink =
-
let response = get ~sw ?client ?headers ?auth ?timeout url in
+
let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress
+
?verify_tls ?tls_config url ~sink =
+
let response = get ~sw ~clock ~net ?headers ?auth ?timeout
+
?verify_tls ?tls_config url in
try
(* Get content length for progress tracking *)
+80 -94
stack/requests/lib/one.mli
···
(** One-shot HTTP client for stateless requests
The One module provides a stateless HTTP client for single requests without
-
session state like cookies or persistent configuration. For stateful requests
-
with automatic cookie handling and persistent configuration, use the main
-
{!Requests} module instead.
+
session state like cookies, connection pooling, or persistent configuration.
+
Each request opens a new connection that is closed after use.
+
+
For stateful requests with automatic cookie handling, connection pooling,
+
and persistent configuration, use the main {!Requests} module instead.
{2 Examples}
···
let () = run @@ fun env ->
Switch.run @@ fun sw ->
-
-
(* Create a one-shot client *)
-
let client = One.create ~clock:env#clock ~net:env#net () in
(* Simple GET request *)
-
let response = One.get ~sw ~client "https://example.com" in
+
let response = One.get ~sw
+
~clock:env#clock ~net:env#net
+
"https://example.com" in
Printf.printf "Status: %d\n" (Response.status_code response);
Response.close response;
(* POST with JSON body *)
-
let response = One.post ~sw ~client
+
let response = One.post ~sw
+
~clock:env#clock ~net:env#net
~body:(Body.json {|{"key": "value"}|})
~headers:(Headers.empty |> Headers.content_type Mime.json)
"https://api.example.com/data" in
Response.close response;
(* Download file with streaming *)
-
One.download ~sw ~client
+
One.download ~sw
+
~clock:env#clock ~net:env#net
"https://example.com/large-file.zip"
~sink:(Eio.Path.(fs / "download.zip" |> sink))
]}
···
(** Log source for one-shot request operations *)
val src : Logs.Src.t
-
type ('a,'b) t
-
(** One-shot client configuration with clock and network types.
-
The type parameters track the Eio environment capabilities. *)
-
-
(** {1 Creation} *)
-
-
val create :
-
sw:Eio.Switch.t ->
-
?default_headers:Headers.t ->
-
?timeout:Timeout.t ->
-
?max_retries:int ->
-
?retry_backoff:float ->
-
?verify_tls:bool ->
-
?tls_config:Tls.Config.client ->
-
?max_connections_per_host:int ->
-
?connection_idle_timeout:float ->
-
?connection_lifetime:float ->
-
clock:'a Eio.Time.clock ->
-
net:'b Eio.Net.t ->
-
unit -> ('a Eio.Time.clock, 'b Eio.Net.t) t
-
(** [create ~sw ?default_headers ?timeout ?max_retries ?retry_backoff ?verify_tls ?tls_config ~clock ~net ()]
-
creates a new HTTP client with connection pooling.
-
-
@param sw Switch for resource management (client bound to this switch)
-
@param default_headers Headers to include in every request (default: empty)
-
@param timeout Default timeout configuration (default: 30s connect, 60s read)
-
@param max_retries Maximum number of retries for failed requests (default: 3)
-
@param retry_backoff Exponential backoff factor for retries (default: 2.0)
-
@param verify_tls Whether to verify TLS certificates (default: true)
-
@param tls_config Custom TLS configuration (default: uses system CA certificates)
-
@param max_connections_per_host Maximum pooled connections per host:port (default: 10)
-
@param connection_idle_timeout Max idle time before closing pooled connection (default: 60s)
-
@param connection_lifetime Max lifetime of any pooled connection (default: 300s)
-
@param clock Eio clock for timeouts and scheduling
-
@param net Eio network capability for making connections
-
*)
-
-
val default : sw:Eio.Switch.t -> clock:'a Eio.Time.clock -> net:'b Eio.Net.t -> ('a Eio.Time.clock, 'b Eio.Net.t) t
-
(** [default ~sw ~clock ~net] creates a client with default configuration.
-
Equivalent to [create ~sw ~clock ~net ()]. *)
-
-
(** {1 Configuration Access} *)
-
-
val clock : ('a,'b) t -> 'a
-
(** [clock client] returns the clock capability. *)
-
-
val net : ('a,'b) t -> 'b
-
(** [net client] returns the network capability. *)
-
-
val default_headers : ('a,'b) t -> Headers.t
-
(** [default_headers client] returns the default headers. *)
-
-
val timeout : ('a,'b) t -> Timeout.t
-
(** [timeout client] returns the timeout configuration. *)
-
-
val max_retries : ('a,'b) t -> int
-
(** [max_retries client] returns the maximum retry count. *)
-
-
val retry_backoff : ('a,'b) t -> float
-
(** [retry_backoff client] returns the retry backoff factor. *)
-
-
val verify_tls : ('a,'b) t -> bool
-
(** [verify_tls client] returns whether TLS verification is enabled. *)
-
-
val tls_config : ('a,'b) t -> Tls.Config.client option
-
(** [tls_config client] returns the TLS configuration if set. *)
+
(** {1 HTTP Request Methods}
-
(** {1 HTTP Request Methods} *)
+
All functions are stateless - they open a new TCP connection for each request
+
and close it when the switch closes. No connection pooling or reuse. *)
val request :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?body:Body.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
?follow_redirects:bool ->
?max_redirects:int ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
method_:Method.t ->
string ->
Response.t
-
(** Make a streaming request *)
+
(** [request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?follow_redirects
+
?max_redirects ?verify_tls ?tls_config ~method_ url] makes a single HTTP
+
request without connection pooling.
+
+
Each call opens a new TCP connection (with TLS if https://), makes the
+
request, and closes the connection when the switch closes.
+
+
@param sw Switch for resource management (response/connection bound to this)
+
@param clock Clock for timeouts
+
@param net Network interface for TCP connections
+
@param headers Request headers (default: empty)
+
@param body Request body (default: none)
+
@param auth Authentication to apply (default: none)
+
@param timeout Request timeout (default: 30s connect, 60s read)
+
@param follow_redirects Whether to follow HTTP redirects (default: true)
+
@param max_redirects Maximum redirects to follow (default: 10)
+
@param verify_tls Whether to verify TLS certificates (default: true)
+
@param tls_config Custom TLS configuration (default: system CA certs)
+
@param method_ HTTP method (GET, POST, etc.)
+
@param url URL to request
+
*)
val get :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
?follow_redirects:bool ->
?max_redirects:int ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
Response.t
-
(** GET request *)
+
(** GET request. See {!request} for parameter details. *)
val post :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?body:Body.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
Response.t
-
(** POST request *)
+
(** POST request. See {!request} for parameter details. *)
val put :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?body:Body.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
Response.t
-
(** PUT request *)
+
(** PUT request. See {!request} for parameter details. *)
val delete :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
Response.t
-
(** DELETE request *)
+
(** DELETE request. See {!request} for parameter details. *)
val head :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
Response.t
-
(** HEAD request *)
+
(** HEAD request. See {!request} for parameter details. *)
val patch :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?body:Body.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
Response.t
-
(** PATCH request *)
+
(** PATCH request. See {!request} for parameter details. *)
val upload :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
···
?mime:Mime.t ->
?length:int64 ->
?on_progress:(sent:int64 -> total:int64 option -> unit) ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
source:Eio.Flow.source_ty Eio.Resource.t ->
string ->
Response.t
-
(** Upload from stream *)
+
(** Upload from stream. See {!request} for parameter details. *)
val download :
sw:Eio.Switch.t ->
-
?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
+
clock:_ Eio.Time.clock ->
+
net:_ Eio.Net.t ->
?headers:Headers.t ->
?auth:Auth.t ->
?timeout:Timeout.t ->
?on_progress:(received:int64 -> total:int64 option -> unit) ->
+
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
string ->
sink:Eio.Flow.sink_ty Eio.Resource.t ->
unit
-
(** Download to stream *)
+
(** Download to stream. See {!request} for parameter details. *)
+239 -80
stack/requests/lib/requests.ml
···
We don't call use_default() here as it spawns background threads
that are incompatible with Eio's structured concurrency. *)
-
(* Main API - Session functionality with concurrent fiber spawning *)
+
(* Main API - Session functionality with connection pooling *)
type ('clock, 'net) t = {
sw : Eio.Switch.t;
-
client : ('clock, 'net) One.t;
clock : 'clock;
+
net : 'net;
+
+
(* Connection pools owned by this session *)
+
http_pool : ('clock, 'net) Conpool.t;
+
https_pool : ('clock, 'net) Conpool.t;
+
+
(* Session state - immutable *)
cookie_jar : Cookeio.jar;
cookie_mutex : Eio.Mutex.t;
-
mutable default_headers : Headers.t;
-
mutable auth : Auth.t option;
-
mutable timeout : Timeout.t;
-
mutable follow_redirects : bool;
-
mutable max_redirects : int;
-
mutable retry : Retry.config option;
+
default_headers : Headers.t;
+
auth : Auth.t option;
+
timeout : Timeout.t;
+
follow_redirects : bool;
+
max_redirects : int;
+
verify_tls : bool;
+
tls_config : Tls.Config.client option;
+
retry : Retry.config option;
persist_cookies : bool;
xdg : Xdge.t option;
cache : Cache.t option;
-
(* Statistics *)
+
+
(* Statistics - mutable for tracking across all derived sessions *)
mutable requests_made : int;
mutable total_time : float;
mutable retries_count : int;
···
let create
~sw
-
?client
+
?http_pool
+
?https_pool
?cookie_jar
?(default_headers = Headers.empty)
?auth
···
?(follow_redirects = true)
?(max_redirects = 10)
?(verify_tls = true)
+
?tls_config
+
?(max_connections_per_host = 10)
+
?(connection_idle_timeout = 60.0)
+
?(connection_lifetime = 300.0)
?retry
?(persist_cookies = false)
?(enable_cache = false)
?xdg
env =
+
let clock = env#clock in
+
let net = env#net in
+
let xdg = match xdg, persist_cookies || enable_cache with
| Some x, _ -> Some x
| None, true -> Some (Xdge.create env#fs "requests")
| None, false -> None
in
-
let client = match client with
-
| Some c -> c
+
(* Create TLS config for HTTPS pool if needed *)
+
let tls_config = match tls_config, verify_tls with
+
| Some cfg, _ -> Some cfg
+
| None, true ->
+
(* Use CA certificates for verification *)
+
(match Ca_certs.authenticator () with
+
| Ok authenticator ->
+
(match Tls.Config.client ~authenticator () with
+
| Ok cfg -> Some cfg
+
| Error (`Msg msg) ->
+
Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
+
None)
+
| Error (`Msg msg) ->
+
Log.warn (fun m -> m "Failed to load CA certificates: %s" msg);
+
None)
+
| None, false -> None
+
in
+
+
(* Create connection pools if not provided *)
+
let pool_config = Conpool.Config.make
+
~max_connections_per_endpoint:max_connections_per_host
+
~max_idle_time:connection_idle_timeout
+
~max_connection_lifetime:connection_lifetime
+
()
+
in
+
+
(* HTTP pool - plain TCP connections *)
+
let http_pool = match http_pool with
+
| Some p -> p
| None ->
-
One.create ~sw ~verify_tls ~timeout
-
~clock:env#clock ~net:env#net ()
+
Conpool.create ~sw ~net ~clock ~config:pool_config ()
in
+
(* HTTPS pool - TLS-wrapped connections *)
+
let https_pool = match https_pool with
+
| Some p -> p
+
| None ->
+
let https_tls_config = Option.map (fun cfg ->
+
Conpool.Tls_config.make ~config:cfg ()
+
) tls_config in
+
Conpool.create ~sw ~net ~clock ?tls:https_tls_config ~config:pool_config ()
+
in
+
+
Log.info (fun m -> m "Created Requests session with connection pools (max_per_host=%d, TLS=%b)"
+
max_connections_per_host (Option.is_some tls_config));
+
let cookie_jar = match cookie_jar, persist_cookies, xdg with
| Some jar, _, _ -> jar
| None, true, Some xdg_ctx ->
···
{
sw;
-
client;
-
clock = env#clock;
+
clock;
+
net;
+
http_pool;
+
https_pool;
cookie_jar;
cookie_mutex = Eio.Mutex.create ();
default_headers;
···
timeout;
follow_redirects;
max_redirects;
+
verify_tls;
+
tls_config;
retry;
persist_cookies;
xdg;
···
retries_count = 0;
}
-
(* Configuration management *)
let set_default_header t key value =
-
t.default_headers <- Headers.set key value t.default_headers
+
{ t with default_headers = Headers.set key value t.default_headers }
let remove_default_header t key =
-
t.default_headers <- Headers.remove key t.default_headers
+
{ t with default_headers = Headers.remove key t.default_headers }
let set_auth t auth =
Log.debug (fun m -> m "Setting authentication method");
-
t.auth <- Some auth
+
{ t with auth = Some auth }
let clear_auth t =
Log.debug (fun m -> m "Clearing authentication");
-
t.auth <- None
+
{ t with auth = None }
let set_timeout t timeout =
Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout);
-
t.timeout <- timeout
+
{ t with timeout }
let set_retry t config =
Log.debug (fun m -> m "Setting retry config: max_retries=%d" config.Retry.max_retries);
-
t.retry <- Some config
+
{ t with retry = Some config }
let cookies t = t.cookie_jar
let clear_cookies t = Cookeio.clear t.cookie_jar
-
(* Internal request function that runs in a fiber *)
+
(* Internal request function using connection pools *)
let make_request_internal t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
-
Log.info (fun m -> m "Making %s request to %s" (Method.to_string method_) url);
+
let start_time = Unix.gettimeofday () in
+
let method_str = Method.to_string method_ in
+
+
Log.info (fun m -> m "Making %s request to %s" method_str url);
+
+
(* Parse URL *)
+
let uri = Uri.of_string url in
+
let domain = Option.value ~default:"" (Uri.host uri) in
+
let path = Uri.path uri in
+
let is_secure = Uri.scheme uri = Some "https" in
+
(* Merge headers *)
let headers = match headers with
| Some h -> Headers.merge t.default_headers h
···
| None -> t.auth
in
-
(* Get cookies for this URL *)
-
let uri = Uri.of_string url in
-
let domain = Option.value ~default:"" (Uri.host uri) in
-
let path = Uri.path uri in
-
let is_secure = Uri.scheme uri = Some "https" in
+
(* Apply auth *)
+
let headers = match auth with
+
| Some a ->
+
Log.debug (fun m -> m "Applying authentication");
+
Auth.apply a headers
+
| None -> headers
+
in
+
(* Add content type from body *)
+
let headers = match body with
+
| Some b -> (match Body.content_type b with
+
| Some mime -> Headers.content_type mime headers
+
| None -> headers)
+
| None -> headers
+
in
+
+
(* Get cookies for this URL *)
let headers =
Eio.Mutex.use_ro t.cookie_mutex (fun () ->
let cookies = Cookeio.get_cookies t.cookie_jar ~domain ~path ~is_secure in
···
)
in
+
(* Convert body to string for sending *)
+
let request_body_str = match body with
+
| None -> ""
+
| Some b -> Body.Private.to_string b
+
in
+
(* Check cache for GET and HEAD requests when body is not present *)
-
let response = match t.cache, method_, body with
+
let cached_response = match t.cache, method_, body with
| Some cache, (`GET | `HEAD), None ->
-
Log.debug (fun m -> m "Checking cache for %s request to %s" (Method.to_string method_) url);
+
Log.debug (fun m -> m "Checking cache for %s request to %s" method_str url);
let headers_cohttp = Cohttp.Header.of_list (Headers.to_list headers) in
-
(match Cache.get cache ~method_ ~url:uri ~headers:headers_cohttp with
-
| Some cached_response ->
-
Log.info (fun m -> m "Cache HIT for %s request to %s" (Method.to_string method_) url);
-
(* Convert cached response to Response.t *)
-
let status = Cohttp.Code.code_of_status cached_response.Cache.status in
-
let resp_headers = Headers.of_list (Cohttp.Header.to_list cached_response.Cache.headers) in
-
let body_flow = Eio.Flow.string_source cached_response.Cache.body in
-
Response.make ~sw:t.sw ~status ~headers:resp_headers ~body:body_flow ~url ~elapsed:0.0
-
| None ->
-
Log.info (fun m -> m "Cache MISS for %s request to %s" (Method.to_string method_) url);
-
(* Make the actual request *)
-
let response = One.request ~sw:t.sw ~client:t.client
-
?body ?auth
-
~timeout:(Option.value timeout ~default:t.timeout)
-
~follow_redirects:(Option.value follow_redirects ~default:t.follow_redirects)
-
~max_redirects:(Option.value max_redirects ~default:t.max_redirects)
-
~headers ~method_ url
-
in
-
(* Store in cache if successful *)
-
(match t.cache with
-
| Some cache when Response.ok response ->
-
Log.debug (fun m -> m "Storing response in cache for %s" url);
-
let status_code = Response.status_code response in
-
let status = Cohttp.Code.status_of_code status_code in
-
let resp_headers = Response.headers response in
-
let resp_headers_cohttp = Cohttp.Header.of_list (Headers.to_list resp_headers) in
-
(* Read body to cache it *)
-
let body_flow = Response.body response in
-
let buf = Buffer.create 4096 in
-
Eio.Flow.copy body_flow (Eio.Flow.buffer_sink buf);
-
let body_str = Buffer.contents buf in
-
Cache.put cache ~method_ ~url:uri ~request_headers:headers_cohttp
-
~status ~headers:resp_headers_cohttp ~body:body_str;
-
(* Return a new response with the buffered body *)
-
let new_body_flow = Eio.Flow.string_source body_str in
-
Response.make ~sw:t.sw ~status:status_code ~headers:resp_headers ~body:new_body_flow ~url ~elapsed:0.0
-
| _ -> response))
-
| _ ->
-
Log.debug (fun m -> m "Cache not applicable for %s request to %s (cache enabled: %b, body present: %b)"
-
(Method.to_string method_) url (Option.is_some t.cache) (Option.is_some body));
-
(* Make the actual request without caching *)
-
One.request ~sw:t.sw ~client:t.client
-
?body ?auth
-
~timeout:(Option.value timeout ~default:t.timeout)
-
~follow_redirects:(Option.value follow_redirects ~default:t.follow_redirects)
-
~max_redirects:(Option.value max_redirects ~default:t.max_redirects)
-
~headers ~method_ url
+
Cache.get cache ~method_ ~url:uri ~headers:headers_cohttp
+
| _ -> None
+
in
+
+
let response = match cached_response with
+
| Some cached ->
+
Log.info (fun m -> m "Cache HIT for %s request to %s" method_str url);
+
(* Convert cached response to Response.t *)
+
let status = Cohttp.Code.code_of_status cached.Cache.status in
+
let resp_headers = Headers.of_list (Cohttp.Header.to_list cached.Cache.headers) in
+
let body_flow = Eio.Flow.string_source cached.Cache.body in
+
Response.Private.make ~sw:t.sw ~status ~headers:resp_headers ~body:body_flow ~url ~elapsed:0.0
+
| None ->
+
Log.info (fun m -> m "Cache MISS or not applicable for %s request to %s" method_str url);
+
+
(* Execute request with redirect handling *)
+
let rec make_with_redirects url_to_fetch redirects_left =
+
let uri_to_fetch = Uri.of_string url_to_fetch in
+
+
(* Parse the redirect URL to get correct host and port *)
+
let redirect_host = match Uri.host uri_to_fetch with
+
| Some h -> h
+
| None -> failwith "Redirect URL must contain a host"
+
in
+
let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with
+
| Some "https", None -> 443
+
| Some "https", Some p -> p
+
| Some "http", None -> 80
+
| Some "http", Some p -> p
+
| _, Some p -> p
+
| _ -> 80
+
in
+
+
(* Create endpoint for this specific URL *)
+
let redirect_endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in
+
+
(* Determine if we need TLS based on this URL's scheme *)
+
let redirect_is_https = match Uri.scheme uri_to_fetch with
+
| Some "https" -> true
+
| _ -> false
+
in
+
+
(* Choose the appropriate connection pool for this URL *)
+
let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in
+
+
let make_request_fn () =
+
Conpool.with_connection redirect_pool redirect_endpoint (fun flow ->
+
(* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *)
+
(* Use our low-level HTTP client *)
+
Http_client.make_request ~method_:method_str ~uri:uri_to_fetch
+
~headers ~body_str:request_body_str flow
+
)
+
in
+
+
(* Apply timeout if specified *)
+
let status, resp_headers, response_body_str =
+
let timeout_val = Option.value timeout ~default:t.timeout in
+
match Timeout.total timeout_val with
+
| Some seconds ->
+
Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
+
Eio.Time.with_timeout_exn t.clock seconds make_request_fn
+
| None -> make_request_fn ()
+
in
+
+
Log.info (fun m -> m "Received response: status=%d" status);
+
+
(* Handle redirects if enabled *)
+
let follow = Option.value follow_redirects ~default:t.follow_redirects in
+
let max_redir = Option.value max_redirects ~default:t.max_redirects in
+
+
if follow && (status >= 300 && status < 400) then begin
+
if redirects_left <= 0 then begin
+
Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url);
+
raise (Error.TooManyRedirects { url; count = max_redir; max = max_redir })
+
end;
+
+
match Headers.get "location" resp_headers with
+
| None ->
+
Log.debug (fun m -> m "Redirect response missing Location header");
+
(status, resp_headers, response_body_str, url_to_fetch)
+
| Some location ->
+
Log.info (fun m -> m "Following redirect to %s (%d remaining)" location redirects_left);
+
make_with_redirects location (redirects_left - 1)
+
end else
+
(status, resp_headers, response_body_str, url_to_fetch)
+
in
+
+
let max_redir = Option.value max_redirects ~default:t.max_redirects in
+
let final_status, final_headers, final_body_str, final_url =
+
make_with_redirects url max_redir
+
in
+
+
let elapsed = Unix.gettimeofday () -. start_time in
+
Log.info (fun m -> m "Request completed in %.3f seconds" elapsed);
+
+
(* Store in cache if successful and caching enabled *)
+
(match t.cache with
+
| Some cache when final_status >= 200 && final_status < 300 ->
+
Log.debug (fun m -> m "Storing response in cache for %s" url);
+
let status = Cohttp.Code.status_of_code final_status in
+
let resp_headers_cohttp = Cohttp.Header.of_list (Headers.to_list final_headers) in
+
let headers_cohttp = Cohttp.Header.of_list (Headers.to_list headers) in
+
Cache.put cache ~method_ ~url:uri ~request_headers:headers_cohttp
+
~status ~headers:resp_headers_cohttp ~body:final_body_str
+
| _ -> ());
+
+
(* Create a flow from the body string *)
+
let body_flow = Eio.Flow.string_source final_body_str in
+
+
Response.Private.make
+
~sw:t.sw
+
~status:final_status
+
~headers:final_headers
+
~body:body_flow
+
~url:final_url
+
~elapsed
in
(* Extract and store cookies from response *)
···
(* Update statistics *)
t.requests_made <- t.requests_made + 1;
+
t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time);
Log.info (fun m -> m "Request completed with status %d" (Response.status_code response));
response
···
env in
(* Set user agent if provided *)
-
Option.iter (set_default_header req "User-Agent") config.user_agent;
+
let req = match config.user_agent with
+
| Some ua -> set_default_header req "User-Agent" ua
+
| None -> req
+
in
req
+43 -16
stack/requests/lib/requests.mli
···
*)
type ('clock, 'net) t
-
(** A stateful HTTP client that maintains cookies, auth, and configuration across requests. *)
+
(** A stateful HTTP client that maintains cookies, auth, configuration, and
+
connection pools across requests. *)
(** {2 Creation and Configuration} *)
val create :
sw:Eio.Switch.t ->
-
?client:('clock Eio.Time.clock,'net Eio.Net.t) One.t ->
+
?http_pool:('clock Eio.Time.clock, 'net Eio.Net.t) Conpool.t ->
+
?https_pool:('clock Eio.Time.clock, 'net Eio.Net.t) Conpool.t ->
?cookie_jar:Cookeio.jar ->
?default_headers:Headers.t ->
?auth:Auth.t ->
···
?follow_redirects:bool ->
?max_redirects:int ->
?verify_tls:bool ->
+
?tls_config:Tls.Config.client ->
+
?max_connections_per_host:int ->
+
?connection_idle_timeout:float ->
+
?connection_lifetime:float ->
?retry:Retry.config ->
?persist_cookies:bool ->
?enable_cache:bool ->
?xdg:Xdge.t ->
< clock: 'clock Eio.Resource.t; net: 'net Eio.Resource.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
('clock Eio.Resource.t, 'net Eio.Resource.t) t
-
(** Create a new requests instance with persistent state.
-
All resources are bound to the provided switch and will be cleaned up automatically. *)
+
(** Create a new requests instance with persistent state and connection pooling.
+
All resources are bound to the provided switch and will be cleaned up automatically.
+
+
@param sw Switch for resource management
+
@param http_pool Optional pre-configured HTTP connection pool (creates new if not provided)
+
@param https_pool Optional pre-configured HTTPS connection pool (creates new if not provided)
+
@param cookie_jar Cookie storage (default: empty in-memory jar)
+
@param default_headers Headers included in every request
+
@param auth Default authentication
+
@param timeout Default timeout configuration
+
@param follow_redirects Whether to follow HTTP redirects (default: true)
+
@param max_redirects Maximum redirects to follow (default: 10)
+
@param verify_tls Whether to verify TLS certificates (default: true)
+
@param tls_config Custom TLS configuration for HTTPS pool (default: system CA certs)
+
@param max_connections_per_host Maximum pooled connections per host:port (default: 10)
+
@param connection_idle_timeout Max idle time before closing pooled connection (default: 60s)
+
@param connection_lifetime Max lifetime of any pooled connection (default: 300s)
+
@param retry Retry configuration for failed requests
+
@param persist_cookies Whether to persist cookies to disk (default: false)
+
@param enable_cache Whether to enable HTTP caching (default: false)
+
@param xdg XDG directory context for cookies/cache (required if persist_cookies or enable_cache)
+
*)
(** {2 Configuration Management} *)
-
val set_default_header : ('clock, 'net) t -> string -> string -> unit
-
(** Set a default header for all requests *)
+
val set_default_header : ('clock, 'net) t -> string -> string -> ('clock, 'net) t
+
(** Add or update a default header. Returns a new session with the updated header.
+
The original session's connection pools are shared. *)
-
val remove_default_header : ('clock, 'net) t -> string -> unit
-
(** Remove a default header *)
+
val remove_default_header : ('clock, 'net) t -> string -> ('clock, 'net) t
+
(** Remove a default header. Returns a new session without the header. *)
-
val set_auth : ('clock, 'net) t -> Auth.t -> unit
-
(** Set default authentication *)
+
val set_auth : ('clock, 'net) t -> Auth.t -> ('clock, 'net) t
+
(** Set default authentication. Returns a new session with auth configured. *)
-
val clear_auth : ('clock, 'net) t -> unit
-
(** Clear authentication *)
+
val clear_auth : ('clock, 'net) t -> ('clock, 'net) t
+
(** Clear authentication. Returns a new session without auth. *)
-
val set_timeout : ('clock, 'net) t -> Timeout.t -> unit
-
(** Set default timeout *)
+
val set_timeout : ('clock, 'net) t -> Timeout.t -> ('clock, 'net) t
+
(** Set default timeout. Returns a new session with the timeout configured. *)
-
val set_retry : ('clock, 'net) t -> Retry.config -> unit
-
(** Set retry configuration *)
+
val set_retry : ('clock, 'net) t -> Retry.config -> ('clock, 'net) t
+
(** Set retry configuration. Returns a new session with retry configured. *)
(** {2 Request Methods}
+12 -23
stack/requests/test/test_connection_pool.ml
···
-
(** Test connection pooling with conpool integration *)
+
(** Test stateless One API - each request opens a fresh connection *)
open Eio.Std
-
let test_connection_pooling () =
+
let test_one_stateless () =
(* Initialize RNG for TLS *)
Mirage_crypto_rng_unix.use_default ();
Eio_main.run @@ fun env ->
Switch.run @@ fun sw ->
-
(* Configure logging to see connection pool activity *)
+
(* Configure logging to see One request activity *)
Logs.set_reporter (Logs_fmt.reporter ());
Logs.set_level (Some Logs.Info);
-
Logs.Src.set_level Conpool.src (Some Logs.Info);
Logs.Src.set_level Requests.One.src (Some Logs.Info);
-
traceln "=== Testing Connection Pooling ===\n";
+
traceln "=== Testing One Stateless API ===\n";
+
traceln "The One API creates fresh connections for each request (no pooling)\n";
-
(* Create a client with connection pooling *)
-
let client = Requests.One.create
-
~sw
-
~clock:env#clock
-
~net:env#net
-
~max_connections_per_host:5
-
~connection_idle_timeout:30.0
-
~verify_tls:true
-
()
-
in
-
-
traceln "Client created with connection pool";
-
traceln "Making 10 requests to example.com...\n";
-
-
(* Make multiple requests to the same host *)
+
(* Make multiple requests to the same host using stateless One API *)
let start_time = Unix.gettimeofday () in
for i = 1 to 10 do
traceln "Request %d:" i;
-
let response = Requests.One.get ~sw ~client "http://example.com" in
+
let response = Requests.One.get ~sw
+
~clock:env#clock ~net:env#net
+
"http://example.com"
+
in
traceln " Status: %d" (Requests.Response.status_code response);
traceln " Content-Length: %s"
···
| Some len -> Int64.to_string len
| None -> "unknown");
-
(* Body already drained - connection automatically returned to pool *)
+
(* Connection is fresh for each request - no pooling *)
traceln ""
done;
···
let () =
try
-
test_connection_pooling ()
+
test_one_stateless ()
with e ->
traceln "Test failed with exception: %s" (Printexc.to_string e);
Printexc.print_backtrace stdout;
+7 -5
stack/requests/test/test_requests.ml
···
end in
Test_server.start_server ~port test_env;
-
let client = Requests.One.create ~sw ~clock:env#clock ~net:env#net () in
-
let response = Requests.One.get ~sw ~client (base_url ^ "/echo") in
+
let response = Requests.One.get ~sw
+
~clock:env#clock ~net:env#net
+
(base_url ^ "/echo")
+
in
Alcotest.(check int) "One module status" 200 (Requests.Response.status_code response)
···
let req = Requests.create ~sw env in
-
Requests.set_default_header req "X-Session" "session-123";
+
let req = Requests.set_default_header req "X-Session" "session-123" in
let auth = Requests.Auth.bearer ~token:"test_token" in
-
Requests.set_auth req auth;
+
let req = Requests.set_auth req auth in
let response1 = Requests.get req (base_url ^ "/echo") in
let body_str1 = Requests.Response.body response1 |> Eio.Flow.read_all in
···
Alcotest.(check string) "Session header persisted" "session-123" session_header;
-
Requests.remove_default_header req "X-Session";
+
let req = Requests.remove_default_header req "X-Session" in
let response2 = Requests.get req (base_url ^ "/echo") in
let body_str2 = Requests.Response.body response2 |> Eio.Flow.read_all in
+4
stack/typesense-cliente/dune
···
+
(library
+
(public_name typesense-cliente)
+
(name typesense_cliente)
+
(libraries eio requests ezjsonm fmt uri ptime))
+18
stack/typesense-cliente/dune-project
···
+
(lang dune 3.0)
+
(name typesense-cliente)
+
(version 0.1.0)
+
+
(generate_opam_files true)
+
+
(package
+
(name typesense-cliente)
+
(synopsis "Typesense search API client for OCaml using Eio")
+
(description "An Eio-based OCaml client library for Typesense search engine")
+
(depends
+
(ocaml (>= 4.14))
+
eio
+
requests
+
ezjsonm
+
fmt
+
uri
+
ptime))
+30
stack/typesense-cliente/typesense-cliente.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
version: "0.1.0"
+
synopsis: "Typesense search API client for OCaml using Eio"
+
description: "An Eio-based OCaml client library for Typesense search engine"
+
depends: [
+
"dune" {>= "3.0"}
+
"ocaml" {>= "4.14"}
+
"eio"
+
"requests"
+
"ezjsonm"
+
"fmt"
+
"uri"
+
"ptime"
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+85
stack/typesense-cliente/typesense_cliente.mli
···
+
(** Typesense client for OCaml using Eio and Requests *)
+
+
(** Configuration for Typesense client *)
+
type config = {
+
endpoint : string;
+
api_key : string;
+
}
+
+
(** Error types for Typesense operations *)
+
type error =
+
| Http_error of int * string
+
| Json_error of string
+
| Connection_error of string
+
+
val pp_error : Format.formatter -> error -> unit
+
+
(** Search result types *)
+
type search_result = {
+
id: string;
+
title: string;
+
content: string;
+
score: float;
+
collection: string;
+
highlights: (string * string list) list;
+
document: Ezjsonm.value; (* Store raw document for flexible field access *)
+
}
+
+
type search_response = {
+
hits: search_result list;
+
total: int;
+
query_time: float;
+
}
+
+
(** Multisearch result types *)
+
type multisearch_response = {
+
results: search_response list;
+
}
+
+
(** Search a single collection *)
+
val search_collection :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
string ->
+
?limit:int ->
+
?offset:int ->
+
unit ->
+
(search_response, error) result
+
+
(** Perform multisearch across all collections *)
+
val multisearch :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
string ->
+
?limit:int ->
+
unit ->
+
(multisearch_response, error) result
+
+
(** Combine multisearch results into single result set *)
+
val combine_multisearch_results :
+
multisearch_response ->
+
?limit:int ->
+
?offset:int ->
+
unit ->
+
search_response
+
+
(** List all collections *)
+
val list_collections :
+
sw:Eio.Switch.t ->
+
env:< clock: [> float Eio.Time.clock_ty ] Eio.Resource.t; net: [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t; .. > ->
+
config ->
+
((string * int) list, error) result
+
+
(** Pretty printer utilities *)
+
val extract_field_string : Ezjsonm.value -> string -> string
+
val extract_field_string_list : Ezjsonm.value -> string -> string list
+
val extract_field_bool : Ezjsonm.value -> string -> bool
+
val format_authors : string list -> string
+
val format_date : string -> string
+
val format_tags : string list -> string
+
+
(** One-line pretty printer for search results *)
+
val pp_search_result_oneline : search_result -> string
+1 -1
stack/zotero-translation/dune
···
(library
(name zotero_translation)
(public_name zotero-translation)
-
(libraries astring cohttp-lwt-unix ezjsonm http fpath))
+
(libraries astring eio requests ezjsonm http fpath uri))
+66 -68
stack/zotero-translation/zotero_translation.ml
···
(** Resolve a DOI from a Zotero translation server *)
-
module C = Cohttp
-
module CL = Cohttp_lwt
-
module CLU = Cohttp_lwt_unix.Client
module J = Ezjsonm
(* From the ZTS source code: https://github.com/zotero/translation-server/blob/master/src/formats.js
···
| true -> Uri.of_string (base_uri ^ "import")
| false -> Uri.of_string (base_uri ^ "/import")
-
open Lwt.Infix
-
-
(* The Eio version has more in here, hence I'm just keeping this around. *)
-
type t = {
+
type ('clock, 'net) t = {
base_uri: string;
+
requests_session: ('clock, 'net) Requests.t;
}
-
let v base_uri = { base_uri }
+
let create ~sw ~env ?requests_session base_uri =
+
let requests_session = match requests_session with
+
| Some session -> session
+
| None -> Requests.create ~sw env
+
in
+
{ base_uri; requests_session }
-
let resolve_doi { base_uri } doi =
-
let body = "https://doi.org/" ^ doi in
-
let doi_body = CL.Body.of_string body in
-
let headers = C.Header.init_with "content-type" "text/plain" in
+
let v _base_uri =
+
failwith "Zotero_translation.v is deprecated. Use Zotero_translation.create ~sw ~env base_uri instead"
+
+
let resolve_doi { base_uri; requests_session } doi =
+
let body_str = "https://doi.org/" ^ doi in
let uri = web_endp base_uri in
-
CLU.call ~headers ~body:doi_body `POST uri >>= fun (resp, body) ->
-
let status = C.Response.status resp in
-
body |> Cohttp_lwt.Body.to_string >>= fun body ->
-
if status = `OK then begin
+
let body = Requests.Body.text body_str in
+
let response = Requests.post requests_session ~body (Uri.to_string uri) in
+
let status = Requests.Response.status_code response in
+
let body = Requests.Response.body response |> Eio.Flow.read_all in
+
if status = 200 then begin
try
let doi_json = J.from_string body in
-
Lwt.return_ok doi_json
-
with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
+
Ok doi_json
+
with exn -> Error (`Msg (Printexc.to_string exn))
end else
-
Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
+
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
-
let resolve_url { base_uri } url =
-
let url_body = CL.Body.of_string url in
-
let headers = C.Header.init_with "content-type" "text/plain" in
+
let resolve_url { base_uri; requests_session } url =
+
let body_str = url in
let uri = web_endp base_uri in
-
CLU.call ~headers ~body:url_body `POST uri >>= fun (resp, body) ->
-
let status = C.Response.status resp in
-
body |> Cohttp_lwt.Body.to_string >>= fun body ->
-
if status = `OK then begin
+
let body = Requests.Body.text body_str in
+
let response = Requests.post requests_session ~body (Uri.to_string uri) in
+
let status = Requests.Response.status_code response in
+
let body = Requests.Response.body response |> Eio.Flow.read_all in
+
if status = 200 then begin
try
let url_json = J.from_string body in
-
Lwt.return_ok url_json
-
with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
+
Ok url_json
+
with exn -> Error (`Msg (Printexc.to_string exn))
end else
-
Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
+
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
-
let search_id { base_uri} doi =
-
let body = "https://doi.org/" ^ doi in
-
let doi_body = CL.Body.of_string body in
-
let headers = C.Header.init_with "content-type" "text/plain" in
+
let search_id { base_uri; requests_session } doi =
+
let body_str = "https://doi.org/" ^ doi in
let uri = search_endp base_uri in
-
CLU.call ~headers ~body:doi_body `POST uri >>= fun (resp, body) ->
-
let status = C.Response.status resp in
-
body |> Cohttp_lwt.Body.to_string >>= fun body ->
-
if status = `OK then begin
+
let body = Requests.Body.text body_str in
+
let response = Requests.post requests_session ~body (Uri.to_string uri) in
+
let status = Requests.Response.status_code response in
+
let body = Requests.Response.body response |> Eio.Flow.read_all in
+
if status = 200 then begin
try
let doi_json = J.from_string body in
-
Lwt.return_ok doi_json
-
with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
+
Ok doi_json
+
with exn -> Error (`Msg (Printexc.to_string exn))
end else
-
Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
+
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
-
let export {base_uri} format api =
-
let body = CL.Body.of_string (J.to_string api) in
-
let headers = C.Header.init_with "content-type" "application/json" in
+
let export { base_uri; requests_session } format api =
+
let body_str = J.to_string api in
let uri = Uri.with_query' (export_endp base_uri ) ["format", (format_to_string format)] in
-
CLU.call ~headers ~body `POST uri >>= fun (resp, body) ->
-
let status = C.Response.status resp in
-
body |> Cohttp_lwt.Body.to_string >>= fun body ->
-
if status = `OK then begin
+
let body = Requests.Body.of_string Requests.Mime.json body_str in
+
let response = Requests.post requests_session ~body (Uri.to_string uri) in
+
let status = Requests.Response.status_code response in
+
let body = Requests.Response.body response |> Eio.Flow.read_all in
+
if status = 200 then begin
try
match format with
-
| Bibtex -> Lwt.return_ok (Astring.String.trim body)
-
| _ -> Lwt.return_ok body
-
with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
+
| Bibtex -> Ok (Astring.String.trim body)
+
| _ -> Ok body
+
with exn -> Error (`Msg (Printexc.to_string exn))
end else
-
Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
+
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
let unescape_hex s =
let buf = Buffer.create (String.length s) in
···
| Error e ->
prerr_endline bib;
Fmt.epr "%a\n%!" Bibtex.pp_error e;
-
Lwt.fail_with "bib parse err TODO"
+
failwith "bib parse err TODO"
| Ok [bib] ->
let f = Bibtex.fields bib |> Bibtex.SM.bindings |> List.map (fun (k,v) -> k, (unescape_bibtex v)) in
let ty = match Bibtex.type' bib with "inbook" -> "book" | x -> x in
let v = List.fold_left (fun acc (k,v) -> (k,(`String v))::acc) ["bibtype",`String ty] f in
-
Lwt.return v
-
| Ok _ -> Lwt.fail_with "one bib at a time plz"
+
v
+
| Ok _ -> failwith "one bib at a time plz"
let bib_of_doi zt doi =
prerr_endline ("Fetching " ^ doi);
-
let v = resolve_doi zt doi >>= function
-
| Ok r ->
-
Lwt.return r
+
let v = match resolve_doi zt doi with
+
| Ok r -> r
| Error (`Msg _) ->
Printf.eprintf "%s failed on /web, trying to /search\n%!" doi;
-
search_id zt doi >>= function
-
| Error (`Msg e) -> Lwt.fail_with e
-
| Ok r ->
-
Lwt.return r
+
match search_id zt doi with
+
| Error (`Msg e) -> failwith e
+
| Ok r -> r
in
-
v >>= fun v ->
-
export zt Bibtex v >>= function
-
| Error (`Msg e) -> Lwt.fail_with e
+
match export zt Bibtex v with
+
| Error (`Msg e) -> failwith e
| Ok r ->
print_endline r;
-
Lwt.return r
+
r
let split_authors keys =
let authors =
···
fun bib -> J.update y ["bib"] (Some (`String bib))
let json_of_doi zt ~slug doi =
-
bib_of_doi zt doi >>= fun x ->
-
fields_of_bib x >>= fun x ->
-
Lwt.return (split_authors x |> add_bibtex ~slug)
+
let x = bib_of_doi zt doi in
+
let x = fields_of_bib x in
+
split_authors x |> add_bibtex ~slug
+25 -7
stack/zotero-translation/zotero_translation.mli
···
(** {1 Interface to the Zotero Translation Server} *)
-
type t
+
type ('clock, 'net) t
type format =
| Bibtex
···
val format_to_string: format -> string
val format_of_string: string -> format option
-
val v : string -> t
+
(** Create a Zotero Translation client.
+
@param requests_session Optional Requests session for connection pooling.
+
If not provided, a new session is created. *)
+
val create :
+
sw:Eio.Switch.t ->
+
env:< clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t;
+
net: ([> [> `Generic ] Eio.Net.ty ] as 'net) Eio.Resource.t;
+
fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
+
?requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
+
string -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
-
val resolve_doi: t -> string -> ([>Ezjsonm.t], [>`Msg of string]) Lwt_result.t
+
(** Deprecated: use [create] instead *)
+
val v : string -> (_, _) t
+
[@@deprecated "Use create ~sw ~env base_uri instead"]
-
val resolve_url: t -> string -> ([>Ezjsonm.t], [>`Msg of string]) Lwt_result.t
+
val resolve_doi: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
string -> (Ezjsonm.t, [>`Msg of string]) result
-
val search_id: t -> string -> ([>Ezjsonm.t], [>`Msg of string]) Lwt_result.t
+
val resolve_url: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
string -> (Ezjsonm.t, [>`Msg of string]) result
-
val export: t -> format -> Ezjsonm.t -> (string, [>`Msg of string]) Lwt_result.t
+
val search_id: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
string -> (Ezjsonm.t, [>`Msg of string]) result
-
val json_of_doi : t -> slug:string -> string -> Ezjsonm.value Lwt.t
+
val export: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
format -> Ezjsonm.t -> (string, [>`Msg of string]) result
+
+
val json_of_doi : ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
+
slug:string -> string -> Ezjsonm.value