···11+(*
22+ * Copyright (c) 2014, OCaml.org project
33+ * Copyright (c) 2015 KC Sivaramakrishnan <sk826@cl.cam.ac.uk>
44+ *
55+ * Permission to use, copy, modify, and distribute this software for any
66+ * purpose with or without fee is hereby granted, provided that the above
77+ * copyright notice and this permission notice appear in all copies.
88+ *
99+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616+ *)
1717+1818+(** Custom categories for organizing posts. *)
1919+2020+type t = {
2121+ id : string;
2222+ name : string;
2323+ description : string option;
2424+}
2525+2626+let create ~id ~name ?description () =
2727+ { id; name; description }
2828+2929+let id t = t.id
3030+let name t = t.name
3131+let description t = t.description
3232+3333+(* Jsont codec *)
3434+let jsont =
3535+ let make id name description = { id; name; description } in
3636+ Jsont.Object.map ~kind:"Category" make
3737+ |> Jsont.Object.mem "id" Jsont.string ~enc:id
3838+ |> Jsont.Object.mem "name" Jsont.string ~enc:name
3939+ |> Jsont.Object.mem "description" (Jsont.option Jsont.string) ~enc:description
4040+ |> Jsont.Object.finish
4141+4242+let to_json t =
4343+ match Jsont_bytesrw.encode_string jsont t with
4444+ | Ok json_str ->
4545+ (match Jsont_bytesrw.decode_string Jsont.json json_str with
4646+ | Ok json -> json
4747+ | Error err -> failwith ("Failed to decode encoded category: " ^ err))
4848+ | Error err -> failwith ("Failed to encode category: " ^ err)
4949+5050+let of_json json =
5151+ match Jsont_bytesrw.encode_string Jsont.json json with
5252+ | Ok json_str ->
5353+ (match Jsont_bytesrw.decode_string jsont json_str with
5454+ | Ok t -> Ok t
5555+ | Error err -> Error err)
5656+ | Error err -> Error err
+54
stack/river/lib/category.mli
···11+(*
22+ * Copyright (c) 2014, OCaml.org project
33+ * Copyright (c) 2015 KC Sivaramakrishnan <sk826@cl.cam.ac.uk>
44+ *
55+ * Permission to use, copy, modify, and distribute this software for any
66+ * purpose with or without fee is hereby granted, provided that the above
77+ * copyright notice and this permission notice appear in all copies.
88+ *
99+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616+ *)
1717+1818+(** Custom categories for organizing posts.
1919+2020+ Categories are manually defined and can be assigned to posts for
2121+ organization and filtering. This is separate from feed-extracted tags. *)
2222+2323+type t
2424+(** A custom category with metadata. *)
2525+2626+val create :
2727+ id:string ->
2828+ name:string ->
2929+ ?description:string ->
3030+ unit ->
3131+ t
3232+(** [create ~id ~name ?description ()] creates a new category.
3333+3434+ @param id Unique identifier for the category (e.g., "ocaml-projects")
3535+ @param name Display name (e.g., "OCaml Projects")
3636+ @param description Optional longer description *)
3737+3838+val id : t -> string
3939+(** [id category] returns the unique identifier of the category. *)
4040+4141+val name : t -> string
4242+(** [name category] returns the display name of the category. *)
4343+4444+val description : t -> string option
4545+(** [description category] returns the description, if any. *)
4646+4747+val to_json : t -> Jsont.json
4848+(** [to_json category] serializes a category to JSON. *)
4949+5050+val of_json : Jsont.json -> (t, string) result
5151+(** [of_json json] deserializes a category from JSON. *)
5252+5353+val jsont : t Jsont.t
5454+(** Jsont codec for categories. *)
+1
stack/river/lib/river.ml
···2424module Feed = Feed
2525module Post = Post
2626module Format = Format
2727+module Category = Category
2728module User = User
2829module Quality = Quality
2930module State = State
+87
stack/river/lib/river.mli
···234234 end
235235end
236236237237+(** {1 Category Management} *)
238238+239239+module Category : sig
240240+ (** Custom categories for organizing posts.
241241+242242+ Categories are manually defined and can be assigned to posts for
243243+ organization and filtering. This is separate from feed-extracted tags. *)
244244+245245+ type t
246246+ (** A custom category with metadata. *)
247247+248248+ val create :
249249+ id:string ->
250250+ name:string ->
251251+ ?description:string ->
252252+ unit ->
253253+ t
254254+ (** [create ~id ~name ?description ()] creates a new category.
255255+256256+ @param id Unique identifier for the category (e.g., "ocaml-projects")
257257+ @param name Display name (e.g., "OCaml Projects")
258258+ @param description Optional longer description *)
259259+260260+ val id : t -> string
261261+ (** [id category] returns the unique identifier of the category. *)
262262+263263+ val name : t -> string
264264+ (** [name category] returns the display name of the category. *)
265265+266266+ val description : t -> string option
267267+ (** [description category] returns the description, if any. *)
268268+269269+ val to_json : t -> Jsont.json
270270+ (** [to_json category] serializes a category to JSON. *)
271271+272272+ val of_json : Jsont.json -> (t, string) result
273273+ (** [of_json json] deserializes a category from JSON. *)
274274+275275+ val jsont : t Jsont.t
276276+ (** Jsont codec for categories. *)
277277+end
278278+237279(** {1 User Management} *)
238280239281module User : sig
···437479 @param output_dir Directory to write HTML files to
438480 @param title Site title
439481 @param posts_per_page Number of posts per page (default: 25) *)
482482+483483+ (** {2 Category Management} *)
484484+485485+ val list_categories : t -> Category.t list
486486+ (** [list_categories state] returns all custom categories. *)
487487+488488+ val get_category : t -> id:string -> Category.t option
489489+ (** [get_category state ~id] retrieves a category by ID. *)
490490+491491+ val add_category : t -> Category.t -> (unit, string) result
492492+ (** [add_category state category] adds or updates a category.
493493+494494+ @param category The category to add/update *)
495495+496496+ val remove_category : t -> id:string -> (unit, string) result
497497+ (** [remove_category state ~id] removes a category.
498498+499499+ This also removes the category from any posts that were tagged with it.
500500+ @param id The category ID to remove *)
501501+502502+ val get_post_categories : t -> post_id:string -> string list
503503+ (** [get_post_categories state ~post_id] returns the list of category IDs
504504+ assigned to a post. *)
505505+506506+ val set_post_categories : t -> post_id:string -> category_ids:string list -> (unit, string) result
507507+ (** [set_post_categories state ~post_id ~category_ids] sets the categories for a post.
508508+509509+ Replaces any existing category assignments for this post.
510510+ @param post_id The post ID to categorize
511511+ @param category_ids List of category IDs to assign *)
512512+513513+ val add_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
514514+ (** [add_post_category state ~post_id ~category_id] adds a category to a post.
515515+516516+ @param post_id The post ID
517517+ @param category_id The category ID to add *)
518518+519519+ val remove_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
520520+ (** [remove_post_category state ~post_id ~category_id] removes a category from a post.
521521+522522+ @param post_id The post ID
523523+ @param category_id The category ID to remove *)
524524+525525+ val get_posts_by_category : t -> category_id:string -> string list
526526+ (** [get_posts_by_category state ~category_id] returns all post IDs with this category. *)
440527441528 (** {2 Analysis} *)
442529
+260-33
stack/river/lib/state.ml
···9898 save state updated
9999end
100100101101+(** Category storage - manages custom categories *)
102102+module Category_storage = struct
103103+ let categories_file state = Eio.Path.(Xdge.state_dir state.xdg / "categories.json")
104104+105105+ let jsont = Jsont.list Category.jsont
106106+107107+ let load state =
108108+ let file = categories_file state in
109109+ try
110110+ let content = Eio.Path.load file in
111111+ match Jsont_bytesrw.decode_string' jsont content with
112112+ | Ok categories -> categories
113113+ | Error err ->
114114+ Log.warn (fun m -> m "Failed to parse categories: %s" (Jsont.Error.to_string err));
115115+ []
116116+ with
117117+ | Eio.Io (Eio.Fs.E (Not_found _), _) -> []
118118+ | e ->
119119+ Log.err (fun m -> m "Error loading categories: %s" (Printexc.to_string e));
120120+ []
121121+122122+ let save state categories =
123123+ let file = categories_file state in
124124+ match Jsont_bytesrw.encode_string' ~format:Jsont.Indent jsont categories with
125125+ | Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
126126+ | Error err -> failwith ("Failed to encode categories: " ^ Jsont.Error.to_string err)
127127+128128+ let get state id =
129129+ load state |> List.find_opt (fun cat -> Category.id cat = id)
130130+131131+ let add state category =
132132+ let categories = load state in
133133+ let filtered = List.filter (fun cat -> Category.id cat <> Category.id category) categories in
134134+ save state (category :: filtered)
135135+136136+ let remove state id =
137137+ let categories = load state in
138138+ save state (List.filter (fun cat -> Category.id cat <> id) categories)
139139+end
140140+141141+(** Post-category mapping storage - maps post IDs to category IDs *)
142142+module Post_category_storage = struct
143143+ let post_categories_file state = Eio.Path.(Xdge.state_dir state.xdg / "post_categories.json")
144144+145145+ (* Type: list of (post_id, category_ids) pairs *)
146146+ let jsont =
147147+ let pair_t =
148148+ let make post_id category_ids = (post_id, category_ids) in
149149+ Jsont.Object.map ~kind:"PostCategoryMapping" make
150150+ |> Jsont.Object.mem "post_id" Jsont.string ~enc:fst
151151+ |> Jsont.Object.mem "category_ids" (Jsont.list Jsont.string) ~enc:snd
152152+ |> Jsont.Object.finish
153153+ in
154154+ Jsont.list pair_t
155155+156156+ let load state =
157157+ let file = post_categories_file state in
158158+ try
159159+ let content = Eio.Path.load file in
160160+ match Jsont_bytesrw.decode_string' jsont content with
161161+ | Ok mappings -> mappings
162162+ | Error err ->
163163+ Log.warn (fun m -> m "Failed to parse post categories: %s" (Jsont.Error.to_string err));
164164+ []
165165+ with
166166+ | Eio.Io (Eio.Fs.E (Not_found _), _) -> []
167167+ | e ->
168168+ Log.err (fun m -> m "Error loading post categories: %s" (Printexc.to_string e));
169169+ []
170170+171171+ let save state mappings =
172172+ let file = post_categories_file state in
173173+ match Jsont_bytesrw.encode_string' ~format:Jsont.Indent jsont mappings with
174174+ | Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
175175+ | Error err -> failwith ("Failed to encode post categories: " ^ Jsont.Error.to_string err)
176176+177177+ let get state post_id =
178178+ load state |> List.assoc_opt post_id |> Option.value ~default:[]
179179+180180+ let set state post_id category_ids =
181181+ let mappings = load state in
182182+ let filtered = List.remove_assoc post_id mappings in
183183+ let updated = if category_ids = [] then filtered else (post_id, category_ids) :: filtered in
184184+ save state updated
185185+186186+ let add state post_id category_id =
187187+ let current = get state post_id in
188188+ if List.mem category_id current then ()
189189+ else set state post_id (category_id :: current)
190190+191191+ let remove state post_id category_id =
192192+ let current = get state post_id in
193193+ set state post_id (List.filter ((<>) category_id) current)
194194+195195+ let get_posts_by_category state category_id =
196196+ load state
197197+ |> List.filter (fun (_, category_ids) -> List.mem category_id category_ids)
198198+ |> List.map fst
199199+200200+ let remove_category state category_id =
201201+ let mappings = load state in
202202+ let updated = List.filter_map (fun (post_id, category_ids) ->
203203+ let filtered = List.filter ((<>) category_id) category_ids in
204204+ if filtered = [] then None else Some (post_id, filtered)
205205+ ) mappings in
206206+ save state updated
207207+end
208208+209209+(** {2 Category Management - Internal functions} *)
210210+211211+let list_categories state =
212212+ Category_storage.load state
213213+214214+let get_category state ~id =
215215+ Category_storage.get state id
216216+217217+let add_category state category =
218218+ try
219219+ Category_storage.add state category;
220220+ Ok ()
221221+ with e ->
222222+ Error (Printf.sprintf "Failed to add category: %s" (Printexc.to_string e))
223223+224224+let remove_category state ~id =
225225+ try
226226+ Category_storage.remove state id;
227227+ Post_category_storage.remove_category state id;
228228+ Ok ()
229229+ with e ->
230230+ Error (Printf.sprintf "Failed to remove category: %s" (Printexc.to_string e))
231231+232232+let get_post_categories state ~post_id =
233233+ Post_category_storage.get state post_id
234234+235235+let set_post_categories state ~post_id ~category_ids =
236236+ try
237237+ Post_category_storage.set state post_id category_ids;
238238+ Ok ()
239239+ with e ->
240240+ Error (Printf.sprintf "Failed to set post categories: %s" (Printexc.to_string e))
241241+242242+let add_post_category state ~post_id ~category_id =
243243+ try
244244+ Post_category_storage.add state post_id category_id;
245245+ Ok ()
246246+ with e ->
247247+ Error (Printf.sprintf "Failed to add post category: %s" (Printexc.to_string e))
248248+249249+let remove_post_category state ~post_id ~category_id =
250250+ try
251251+ Post_category_storage.remove state post_id category_id;
252252+ Ok ()
253253+ with e ->
254254+ Error (Printf.sprintf "Failed to remove post category: %s" (Printexc.to_string e))
255255+256256+let get_posts_by_category state ~category_id =
257257+ Post_category_storage.get_posts_by_category state category_id
258258+101259module Storage = struct
102260 (** List all usernames with feeds from Sortal *)
103261 let list_users state =
···537695 in
538696 let author, _ = entry.authors in
539697 let tags = List.map (fun (c : Syndic.Atom.category) -> c.term) entry.categories in
540540- (username, title, author.name, entry.updated, link_uri, content_html, tags)
698698+ let post_id = Uri.to_string entry.id in
699699+ (username, title, author.name, entry.updated, link_uri, content_html, tags, post_id)
541700 in
542701543702 (* Get all posts *)
···547706 entry_to_html_data username entry
548707 ) all_posts in
549708550550- let unique_users = List.sort_uniq String.compare (List.map (fun (u, _, _, _, _, _, _) -> u) html_data) in
709709+ let unique_users = List.sort_uniq String.compare (List.map (fun (u, _, _, _, _, _, _, _) -> u) html_data) in
551710 Log.info (fun m -> m "Retrieved %d posts from %d users" (List.length html_data) (List.length unique_users));
552711 Log.info (fun m -> m "Users: %s" (String.concat ", " unique_users));
553712···563722 i >= start_idx && i < start_idx + posts_per_page
564723 ) html_data in
565724566566- let post_htmls = List.map (fun (username, title, _feed_author, date, link, content, tags) ->
725725+ let post_htmls = List.map (fun (username, title, _feed_author, date, link, content, tags, post_id) ->
567726 Log.debug (fun m -> m " Processing post: %s by @%s" title username);
568727569728 (* Get author name from Sortal, fallback to username *)
···583742 in
584743 let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in
585744 let full_content = Format.Html.full_content_from_html content in
745745+746746+ (* Get custom categories for this post *)
747747+ let custom_category_ids = get_post_categories state ~post_id in
748748+ let custom_categories = List.filter_map (fun cat_id ->
749749+ match get_category state ~id:cat_id with
750750+ | Some cat -> Some (Category.id cat, Category.name cat)
751751+ | None -> None
752752+ ) custom_category_ids in
753753+754754+ (* Combine feed tags and custom categories *)
755755+ let all_tags = tags @ List.map fst custom_categories in
586756 let tags_html =
587587- match tags with
757757+ match all_tags with
588758 | [] -> ""
589759 | _ ->
760760+ (* Display feed tags *)
590761 let tag_links = List.map (fun tag ->
591591- Printf.sprintf {|<a href="categories/%s.html">%s</a>|}
762762+ Printf.sprintf {|<a href="categories/%s.html" class="tag-feed">%s</a>|}
592763 (Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag)
593764 ) tags in
594594- Printf.sprintf {|<div class="post-tags">%s</div>|}
765765+ (* Display custom categories with different styling *)
766766+ let category_links = List.map (fun (cat_id, cat_name) ->
767767+ Printf.sprintf {|<a href="categories/%s.html" class="tag-custom">%s</a>|}
768768+ (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name)
769769+ ) custom_categories in
770770+ Printf.sprintf {|<div class="post-tags">%s%s</div>|}
595771 (String.concat "" tag_links)
772772+ (String.concat "" category_links)
596773 in
597774 let thumbnail_html = match get_author_thumbnail username with
598775 | Some thumb_path ->
···649826 (* Generate author index *)
650827 Log.info (fun m -> m "Generating author index and pages");
651828 let authors_map = Hashtbl.create 32 in
652652- List.iter (fun (username, _, author, _, _, _, _) ->
829829+ List.iter (fun (username, _, author, _, _, _, _, _) ->
653830 let count = match Hashtbl.find_opt authors_map username with
654831 | Some (_, c) -> c + 1
655832 | None -> 1
···759936760937 (* Generate individual author pages *)
761938 Hashtbl.iter (fun username (author, _) ->
762762- let author_posts = List.filter (fun (u, _, _, _, _, _, _) -> u = username) html_data in
939939+ let author_posts = List.filter (fun (u, _, _, _, _, _, _, _) -> u = username) html_data in
763940 let author_total = List.length author_posts in
764941 let author_pages = (author_total + posts_per_page - 1) / posts_per_page in
765942 Log.info (fun m -> m " Author: %s (@%s) - %d posts, %d pages" author username author_total author_pages);
···8741051 i >= start_idx && i < start_idx + posts_per_page
8751052 ) author_posts in
8761053877877- let post_htmls = List.map (fun (_username, title, author, date, link, content, tags) ->
10541054+ let post_htmls = List.map (fun (_username, title, author, date, link, content, tags, post_id) ->
8781055 let date_str = Format.Html.format_date date in
8791056 let link_html = match link with
8801057 | Some uri ->
···8851062 in
8861063 let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in
8871064 let full_content = Format.Html.full_content_from_html content in
10651065+10661066+ (* Get custom categories for this post *)
10671067+ let custom_category_ids = get_post_categories state ~post_id in
10681068+ let custom_categories = List.filter_map (fun cat_id ->
10691069+ match get_category state ~id:cat_id with
10701070+ | Some cat -> Some (Category.id cat, Category.name cat)
10711071+ | None -> None
10721072+ ) custom_category_ids in
10731073+8881074 let tags_html =
889889- match tags with
890890- | [] -> ""
891891- | _ ->
892892- let tag_links = List.map (fun tag ->
893893- Printf.sprintf {|<a href="../categories/%s.html">%s</a>|}
894894- (Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag)
895895- ) tags in
896896- Printf.sprintf {|<div class="post-tags">%s</div>|}
897897- (String.concat "" tag_links)
10751075+ let all_tags_exist = tags <> [] || custom_categories <> [] in
10761076+ if not all_tags_exist then ""
10771077+ else
10781078+ (* Display feed tags *)
10791079+ let tag_links = List.map (fun tag ->
10801080+ Printf.sprintf {|<a href="../categories/%s.html" class="tag-feed">%s</a>|}
10811081+ (Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag)
10821082+ ) tags in
10831083+ (* Display custom categories with different styling *)
10841084+ let category_links = List.map (fun (cat_id, cat_name) ->
10851085+ Printf.sprintf {|<a href="../categories/%s.html" class="tag-custom">%s</a>|}
10861086+ (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name)
10871087+ ) custom_categories in
10881088+ Printf.sprintf {|<div class="post-tags">%s%s</div>|}
10891089+ (String.concat "" tag_links)
10901090+ (String.concat "" category_links)
8981091 in
8991092 Printf.sprintf {|<article class="post">
9001093 <h2 class="post-title">%s</h2>
···9401133 (* Generate category index and pages *)
9411134 Log.info (fun m -> m "Generating category index and pages");
9421135 let categories_map = Hashtbl.create 32 in
943943- List.iter (fun (_, _, _, _, _, _, tags) ->
11361136+ List.iter (fun (_, _, _, _, _, _, tags, post_id) ->
11371137+ (* Count feed tags *)
9441138 List.iter (fun tag ->
9451139 let count = match Hashtbl.find_opt categories_map tag with
9461140 | Some c -> c + 1
9471141 | None -> 1
9481142 in
9491143 Hashtbl.replace categories_map tag count
950950- ) tags
11441144+ ) tags;
11451145+ (* Count custom categories *)
11461146+ let custom_cat_ids = get_post_categories state ~post_id in
11471147+ List.iter (fun cat_id ->
11481148+ let count = match Hashtbl.find_opt categories_map cat_id with
11491149+ | Some c -> c + 1
11501150+ | None -> 1
11511151+ in
11521152+ Hashtbl.replace categories_map cat_id count
11531153+ ) custom_cat_ids
9511154 ) html_data;
95211559531156 let categories_list = Hashtbl.fold (fun tag count acc ->
···97911829801183 (* Generate individual category pages *)
9811184 List.iter (fun (tag, count) ->
982982- let tag_posts = List.filter (fun (_, _, _, _, _, _, tags) ->
983983- List.mem tag tags
11851185+ let tag_posts = List.filter (fun (_, _, _, _, _, _, tags, post_id) ->
11861186+ (* Check if tag is in feed tags or custom categories *)
11871187+ let in_feed_tags = List.mem tag tags in
11881188+ let custom_cat_ids = get_post_categories state ~post_id in
11891189+ let in_custom_cats = List.exists (fun cat_id ->
11901190+ match get_category state ~id:cat_id with
11911191+ | Some cat -> Category.id cat = tag
11921192+ | None -> false
11931193+ ) custom_cat_ids in
11941194+ in_feed_tags || in_custom_cats
9841195 ) html_data in
98511969861197 let tag_total = List.length tag_posts in
···9931204 i >= start_idx && i < start_idx + posts_per_page
9941205 ) tag_posts in
9951206996996- let post_htmls = List.map (fun (username, title, author, date, link, content, tags) ->
12071207+ let post_htmls = List.map (fun (username, title, author, date, link, content, tags, post_id) ->
9971208 let date_str = Format.Html.format_date date in
9981209 let link_html = match link with
9991210 | Some uri ->
···10041215 in
10051216 let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in
10061217 let full_content = Format.Html.full_content_from_html content in
12181218+12191219+ (* Get custom categories for this post *)
12201220+ let custom_category_ids = get_post_categories state ~post_id in
12211221+ let custom_categories = List.filter_map (fun cat_id ->
12221222+ match get_category state ~id:cat_id with
12231223+ | Some cat -> Some (Category.id cat, Category.name cat)
12241224+ | None -> None
12251225+ ) custom_category_ids in
12261226+10071227 let tags_html =
10081008- match tags with
10091009- | [] -> ""
10101010- | _ ->
10111011- let tag_links = List.map (fun t ->
10121012- Printf.sprintf {|<a href="%s.html">%s</a>|}
10131013- (Format.Html.html_escape (sanitize_filename t)) (Format.Html.html_escape t)
10141014- ) tags in
10151015- Printf.sprintf {|<div class="post-tags">%s</div>|}
10161016- (String.concat "" tag_links)
12281228+ let all_tags_exist = tags <> [] || custom_categories <> [] in
12291229+ if not all_tags_exist then ""
12301230+ else
12311231+ (* Display feed tags *)
12321232+ let tag_links = List.map (fun t ->
12331233+ Printf.sprintf {|<a href="%s.html" class="tag-feed">%s</a>|}
12341234+ (Format.Html.html_escape (sanitize_filename t)) (Format.Html.html_escape t)
12351235+ ) tags in
12361236+ (* Display custom categories with different styling *)
12371237+ let category_links = List.map (fun (cat_id, cat_name) ->
12381238+ Printf.sprintf {|<a href="%s.html" class="tag-custom">%s</a>|}
12391239+ (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name)
12401240+ ) custom_categories in
12411241+ Printf.sprintf {|<div class="post-tags">%s%s</div>|}
12421242+ (String.concat "" tag_links)
12431243+ (String.concat "" category_links)
10171244 in
10181245 Printf.sprintf {|<article class="post">
10191246 <h2 class="post-title">%s</h2>
···1058128510591286 (* Generate links page *)
10601287 Log.info (fun m -> m "Generating links page");
10611061- let all_links = List.concat_map (fun (username, title, author, date, post_link, content, _) ->
12881288+ let all_links = List.concat_map (fun (username, title, author, date, post_link, content, _, _) ->
10621289 let links = Html_markdown.extract_links content in
10631290 List.map (fun (href, link_text) ->
10641291 (href, link_text, username, author, title, post_link, date)
+45
stack/river/lib/state.mli
···136136 @param title Site title
137137 @param posts_per_page Number of posts per page (default: 25) *)
138138139139+(** {2 Category Management} *)
140140+141141+val list_categories : t -> Category.t list
142142+(** [list_categories state] returns all custom categories. *)
143143+144144+val get_category : t -> id:string -> Category.t option
145145+(** [get_category state ~id] retrieves a category by ID. *)
146146+147147+val add_category : t -> Category.t -> (unit, string) result
148148+(** [add_category state category] adds or updates a category.
149149+150150+ @param category The category to add/update *)
151151+152152+val remove_category : t -> id:string -> (unit, string) result
153153+(** [remove_category state ~id] removes a category.
154154+155155+ This also removes the category from any posts that were tagged with it.
156156+ @param id The category ID to remove *)
157157+158158+val get_post_categories : t -> post_id:string -> string list
159159+(** [get_post_categories state ~post_id] returns the list of category IDs
160160+ assigned to a post. *)
161161+162162+val set_post_categories : t -> post_id:string -> category_ids:string list -> (unit, string) result
163163+(** [set_post_categories state ~post_id ~category_ids] sets the categories for a post.
164164+165165+ Replaces any existing category assignments for this post.
166166+ @param post_id The post ID to categorize
167167+ @param category_ids List of category IDs to assign *)
168168+169169+val add_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
170170+(** [add_post_category state ~post_id ~category_id] adds a category to a post.
171171+172172+ @param post_id The post ID
173173+ @param category_id The category ID to add *)
174174+175175+val remove_post_category : t -> post_id:string -> category_id:string -> (unit, string) result
176176+(** [remove_post_category state ~post_id ~category_id] removes a category from a post.
177177+178178+ @param post_id The post ID
179179+ @param category_id The category ID to remove *)
180180+181181+val get_posts_by_category : t -> category_id:string -> string list
182182+(** [get_posts_by_category state ~category_id] returns all post IDs with this category. *)
183183+139184(** {2 Analysis} *)
140185141186val analyze_user_quality :