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

json

+90 -1
stack/river/bin/river_cli.ml
···
Post.list state ~username_opt ~limit
) $ username_opt_arg $ limit_arg)
+
(* Info command - show detailed post information *)
+
let info_cmd =
+
let doc = "Display detailed information about a post by ID" in
+
let id_arg =
+
let doc = "Post ID or partial ID to search for" in
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"ID" ~doc)
+
in
+
let verbose_flag =
+
let doc = "Show full content and all metadata" in
+
Arg.(value & flag & info ["v"; "verbose"] ~doc)
+
in
+
Eiocmd.run
+
~use_keyeio:false
+
~info:(Cmd.info "info" ~doc)
+
~app_name:"river"
+
~service:"river"
+
Term.(const (fun id verbose _env xdg _profile ->
+
let store = River_store.create_with_xdge xdg in
+
+
match River_store.find_entry_by_id store ~id with
+
| None ->
+
Fmt.pr "%a@." Fmt.(styled `Red string) (Printf.sprintf "No post found with ID: %s" id);
+
1
+
| Some entry ->
+
(* Print header *)
+
Fmt.pr "@.";
+
Fmt.pr "%a@." Fmt.(styled `Bold string)
+
(String.make 70 '=');
+
Fmt.pr " %a@." Fmt.(styled `Bold (styled (`Fg `Blue) string)) entry.title;
+
Fmt.pr "%a@.@." Fmt.(styled `Bold string)
+
(String.make 70 '=');
+
+
(* Basic metadata *)
+
Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "ID: " entry.atom_id;
+
+
(match entry.link with
+
| Some url ->
+
Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "URL: " (Uri.to_string url)
+
| None -> ());
+
+
Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "Feed: " entry.feed_name;
+
Fmt.pr "%a %s@." Fmt.(styled `Faint string) " " ("(" ^ entry.feed_url ^ ")");
+
+
Fmt.pr "%a %s" Fmt.(styled `Cyan string) "Author: " entry.author_name;
+
(match entry.author_email with
+
| Some email -> Fmt.pr " <%s>" email
+
| None -> ());
+
Fmt.pr "@.";
+
+
(match entry.published with
+
| Some date ->
+
Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "Published:" (Ptime.to_rfc3339 date)
+
| None -> ());
+
+
Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "Stored: " (Ptime.to_rfc3339 entry.stored_at);
+
+
(* Tags *)
+
(match entry.tags with
+
| [] -> ()
+
| tags ->
+
Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "Tags: " (String.concat ", " tags));
+
+
(* Summary *)
+
(match entry.summary with
+
| Some summary ->
+
Fmt.pr "@.%a@." Fmt.(styled (`Fg `Yellow) string) "Summary:";
+
Fmt.pr " %s@." summary
+
| None -> ());
+
+
(* Content *)
+
Fmt.pr "@.%a@." Fmt.(styled (`Fg `Green) string) "Content:";
+
if verbose then
+
Fmt.pr "%s@." entry.content
+
else begin
+
let preview =
+
if String.length entry.content > 500 then
+
String.sub entry.content 0 500 ^ "..."
+
else
+
entry.content
+
in
+
Fmt.pr "%s@." preview;
+
if String.length entry.content > 500 then
+
Fmt.pr "@.%a@." Fmt.(styled `Faint string) "(Use --verbose to see full content)"
+
end;
+
+
Fmt.pr "@.";
+
0
+
) $ id_arg $ verbose_flag)
+
let main_cmd =
let doc = "River feed management CLI" in
let info = Cmd.info "river-cli" ~version:"1.0" ~doc in
-
Cmd.group info [user_cmd; sync_cmd; list_cmd]
+
Cmd.group info [user_cmd; sync_cmd; list_cmd; info_cmd]
let () = exit (Cmd.eval' main_cmd)
+6 -3
stack/river/dune-project
···
(package
(name river)
-
(synopsis "RSS2 and Atom feed aggregator for OCaml")
-
(description "RSS2 and Atom feed aggregator for OCaml")
+
(synopsis "RSS2, Atom and JSON Feed aggregator for OCaml")
+
(description "RSS2, Atom and JSON Feed aggregator for OCaml")
(depends
(ocaml
-
(>= 4.08.0))
+
(>= 5.2.0))
dune
(syndic
(>= 1.5))
···
yojson
fmt
xdge
+
(jsonfeed (>= 1.1.0))
+
(jsont (>= 0.2.0))
+
bytesrw
(odoc :with-doc)))
+2 -1
stack/river/lib/dune
···
(library
(name river)
(public_name river)
-
(libraries eio eio_main requests requests_json_api logs str syndic lambdasoup uri ptime))
+
(wrapped false)
+
(libraries eio eio_main requests requests_json_api logs str syndic lambdasoup uri ptime jsonfeed jsont bytesrw))
+48 -26
stack/river/lib/feed.ml
···
module Log = (val Logs.src_log src : Logs.LOG)
type source = { name : string; url : string }
-
type content = Atom of Syndic.Atom.feed | Rss2 of Syndic.Rss2.channel
+
type content = Atom of Syndic.Atom.feed | Rss2 of Syndic.Rss2.channel | Json of Jsonfeed.t
-
let string_of_feed = function Atom _ -> "Atom" | Rss2 _ -> "Rss2"
+
let string_of_feed = function Atom _ -> "Atom" | Rss2 _ -> "Rss2" | Json _ -> "JSONFeed"
type t = { name : string; title : string; url : string; content : content }
-
let classify_feed ~xmlbase (xml : string) =
-
Log.debug (fun m -> m "Attempting to parse feed (%d bytes)" (String.length xml));
+
let classify_feed ~xmlbase (body : string) =
+
Log.debug (fun m -> m "Attempting to parse feed (%d bytes)" (String.length body));
+
+
(* Quick check - does it look like JSON? *)
+
let looks_like_json =
+
String.length body > 0 &&
+
let first_char = String.get body 0 in
+
first_char = '{' || first_char = '['
+
in
-
try
-
let feed = Atom (Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, xml)))) in
-
Log.debug (fun m -> m "Successfully parsed as Atom feed");
-
feed
-
with
-
| Syndic.Atom.Error.Error (pos, msg) -> (
-
Log.debug (fun m -> m "Not an Atom feed: %s at position (%d, %d)"
-
msg (fst pos) (snd pos));
+
if looks_like_json then (
+
(* Try JSONFeed first *)
+
Log.debug (fun m -> m "Body looks like JSON, trying JSONFeed parser");
+
match Jsonfeed.of_string body with
+
| Ok jsonfeed ->
+
Log.debug (fun m -> m "Successfully parsed as JSONFeed");
+
Json jsonfeed
+
| Error err ->
+
Log.debug (fun m -> m "Not a JSONFeed: %s" (Jsont.Error.to_string err));
+
(* Fall through to XML parsing *)
+
failwith "Not a valid JSONFeed"
+
) else (
+
(* Try XML formats *)
try
-
let feed = Rss2 (Syndic.Rss2.parse ~xmlbase (Xmlm.make_input (`String (0, xml)))) in
-
Log.debug (fun m -> m "Successfully parsed as RSS2 feed");
+
let feed = Atom (Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, body)))) in
+
Log.debug (fun m -> m "Successfully parsed as Atom feed");
feed
-
with Syndic.Rss2.Error.Error (pos, msg) ->
-
Log.err (fun m -> m "Failed to parse as RSS2: %s at position (%d, %d)"
+
with
+
| Syndic.Atom.Error.Error (pos, msg) -> (
+
Log.debug (fun m -> m "Not an Atom feed: %s at position (%d, %d)"
msg (fst pos) (snd pos));
-
failwith "Neither Atom nor RSS2 feed")
-
| Not_found as e ->
-
Log.err (fun m -> m "Not_found exception during Atom feed parsing");
-
Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
-
raise e
-
| e ->
-
Log.err (fun m -> m "Unexpected exception during feed parsing: %s"
-
(Printexc.to_string e));
-
Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
-
raise e
+
try
+
let feed = Rss2 (Syndic.Rss2.parse ~xmlbase (Xmlm.make_input (`String (0, body)))) in
+
Log.debug (fun m -> m "Successfully parsed as RSS2 feed");
+
feed
+
with Syndic.Rss2.Error.Error (pos, msg) ->
+
Log.err (fun m -> m "Failed to parse as RSS2: %s at position (%d, %d)"
+
msg (fst pos) (snd pos));
+
failwith "Neither Atom nor RSS2 feed")
+
| Not_found as e ->
+
Log.err (fun m -> m "Not_found exception during Atom feed parsing");
+
Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
+
raise e
+
| e ->
+
Log.err (fun m -> m "Unexpected exception during feed parsing: %s"
+
(Printexc.to_string e));
+
Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
+
raise e
+
)
let fetch client (source : source) =
Log.info (fun m -> m "Fetching feed '%s' from %s" source.name source.url);
···
match content with
| Atom atom -> Util.string_of_text_construct atom.Syndic.Atom.title
| Rss2 ch -> ch.Syndic.Rss2.title
+
| Json jsonfeed -> Jsonfeed.title jsonfeed
in
Log.info (fun m -> m "Successfully fetched %s feed '%s' (title: '%s')"
+172 -3
stack/river/lib/post.ml
···
module Log = (val Logs.src_log src : Logs.LOG)
type t = {
+
id : string;
title : string;
link : Uri.t option;
date : Syndic.Date.t option;
···
email : string;
content : Soup.soup Soup.node;
mutable link_response : (string, string) result option;
+
tags : string list;
+
summary : string option;
}
+
+
(** Generate a stable, unique ID from available data *)
+
let generate_id ?guid ?link ?title ?date ~feed_url () =
+
match guid with
+
| Some id when id <> "" ->
+
(* Use explicit ID/GUID if available *)
+
id
+
| _ ->
+
match link with
+
| Some uri when Uri.to_string uri <> "" ->
+
(* Use permalink as ID (stable and unique) *)
+
Uri.to_string uri
+
| _ ->
+
(* Fallback: hash of feed_url + title + date *)
+
let title_str = Option.value title ~default:"" in
+
let date_str =
+
match date with
+
| Some d -> Ptime.to_rfc3339 d
+
| None -> ""
+
in
+
let composite = Printf.sprintf "%s|%s|%s" feed_url title_str date_str in
+
(* Use SHA256 for stable hashing *)
+
Digest.string composite |> Digest.to_hex
+
+
let post_id post = post.id
let resolve_links_attr ~xmlbase attr el =
Soup.R.attribute attr el
···
| _ ->
(* Use feed title *)
Util.string_of_text_construct atom_feed.Syndic.Atom.title)
-
| Feed.Rss2 _ ->
-
(* For RSS2, use the feed name which is the source name *)
+
| Feed.Rss2 _ | Feed.Json _ ->
+
(* For RSS2 and JSONFeed, use the feed name which is the source name *)
feed.name)
in
+
(* Extract tags from Atom categories *)
+
let tags =
+
List.map (fun cat -> cat.Syndic.Atom.term) e.categories
+
in
+
(* Extract summary - convert from text_construct to string *)
+
let summary =
+
match e.summary with
+
| Some s -> Some (Util.string_of_text_construct s)
+
| None -> None
+
in
+
(* Generate unique ID *)
+
let guid = Uri.to_string e.id in
+
let title_str = Util.string_of_text_construct e.title in
+
let id =
+
generate_id ~guid ?link ~title:title_str ?date ~feed_url:feed.url ()
+
in
{
-
title = Util.string_of_text_construct e.title;
+
id;
+
title = title_str;
link;
date;
feed;
···
email = "";
content;
link_response = None;
+
tags;
+
summary;
}
let post_of_rss2 ~(feed : Feed.t) it =
···
| _, "" -> html_of_text ?xmlbase d
| xmlbase, c -> html_of_text ?xmlbase c ))
in
+
(* Note: it.link is of type Uri.t option in Syndic *)
let link =
match (it.guid, it.link) with
| Some u, _ when u.permalink -> Some u.data
···
Some u.data
| None, None -> None
in
+
(* Extract GUID string for ID generation *)
+
let guid_str =
+
match it.guid with
+
| Some u -> Some (Uri.to_string u.data)
+
| None -> None
+
in
+
(* RSS2 doesn't have a categories field exposed, use empty list *)
+
let tags = [] in
+
(* RSS2 doesn't have a separate summary field, so leave it empty *)
+
let summary = None in
+
(* Generate unique ID *)
+
let id =
+
generate_id ?guid:guid_str ?link ~title ?date:it.pubDate ~feed_url:feed.url ()
+
in
{
+
id;
title;
link;
feed;
···
content;
date = it.pubDate;
link_response = None;
+
tags;
+
summary;
+
}
+
+
let post_of_jsonfeed_item ~(feed : Feed.t) (item : Jsonfeed.Item.t) =
+
Log.debug (fun m -> m "Processing JSONFeed item: %s"
+
(Option.value (Jsonfeed.Item.title item) ~default:"Untitled"));
+
+
(* Extract content - prefer HTML, fall back to text *)
+
let content =
+
match Jsonfeed.Item.content item with
+
| `Html html -> html_of_text html
+
| `Text text -> html_of_text text
+
| `Both (html, _text) -> html_of_text html
+
in
+
+
(* Extract author - use first author if multiple *)
+
let author_name, author_email =
+
match Jsonfeed.Item.authors item with
+
| Some (first :: _) ->
+
let name = Jsonfeed.Author.name first |> Option.value ~default:"" in
+
(* JSONFeed authors don't typically have email *)
+
(name, "")
+
| _ ->
+
(* Fall back to feed-level authors or feed title *)
+
(match feed.content with
+
| Feed.Json jsonfeed ->
+
(match Jsonfeed.authors jsonfeed with
+
| Some (first :: _) ->
+
let name = Jsonfeed.Author.name first |> Option.value ~default:feed.title in
+
(name, "")
+
| _ -> (feed.title, ""))
+
| _ -> (feed.title, ""))
+
in
+
+
(* Link - use url field *)
+
let link =
+
Jsonfeed.Item.url item
+
|> Option.map Uri.of_string
+
in
+
+
(* Date *)
+
let date = Jsonfeed.Item.date_published item in
+
+
(* Summary *)
+
let summary = Jsonfeed.Item.summary item in
+
+
(* Tags *)
+
let tags =
+
Jsonfeed.Item.tags item
+
|> Option.value ~default:[]
+
in
+
+
(* Generate unique ID - JSONFeed items always have an id field (required) *)
+
let guid = Jsonfeed.Item.id item in
+
let title_str = Jsonfeed.Item.title item |> Option.value ~default:"Untitled" in
+
let id =
+
generate_id ~guid ?link ~title:title_str ?date ~feed_url:feed.url ()
+
in
+
+
{
+
id;
+
title = title_str;
+
link;
+
date;
+
feed;
+
author = author_name;
+
email = author_email;
+
content;
+
link_response = None;
+
tags;
+
summary;
}
let posts_of_feed c =
···
| Feed.Rss2 ch ->
let posts = List.map (post_of_rss2 ~feed:c) ch.Syndic.Rss2.items in
Log.debug (fun m -> m "Extracted %d posts from RSS2 feed '%s'"
+
(List.length posts) c.Feed.name);
+
posts
+
| Feed.Json jsonfeed ->
+
let items = Jsonfeed.items jsonfeed in
+
let posts = List.map (post_of_jsonfeed_item ~feed:c) items in
+
Log.debug (fun m -> m "Extracted %d posts from JSONFeed '%s'"
(List.length posts) c.Feed.name);
posts
···
()
let mk_entries posts = List.map mk_entry posts
+
+
let mk_jsonfeed_item post =
+
(* Convert HTML content back to string *)
+
let html = Soup.to_string post.content in
+
let content = `Html html in
+
+
(* Create author *)
+
let authors =
+
if post.author <> "" then
+
let author = Jsonfeed.Author.create ~name:post.author () in
+
Some [author]
+
else
+
None
+
in
+
+
(* Create item *)
+
Jsonfeed.Item.create
+
~id:post.id
+
~content
+
?url:(Option.map Uri.to_string post.link)
+
~title:post.title
+
?summary:post.summary
+
?date_published:post.date
+
?authors
+
~tags:post.tags
+
()
+
+
let mk_jsonfeed_items posts = List.map mk_jsonfeed_item posts
let get_posts ?n ?(ofs = 0) planet_feeds =
Log.info (fun m -> m "Processing %d feeds for posts" (List.length planet_feeds));
+30 -1
stack/river/lib/river.ml
···
let author post = post.Post.author
let email post = post.Post.email
let content post = Soup.to_string post.Post.content
+
let id post = post.Post.id
+
let tags post = post.Post.tags
+
let summary post = post.Post.summary
let meta_description _post =
(* TODO: This requires environment for HTTP access *)
···
let create_atom_entries posts =
Log.info (fun m -> m "Creating Atom entries for %d posts" (List.length posts));
-
Post.mk_entries posts
+
Post.mk_entries posts
+
+
(* JSONFeed support *)
+
let create_jsonfeed_items posts =
+
Log.info (fun m -> m "Creating JSONFeed items for %d posts" (List.length posts));
+
Post.mk_jsonfeed_items posts
+
+
let create_jsonfeed ~title ?home_page_url ?feed_url ?description ?icon ?favicon posts =
+
Log.info (fun m -> m "Creating JSONFeed with %d posts" (List.length posts));
+
let items = create_jsonfeed_items posts in
+
Jsonfeed.create ~title ?home_page_url ?feed_url ?description ?icon ?favicon ~items ()
+
+
let jsonfeed_to_string ?(minify = false) jsonfeed =
+
match Jsonfeed.to_string ~minify jsonfeed with
+
| Ok s -> Ok s
+
| Error err -> Error (Jsont.Error.to_string err)
+
+
type feed_content =
+
| Atom of Syndic.Atom.feed
+
| Rss2 of Syndic.Rss2.channel
+
| JSONFeed of Jsonfeed.t
+
+
let feed_content feed =
+
match feed.Feed.content with
+
| Feed.Atom f -> Atom f
+
| Feed.Rss2 ch -> Rss2 ch
+
| Feed.Json jf -> JSONFeed jf
+47 -1
stack/river/lib/river.mli
···
(** The source of a feed. *)
type feed
-
(** An Atom or RSS feed. *)
+
(** An Atom, RSS2, or JSON Feed. *)
type post
(** A post from a feed. *)
···
val content : post -> string
(** [content post] is the content of the post. *)
+
val id : post -> string
+
(** [id post] is the unique identifier of the post. *)
+
+
val tags : post -> string list
+
(** [tags post] is the list of tags associated with the post. *)
+
+
val summary : post -> string option
+
(** [summary post] is the summary/excerpt of the post, if available. *)
+
val meta_description : post -> string option
(** [meta_description post] is the meta description of the post on the origin
site.
···
val create_atom_entries : post list -> Syndic.Atom.entry list
(** [create_atom_feed posts] creates a list of atom entries, which can then be
used to create an atom feed that is an aggregate of the posts. *)
+
+
(** {1 JSON Feed Support} *)
+
+
val create_jsonfeed_items : post list -> Jsonfeed.Item.t list
+
(** [create_jsonfeed_items posts] creates a list of JSONFeed items from posts. *)
+
+
val create_jsonfeed :
+
title:string ->
+
?home_page_url:string ->
+
?feed_url:string ->
+
?description:string ->
+
?icon:string ->
+
?favicon:string ->
+
post list ->
+
Jsonfeed.t
+
(** [create_jsonfeed ~title ?home_page_url ?feed_url ?description posts]
+
creates a complete JSONFeed from the given posts.
+
+
@param title The feed title (required)
+
@param home_page_url The URL of the website the feed represents
+
@param feed_url The URL of the feed itself
+
@param description A description of the feed
+
@param icon URL of an icon for the feed (512x512 recommended)
+
@param favicon URL of a favicon for the feed (64x64 recommended)
+
@param posts The posts to include in the feed *)
+
+
val jsonfeed_to_string : ?minify:bool -> Jsonfeed.t -> (string, string) result
+
(** [jsonfeed_to_string ?minify jsonfeed] serializes a JSONFeed to a string.
+
+
@param minify If true, output compact JSON; if false, pretty-print (default: false) *)
+
+
type feed_content = Atom of Syndic.Atom.feed | Rss2 of Syndic.Rss2.channel | JSONFeed of Jsonfeed.t
+
(** The native format of a feed. *)
+
+
val feed_content : feed -> feed_content
+
(** [feed_content feed] returns the feed in its native format (Atom, RSS2, or JSONFeed).
+
This allows access to format-specific features like JSONFeed attachments. *)
+55 -4
stack/river/lib/river_store.ml
···
feed_name : string;
feed_title : string;
stored_at : Ptime.t;
+
tags : string list;
+
summary : string option;
}
type feed_info = {
···
"feed_name", `String entry.feed_name;
"feed_title", `String entry.feed_title;
"stored_at", `String (Ptime.to_rfc3339 entry.stored_at);
+
"tags", `List (List.map (fun t -> `String t) entry.tags);
+
"summary", (match entry.summary with Some s -> `String s | None -> `Null);
]
let entry_of_json json =
···
feed_name = json |> member "feed_name" |> to_string;
feed_title = json |> member "feed_title" |> to_string;
stored_at = json |> member "stored_at" |> to_string |> parse_time;
+
tags = (try json |> member "tags" |> to_list |> List.map to_string with _ -> []);
+
summary = (try json |> member "summary" |> to_string_option with _ -> None);
}
let feed_meta_to_json meta =
···
(* Convert Post.t to stored_entry *)
let entry_of_post ~feed_url ~feed_name ~feed_title (post : Post.t) =
-
let atom_id = match post.link with
-
| Some uri -> Uri.to_string uri
-
| None -> Digest.to_hex (Digest.string post.title)
-
in
+
let atom_id = post.id in (* Use the post's unique ID *)
let updated = match post.date with
| Some d -> d
| None -> Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get
···
feed_name;
feed_title;
stored_at = Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get;
+
tags = post.tags;
+
summary = post.summary;
}
(* Convert Syndic.Atom.entry to stored_entry *)
···
| l :: _ -> Some l.href
| [] -> None
in
+
(* Extract tags from categories *)
+
let tags = List.map (fun cat -> cat.Syndic.Atom.term) atom_entry.categories in
+
(* Extract summary *)
+
let summary = match atom_entry.summary with
+
| Some s -> Some (Util.string_of_text_construct s)
+
| None -> None
+
in
{
atom_id;
title = Util.string_of_text_construct atom_entry.title;
···
feed_name;
feed_title;
stored_at = Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get;
+
tags;
+
summary;
}
(* Feed metadata management *)
···
) entries in
let sorted = List.sort (fun a b -> Ptime.compare b.updated a.updated) all_entries in
List.filteri (fun i _ -> i < limit) sorted
+
+
let find_entry_by_id store ~id =
+
Log.debug (fun m -> m "Searching for entry with ID: %s" id);
+
let entries = Cacheio.scan store.cache in
+
let matching_entry = List.find_map (fun (cache_entry : Cacheio.Entry.t) ->
+
let key = Cacheio.Entry.key cache_entry in
+
if String.contains key '/' &&
+
String.ends_with ~suffix:"entries/" (String.sub key 0 (String.rindex key '/') ^ "/") then
+
Eio.Switch.run @@ fun sw ->
+
match Cacheio.get store.cache ~key ~sw with
+
| None -> None
+
| Some source ->
+
(try
+
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
+
let json = Yojson.Safe.from_string json_str in
+
let entry = entry_of_json json in
+
(* Check if atom_id matches exactly or contains the search id *)
+
let contains_substring s substr =
+
try
+
let _ = Str.search_forward (Str.regexp_string substr) s 0 in
+
true
+
with Not_found -> false
+
in
+
if entry.atom_id = id || String.starts_with ~prefix:id entry.atom_id ||
+
contains_substring entry.atom_id id then
+
Some entry
+
else
+
None
+
with e ->
+
Log.err (fun m -> m "Failed to parse entry: %s" (Printexc.to_string e));
+
None)
+
else None
+
) entries in
+
(match matching_entry with
+
| Some e -> Log.debug (fun m -> m "Found entry: %s" e.title)
+
| None -> Log.debug (fun m -> m "No entry found with ID: %s" id));
+
matching_entry
(* Entry management *)
+9
stack/river/lib/river_store.mli
···
stored_at : Ptime.t;
(** When this entry was stored *)
+
+
tags : string list;
+
(** Tags associated with the entry *)
+
+
summary : string option;
+
(** Summary/excerpt of the entry *)
}
(** Feed metadata *)
···
(** Get the most recent entries across all feeds *)
val get_recent_entries : t -> ?limit:int -> unit -> stored_entry list
+
+
(** Find an entry by ID across all feeds (searches by atom_id) *)
+
val find_entry_by_id : t -> id:string -> stored_entry option
(** {1 Entry Management} *)
+6 -3
stack/river/river.opam
···
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
-
synopsis: "RSS2 and Atom feed aggregator for OCaml"
-
description: "RSS2 and Atom feed aggregator for OCaml"
+
synopsis: "RSS2, Atom and JSON Feed aggregator for OCaml"
+
description: "RSS2, Atom and JSON Feed aggregator for OCaml"
maintainer: ["KC Sivaramakrishnan <sk826@cl.cam.ac.uk>"]
authors: ["KC Sivaramakrishnan <sk826@cl.cam.ac.uk>"]
license: "MIT"
···
doc: "https://kayceesrk.github.io/river/"
bug-reports: "https://github.com/kayceesrk/river/issues"
depends: [
-
"ocaml" {>= "4.08.0"}
+
"ocaml" {>= "5.2.0"}
"dune" {>= "3.0"}
"syndic" {>= "1.5"}
"eio" {>= "1.0"}
···
"yojson"
"fmt"
"xdge"
+
"jsonfeed" {>= "1.1.0"}
+
"jsont" {>= "0.2.0"}
+
"bytesrw"
"odoc" {with-doc}
]
build: [