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

river

Changed files
+503 -33
stack
+56
stack/river/lib/category.ml
···
+
(*
+
* Copyright (c) 2014, OCaml.org project
+
* Copyright (c) 2015 KC Sivaramakrishnan <sk826@cl.cam.ac.uk>
+
*
+
* Permission to use, copy, modify, and distribute this software for any
+
* purpose with or without fee is hereby granted, provided that the above
+
* copyright notice and this permission notice appear in all copies.
+
*
+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
*)
+
+
(** Custom categories for organizing posts. *)
+
+
type t = {
+
id : string;
+
name : string;
+
description : string option;
+
}
+
+
let create ~id ~name ?description () =
+
{ id; name; description }
+
+
let id t = t.id
+
let name t = t.name
+
let description t = t.description
+
+
(* Jsont codec *)
+
let jsont =
+
let make id name description = { id; name; description } in
+
Jsont.Object.map ~kind:"Category" make
+
|> Jsont.Object.mem "id" Jsont.string ~enc:id
+
|> Jsont.Object.mem "name" Jsont.string ~enc:name
+
|> Jsont.Object.mem "description" (Jsont.option Jsont.string) ~enc:description
+
|> Jsont.Object.finish
+
+
let to_json t =
+
match Jsont_bytesrw.encode_string jsont t with
+
| Ok json_str ->
+
(match Jsont_bytesrw.decode_string Jsont.json json_str with
+
| Ok json -> json
+
| Error err -> failwith ("Failed to decode encoded category: " ^ err))
+
| Error err -> failwith ("Failed to encode category: " ^ err)
+
+
let of_json json =
+
match Jsont_bytesrw.encode_string Jsont.json json with
+
| Ok json_str ->
+
(match Jsont_bytesrw.decode_string jsont json_str with
+
| Ok t -> Ok t
+
| Error err -> Error err)
+
| Error err -> Error err
+54
stack/river/lib/category.mli
···
+
(*
+
* Copyright (c) 2014, OCaml.org project
+
* Copyright (c) 2015 KC Sivaramakrishnan <sk826@cl.cam.ac.uk>
+
*
+
* Permission to use, copy, modify, and distribute this software for any
+
* purpose with or without fee is hereby granted, provided that the above
+
* copyright notice and this permission notice appear in all copies.
+
*
+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
*)
+
+
(** Custom categories for organizing posts.
+
+
Categories are manually defined and can be assigned to posts for
+
organization and filtering. This is separate from feed-extracted tags. *)
+
+
type t
+
(** A custom category with metadata. *)
+
+
val create :
+
id:string ->
+
name:string ->
+
?description:string ->
+
unit ->
+
t
+
(** [create ~id ~name ?description ()] creates a new category.
+
+
@param id Unique identifier for the category (e.g., "ocaml-projects")
+
@param name Display name (e.g., "OCaml Projects")
+
@param description Optional longer description *)
+
+
val id : t -> string
+
(** [id category] returns the unique identifier of the category. *)
+
+
val name : t -> string
+
(** [name category] returns the display name of the category. *)
+
+
val description : t -> string option
+
(** [description category] returns the description, if any. *)
+
+
val to_json : t -> Jsont.json
+
(** [to_json category] serializes a category to JSON. *)
+
+
val of_json : Jsont.json -> (t, string) result
+
(** [of_json json] deserializes a category from JSON. *)
+
+
val jsont : t Jsont.t
+
(** Jsont codec for categories. *)
+1
stack/river/lib/river.ml
···
module Feed = Feed
module Post = Post
module Format = Format
+
module Category = Category
module User = User
module Quality = Quality
module State = State
+87
stack/river/lib/river.mli
···
end
end
+
(** {1 Category Management} *)
+
+
module Category : sig
+
(** Custom categories for organizing posts.
+
+
Categories are manually defined and can be assigned to posts for
+
organization and filtering. This is separate from feed-extracted tags. *)
+
+
type t
+
(** A custom category with metadata. *)
+
+
val create :
+
id:string ->
+
name:string ->
+
?description:string ->
+
unit ->
+
t
+
(** [create ~id ~name ?description ()] creates a new category.
+
+
@param id Unique identifier for the category (e.g., "ocaml-projects")
+
@param name Display name (e.g., "OCaml Projects")
+
@param description Optional longer description *)
+
+
val id : t -> string
+
(** [id category] returns the unique identifier of the category. *)
+
+
val name : t -> string
+
(** [name category] returns the display name of the category. *)
+
+
val description : t -> string option
+
(** [description category] returns the description, if any. *)
+
+
val to_json : t -> Jsont.json
+
(** [to_json category] serializes a category to JSON. *)
+
+
val of_json : Jsont.json -> (t, string) result
+
(** [of_json json] deserializes a category from JSON. *)
+
+
val jsont : t Jsont.t
+
(** Jsont codec for categories. *)
+
end
+
(** {1 User Management} *)
module User : sig
···
@param output_dir Directory to write HTML files to
@param title Site title
@param posts_per_page Number of posts per page (default: 25) *)
+
+
(** {2 Category Management} *)
+
+
val list_categories : t -> Category.t list
+
(** [list_categories state] returns all custom categories. *)
+
+
val get_category : t -> id:string -> Category.t option
+
(** [get_category state ~id] retrieves a category by ID. *)
+
+
val add_category : t -> Category.t -> (unit, string) result
+
(** [add_category state category] adds or updates a category.
+
+
@param category The category to add/update *)
+
+
val remove_category : t -> id:string -> (unit, string) result
+
(** [remove_category state ~id] removes a category.
+
+
This also removes the category from any posts that were tagged with it.
+
@param id The category ID to remove *)
+
+
val get_post_categories : t -> post_id:string -> string list
+
(** [get_post_categories state ~post_id] returns the list of category IDs
+
assigned to a post. *)
+
+
val set_post_categories : t -> post_id:string -> category_ids:string list -> (unit, string) result
+
(** [set_post_categories state ~post_id ~category_ids] sets the categories for a post.
+
+
Replaces any existing category assignments for this post.
+
@param post_id The post ID to categorize
+
@param category_ids List of category IDs to assign *)
+
+
val add_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
+
(** [add_post_category state ~post_id ~category_id] adds a category to a post.
+
+
@param post_id The post ID
+
@param category_id The category ID to add *)
+
+
val remove_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
+
(** [remove_post_category state ~post_id ~category_id] removes a category from a post.
+
+
@param post_id The post ID
+
@param category_id The category ID to remove *)
+
+
val get_posts_by_category : t -> category_id:string -> string list
+
(** [get_posts_by_category state ~category_id] returns all post IDs with this category. *)
(** {2 Analysis} *)
+260 -33
stack/river/lib/state.ml
···
save state updated
end
+
(** Category storage - manages custom categories *)
+
module Category_storage = struct
+
let categories_file state = Eio.Path.(Xdge.state_dir state.xdg / "categories.json")
+
+
let jsont = Jsont.list Category.jsont
+
+
let load state =
+
let file = categories_file state in
+
try
+
let content = Eio.Path.load file in
+
match Jsont_bytesrw.decode_string' jsont content with
+
| Ok categories -> categories
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse categories: %s" (Jsont.Error.to_string err));
+
[]
+
with
+
| Eio.Io (Eio.Fs.E (Not_found _), _) -> []
+
| e ->
+
Log.err (fun m -> m "Error loading categories: %s" (Printexc.to_string e));
+
[]
+
+
let save state categories =
+
let file = categories_file state in
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent jsont categories with
+
| Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
+
| Error err -> failwith ("Failed to encode categories: " ^ Jsont.Error.to_string err)
+
+
let get state id =
+
load state |> List.find_opt (fun cat -> Category.id cat = id)
+
+
let add state category =
+
let categories = load state in
+
let filtered = List.filter (fun cat -> Category.id cat <> Category.id category) categories in
+
save state (category :: filtered)
+
+
let remove state id =
+
let categories = load state in
+
save state (List.filter (fun cat -> Category.id cat <> id) categories)
+
end
+
+
(** Post-category mapping storage - maps post IDs to category IDs *)
+
module Post_category_storage = struct
+
let post_categories_file state = Eio.Path.(Xdge.state_dir state.xdg / "post_categories.json")
+
+
(* Type: list of (post_id, category_ids) pairs *)
+
let jsont =
+
let pair_t =
+
let make post_id category_ids = (post_id, category_ids) in
+
Jsont.Object.map ~kind:"PostCategoryMapping" make
+
|> Jsont.Object.mem "post_id" Jsont.string ~enc:fst
+
|> Jsont.Object.mem "category_ids" (Jsont.list Jsont.string) ~enc:snd
+
|> Jsont.Object.finish
+
in
+
Jsont.list pair_t
+
+
let load state =
+
let file = post_categories_file state in
+
try
+
let content = Eio.Path.load file in
+
match Jsont_bytesrw.decode_string' jsont content with
+
| Ok mappings -> mappings
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse post categories: %s" (Jsont.Error.to_string err));
+
[]
+
with
+
| Eio.Io (Eio.Fs.E (Not_found _), _) -> []
+
| e ->
+
Log.err (fun m -> m "Error loading post categories: %s" (Printexc.to_string e));
+
[]
+
+
let save state mappings =
+
let file = post_categories_file state in
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent jsont mappings with
+
| Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
+
| Error err -> failwith ("Failed to encode post categories: " ^ Jsont.Error.to_string err)
+
+
let get state post_id =
+
load state |> List.assoc_opt post_id |> Option.value ~default:[]
+
+
let set state post_id category_ids =
+
let mappings = load state in
+
let filtered = List.remove_assoc post_id mappings in
+
let updated = if category_ids = [] then filtered else (post_id, category_ids) :: filtered in
+
save state updated
+
+
let add state post_id category_id =
+
let current = get state post_id in
+
if List.mem category_id current then ()
+
else set state post_id (category_id :: current)
+
+
let remove state post_id category_id =
+
let current = get state post_id in
+
set state post_id (List.filter ((<>) category_id) current)
+
+
let get_posts_by_category state category_id =
+
load state
+
|> List.filter (fun (_, category_ids) -> List.mem category_id category_ids)
+
|> List.map fst
+
+
let remove_category state category_id =
+
let mappings = load state in
+
let updated = List.filter_map (fun (post_id, category_ids) ->
+
let filtered = List.filter ((<>) category_id) category_ids in
+
if filtered = [] then None else Some (post_id, filtered)
+
) mappings in
+
save state updated
+
end
+
+
(** {2 Category Management - Internal functions} *)
+
+
let list_categories state =
+
Category_storage.load state
+
+
let get_category state ~id =
+
Category_storage.get state id
+
+
let add_category state category =
+
try
+
Category_storage.add state category;
+
Ok ()
+
with e ->
+
Error (Printf.sprintf "Failed to add category: %s" (Printexc.to_string e))
+
+
let remove_category state ~id =
+
try
+
Category_storage.remove state id;
+
Post_category_storage.remove_category state id;
+
Ok ()
+
with e ->
+
Error (Printf.sprintf "Failed to remove category: %s" (Printexc.to_string e))
+
+
let get_post_categories state ~post_id =
+
Post_category_storage.get state post_id
+
+
let set_post_categories state ~post_id ~category_ids =
+
try
+
Post_category_storage.set state post_id category_ids;
+
Ok ()
+
with e ->
+
Error (Printf.sprintf "Failed to set post categories: %s" (Printexc.to_string e))
+
+
let add_post_category state ~post_id ~category_id =
+
try
+
Post_category_storage.add state post_id category_id;
+
Ok ()
+
with e ->
+
Error (Printf.sprintf "Failed to add post category: %s" (Printexc.to_string e))
+
+
let remove_post_category state ~post_id ~category_id =
+
try
+
Post_category_storage.remove state post_id category_id;
+
Ok ()
+
with e ->
+
Error (Printf.sprintf "Failed to remove post category: %s" (Printexc.to_string e))
+
+
let get_posts_by_category state ~category_id =
+
Post_category_storage.get_posts_by_category state category_id
+
module Storage = struct
(** List all usernames with feeds from Sortal *)
let list_users state =
···
in
let author, _ = entry.authors in
let tags = List.map (fun (c : Syndic.Atom.category) -> c.term) entry.categories in
-
(username, title, author.name, entry.updated, link_uri, content_html, tags)
+
let post_id = Uri.to_string entry.id in
+
(username, title, author.name, entry.updated, link_uri, content_html, tags, post_id)
in
(* Get all posts *)
···
entry_to_html_data username entry
) all_posts in
-
let unique_users = List.sort_uniq String.compare (List.map (fun (u, _, _, _, _, _, _) -> u) html_data) in
+
let unique_users = List.sort_uniq String.compare (List.map (fun (u, _, _, _, _, _, _, _) -> u) html_data) in
Log.info (fun m -> m "Retrieved %d posts from %d users" (List.length html_data) (List.length unique_users));
Log.info (fun m -> m "Users: %s" (String.concat ", " unique_users));
···
i >= start_idx && i < start_idx + posts_per_page
) html_data in
-
let post_htmls = List.map (fun (username, title, _feed_author, date, link, content, tags) ->
+
let post_htmls = List.map (fun (username, title, _feed_author, date, link, content, tags, post_id) ->
Log.debug (fun m -> m " Processing post: %s by @%s" title username);
(* Get author name from Sortal, fallback to username *)
···
in
let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in
let full_content = Format.Html.full_content_from_html content in
+
+
(* Get custom categories for this post *)
+
let custom_category_ids = get_post_categories state ~post_id in
+
let custom_categories = List.filter_map (fun cat_id ->
+
match get_category state ~id:cat_id with
+
| Some cat -> Some (Category.id cat, Category.name cat)
+
| None -> None
+
) custom_category_ids in
+
+
(* Combine feed tags and custom categories *)
+
let all_tags = tags @ List.map fst custom_categories in
let tags_html =
-
match tags with
+
match all_tags with
| [] -> ""
| _ ->
+
(* Display feed tags *)
let tag_links = List.map (fun tag ->
-
Printf.sprintf {|<a href="categories/%s.html">%s</a>|}
+
Printf.sprintf {|<a href="categories/%s.html" class="tag-feed">%s</a>|}
(Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag)
) tags in
-
Printf.sprintf {|<div class="post-tags">%s</div>|}
+
(* Display custom categories with different styling *)
+
let category_links = List.map (fun (cat_id, cat_name) ->
+
Printf.sprintf {|<a href="categories/%s.html" class="tag-custom">%s</a>|}
+
(Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name)
+
) custom_categories in
+
Printf.sprintf {|<div class="post-tags">%s%s</div>|}
(String.concat "" tag_links)
+
(String.concat "" category_links)
in
let thumbnail_html = match get_author_thumbnail username with
| Some thumb_path ->
···
(* Generate author index *)
Log.info (fun m -> m "Generating author index and pages");
let authors_map = Hashtbl.create 32 in
-
List.iter (fun (username, _, author, _, _, _, _) ->
+
List.iter (fun (username, _, author, _, _, _, _, _) ->
let count = match Hashtbl.find_opt authors_map username with
| Some (_, c) -> c + 1
| None -> 1
···
(* Generate individual author pages *)
Hashtbl.iter (fun username (author, _) ->
-
let author_posts = List.filter (fun (u, _, _, _, _, _, _) -> u = username) html_data in
+
let author_posts = List.filter (fun (u, _, _, _, _, _, _, _) -> u = username) html_data in
let author_total = List.length author_posts in
let author_pages = (author_total + posts_per_page - 1) / posts_per_page in
Log.info (fun m -> m " Author: %s (@%s) - %d posts, %d pages" author username author_total author_pages);
···
i >= start_idx && i < start_idx + posts_per_page
) author_posts in
-
let post_htmls = List.map (fun (_username, title, author, date, link, content, tags) ->
+
let post_htmls = List.map (fun (_username, title, author, date, link, content, tags, post_id) ->
let date_str = Format.Html.format_date date in
let link_html = match link with
| Some uri ->
···
in
let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in
let full_content = Format.Html.full_content_from_html content in
+
+
(* Get custom categories for this post *)
+
let custom_category_ids = get_post_categories state ~post_id in
+
let custom_categories = List.filter_map (fun cat_id ->
+
match get_category state ~id:cat_id with
+
| Some cat -> Some (Category.id cat, Category.name cat)
+
| None -> None
+
) custom_category_ids in
+
let tags_html =
-
match tags with
-
| [] -> ""
-
| _ ->
-
let tag_links = List.map (fun tag ->
-
Printf.sprintf {|<a href="../categories/%s.html">%s</a>|}
-
(Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag)
-
) tags in
-
Printf.sprintf {|<div class="post-tags">%s</div>|}
-
(String.concat "" tag_links)
+
let all_tags_exist = tags <> [] || custom_categories <> [] in
+
if not all_tags_exist then ""
+
else
+
(* Display feed tags *)
+
let tag_links = List.map (fun tag ->
+
Printf.sprintf {|<a href="../categories/%s.html" class="tag-feed">%s</a>|}
+
(Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag)
+
) tags in
+
(* Display custom categories with different styling *)
+
let category_links = List.map (fun (cat_id, cat_name) ->
+
Printf.sprintf {|<a href="../categories/%s.html" class="tag-custom">%s</a>|}
+
(Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name)
+
) custom_categories in
+
Printf.sprintf {|<div class="post-tags">%s%s</div>|}
+
(String.concat "" tag_links)
+
(String.concat "" category_links)
in
Printf.sprintf {|<article class="post">
<h2 class="post-title">%s</h2>
···
(* Generate category index and pages *)
Log.info (fun m -> m "Generating category index and pages");
let categories_map = Hashtbl.create 32 in
-
List.iter (fun (_, _, _, _, _, _, tags) ->
+
List.iter (fun (_, _, _, _, _, _, tags, post_id) ->
+
(* Count feed tags *)
List.iter (fun tag ->
let count = match Hashtbl.find_opt categories_map tag with
| Some c -> c + 1
| None -> 1
in
Hashtbl.replace categories_map tag count
-
) tags
+
) tags;
+
(* Count custom categories *)
+
let custom_cat_ids = get_post_categories state ~post_id in
+
List.iter (fun cat_id ->
+
let count = match Hashtbl.find_opt categories_map cat_id with
+
| Some c -> c + 1
+
| None -> 1
+
in
+
Hashtbl.replace categories_map cat_id count
+
) custom_cat_ids
) html_data;
let categories_list = Hashtbl.fold (fun tag count acc ->
···
(* Generate individual category pages *)
List.iter (fun (tag, count) ->
-
let tag_posts = List.filter (fun (_, _, _, _, _, _, tags) ->
-
List.mem tag tags
+
let tag_posts = List.filter (fun (_, _, _, _, _, _, tags, post_id) ->
+
(* Check if tag is in feed tags or custom categories *)
+
let in_feed_tags = List.mem tag tags in
+
let custom_cat_ids = get_post_categories state ~post_id in
+
let in_custom_cats = List.exists (fun cat_id ->
+
match get_category state ~id:cat_id with
+
| Some cat -> Category.id cat = tag
+
| None -> false
+
) custom_cat_ids in
+
in_feed_tags || in_custom_cats
) html_data in
let tag_total = List.length tag_posts in
···
i >= start_idx && i < start_idx + posts_per_page
) tag_posts in
-
let post_htmls = List.map (fun (username, title, author, date, link, content, tags) ->
+
let post_htmls = List.map (fun (username, title, author, date, link, content, tags, post_id) ->
let date_str = Format.Html.format_date date in
let link_html = match link with
| Some uri ->
···
in
let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in
let full_content = Format.Html.full_content_from_html content in
+
+
(* Get custom categories for this post *)
+
let custom_category_ids = get_post_categories state ~post_id in
+
let custom_categories = List.filter_map (fun cat_id ->
+
match get_category state ~id:cat_id with
+
| Some cat -> Some (Category.id cat, Category.name cat)
+
| None -> None
+
) custom_category_ids in
+
let tags_html =
-
match tags with
-
| [] -> ""
-
| _ ->
-
let tag_links = List.map (fun t ->
-
Printf.sprintf {|<a href="%s.html">%s</a>|}
-
(Format.Html.html_escape (sanitize_filename t)) (Format.Html.html_escape t)
-
) tags in
-
Printf.sprintf {|<div class="post-tags">%s</div>|}
-
(String.concat "" tag_links)
+
let all_tags_exist = tags <> [] || custom_categories <> [] in
+
if not all_tags_exist then ""
+
else
+
(* Display feed tags *)
+
let tag_links = List.map (fun t ->
+
Printf.sprintf {|<a href="%s.html" class="tag-feed">%s</a>|}
+
(Format.Html.html_escape (sanitize_filename t)) (Format.Html.html_escape t)
+
) tags in
+
(* Display custom categories with different styling *)
+
let category_links = List.map (fun (cat_id, cat_name) ->
+
Printf.sprintf {|<a href="%s.html" class="tag-custom">%s</a>|}
+
(Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name)
+
) custom_categories in
+
Printf.sprintf {|<div class="post-tags">%s%s</div>|}
+
(String.concat "" tag_links)
+
(String.concat "" category_links)
in
Printf.sprintf {|<article class="post">
<h2 class="post-title">%s</h2>
···
(* Generate links page *)
Log.info (fun m -> m "Generating links page");
-
let all_links = List.concat_map (fun (username, title, author, date, post_link, content, _) ->
+
let all_links = List.concat_map (fun (username, title, author, date, post_link, content, _, _) ->
let links = Html_markdown.extract_links content in
List.map (fun (href, link_text) ->
(href, link_text, username, author, title, post_link, date)
+45
stack/river/lib/state.mli
···
@param title Site title
@param posts_per_page Number of posts per page (default: 25) *)
+
(** {2 Category Management} *)
+
+
val list_categories : t -> Category.t list
+
(** [list_categories state] returns all custom categories. *)
+
+
val get_category : t -> id:string -> Category.t option
+
(** [get_category state ~id] retrieves a category by ID. *)
+
+
val add_category : t -> Category.t -> (unit, string) result
+
(** [add_category state category] adds or updates a category.
+
+
@param category The category to add/update *)
+
+
val remove_category : t -> id:string -> (unit, string) result
+
(** [remove_category state ~id] removes a category.
+
+
This also removes the category from any posts that were tagged with it.
+
@param id The category ID to remove *)
+
+
val get_post_categories : t -> post_id:string -> string list
+
(** [get_post_categories state ~post_id] returns the list of category IDs
+
assigned to a post. *)
+
+
val set_post_categories : t -> post_id:string -> category_ids:string list -> (unit, string) result
+
(** [set_post_categories state ~post_id ~category_ids] sets the categories for a post.
+
+
Replaces any existing category assignments for this post.
+
@param post_id The post ID to categorize
+
@param category_ids List of category IDs to assign *)
+
+
val add_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
+
(** [add_post_category state ~post_id ~category_id] adds a category to a post.
+
+
@param post_id The post ID
+
@param category_id The category ID to add *)
+
+
val remove_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
+
(** [remove_post_category state ~post_id ~category_id] removes a category from a post.
+
+
@param post_id The post ID
+
@param category_id The category ID to remove *)
+
+
val get_posts_by_category : t -> category_id:string -> string list
+
(** [get_posts_by_category state ~category_id] returns all post IDs with this category. *)
+
(** {2 Analysis} *)
val analyze_user_quality :