···1111(* Format datetime for AT Protocol (RFC 3339 with millisecond precision and UTC Z suffix) *)
1212let now_rfc3339 () = Ptime.to_rfc3339 ~tz_offset_s:0 ~frac_s:3 (Ptime_clock.now ())
13131414+(* Normalize a datetime string to AT Protocol format *)
1515+let normalize_datetime s =
1616+ match Ptime.of_rfc3339 s with
1717+ | Ok (t, _, _) -> Ptime.to_rfc3339 ~tz_offset_s:0 ~frac_s:3 t
1818+ | Error _ -> s (* If parsing fails, return original and let server validate *)
1919+1420(* Helper to load session and create API *)
1521let with_api env f =
1622 Eio.Switch.run @@ fun sw ->
···231237232238(* Update command *)
233239240240+let update_rkey_arg =
241241+ let doc = "Record key of the document. If not provided, use --path to find the document." in
242242+ Arg.(value & pos 0 (some string) None & info [] ~docv:"RKEY" ~doc)
243243+244244+let update_path_arg =
245245+ let doc = "URL path to find the document by (e.g., /notes/my-post). Used to look up the document if RKEY is not provided, or to update the path if RKEY is provided." in
246246+ Arg.(value & opt (some string) None & info [ "path"; "p" ] ~docv:"PATH" ~doc)
247247+234248let update_action ~rkey ~title ~site ~path ~description ~content ~tags ~bsky_post env =
235249 with_api env @@ fun api ->
236250 let did = Standard_site.Api.get_did api in
237237- (* First get the existing document to preserve published_at *)
238238- match Standard_site.Api.get_document api ~did ~rkey with
239239- | None ->
240240- Fmt.epr "Document not found: %s@." rkey;
241241- exit 1
242242- | Some existing ->
243243- let site =
244244- match site with
245245- | Some s -> resolve_site_uri ~did s
246246- | None -> existing.site
247247- in
248248- let tags =
249249- Option.map (String.split_on_char ',') tags
250250- |> Option.map (List.map String.trim)
251251- in
252252- let bsky_post_ref =
253253- match bsky_post with
254254- | Some url -> Some (Standard_site.Api.resolve_bsky_post api url)
255255- | None -> existing.bsky_post_ref
256256- in
257257- let updated_at = now_rfc3339 () in
258258- Standard_site.Api.update_document api ~rkey ~site
259259- ~title:(Option.value ~default:existing.title title)
260260- ~published_at:existing.published_at
261261- ?path:(if Option.is_some path then path else existing.path)
262262- ?description:
263263- (if Option.is_some description then description
264264- else existing.description)
265265- ?text_content:
266266- (if Option.is_some content then content else existing.text_content)
267267- ?tags:(if Option.is_some tags then tags else existing.tags)
268268- ?bsky_post_ref
269269- ~updated_at ();
270270- Fmt.pr "Updated document: %s@." rkey
251251+ (* Track whether we looked up by path (to preserve path vs update it) *)
252252+ let looked_up_by_path = Option.is_none rkey in
253253+ (* Find the document - either by rkey or by path *)
254254+ let rkey, existing =
255255+ match rkey with
256256+ | Some rkey -> (
257257+ match Standard_site.Api.get_document api ~did ~rkey with
258258+ | None ->
259259+ Fmt.epr "Document not found: %s@." rkey;
260260+ exit 1
261261+ | Some doc -> (rkey, doc))
262262+ | None -> (
263263+ (* Look up by path *)
264264+ match path with
265265+ | None ->
266266+ Fmt.epr "Error: Either RKEY or --path must be provided.@.";
267267+ exit 1
268268+ | Some search_path ->
269269+ let docs = Standard_site.Api.list_documents api ~did () in
270270+ let matching =
271271+ List.find_opt
272272+ (fun (_, (d : Document.main)) -> d.path = Some search_path)
273273+ docs
274274+ in
275275+ match matching with
276276+ | None ->
277277+ Fmt.epr "No document found with path: %s@." search_path;
278278+ exit 1
279279+ | Some (rkey, doc) -> (rkey, doc))
280280+ in
281281+ let site =
282282+ match site with
283283+ | Some s -> resolve_site_uri ~did s
284284+ | None -> existing.site
285285+ in
286286+ let tags =
287287+ Option.map (String.split_on_char ',') tags
288288+ |> Option.map (List.map String.trim)
289289+ in
290290+ let bsky_post_ref =
291291+ match bsky_post with
292292+ | Some url -> Some (Standard_site.Api.resolve_bsky_post api url)
293293+ | None -> existing.bsky_post_ref
294294+ in
295295+ let updated_at = now_rfc3339 () in
296296+ (* When looking up by path, preserve existing path; otherwise use new path if provided *)
297297+ let new_path =
298298+ if looked_up_by_path then existing.path
299299+ else if Option.is_some path then path
300300+ else existing.path
301301+ in
302302+ Standard_site.Api.update_document api ~rkey ~site
303303+ ~title:(Option.value ~default:existing.title title)
304304+ ~published_at:(normalize_datetime existing.published_at)
305305+ ?path:new_path
306306+ ?description:
307307+ (if Option.is_some description then description else existing.description)
308308+ ?text_content:
309309+ (if Option.is_some content then content else existing.text_content)
310310+ ?tags:(if Option.is_some tags then tags else existing.tags)
311311+ ?bsky_post_ref ~updated_at ();
312312+ Fmt.pr "Updated document: %s@." rkey
271313272314let update_title_arg =
273315 let doc = "New document title." in
···278320 Arg.(value & opt (some string) None & info [ "site"; "s" ] ~docv:"SITE" ~doc)
279321280322let update_cmd =
281281- let doc = "Update an existing document." in
323323+ let doc = "Update an existing document. Specify RKEY as a positional argument, or use --path to find the document by its URL path." in
282324 let info = Cmd.info "update" ~doc in
283325 let update' rkey title site path description content tags bsky_post =
284326 Eio_main.run @@ fun env ->
···287329 in
288330 Cmd.v info
289331 Term.(
290290- const update' $ rkey_arg $ update_title_arg $ update_site_arg $ path_arg
291291- $ description_arg $ content_arg $ tags_arg $ bsky_post_arg)
332332+ const update' $ update_rkey_arg $ update_title_arg $ update_site_arg
333333+ $ update_path_arg $ description_arg $ content_arg $ tags_arg $ bsky_post_arg)
292334293335(* Delete command *)
294336
+42-1
bin/standard-site/cli/cmd_publication.ml
···102102 Cmd.v info
103103 Term.(const create' $ name_arg $ url_arg $ description_arg $ rkey_arg)
104104105105+(* Update command *)
106106+107107+let update_rkey_arg =
108108+ let doc = "Record key of publication to update." in
109109+ Arg.(required & pos 0 (some string) None & info [] ~docv:"RKEY" ~doc)
110110+111111+let update_name_arg =
112112+ let doc = "New publication name." in
113113+ Arg.(value & opt (some string) None & info [ "name"; "n" ] ~docv:"NAME" ~doc)
114114+115115+let update_url_arg =
116116+ let doc = "New publication base URL." in
117117+ Arg.(value & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc)
118118+119119+let update_action ~rkey ~name ~url ~description env =
120120+ with_api env @@ fun api ->
121121+ let did = Standard_site.Api.get_did api in
122122+ (* First get the existing publication to preserve unchanged fields *)
123123+ match Standard_site.Api.get_publication api ~did ~rkey with
124124+ | None ->
125125+ Fmt.epr "Publication not found: %s@." rkey;
126126+ exit 1
127127+ | Some existing ->
128128+ Standard_site.Api.update_publication api ~rkey
129129+ ~name:(Option.value ~default:existing.name name)
130130+ ~url:(Option.value ~default:existing.url url)
131131+ ?description:
132132+ (if Option.is_some description then description
133133+ else existing.description)
134134+ ();
135135+ Fmt.pr "Updated publication: %s@." rkey
136136+137137+let update_cmd =
138138+ let doc = "Update an existing publication." in
139139+ let info = Cmd.info "update" ~doc in
140140+ let update' rkey name url description =
141141+ Eio_main.run @@ fun env -> update_action ~rkey ~name ~url ~description env
142142+ in
143143+ Cmd.v info
144144+ Term.(const update' $ update_rkey_arg $ update_name_arg $ update_url_arg $ description_arg)
145145+105146(* Delete command *)
106147107148let delete_rkey_arg =
···185226let cmd =
186227 let doc = "Publication commands." in
187228 let info = Cmd.info "publication" ~doc in
188188- Cmd.group info [ list_cmd; create_cmd; delete_cmd; uri_cmd ]
229229+ Cmd.group info [ list_cmd; create_cmd; update_cmd; delete_cmd; uri_cmd ]
+31
bin/standard-site/lib/standard_site_api.ml
···154154 | rkey :: _ -> rkey
155155 | [] -> resp.uri
156156157157+let update_publication t ~rkey ~name ~url ?description () =
158158+ let client = Xrpc_auth.Client.get_client t in
159159+ let did = get_did t in
160160+ let record : Standard.Publication.main =
161161+ {
162162+ name;
163163+ url;
164164+ description;
165165+ icon = None;
166166+ basic_theme = None;
167167+ preferences = None;
168168+ }
169169+ in
170170+ let input : Atproto.Repo.PutRecord.input =
171171+ {
172172+ repo = did;
173173+ collection = "site.standard.publication";
174174+ rkey;
175175+ validate = Some false;
176176+ record = encode_to_json Standard.Publication.main_jsont record;
177177+ swap_record = None;
178178+ swap_commit = None;
179179+ }
180180+ in
181181+ let _ =
182182+ Xrpc.Client.procedure client ~nsid:"com.atproto.repo.putRecord" ~params:[]
183183+ ~input:(Some Atproto.Repo.PutRecord.input_jsont) ~input_data:(Some input)
184184+ ~decoder:Atproto.Repo.PutRecord.output_jsont
185185+ in
186186+ ()
187187+157188let delete_publication t ~rkey =
158189 let client = Xrpc_auth.Client.get_client t in
159190 let did = get_did t in
+11
bin/standard-site/lib/standard_site_api.mli
···8686(** [create_publication api ~name ~url ?description ?rkey ()] creates a new
8787 publication. Returns the rkey. *)
88888989+val update_publication :
9090+ t ->
9191+ rkey:string ->
9292+ name:string ->
9393+ url:string ->
9494+ ?description:string ->
9595+ unit ->
9696+ unit
9797+(** [update_publication api ~rkey ~name ~url ?description ()] updates an
9898+ existing publication. *)
9999+89100val delete_publication : t -> rkey:string -> unit
90101(** [delete_publication api ~rkey] deletes a publication. *)
91102