···7777(** [receive t] returns a lazy sequence of messages from Claude.
78787979 The sequence yields messages as they arrive from Claude, including:
8080- - {!Message.Assistant} - Claude's responses
8181- - {!Message.System} - System notifications
8282- - {!Message.Result} - Final result with usage statistics
8080+ - {!constructor:Message.Assistant} - Claude's responses
8181+ - {!constructor:Message.System} - System notifications
8282+ - {!constructor:Message.Result} - Final result with usage statistics
83838484 Control messages (permission requests, hook callbacks) are handled
8585 internally and not yielded to the sequence. *)
+11
stack/kitty_graphics/dune-project
···11+(lang dune 3.20)
22+(name kitty_graphics)
33+44+(package
55+ (name kitty_graphics)
66+ (synopsis "OCaml implementation of the Kitty terminal graphics protocol")
77+ (description
88+ "A standalone library for rendering images in terminals that support the Kitty graphics protocol. Supports image transmission, display, animation, Unicode placeholders, and terminal capability detection.")
99+ (depends
1010+ (ocaml (>= 4.14.0))
1111+ base64))
···11+(* Kitty Terminal Graphics Protocol - Implementation *)
22+33+module Format = struct
44+ type t = Rgba32 | Rgb24 | Png
55+66+ let to_int = function Rgba32 -> 32 | Rgb24 -> 24 | Png -> 100
77+end
88+99+module Transmission = struct
1010+ type t = Direct | File | Tempfile
1111+1212+ let to_char = function Direct -> 'd' | File -> 'f' | Tempfile -> 't'
1313+end
1414+1515+module Compression = struct
1616+ type t = None | Zlib
1717+1818+ let to_char = function None -> Option.none | Zlib -> Some 'z'
1919+end
2020+2121+module Quiet = struct
2222+ type t = Noisy | Errors_only | Silent
2323+2424+ let to_int = function Noisy -> 0 | Errors_only -> 1 | Silent -> 2
2525+end
2626+2727+module Cursor = struct
2828+ type t = Move | Static
2929+3030+ let to_int = function Move -> 0 | Static -> 1
3131+end
3232+3333+module Composition = struct
3434+ type t = Alpha_blend | Overwrite
3535+3636+ let to_int = function Alpha_blend -> 0 | Overwrite -> 1
3737+end
3838+3939+module Delete = struct
4040+ type t =
4141+ | All_visible
4242+ | All_visible_and_free
4343+ | By_id of { image_id : int; placement_id : int option }
4444+ | By_id_and_free of { image_id : int; placement_id : int option }
4545+ | By_number of { image_number : int; placement_id : int option }
4646+ | By_number_and_free of { image_number : int; placement_id : int option }
4747+ | At_cursor
4848+ | At_cursor_and_free
4949+ | At_cell of { x : int; y : int }
5050+ | At_cell_and_free of { x : int; y : int }
5151+ | At_cell_z of { x : int; y : int; z : int }
5252+ | At_cell_z_and_free of { x : int; y : int; z : int }
5353+ | By_column of int
5454+ | By_column_and_free of int
5555+ | By_row of int
5656+ | By_row_and_free of int
5757+ | By_z_index of int
5858+ | By_z_index_and_free of int
5959+ | By_id_range of { min_id : int; max_id : int }
6060+ | By_id_range_and_free of { min_id : int; max_id : int }
6161+ | Frames
6262+ | Frames_and_free
6363+end
6464+6565+module Placement = struct
6666+ type t = {
6767+ source_x : int option;
6868+ source_y : int option;
6969+ source_width : int option;
7070+ source_height : int option;
7171+ cell_x_offset : int option;
7272+ cell_y_offset : int option;
7373+ columns : int option;
7474+ rows : int option;
7575+ z_index : int option;
7676+ placement_id : int option;
7777+ cursor : Cursor.t option;
7878+ unicode_placeholder : bool;
7979+ }
8080+8181+ let empty =
8282+ {
8383+ source_x = None;
8484+ source_y = None;
8585+ source_width = None;
8686+ source_height = None;
8787+ cell_x_offset = None;
8888+ cell_y_offset = None;
8989+ columns = None;
9090+ rows = None;
9191+ z_index = None;
9292+ placement_id = None;
9393+ cursor = None;
9494+ unicode_placeholder = false;
9595+ }
9696+9797+ let make ?source_x ?source_y ?source_width ?source_height ?cell_x_offset
9898+ ?cell_y_offset ?columns ?rows ?z_index ?placement_id ?cursor
9999+ ?(unicode_placeholder = false) () =
100100+ {
101101+ source_x;
102102+ source_y;
103103+ source_width;
104104+ source_height;
105105+ cell_x_offset;
106106+ cell_y_offset;
107107+ columns;
108108+ rows;
109109+ z_index;
110110+ placement_id;
111111+ cursor;
112112+ unicode_placeholder;
113113+ }
114114+end
115115+116116+module Frame = struct
117117+ type t = {
118118+ x : int option;
119119+ y : int option;
120120+ base_frame : int option;
121121+ edit_frame : int option;
122122+ gap_ms : int option;
123123+ composition : Composition.t option;
124124+ background_color : int32 option;
125125+ }
126126+127127+ let empty =
128128+ {
129129+ x = None;
130130+ y = None;
131131+ base_frame = None;
132132+ edit_frame = None;
133133+ gap_ms = None;
134134+ composition = None;
135135+ background_color = None;
136136+ }
137137+138138+ let make ?x ?y ?base_frame ?edit_frame ?gap_ms ?composition ?background_color
139139+ () =
140140+ { x; y; base_frame; edit_frame; gap_ms; composition; background_color }
141141+end
142142+143143+module Animation = struct
144144+ type state = Stop | Loading | Run
145145+146146+ type t =
147147+ | Set_state of { state : state; loops : int option }
148148+ | Set_gap of { frame : int; gap_ms : int }
149149+ | Set_current of int
150150+151151+ let set_state ?loops state = Set_state { state; loops }
152152+ let set_gap ~frame ~gap_ms = Set_gap { frame; gap_ms }
153153+ let set_current_frame frame = Set_current frame
154154+end
155155+156156+module Compose = struct
157157+ type t = {
158158+ source_frame : int;
159159+ dest_frame : int;
160160+ width : int option;
161161+ height : int option;
162162+ source_x : int option;
163163+ source_y : int option;
164164+ dest_x : int option;
165165+ dest_y : int option;
166166+ composition : Composition.t option;
167167+ }
168168+169169+ let make ~source_frame ~dest_frame ?width ?height ?source_x ?source_y ?dest_x
170170+ ?dest_y ?composition () =
171171+ {
172172+ source_frame;
173173+ dest_frame;
174174+ width;
175175+ height;
176176+ source_x;
177177+ source_y;
178178+ dest_x;
179179+ dest_y;
180180+ composition;
181181+ }
182182+end
183183+184184+module Command = struct
185185+ type action =
186186+ | Transmit
187187+ | Transmit_and_display
188188+ | Query
189189+ | Display
190190+ | Delete
191191+ | Frame
192192+ | Animate
193193+ | Compose
194194+195195+ type t = {
196196+ action : action;
197197+ format : Format.t option;
198198+ transmission : Transmission.t option;
199199+ compression : Compression.t option;
200200+ width : int option;
201201+ height : int option;
202202+ size : int option;
203203+ offset : int option;
204204+ quiet : Quiet.t option;
205205+ image_id : int option;
206206+ image_number : int option;
207207+ placement : Placement.t option;
208208+ delete : Delete.t option;
209209+ frame : Frame.t option;
210210+ animation : Animation.t option;
211211+ compose : Compose.t option;
212212+ }
213213+214214+ let make_base action =
215215+ {
216216+ action;
217217+ format = None;
218218+ transmission = None;
219219+ compression = None;
220220+ width = None;
221221+ height = None;
222222+ size = None;
223223+ offset = None;
224224+ quiet = None;
225225+ image_id = None;
226226+ image_number = None;
227227+ placement = None;
228228+ delete = None;
229229+ frame = None;
230230+ animation = None;
231231+ compose = None;
232232+ }
233233+234234+ let transmit ?image_id ?image_number ?format ?transmission ?compression ?width
235235+ ?height ?size ?offset ?quiet () =
236236+ {
237237+ (make_base Transmit) with
238238+ image_id;
239239+ image_number;
240240+ format;
241241+ transmission;
242242+ compression;
243243+ width;
244244+ height;
245245+ size;
246246+ offset;
247247+ quiet;
248248+ }
249249+250250+ let transmit_and_display ?image_id ?image_number ?format ?transmission
251251+ ?compression ?width ?height ?size ?offset ?quiet ?placement () =
252252+ {
253253+ (make_base Transmit_and_display) with
254254+ image_id;
255255+ image_number;
256256+ format;
257257+ transmission;
258258+ compression;
259259+ width;
260260+ height;
261261+ size;
262262+ offset;
263263+ quiet;
264264+ placement;
265265+ }
266266+267267+ let query ?format ?transmission ?width ?height ?quiet () =
268268+ { (make_base Query) with format; transmission; width; height; quiet }
269269+270270+ let display ?image_id ?image_number ?placement ?quiet () =
271271+ { (make_base Display) with image_id; image_number; placement; quiet }
272272+273273+ let delete ?quiet del =
274274+ { (make_base Delete) with quiet; delete = Some del }
275275+276276+ let frame ?image_id ?image_number ?format ?transmission ?compression ?width
277277+ ?height ?quiet ~frame () =
278278+ {
279279+ (make_base Frame) with
280280+ image_id;
281281+ image_number;
282282+ format;
283283+ transmission;
284284+ compression;
285285+ width;
286286+ height;
287287+ quiet;
288288+ frame = Some frame;
289289+ }
290290+291291+ let animate ?image_id ?image_number ?quiet anim =
292292+ { (make_base Animate) with image_id; image_number; quiet; animation = Some anim }
293293+294294+ let compose ?image_id ?image_number ?quiet comp =
295295+ { (make_base Compose) with image_id; image_number; quiet; compose = Some comp }
296296+297297+ (* APC escape sequences *)
298298+ let apc_start = "\027_G"
299299+ let apc_end = "\027\\"
300300+301301+ (* Helper to add key=value pairs *)
302302+ let add_kv buf key value =
303303+ Buffer.add_char buf key;
304304+ Buffer.add_char buf '=';
305305+ Buffer.add_string buf value
306306+307307+ let add_kv_int buf key value =
308308+ Buffer.add_char buf key;
309309+ Buffer.add_char buf '=';
310310+ Buffer.add_string buf (string_of_int value)
311311+312312+ let add_kv_int32 buf key value =
313313+ Buffer.add_char buf key;
314314+ Buffer.add_char buf '=';
315315+ Buffer.add_string buf (Int32.to_string value)
316316+317317+ let add_comma buf = Buffer.add_char buf ','
318318+319319+ let action_char = function
320320+ | Transmit -> 't'
321321+ | Transmit_and_display -> 'T'
322322+ | Query -> 'q'
323323+ | Display -> 'p'
324324+ | Delete -> 'd'
325325+ | Frame -> 'f'
326326+ | Animate -> 'a'
327327+ | Compose -> 'c'
328328+329329+ let delete_char = function
330330+ | Delete.All_visible -> 'a'
331331+ | All_visible_and_free -> 'A'
332332+ | By_id _ -> 'i'
333333+ | By_id_and_free _ -> 'I'
334334+ | By_number _ -> 'n'
335335+ | By_number_and_free _ -> 'N'
336336+ | At_cursor -> 'c'
337337+ | At_cursor_and_free -> 'C'
338338+ | At_cell _ -> 'p'
339339+ | At_cell_and_free _ -> 'P'
340340+ | At_cell_z _ -> 'q'
341341+ | At_cell_z_and_free _ -> 'Q'
342342+ | By_column _ -> 'x'
343343+ | By_column_and_free _ -> 'X'
344344+ | By_row _ -> 'y'
345345+ | By_row_and_free _ -> 'Y'
346346+ | By_z_index _ -> 'z'
347347+ | By_z_index_and_free _ -> 'Z'
348348+ | By_id_range _ -> 'r'
349349+ | By_id_range_and_free _ -> 'R'
350350+ | Frames -> 'f'
351351+ | Frames_and_free -> 'F'
352352+353353+ let write_control_data buf cmd =
354354+ let first = ref true in
355355+ let sep () =
356356+ if !first then first := false else add_comma buf
357357+ in
358358+ (* Action *)
359359+ sep ();
360360+ add_kv buf 'a' (String.make 1 (action_char cmd.action));
361361+ (* Quiet *)
362362+ Option.iter
363363+ (fun q ->
364364+ let v = Quiet.to_int q in
365365+ if v <> 0 then (
366366+ sep ();
367367+ add_kv_int buf 'q' v))
368368+ cmd.quiet;
369369+ (* Format *)
370370+ Option.iter
371371+ (fun f ->
372372+ sep ();
373373+ add_kv_int buf 'f' (Format.to_int f))
374374+ cmd.format;
375375+ (* Transmission *)
376376+ Option.iter
377377+ (fun t ->
378378+ let c = Transmission.to_char t in
379379+ if c <> 'd' then (
380380+ sep ();
381381+ add_kv buf 't' (String.make 1 c)))
382382+ cmd.transmission;
383383+ (* Compression *)
384384+ Option.iter
385385+ (fun c ->
386386+ match Compression.to_char c with
387387+ | Some ch ->
388388+ sep ();
389389+ add_kv buf 'o' (String.make 1 ch)
390390+ | None -> ())
391391+ cmd.compression;
392392+ (* Dimensions *)
393393+ Option.iter
394394+ (fun w ->
395395+ sep ();
396396+ add_kv_int buf 's' w)
397397+ cmd.width;
398398+ Option.iter
399399+ (fun h ->
400400+ sep ();
401401+ add_kv_int buf 'v' h)
402402+ cmd.height;
403403+ (* File size/offset *)
404404+ Option.iter
405405+ (fun s ->
406406+ sep ();
407407+ add_kv_int buf 'S' s)
408408+ cmd.size;
409409+ Option.iter
410410+ (fun o ->
411411+ sep ();
412412+ add_kv_int buf 'O' o)
413413+ cmd.offset;
414414+ (* Image ID *)
415415+ Option.iter
416416+ (fun id ->
417417+ sep ();
418418+ add_kv_int buf 'i' id)
419419+ cmd.image_id;
420420+ (* Image number *)
421421+ Option.iter
422422+ (fun n ->
423423+ sep ();
424424+ add_kv_int buf 'I' n)
425425+ cmd.image_number;
426426+ (* Placement options *)
427427+ Option.iter
428428+ (fun (p : Placement.t) ->
429429+ Option.iter
430430+ (fun v ->
431431+ sep ();
432432+ add_kv_int buf 'x' v)
433433+ p.source_x;
434434+ Option.iter
435435+ (fun v ->
436436+ sep ();
437437+ add_kv_int buf 'y' v)
438438+ p.source_y;
439439+ Option.iter
440440+ (fun v ->
441441+ sep ();
442442+ add_kv_int buf 'w' v)
443443+ p.source_width;
444444+ Option.iter
445445+ (fun v ->
446446+ sep ();
447447+ add_kv_int buf 'h' v)
448448+ p.source_height;
449449+ Option.iter
450450+ (fun v ->
451451+ sep ();
452452+ add_kv_int buf 'X' v)
453453+ p.cell_x_offset;
454454+ Option.iter
455455+ (fun v ->
456456+ sep ();
457457+ add_kv_int buf 'Y' v)
458458+ p.cell_y_offset;
459459+ Option.iter
460460+ (fun v ->
461461+ sep ();
462462+ add_kv_int buf 'c' v)
463463+ p.columns;
464464+ Option.iter
465465+ (fun v ->
466466+ sep ();
467467+ add_kv_int buf 'r' v)
468468+ p.rows;
469469+ Option.iter
470470+ (fun v ->
471471+ sep ();
472472+ add_kv_int buf 'z' v)
473473+ p.z_index;
474474+ Option.iter
475475+ (fun v ->
476476+ sep ();
477477+ add_kv_int buf 'p' v)
478478+ p.placement_id;
479479+ Option.iter
480480+ (fun c ->
481481+ let v = Cursor.to_int c in
482482+ if v <> 0 then (
483483+ sep ();
484484+ add_kv_int buf 'C' v))
485485+ p.cursor;
486486+ if p.unicode_placeholder then (
487487+ sep ();
488488+ add_kv_int buf 'U' 1))
489489+ cmd.placement;
490490+ (* Delete options *)
491491+ Option.iter
492492+ (fun d ->
493493+ sep ();
494494+ add_kv buf 'd' (String.make 1 (delete_char d));
495495+ match d with
496496+ | Delete.By_id { image_id; placement_id }
497497+ | Delete.By_id_and_free { image_id; placement_id } ->
498498+ sep ();
499499+ add_kv_int buf 'i' image_id;
500500+ Option.iter
501501+ (fun p ->
502502+ sep ();
503503+ add_kv_int buf 'p' p)
504504+ placement_id
505505+ | Delete.By_number { image_number; placement_id }
506506+ | Delete.By_number_and_free { image_number; placement_id } ->
507507+ sep ();
508508+ add_kv_int buf 'I' image_number;
509509+ Option.iter
510510+ (fun p ->
511511+ sep ();
512512+ add_kv_int buf 'p' p)
513513+ placement_id
514514+ | Delete.At_cell { x; y } | Delete.At_cell_and_free { x; y } ->
515515+ sep ();
516516+ add_kv_int buf 'x' x;
517517+ sep ();
518518+ add_kv_int buf 'y' y
519519+ | Delete.At_cell_z { x; y; z }
520520+ | Delete.At_cell_z_and_free { x; y; z } ->
521521+ sep ();
522522+ add_kv_int buf 'x' x;
523523+ sep ();
524524+ add_kv_int buf 'y' y;
525525+ sep ();
526526+ add_kv_int buf 'z' z
527527+ | Delete.By_column c | Delete.By_column_and_free c ->
528528+ sep ();
529529+ add_kv_int buf 'x' c
530530+ | Delete.By_row r | Delete.By_row_and_free r ->
531531+ sep ();
532532+ add_kv_int buf 'y' r
533533+ | Delete.By_z_index z | Delete.By_z_index_and_free z ->
534534+ sep ();
535535+ add_kv_int buf 'z' z
536536+ | Delete.By_id_range { min_id; max_id }
537537+ | Delete.By_id_range_and_free { min_id; max_id } ->
538538+ sep ();
539539+ add_kv_int buf 'x' min_id;
540540+ sep ();
541541+ add_kv_int buf 'y' max_id
542542+ | _ -> ())
543543+ cmd.delete;
544544+ (* Frame options *)
545545+ Option.iter
546546+ (fun (f : Frame.t) ->
547547+ Option.iter
548548+ (fun v ->
549549+ sep ();
550550+ add_kv_int buf 'x' v)
551551+ f.x;
552552+ Option.iter
553553+ (fun v ->
554554+ sep ();
555555+ add_kv_int buf 'y' v)
556556+ f.y;
557557+ Option.iter
558558+ (fun v ->
559559+ sep ();
560560+ add_kv_int buf 'c' v)
561561+ f.base_frame;
562562+ Option.iter
563563+ (fun v ->
564564+ sep ();
565565+ add_kv_int buf 'r' v)
566566+ f.edit_frame;
567567+ Option.iter
568568+ (fun v ->
569569+ sep ();
570570+ add_kv_int buf 'z' v)
571571+ f.gap_ms;
572572+ Option.iter
573573+ (fun c ->
574574+ let v = Composition.to_int c in
575575+ if v <> 0 then (
576576+ sep ();
577577+ add_kv_int buf 'X' v))
578578+ f.composition;
579579+ Option.iter
580580+ (fun v ->
581581+ sep ();
582582+ add_kv_int32 buf 'Y' v)
583583+ f.background_color)
584584+ cmd.frame;
585585+ (* Animation options *)
586586+ Option.iter
587587+ (fun a ->
588588+ match a with
589589+ | Animation.Set_state { state; loops } ->
590590+ let s =
591591+ match state with
592592+ | Animation.Stop -> 1
593593+ | Animation.Loading -> 2
594594+ | Animation.Run -> 3
595595+ in
596596+ sep ();
597597+ add_kv_int buf 's' s;
598598+ Option.iter
599599+ (fun v ->
600600+ sep ();
601601+ add_kv_int buf 'v' v)
602602+ loops
603603+ | Animation.Set_gap { frame; gap_ms } ->
604604+ sep ();
605605+ add_kv_int buf 'r' frame;
606606+ sep ();
607607+ add_kv_int buf 'z' gap_ms
608608+ | Animation.Set_current frame ->
609609+ sep ();
610610+ add_kv_int buf 'c' frame)
611611+ cmd.animation;
612612+ (* Compose options *)
613613+ Option.iter
614614+ (fun (c : Compose.t) ->
615615+ sep ();
616616+ add_kv_int buf 'r' c.source_frame;
617617+ sep ();
618618+ add_kv_int buf 'c' c.dest_frame;
619619+ Option.iter
620620+ (fun v ->
621621+ sep ();
622622+ add_kv_int buf 'w' v)
623623+ c.width;
624624+ Option.iter
625625+ (fun v ->
626626+ sep ();
627627+ add_kv_int buf 'h' v)
628628+ c.height;
629629+ Option.iter
630630+ (fun v ->
631631+ sep ();
632632+ add_kv_int buf 'x' v)
633633+ c.dest_x;
634634+ Option.iter
635635+ (fun v ->
636636+ sep ();
637637+ add_kv_int buf 'y' v)
638638+ c.dest_y;
639639+ Option.iter
640640+ (fun v ->
641641+ sep ();
642642+ add_kv_int buf 'X' v)
643643+ c.source_x;
644644+ Option.iter
645645+ (fun v ->
646646+ sep ();
647647+ add_kv_int buf 'Y' v)
648648+ c.source_y;
649649+ Option.iter
650650+ (fun comp ->
651651+ let v = Composition.to_int comp in
652652+ if v <> 0 then (
653653+ sep ();
654654+ add_kv_int buf 'C' v))
655655+ c.composition)
656656+ cmd.compose
657657+658658+ let chunk_size = 4096
659659+660660+ let write buf cmd ~data =
661661+ Buffer.add_string buf apc_start;
662662+ write_control_data buf cmd;
663663+ if String.length data > 0 then begin
664664+ let encoded = Base64.encode_string data in
665665+ let len = String.length encoded in
666666+ if len <= chunk_size then (
667667+ Buffer.add_char buf ';';
668668+ Buffer.add_string buf encoded;
669669+ Buffer.add_string buf apc_end)
670670+ else begin
671671+ (* Multiple chunks *)
672672+ let pos = ref 0 in
673673+ let first = ref true in
674674+ while !pos < len do
675675+ let remaining = len - !pos in
676676+ let this_chunk = min chunk_size remaining in
677677+ let is_last = !pos + this_chunk >= len in
678678+ if !first then (
679679+ (* First chunk *)
680680+ first := false;
681681+ add_comma buf;
682682+ add_kv_int buf 'm' 1;
683683+ Buffer.add_char buf ';';
684684+ Buffer.add_substring buf encoded !pos this_chunk;
685685+ Buffer.add_string buf apc_end)
686686+ else (
687687+ (* Continuation chunk *)
688688+ Buffer.add_string buf apc_start;
689689+ add_kv_int buf 'm' (if is_last then 0 else 1);
690690+ Buffer.add_char buf ';';
691691+ Buffer.add_substring buf encoded !pos this_chunk;
692692+ Buffer.add_string buf apc_end);
693693+ pos := !pos + this_chunk
694694+ done
695695+ end
696696+ end
697697+ else Buffer.add_string buf apc_end
698698+699699+ let to_string cmd ~data =
700700+ let buf = Buffer.create 1024 in
701701+ write buf cmd ~data;
702702+ Buffer.contents buf
703703+end
704704+705705+module Response = struct
706706+ type t = {
707707+ message : string;
708708+ image_id : int option;
709709+ image_number : int option;
710710+ placement_id : int option;
711711+ }
712712+713713+ let is_ok t = t.message = "OK"
714714+ let message t = t.message
715715+716716+ let error_code t =
717717+ if is_ok t then None
718718+ else
719719+ match String.index_opt t.message ':' with
720720+ | Some i -> Some (String.sub t.message 0 i)
721721+ | None -> Some t.message
722722+723723+ let image_id t = t.image_id
724724+ let image_number t = t.image_number
725725+ let placement_id t = t.placement_id
726726+727727+ let parse s =
728728+ (* Format: <ESC>_G<keys>;message<ESC>\ *)
729729+ let esc = '\027' in
730730+ let len = String.length s in
731731+ if len < 5 then None
732732+ else if s.[0] <> esc || s.[1] <> '_' || s.[2] <> 'G' then None
733733+ else
734734+ (* Find the semicolon and end *)
735735+ match String.index_from_opt s 3 ';' with
736736+ | None -> None
737737+ | Some semi_pos -> (
738738+ (* Find the APC terminator *)
739739+ let rec find_end pos =
740740+ if pos + 1 < len && s.[pos] = esc && s.[pos + 1] = '\\' then
741741+ Some pos
742742+ else if pos + 1 < len then find_end (pos + 1)
743743+ else None
744744+ in
745745+ match find_end (semi_pos + 1) with
746746+ | None -> None
747747+ | Some end_pos ->
748748+ let keys_str = String.sub s 3 (semi_pos - 3) in
749749+ let message =
750750+ String.sub s (semi_pos + 1) (end_pos - semi_pos - 1)
751751+ in
752752+ (* Parse keys *)
753753+ let image_id = ref None in
754754+ let image_number = ref None in
755755+ let placement_id = ref None in
756756+ let parts = String.split_on_char ',' keys_str in
757757+ List.iter
758758+ (fun part ->
759759+ if String.length part >= 3 && part.[1] = '=' then
760760+ let key = part.[0] in
761761+ let value = String.sub part 2 (String.length part - 2) in
762762+ match key with
763763+ | 'i' -> image_id := int_of_string_opt value
764764+ | 'I' -> image_number := int_of_string_opt value
765765+ | 'p' -> placement_id := int_of_string_opt value
766766+ | _ -> ())
767767+ parts;
768768+ Some
769769+ {
770770+ message;
771771+ image_id = !image_id;
772772+ image_number = !image_number;
773773+ placement_id = !placement_id;
774774+ })
775775+end
776776+777777+module Unicode_placeholder = struct
778778+ let placeholder_char = Uchar.of_int 0x10EEEE
779779+780780+ (* Row/column diacritics from the protocol spec *)
781781+ let diacritics =
782782+ [|
783783+ 0x0305; 0x030D; 0x030E; 0x0310; 0x0312; 0x033D; 0x033E; 0x033F;
784784+ 0x0346; 0x034A; 0x034B; 0x034C; 0x0350; 0x0351; 0x0352; 0x0357;
785785+ 0x035B; 0x0363; 0x0364; 0x0365; 0x0366; 0x0367; 0x0368; 0x0369;
786786+ 0x036A; 0x036B; 0x036C; 0x036D; 0x036E; 0x036F; 0x0483; 0x0484;
787787+ 0x0485; 0x0486; 0x0487; 0x0592; 0x0593; 0x0594; 0x0595; 0x0597;
788788+ 0x0598; 0x0599; 0x059C; 0x059D; 0x059E; 0x059F; 0x05A0; 0x05A1;
789789+ 0x05A8; 0x05A9; 0x05AB; 0x05AC; 0x05AF; 0x05C4; 0x0610; 0x0611;
790790+ 0x0612; 0x0613; 0x0614; 0x0615; 0x0616; 0x0617; 0x0657; 0x0658;
791791+ 0x0659; 0x065A; 0x065B; 0x065D; 0x065E; 0x06D6; 0x06D7; 0x06D8;
792792+ 0x06D9; 0x06DA; 0x06DB; 0x06DC; 0x06DF; 0x06E0; 0x06E1; 0x06E2;
793793+ 0x06E4; 0x06E7; 0x06E8; 0x06EB; 0x06EC; 0x0730; 0x0732; 0x0733;
794794+ 0x0735; 0x0736; 0x073A; 0x073D; 0x073F; 0x0740; 0x0741; 0x0743;
795795+ 0x0745; 0x0747; 0x0749; 0x074A; 0x07EB; 0x07EC; 0x07ED; 0x07EE;
796796+ 0x07EF; 0x07F0; 0x07F1; 0x07F3; 0x0816; 0x0817; 0x0818; 0x0819;
797797+ 0x081B; 0x081C; 0x081D; 0x081E; 0x081F; 0x0820; 0x0821; 0x0822;
798798+ 0x0823; 0x0825; 0x0826; 0x0827; 0x0829; 0x082A; 0x082B; 0x082C;
799799+ 0x082D; 0x0951; 0x0953; 0x0954; 0x0F82; 0x0F83; 0x0F86; 0x0F87;
800800+ 0x135D; 0x135E; 0x135F; 0x17DD; 0x193A; 0x1A17; 0x1A75; 0x1A76;
801801+ 0x1A77; 0x1A78; 0x1A79; 0x1A7A; 0x1A7B; 0x1A7C; 0x1B6B; 0x1B6D;
802802+ 0x1B6E; 0x1B6F; 0x1B70; 0x1B71; 0x1B72; 0x1B73; 0x1CD0; 0x1CD1;
803803+ 0x1CD2; 0x1CDA; 0x1CDB; 0x1CE0; 0x1DC0; 0x1DC1; 0x1DC3; 0x1DC4;
804804+ 0x1DC5; 0x1DC6; 0x1DC7; 0x1DC8; 0x1DC9; 0x1DCB; 0x1DCC; 0x1DD1;
805805+ 0x1DD2; 0x1DD3; 0x1DD4; 0x1DD5; 0x1DD6; 0x1DD7; 0x1DD8; 0x1DD9;
806806+ 0x1DDA; 0x1DDB; 0x1DDC; 0x1DDD; 0x1DDE; 0x1DDF; 0x1DE0; 0x1DE1;
807807+ 0x1DE2; 0x1DE3; 0x1DE4; 0x1DE5; 0x1DE6; 0x1DFE; 0x20D0; 0x20D1;
808808+ 0x20D4; 0x20D5; 0x20D6; 0x20D7; 0x20DB; 0x20DC; 0x20E1; 0x20E7;
809809+ 0x20E9; 0x20F0; 0xA66F; 0xA67C; 0xA67D; 0xA6F0; 0xA6F1; 0xA8E0;
810810+ 0xA8E1; 0xA8E2; 0xA8E3; 0xA8E4; 0xA8E5; 0xA8E6; 0xA8E7; 0xA8E8;
811811+ 0xA8E9; 0xA8EA; 0xA8EB; 0xA8EC; 0xA8ED; 0xA8EE; 0xA8EF; 0xA8F0;
812812+ 0xA8F1; 0xAAB0; 0xAAB2; 0xAAB3; 0xAAB7; 0xAAB8; 0xAABE; 0xAABF;
813813+ 0xAAC1; 0xFE20; 0xFE21; 0xFE22; 0xFE23; 0xFE24; 0xFE25; 0xFE26;
814814+ 0x10A0F; 0x10A38; 0x1D185; 0x1D186; 0x1D187; 0x1D188; 0x1D189;
815815+ 0x1D1AA; 0x1D1AB; 0x1D1AC; 0x1D1AD; 0x1D242; 0x1D243; 0x1D244;
816816+ |]
817817+818818+ let row_diacritic n =
819819+ if n >= 0 && n < Array.length diacritics then
820820+ Uchar.of_int diacritics.(n)
821821+ else Uchar.of_int diacritics.(0)
822822+823823+ let column_diacritic = row_diacritic
824824+ let id_high_byte_diacritic = row_diacritic
825825+826826+ let add_uchar buf u =
827827+ let b = Bytes.create 4 in
828828+ let len = Uchar.utf_8_byte_length u in
829829+ let _ = Uchar.unsafe_to_char u in
830830+ (* Encode UTF-8 manually *)
831831+ let code = Uchar.to_int u in
832832+ if code < 0x80 then (
833833+ Bytes.set b 0 (Char.chr code);
834834+ Buffer.add_subbytes buf b 0 1)
835835+ else if code < 0x800 then (
836836+ Bytes.set b 0 (Char.chr (0xC0 lor (code lsr 6)));
837837+ Bytes.set b 1 (Char.chr (0x80 lor (code land 0x3F)));
838838+ Buffer.add_subbytes buf b 0 2)
839839+ else if code < 0x10000 then (
840840+ Bytes.set b 0 (Char.chr (0xE0 lor (code lsr 12)));
841841+ Bytes.set b 1 (Char.chr (0x80 lor ((code lsr 6) land 0x3F)));
842842+ Bytes.set b 2 (Char.chr (0x80 lor (code land 0x3F)));
843843+ Buffer.add_subbytes buf b 0 3)
844844+ else (
845845+ Bytes.set b 0 (Char.chr (0xF0 lor (code lsr 18)));
846846+ Bytes.set b 1 (Char.chr (0x80 lor ((code lsr 12) land 0x3F)));
847847+ Bytes.set b 2 (Char.chr (0x80 lor ((code lsr 6) land 0x3F)));
848848+ Bytes.set b 3 (Char.chr (0x80 lor (code land 0x3F)));
849849+ Buffer.add_subbytes buf b 0 len)
850850+851851+ let write buf ~image_id ?placement_id ~rows ~cols () =
852852+ (* Set foreground color using 24-bit mode *)
853853+ let r = (image_id lsr 16) land 0xFF in
854854+ let g = (image_id lsr 8) land 0xFF in
855855+ let b = image_id land 0xFF in
856856+ Buffer.add_string buf (Printf.sprintf "\027[38;2;%d;%d;%dm" r g b);
857857+ (* Optionally set underline color for placement ID *)
858858+ (match placement_id with
859859+ | Some pid ->
860860+ let pr = (pid lsr 16) land 0xFF in
861861+ let pg = (pid lsr 8) land 0xFF in
862862+ let pb = pid land 0xFF in
863863+ Buffer.add_string buf (Printf.sprintf "\027[58;2;%d;%d;%dm" pr pg pb)
864864+ | None -> ());
865865+ (* High byte diacritic if needed *)
866866+ let high_byte = (image_id lsr 24) land 0xFF in
867867+ let high_diac =
868868+ if high_byte > 0 then Some (id_high_byte_diacritic high_byte) else None
869869+ in
870870+ (* Write placeholder grid *)
871871+ for row = 0 to rows - 1 do
872872+ for col = 0 to cols - 1 do
873873+ add_uchar buf placeholder_char;
874874+ add_uchar buf (row_diacritic row);
875875+ add_uchar buf (column_diacritic col);
876876+ Option.iter (add_uchar buf) high_diac
877877+ done;
878878+ if row < rows - 1 then Buffer.add_string buf "\n\r"
879879+ done;
880880+ (* Reset colors *)
881881+ Buffer.add_string buf "\027[39m";
882882+ match placement_id with Some _ -> Buffer.add_string buf "\027[59m" | None -> ()
883883+end
884884+885885+module Detect = struct
886886+ let make_query () =
887887+ (* Send a 1x1 transparent pixel query *)
888888+ let cmd =
889889+ Command.query ~format:Format.Rgb24 ~transmission:Transmission.Direct
890890+ ~width:1 ~height:1 ()
891891+ in
892892+ let data = "\x00\x00\x00" in
893893+ let query = Command.to_string cmd ~data in
894894+ (* Add DA1 query to detect non-supporting terminals *)
895895+ query ^ "\027[c"
896896+897897+ let supports_graphics response ~da1_received =
898898+ match response with
899899+ | Some r -> Response.is_ok r
900900+ | None -> not da1_received
901901+end
+520
stack/kitty_graphics/lib/kitty_graphics.mli
···11+(** Kitty Terminal Graphics Protocol
22+33+ This library implements the Kitty terminal graphics protocol, allowing
44+ OCaml programs to display images in terminals that support the protocol
55+ (Kitty, WezTerm, Konsole, Ghostty, etc.).
66+77+ The protocol uses APC (Application Programming Command) escape sequences
88+ to transmit and display pixel graphics. Images can be transmitted as raw
99+ RGB/RGBA data or PNG, and displayed at specific positions with various
1010+ placement options.
1111+1212+ {2 Basic Usage}
1313+1414+ {[
1515+ (* Display a PNG image *)
1616+ let png_data = read_file "image.png" in
1717+ let cmd = Kitty_graphics.Command.transmit_and_display
1818+ ~format:Kitty_graphics.Format.Png
1919+ ()
2020+ in
2121+ let buf = Buffer.create 1024 in
2222+ Kitty_graphics.Command.write buf cmd ~data:png_data;
2323+ print_string (Buffer.contents buf)
2424+ ]}
2525+2626+ {2 Protocol Reference}
2727+2828+ See {{:https://sw.kovidgoyal.net/kitty/graphics-protocol/} Kitty Graphics Protocol}
2929+ for the full specification. *)
3030+3131+(** {1 Core Types} *)
3232+3333+(** Image data formats. *)
3434+module Format : sig
3535+ type t =
3636+ | Rgba32 (** 32-bit RGBA, 4 bytes per pixel *)
3737+ | Rgb24 (** 24-bit RGB, 3 bytes per pixel *)
3838+ | Png (** PNG encoded data *)
3939+4040+ val to_int : t -> int
4141+ (** Convert to protocol integer value (32, 24, or 100). *)
4242+end
4343+4444+(** Transmission methods for image data. *)
4545+module Transmission : sig
4646+ type t =
4747+ | Direct (** Data transmitted inline in the escape sequence *)
4848+ | File (** Data read from a file path *)
4949+ | Tempfile (** Data read from a temp file, deleted after reading *)
5050+5151+ val to_char : t -> char
5252+ (** Convert to protocol character ('d', 'f', or 't'). *)
5353+end
5454+5555+(** Compression options for transmitted data. *)
5656+module Compression : sig
5757+ type t =
5858+ | None (** No compression *)
5959+ | Zlib (** RFC 1950 zlib compression *)
6060+6161+ val to_char : t -> char option
6262+ (** Convert to protocol character (None or Some 'z'). *)
6363+end
6464+6565+(** Response suppression modes. *)
6666+module Quiet : sig
6767+ type t =
6868+ | Noisy (** Terminal sends all responses (default) *)
6969+ | Errors_only (** Terminal only sends error responses *)
7070+ | Silent (** Terminal sends no responses *)
7171+7272+ val to_int : t -> int
7373+ (** Convert to protocol integer (0, 1, or 2). *)
7474+end
7575+7676+(** Cursor movement policy after displaying an image. *)
7777+module Cursor : sig
7878+ type t =
7979+ | Move (** Move cursor after image (default) *)
8080+ | Static (** Keep cursor in place *)
8181+8282+ val to_int : t -> int
8383+ (** Convert to protocol integer (0 or 1). *)
8484+end
8585+8686+(** Composition modes for blending. *)
8787+module Composition : sig
8888+ type t =
8989+ | Alpha_blend (** Full alpha blending (default) *)
9090+ | Overwrite (** Simple pixel replacement *)
9191+9292+ val to_int : t -> int
9393+ (** Convert to protocol integer (0 or 1). *)
9494+end
9595+9696+(** {1 Delete Operations} *)
9797+9898+(** Specifies what to delete when using delete commands. *)
9999+module Delete : sig
100100+ (** Delete target specification.
101101+102102+ Each variant has two forms: one that only removes placements (keeping
103103+ image data for potential reuse) and one that also frees the image data. *)
104104+ type t =
105105+ | All_visible
106106+ (** Delete all visible placements. *)
107107+ | All_visible_and_free
108108+ (** Delete all visible placements and free their image data. *)
109109+ | By_id of { image_id : int; placement_id : int option }
110110+ (** Delete placements for a specific image ID, optionally filtered
111111+ by placement ID. *)
112112+ | By_id_and_free of { image_id : int; placement_id : int option }
113113+ (** Delete and free by image ID. *)
114114+ | By_number of { image_number : int; placement_id : int option }
115115+ (** Delete by image number (newest with that number). *)
116116+ | By_number_and_free of { image_number : int; placement_id : int option }
117117+ (** Delete and free by image number. *)
118118+ | At_cursor
119119+ (** Delete placements intersecting cursor position. *)
120120+ | At_cursor_and_free
121121+ (** Delete and free at cursor position. *)
122122+ | At_cell of { x : int; y : int }
123123+ (** Delete placements intersecting a specific cell (1-based). *)
124124+ | At_cell_and_free of { x : int; y : int }
125125+ (** Delete and free at specific cell. *)
126126+ | At_cell_z of { x : int; y : int; z : int }
127127+ (** Delete at cell with specific z-index. *)
128128+ | At_cell_z_and_free of { x : int; y : int; z : int }
129129+ (** Delete and free at cell with z-index. *)
130130+ | By_column of int
131131+ (** Delete all placements intersecting a column (1-based). *)
132132+ | By_column_and_free of int
133133+ (** Delete and free by column. *)
134134+ | By_row of int
135135+ (** Delete all placements intersecting a row (1-based). *)
136136+ | By_row_and_free of int
137137+ (** Delete and free by row. *)
138138+ | By_z_index of int
139139+ (** Delete all placements with a specific z-index. *)
140140+ | By_z_index_and_free of int
141141+ (** Delete and free by z-index. *)
142142+ | By_id_range of { min_id : int; max_id : int }
143143+ (** Delete images with IDs in range [min_id, max_id]. *)
144144+ | By_id_range_and_free of { min_id : int; max_id : int }
145145+ (** Delete and free by ID range. *)
146146+ | Frames
147147+ (** Delete animation frames. *)
148148+ | Frames_and_free
149149+ (** Delete animation frames and free if no frames remain. *)
150150+end
151151+152152+(** {1 Placement Options} *)
153153+154154+(** Image placement configuration.
155155+156156+ Controls how an image is positioned and scaled when displayed. *)
157157+module Placement : sig
158158+ type t
159159+ (** Placement configuration. *)
160160+161161+ val make :
162162+ ?source_x:int ->
163163+ ?source_y:int ->
164164+ ?source_width:int ->
165165+ ?source_height:int ->
166166+ ?cell_x_offset:int ->
167167+ ?cell_y_offset:int ->
168168+ ?columns:int ->
169169+ ?rows:int ->
170170+ ?z_index:int ->
171171+ ?placement_id:int ->
172172+ ?cursor:Cursor.t ->
173173+ ?unicode_placeholder:bool ->
174174+ unit ->
175175+ t
176176+ (** Create a placement configuration.
177177+178178+ @param source_x Left edge of source rectangle in pixels (default 0)
179179+ @param source_y Top edge of source rectangle in pixels (default 0)
180180+ @param source_width Width of source rectangle (default: full width)
181181+ @param source_height Height of source rectangle (default: full height)
182182+ @param cell_x_offset X offset within the first cell in pixels
183183+ @param cell_y_offset Y offset within the first cell in pixels
184184+ @param columns Number of columns to display over (scales image)
185185+ @param rows Number of rows to display over (scales image)
186186+ @param z_index Stacking order (negative = under text)
187187+ @param placement_id Unique ID for this placement
188188+ @param cursor Cursor movement policy after display
189189+ @param unicode_placeholder Create virtual placement for Unicode mode *)
190190+191191+ val empty : t
192192+ (** Empty placement with all defaults. *)
193193+end
194194+195195+(** {1 Animation} *)
196196+197197+(** Animation frame specification. *)
198198+module Frame : sig
199199+ type t
200200+ (** Animation frame configuration. *)
201201+202202+ val make :
203203+ ?x:int ->
204204+ ?y:int ->
205205+ ?base_frame:int ->
206206+ ?edit_frame:int ->
207207+ ?gap_ms:int ->
208208+ ?composition:Composition.t ->
209209+ ?background_color:int32 ->
210210+ unit ->
211211+ t
212212+ (** Create a frame specification.
213213+214214+ @param x Left edge where frame data is placed (pixels)
215215+ @param y Top edge where frame data is placed (pixels)
216216+ @param base_frame 1-based frame number to use as background canvas
217217+ @param edit_frame 1-based frame number to edit (0 = new frame)
218218+ @param gap_ms Delay before next frame in milliseconds
219219+ @param composition How to blend pixels onto the canvas
220220+ @param background_color 32-bit RGBA background when no base frame *)
221221+222222+ val empty : t
223223+ (** Empty frame spec with defaults. *)
224224+end
225225+226226+(** Animation control operations. *)
227227+module Animation : sig
228228+ type state =
229229+ | Stop (** Stop the animation *)
230230+ | Loading (** Run but wait for new frames at end *)
231231+ | Run (** Run normally, loop at end *)
232232+233233+ type t
234234+ (** Animation control configuration. *)
235235+236236+ val set_state : ?loops:int -> state -> t
237237+ (** Set animation state.
238238+239239+ @param loops Number of loops: 0 = ignored, 1 = infinite, n = n-1 loops *)
240240+241241+ val set_gap : frame:int -> gap_ms:int -> t
242242+ (** Set the gap (delay) for a specific frame.
243243+244244+ @param frame 1-based frame number
245245+ @param gap_ms Delay in milliseconds (negative = gapless) *)
246246+247247+ val set_current_frame : int -> t
248248+ (** Make a specific frame (1-based) the current displayed frame. *)
249249+end
250250+251251+(** Frame composition for combining frame regions. *)
252252+module Compose : sig
253253+ type t
254254+ (** Composition operation. *)
255255+256256+ val make :
257257+ source_frame:int ->
258258+ dest_frame:int ->
259259+ ?width:int ->
260260+ ?height:int ->
261261+ ?source_x:int ->
262262+ ?source_y:int ->
263263+ ?dest_x:int ->
264264+ ?dest_y:int ->
265265+ ?composition:Composition.t ->
266266+ unit ->
267267+ t
268268+ (** Compose a rectangle from one frame onto another.
269269+270270+ @param source_frame 1-based source frame number
271271+ @param dest_frame 1-based destination frame number
272272+ @param width Rectangle width in pixels (default: full width)
273273+ @param height Rectangle height in pixels (default: full height)
274274+ @param source_x Left edge of source rectangle
275275+ @param source_y Top edge of source rectangle
276276+ @param dest_x Left edge of destination rectangle
277277+ @param dest_y Top edge of destination rectangle
278278+ @param composition Blend mode *)
279279+end
280280+281281+(** {1 Commands} *)
282282+283283+(** Graphics command builder.
284284+285285+ This is the main API for constructing graphics protocol commands.
286286+ Commands are built using the various constructors, then written to
287287+ a buffer with {!write}. *)
288288+module Command : sig
289289+ type t
290290+ (** A graphics protocol command. *)
291291+292292+ (** {2 Image Transmission} *)
293293+294294+ val transmit :
295295+ ?image_id:int ->
296296+ ?image_number:int ->
297297+ ?format:Format.t ->
298298+ ?transmission:Transmission.t ->
299299+ ?compression:Compression.t ->
300300+ ?width:int ->
301301+ ?height:int ->
302302+ ?size:int ->
303303+ ?offset:int ->
304304+ ?quiet:Quiet.t ->
305305+ unit ->
306306+ t
307307+ (** Transmit image data without displaying.
308308+309309+ @param image_id Unique ID for the image (1-4294967295)
310310+ @param image_number Image number (terminal assigns ID)
311311+ @param format Pixel format of the data
312312+ @param transmission How data is transmitted
313313+ @param compression Compression applied to data
314314+ @param width Image width in pixels (required for RGB/RGBA)
315315+ @param height Image height in pixels (required for RGB/RGBA)
316316+ @param size Number of bytes to read (for file transmission)
317317+ @param offset Byte offset to start reading (for file transmission)
318318+ @param quiet Response suppression mode *)
319319+320320+ val transmit_and_display :
321321+ ?image_id:int ->
322322+ ?image_number:int ->
323323+ ?format:Format.t ->
324324+ ?transmission:Transmission.t ->
325325+ ?compression:Compression.t ->
326326+ ?width:int ->
327327+ ?height:int ->
328328+ ?size:int ->
329329+ ?offset:int ->
330330+ ?quiet:Quiet.t ->
331331+ ?placement:Placement.t ->
332332+ unit ->
333333+ t
334334+ (** Transmit image data and display it immediately.
335335+336336+ This is the most common operation for displaying images.
337337+ See {!transmit} for transmission parameters and {!Placement}
338338+ for display options. *)
339339+340340+ val query :
341341+ ?format:Format.t ->
342342+ ?transmission:Transmission.t ->
343343+ ?width:int ->
344344+ ?height:int ->
345345+ ?quiet:Quiet.t ->
346346+ unit ->
347347+ t
348348+ (** Query terminal support without storing the image.
349349+350350+ Send a small test image to check if the terminal supports
351351+ the graphics protocol. The terminal responds with OK or
352352+ an error without storing the image. *)
353353+354354+ (** {2 Display} *)
355355+356356+ val display :
357357+ ?image_id:int ->
358358+ ?image_number:int ->
359359+ ?placement:Placement.t ->
360360+ ?quiet:Quiet.t ->
361361+ unit ->
362362+ t
363363+ (** Display a previously transmitted image.
364364+365365+ @param image_id ID of a previously transmitted image
366366+ @param image_number Number of the image to display
367367+ @param placement Display placement options
368368+ @param quiet Response suppression *)
369369+370370+ (** {2 Deletion} *)
371371+372372+ val delete : ?quiet:Quiet.t -> Delete.t -> t
373373+ (** Delete images or placements.
374374+375375+ See {!Delete} for the various deletion modes. *)
376376+377377+ (** {2 Animation} *)
378378+379379+ val frame :
380380+ ?image_id:int ->
381381+ ?image_number:int ->
382382+ ?format:Format.t ->
383383+ ?transmission:Transmission.t ->
384384+ ?compression:Compression.t ->
385385+ ?width:int ->
386386+ ?height:int ->
387387+ ?quiet:Quiet.t ->
388388+ frame:Frame.t ->
389389+ unit ->
390390+ t
391391+ (** Transmit animation frame data.
392392+393393+ Similar to {!transmit} but adds frame-specific parameters. *)
394394+395395+ val animate :
396396+ ?image_id:int ->
397397+ ?image_number:int ->
398398+ ?quiet:Quiet.t ->
399399+ Animation.t ->
400400+ t
401401+ (** Control animation playback. *)
402402+403403+ val compose :
404404+ ?image_id:int ->
405405+ ?image_number:int ->
406406+ ?quiet:Quiet.t ->
407407+ Compose.t ->
408408+ t
409409+ (** Compose animation frames. *)
410410+411411+ (** {2 Output} *)
412412+413413+ val write : Buffer.t -> t -> data:string -> unit
414414+ (** Write the command to a buffer.
415415+416416+ @param data The payload data (image bytes, file path, etc.).
417417+ For {!display}, {!delete}, {!animate}, pass empty string. *)
418418+419419+ val to_string : t -> data:string -> string
420420+ (** Convert command to a string. *)
421421+end
422422+423423+(** {1 Response Parsing} *)
424424+425425+(** Terminal response parsing.
426426+427427+ When the terminal processes a graphics command, it may send back
428428+ a response indicating success or failure. *)
429429+module Response : sig
430430+ type t
431431+ (** A parsed terminal response. *)
432432+433433+ val parse : string -> t option
434434+ (** Parse a response from terminal output.
435435+436436+ Expects the format: [<ESC>_G...;message<ESC>\]
437437+ Returns [None] if the string is not a valid graphics response. *)
438438+439439+ val is_ok : t -> bool
440440+ (** Check if the response indicates success. *)
441441+442442+ val message : t -> string
443443+ (** Get the response message ("OK" or error description). *)
444444+445445+ val error_code : t -> string option
446446+ (** Extract the error code if this is an error response.
447447+448448+ Error codes include: ENOENT, EINVAL, ENOSPC, EBADPNG, etc. *)
449449+450450+ val image_id : t -> int option
451451+ (** Get the image ID from the response, if present. *)
452452+453453+ val image_number : t -> int option
454454+ (** Get the image number from the response, if present. *)
455455+456456+ val placement_id : t -> int option
457457+ (** Get the placement ID from the response, if present. *)
458458+end
459459+460460+(** {1 Unicode Placeholders} *)
461461+462462+(** Unicode placeholder generation for tmux/vim compatibility.
463463+464464+ Unicode placeholders allow images to work with applications that
465465+ don't understand the graphics protocol but support Unicode and
466466+ foreground colors. The image is transmitted with a virtual placement,
467467+ then placeholder characters are written to the terminal. *)
468468+module Unicode_placeholder : sig
469469+ val placeholder_char : Uchar.t
470470+ (** The Unicode placeholder character U+10EEEE. *)
471471+472472+ val write :
473473+ Buffer.t ->
474474+ image_id:int ->
475475+ ?placement_id:int ->
476476+ rows:int ->
477477+ cols:int ->
478478+ unit ->
479479+ unit
480480+ (** Write placeholder characters to a buffer.
481481+482482+ The image ID is encoded in the foreground color (24-bit mode).
483483+ Row and column positions are encoded using combining diacritics.
484484+485485+ @param image_id The image ID (should have non-zero bytes for 24-bit)
486486+ @param placement_id Optional placement ID (encoded in underline color)
487487+ @param rows Number of rows to fill
488488+ @param cols Number of columns per row *)
489489+490490+ val row_diacritic : int -> Uchar.t
491491+ (** Get the combining diacritic for a row number (0-based). *)
492492+493493+ val column_diacritic : int -> Uchar.t
494494+ (** Get the combining diacritic for a column number (0-based). *)
495495+496496+ val id_high_byte_diacritic : int -> Uchar.t
497497+ (** Get the diacritic for the high byte of a 32-bit image ID. *)
498498+end
499499+500500+(** {1 Terminal Detection} *)
501501+502502+(** Helpers for detecting terminal graphics support. *)
503503+module Detect : sig
504504+ val make_query : unit -> string
505505+ (** Generate a query command to test graphics support.
506506+507507+ Send this to stdout and read the terminal's response.
508508+ Follow with a DA1 query ([<ESC>[c]) to detect terminals
509509+ that don't support graphics (they'll answer DA1 but not
510510+ the graphics query). *)
511511+512512+ val supports_graphics : Response.t option -> da1_received:bool -> bool
513513+ (** Determine if graphics are supported based on query results.
514514+515515+ @param response The parsed graphics response, if any
516516+ @param da1_received Whether a DA1 response was received
517517+518518+ Returns [true] if a graphics OK response was received,
519519+ or [false] if only DA1 was received (no graphics support). *)
520520+end
+21-26
stack/river/lib/feed.ml
···2020let src = Logs.Src.create "river" ~doc:"River RSS/Atom aggregator"
2121module Log = (val Logs.src_log src : Logs.LOG)
22222323-type feed_content =
2424- | Atom of Syndic.Atom.feed
2525- | Rss2 of Syndic.Rss2.channel
2626- | Json of Jsonfeed.t
2323+type feed_content = River_jsonfeed.t
27242825type t = {
2926 source : Source.t;
3027 title : string;
3128 content : feed_content;
2929+ original_format : string; (* "Atom", "RSS2", or "JSONFeed" *)
3230}
3333-3434-let string_of_feed = function
3535- | Atom _ -> "Atom"
3636- | Rss2 _ -> "Rss2"
3737- | Json _ -> "JSONFeed"
38313932let classify_feed ~xmlbase (body : string) =
4033 Log.debug (fun m -> m "Attempting to parse feed (%d bytes)" (String.length body));
···5245 match Jsonfeed.of_string body with
5346 | Ok jsonfeed ->
5447 Log.debug (fun m -> m "Successfully parsed as JSONFeed");
5555- Json jsonfeed
4848+ (* Wrap plain JSONFeed with River_jsonfeed (no extensions needed) *)
4949+ let river_jsonfeed = { River_jsonfeed.feed = jsonfeed; extension = None } in
5050+ (river_jsonfeed, "JSONFeed")
5651 | Error err ->
5752 let err_str = Jsont.Error.to_string err in
5853 Log.debug (fun m -> m "Not a JSONFeed: %s" err_str);
···6156 ) else (
6257 (* Try XML formats *)
6358 try
6464- let feed = Atom (Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, body)))) in
6565- Log.debug (fun m -> m "Successfully parsed as Atom feed");
6666- feed
5959+ let atom_feed = Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, body))) in
6060+ Log.debug (fun m -> m "Successfully parsed as Atom feed, converting to JSONFeed");
6161+ (* Convert Atom to JSONFeed with extensions *)
6262+ let river_jsonfeed = River_jsonfeed.of_atom atom_feed in
6363+ (river_jsonfeed, "Atom")
6764 with
6865 | Syndic.Atom.Error.Error (pos, msg) -> (
6966 Log.debug (fun m -> m "Not an Atom feed: %s at position (%d, %d)"
7067 msg (fst pos) (snd pos));
7168 try
7272- let feed = Rss2 (Syndic.Rss2.parse ~xmlbase (Xmlm.make_input (`String (0, body)))) in
7373- Log.debug (fun m -> m "Successfully parsed as RSS2 feed");
7474- feed
6969+ let rss2_channel = Syndic.Rss2.parse ~xmlbase (Xmlm.make_input (`String (0, body))) in
7070+ Log.debug (fun m -> m "Successfully parsed as RSS2 feed, converting to JSONFeed");
7171+ (* Convert RSS2 to JSONFeed *)
7272+ let river_jsonfeed = River_jsonfeed.of_rss2 rss2_channel in
7373+ (river_jsonfeed, "RSS2")
7574 with Syndic.Rss2.Error.Error (pos, msg) ->
7675 Log.err (fun m -> m "Failed to parse as RSS2: %s at position (%d, %d)"
7776 msg (fst pos) (snd pos));
···111110 failwith (Printf.sprintf "HTTP %d: %s" status truncated_msg)
112111 in
113112114114- let content =
113113+ let (content, original_format) =
115114 try classify_feed ~xmlbase response
116115 with Failure msg ->
117116 Log.err (fun m -> m "Failed to parse feed '%s' (%s): %s"
118117 (Source.name source) (Source.url source) msg);
119118 raise (Failure msg)
120120- in
121121- let title =
122122- match content with
123123- | Atom atom -> Text_extract.string_of_text_construct atom.Syndic.Atom.title
124124- | Rss2 ch -> ch.Syndic.Rss2.title
125125- | Json jsonfeed -> Jsonfeed.title jsonfeed
126119 in
127120128128- Log.info (fun m -> m "Successfully fetched %s feed '%s' (title: '%s')"
129129- (string_of_feed content) (Source.name source) title);
121121+ let title = Jsonfeed.title content.River_jsonfeed.feed in
130122131131- { source; title; content }
123123+ Log.info (fun m -> m "Successfully fetched %s feed '%s' (title: '%s'), converted to JSONFeed"
124124+ original_format (Source.name source) title);
125125+126126+ { source; title; content; original_format }
132127133128let source t = t.source
134129let content t = t.content
+6-6
stack/river/lib/feed.mli
···17171818(** Feed fetching and parsing. *)
19192020-type feed_content =
2121- | Atom of Syndic.Atom.feed
2222- | Rss2 of Syndic.Rss2.channel
2323- | Json of Jsonfeed.t
2424-(** The underlying feed content, which can be Atom, RSS2, or JSONFeed format. *)
2020+type feed_content = River_jsonfeed.t
2121+(** The underlying feed content, stored in JSONFeed format with extensions.
2222+2323+ All feed formats (Atom, RSS2, JSONFeed) are converted to JSONFeed upon
2424+ fetching. Atom-specific metadata is preserved using extensions. *)
25252626type t
2727-(** An Atom, RSS2, or JSON Feed. *)
2727+(** A feed, stored natively in JSONFeed format. *)
28282929val fetch : Session.t -> Source.t -> t
3030(** [fetch session source] fetches and parses a feed from the given source.
+7-6
stack/river/lib/format.ml
···89899090module Rss2 = struct
9191 let of_feed feed =
9292- match Feed.content feed with
9393- | Feed.Rss2 ch -> Some ch
9494- | _ -> None
9292+ (* Feed content is now always JSONFeed - cannot extract RSS2 directly *)
9393+ (* This function is kept for backwards compatibility but always returns None *)
9494+ let _ = feed in
9595+ None
9596end
96979798module Jsonfeed = struct
···136137 | Error err -> Error (Jsont.Error.to_string err)
137138138139 let of_feed feed =
139139- match Feed.content feed with
140140- | Feed.Json jf -> Some jf
141141- | _ -> None
140140+ (* Feed content is now always River_jsonfeed.t - extract the inner Jsonfeed.t *)
141141+ let jsonfeed_content = Feed.content feed in
142142+ Some jsonfeed_content.River_jsonfeed.feed
142143end
143144144145module Html = struct
+22-36
stack/river/lib/post.ml
···154154 if is_valid_author_name author.name then trimmed
155155 else raise Not_found (* Try feed-level author *)
156156 with Not_found -> (
157157- match Feed.content feed with
158158- | Feed.Atom atom_feed -> (
159159- (* Try feed-level authors *)
160160- match atom_feed.Syndic.Atom.authors with
161161- | author :: _ when is_valid_author_name author.name ->
162162- String.trim author.name
163163- | _ ->
164164- (* Use feed title *)
165165- Text_extract.string_of_text_construct atom_feed.Syndic.Atom.title)
166166- | Feed.Rss2 _ | Feed.Json _ ->
167167- (* For RSS2 and JSONFeed, use the source name *)
168168- Source.name (Feed.source feed))
157157+ (* Feed content is now JSONFeed - try feed-level authors *)
158158+ let jsonfeed_content = Feed.content feed in
159159+ match Jsonfeed.authors jsonfeed_content.River_jsonfeed.feed with
160160+ | Some (first :: _) ->
161161+ let name = Jsonfeed.Author.name first |> Option.value ~default:"" in
162162+ if is_valid_author_name name then name
163163+ else Feed.title feed
164164+ | _ ->
165165+ (* Use feed title as fallback *)
166166+ Feed.title feed)
169167 in
170168 (* Extract tags from Atom categories *)
171169 let tags =
···276274 (name, "")
277275 | _ ->
278276 (* Fall back to feed-level authors or feed title *)
279279- (match Feed.content feed with
280280- | Feed.Json jsonfeed ->
281281- (match Jsonfeed.authors jsonfeed with
282282- | Some (first :: _) ->
283283- let name = Jsonfeed.Author.name first |> Option.value ~default:(Feed.title feed) in
284284- (name, "")
285285- | _ -> (Feed.title feed, ""))
277277+ let jsonfeed_content = Feed.content feed in
278278+ (match Jsonfeed.authors jsonfeed_content.River_jsonfeed.feed with
279279+ | Some (first :: _) ->
280280+ let name = Jsonfeed.Author.name first |> Option.value ~default:(Feed.title feed) in
281281+ (name, "")
286282 | _ -> (Feed.title feed, ""))
287283 in
288284···327323 }
328324329325let posts_of_feed c =
330330- match Feed.content c with
331331- | Feed.Atom f ->
332332- let posts = List.map (post_of_atom ~feed:c) f.Syndic.Atom.entries in
333333- Log.debug (fun m -> m "Extracted %d posts from Atom feed '%s'"
334334- (List.length posts) (Source.name (Feed.source c)));
335335- posts
336336- | Feed.Rss2 ch ->
337337- let posts = List.map (post_of_rss2 ~feed:c) ch.Syndic.Rss2.items in
338338- Log.debug (fun m -> m "Extracted %d posts from RSS2 feed '%s'"
339339- (List.length posts) (Source.name (Feed.source c)));
340340- posts
341341- | Feed.Json jsonfeed ->
342342- let items = Jsonfeed.items jsonfeed in
343343- let posts = List.map (post_of_jsonfeed_item ~feed:c) items in
344344- Log.debug (fun m -> m "Extracted %d posts from JSONFeed '%s'"
345345- (List.length posts) (Source.name (Feed.source c)));
346346- posts
326326+ (* Feed content is now always JSONFeed *)
327327+ let jsonfeed_content = Feed.content c in
328328+ let items = Jsonfeed.items jsonfeed_content.River_jsonfeed.feed in
329329+ let posts = List.map (post_of_jsonfeed_item ~feed:c) items in
330330+ Log.debug (fun m -> m "Extracted %d posts from feed '%s' (converted to JSONFeed)"
331331+ (List.length posts) (Source.name (Feed.source c)));
332332+ posts
347333348334let get_posts ?n ?(ofs = 0) planet_feeds =
349335 Log.info (fun m -> m "Processing %d feeds for posts" (List.length planet_feeds));
+1
stack/river/lib/river.ml
···2424module Feed = Feed
2525module Post = Post
2626module Format = Format
2727+module River_jsonfeed = River_jsonfeed
2728module Category = Category
2829module User = User
2930module Quality = Quality
+122
stack/river/lib/river.mli
···234234 end
235235end
236236237237+(** {1 JSONFeed with Atom Extensions} *)
238238+239239+module River_jsonfeed : sig
240240+ (** JSONFeed with Atom extension support for River.
241241+242242+ This module provides conversion between Atom feeds and JSONFeed format,
243243+ with custom extensions to preserve Atom-specific metadata that doesn't
244244+ have direct JSONFeed equivalents.
245245+246246+ The extensions follow the JSONFeed specification for custom fields:
247247+ - Prefixed with underscore + letter: [_atom]
248248+ - Contains [about] field with documentation URL
249249+ - Feed readers can safely ignore unknown extensions
250250+251251+ See: https://www.jsonfeed.org/mappingrssandatom/ *)
252252+253253+ (** {2 Extension Types} *)
254254+255255+ type category = {
256256+ term : string; (** Category term (required in Atom) *)
257257+ scheme : string option; (** Category scheme/domain *)
258258+ label : string option; (** Human-readable label *)
259259+ }
260260+261261+ type contributor = {
262262+ contributor_name : string;
263263+ contributor_uri : string option;
264264+ contributor_email : string option;
265265+ }
266266+267267+ type generator = {
268268+ generator_name : string; (** Generator name *)
269269+ generator_uri : string option; (** Generator URI *)
270270+ generator_version : string option; (** Generator version *)
271271+ }
272272+273273+ type source = {
274274+ source_id : string; (** Source feed ID *)
275275+ source_title : string; (** Source feed title *)
276276+ source_updated : Ptime.t; (** Source feed update time *)
277277+ }
278278+279279+ type content_type =
280280+ | Text (** Plain text *)
281281+ | Html (** HTML content *)
282282+ | Xhtml (** XHTML content *)
283283+284284+ type feed_extension = {
285285+ feed_subtitle : string option;
286286+ feed_id : string;
287287+ feed_categories : category list;
288288+ feed_contributors : contributor list;
289289+ feed_generator : generator option;
290290+ feed_rights : string option;
291291+ feed_logo : string option;
292292+ }
293293+294294+ type item_extension = {
295295+ item_id : string;
296296+ item_published : Ptime.t option;
297297+ item_contributors : contributor list;
298298+ item_source : source option;
299299+ item_rights : string option;
300300+ item_categories : category list;
301301+ item_content_type : content_type option;
302302+ }
303303+304304+ type t = {
305305+ feed : Jsonfeed.t;
306306+ extension : feed_extension option;
307307+ }
308308+309309+ type item = {
310310+ item : Jsonfeed.Item.t;
311311+ extension : item_extension option;
312312+ }
313313+314314+ (** {2 Conversion from Atom} *)
315315+316316+ val of_atom : Syndic.Atom.feed -> t
317317+ (** [of_atom feed] converts an Atom feed to JSONFeed with extensions.
318318+319319+ All Atom metadata is preserved using extensions. *)
320320+321321+ val item_of_atom : Syndic.Atom.entry -> item
322322+ (** [item_of_atom entry] converts an Atom entry to JSONFeed item with extensions. *)
323323+324324+ (** {2 Conversion from RSS} *)
325325+326326+ val of_rss2 : Syndic.Rss2.channel -> t
327327+ (** [of_rss2 channel] converts an RSS2 channel to JSONFeed. *)
328328+329329+ val item_of_rss2 : Syndic.Rss2.item -> item
330330+ (** [item_of_rss2 item] converts an RSS2 item to JSONFeed item. *)
331331+332332+ (** {2 Conversion to Atom} *)
333333+334334+ val to_atom : t -> Syndic.Atom.feed
335335+ (** [to_atom t] converts JSONFeed with extensions back to Atom feed.
336336+337337+ All original Atom metadata is restored from extensions. *)
338338+339339+ val item_to_atom : item -> Syndic.Atom.entry
340340+ (** [item_to_atom item] converts JSONFeed item with extensions back to Atom entry. *)
341341+342342+ (** {2 Serialization} *)
343343+344344+ val to_string : ?minify:bool -> t -> (string, string) result
345345+ (** [to_string ?minify t] serializes to JSON string with extensions. *)
346346+347347+ val of_string : string -> (t, string) result
348348+ (** [of_string s] parses JSON string with extensions. *)
349349+350350+ (** {2 Utilities} *)
351351+352352+ val of_posts : title:string -> Post.t list -> t
353353+ (** [of_posts ~title posts] creates JSONFeed from Post list with Atom extensions. *)
354354+355355+ val to_posts : feed:Feed.t -> t -> Post.t list
356356+ (** [to_posts ~feed t] extracts posts from extended JSONFeed. *)
357357+end
358358+237359(** {1 Category Management} *)
238360239361module Category : sig
+93-15
stack/river/lib/state.ml
···3838 (** Get the sync state file path *)
3939 let sync_state_file state = Eio.Path.(Xdge.state_dir state.xdg / "sync_state.json")
40404141- (** Get the path to a user's Atom feed file *)
4141+ (** Get the path to a user's JSONFeed file *)
4242 let user_feed_file state username =
4343+ Eio.Path.(user_feeds_dir state / (username ^ ".json"))
4444+4545+ (** Get the path to a user's old Atom feed file (for migration) *)
4646+ let user_feed_file_legacy state username =
4347 Eio.Path.(user_feeds_dir state / (username ^ ".xml"))
44484549 (** Ensure all necessary directories exist *)
···291295 Log.err (fun m -> m "Error getting all users: %s" (Printexc.to_string e));
292296 []
293297294294- (** Load existing Atom entries for a user *)
295295- let load_existing_posts state username =
296296- let file = Paths.user_feed_file state username in
298298+ (** Migrate legacy Atom XML feed to JSONFeed format *)
299299+ let migrate_legacy_feed state username =
300300+ let legacy_file = Paths.user_feed_file_legacy state username in
297301 try
298298- let content = Eio.Path.load file in
302302+ let content = Eio.Path.load legacy_file in
303303+ Log.info (fun m -> m "Migrating legacy Atom feed for %s to JSONFeed" username);
299304 (* Parse existing Atom feed *)
300305 let input = Xmlm.make_input (`String (0, content)) in
301301- let feed = Syndic.Atom.parse input in
302302- feed.Syndic.Atom.entries
306306+ let atom_feed = Syndic.Atom.parse input in
307307+ (* Convert to JSONFeed with extensions *)
308308+ let jsonfeed = River_jsonfeed.of_atom atom_feed in
309309+ (* Save as JSONFeed *)
310310+ let json_file = Paths.user_feed_file state username in
311311+ (match River_jsonfeed.to_string ~minify:false jsonfeed with
312312+ | Ok json ->
313313+ Eio.Path.save ~create:(`Or_truncate 0o644) json_file json;
314314+ Log.info (fun m -> m "Successfully migrated %s from Atom to JSONFeed" username);
315315+ (* Rename legacy file to .xml.backup *)
316316+ let backup_file = Eio.Path.(Paths.user_feeds_dir state / (username ^ ".xml.backup")) in
317317+ (try
318318+ Eio.Path.save ~create:(`Or_truncate 0o644) backup_file content;
319319+ Log.info (fun m -> m "Backed up legacy Atom file to %s.xml.backup" username)
320320+ with e ->
321321+ Log.warn (fun m -> m "Failed to backup legacy file: %s" (Printexc.to_string e)));
322322+ Some jsonfeed
323323+ | Error err ->
324324+ Log.err (fun m -> m "Failed to serialize JSONFeed during migration: %s" err);
325325+ None)
303326 with
304304- | Eio.Io (Eio.Fs.E (Not_found _), _) -> []
327327+ | Eio.Io (Eio.Fs.E (Not_found _), _) -> None
305328 | e ->
306306- Log.err (fun m -> m "Error loading existing posts for %s: %s"
329329+ Log.err (fun m -> m "Error migrating legacy feed for %s: %s"
307330 username (Printexc.to_string e));
308308- []
331331+ None
309332310310- (** Save Atom entries for a user *)
311311- let save_atom_feed state username entries =
333333+ (** Load existing JSONFeed for a user (with legacy migration support) *)
334334+ let load_existing_feed state username =
312335 let file = Paths.user_feed_file state username in
313313- let feed = Format.Atom.feed_of_entries ~title:username entries in
314314- let xml = Format.Atom.to_string feed in
315315- Eio.Path.save ~create:(`Or_truncate 0o644) file xml
336336+ try
337337+ let content = Eio.Path.load file in
338338+ (* Parse JSONFeed *)
339339+ match River_jsonfeed.of_string content with
340340+ | Ok jsonfeed -> Some jsonfeed
341341+ | Error err ->
342342+ Log.err (fun m -> m "Failed to parse JSONFeed for %s: %s" username err);
343343+ (* Try migration from legacy Atom *)
344344+ migrate_legacy_feed state username
345345+ with
346346+ | Eio.Io (Eio.Fs.E (Not_found _), _) ->
347347+ (* JSON file not found, try legacy migration *)
348348+ migrate_legacy_feed state username
349349+ | e ->
350350+ Log.err (fun m -> m "Error loading feed for %s: %s"
351351+ username (Printexc.to_string e));
352352+ None
353353+354354+ (** Load existing posts as Atom entries for a user (for backwards compatibility) *)
355355+ let load_existing_posts state username =
356356+ match load_existing_feed state username with
357357+ | None -> []
358358+ | Some jsonfeed ->
359359+ (* Convert JSONFeed back to Atom for backwards compatibility *)
360360+ let atom_feed = River_jsonfeed.to_atom jsonfeed in
361361+ atom_feed.Syndic.Atom.entries
362362+363363+ (** Save JSONFeed for a user *)
364364+ let save_jsonfeed state username jsonfeed =
365365+ let file = Paths.user_feed_file state username in
366366+ match River_jsonfeed.to_string ~minify:false jsonfeed with
367367+ | Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
368368+ | Error err -> failwith ("Failed to serialize JSONFeed: " ^ err)
369369+370370+ (** Save Atom entries for a user (converts to JSONFeed first) *)
371371+ let save_atom_feed state username entries =
372372+ (* Convert Atom entries to JSONFeed with extensions *)
373373+ let items_with_ext = List.map River_jsonfeed.item_of_atom entries in
374374+ let items = List.map (fun i -> i.River_jsonfeed.item) items_with_ext in
375375+376376+ (* Create feed extension *)
377377+ let feed_ext = {
378378+ River_jsonfeed.feed_subtitle = None;
379379+ feed_id = "urn:river:user:" ^ username;
380380+ feed_categories = [];
381381+ feed_contributors = [];
382382+ feed_generator = Some {
383383+ River_jsonfeed.generator_name = "River Feed Aggregator";
384384+ generator_uri = None;
385385+ generator_version = Some "1.0";
386386+ };
387387+ feed_rights = None;
388388+ feed_logo = None;
389389+ } in
390390+391391+ let jsonfeed_inner = Jsonfeed.create ~title:username ~items () in
392392+ let jsonfeed = { River_jsonfeed.feed = jsonfeed_inner; extension = Some feed_ext } in
393393+ save_jsonfeed state username jsonfeed
316394end
317395318396module Sync = struct