ATProto bits and pieces in OCaml with CLIs for Bluesky, Tangled, Standard.Site
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

add update support to cli

+163 -38
+79 -37
bin/standard-site/cli/cmd_document.ml
··· 11 11 (* Format datetime for AT Protocol (RFC 3339 with millisecond precision and UTC Z suffix) *) 12 12 let now_rfc3339 () = Ptime.to_rfc3339 ~tz_offset_s:0 ~frac_s:3 (Ptime_clock.now ()) 13 13 14 + (* Normalize a datetime string to AT Protocol format *) 15 + let normalize_datetime s = 16 + match Ptime.of_rfc3339 s with 17 + | Ok (t, _, _) -> Ptime.to_rfc3339 ~tz_offset_s:0 ~frac_s:3 t 18 + | Error _ -> s (* If parsing fails, return original and let server validate *) 19 + 14 20 (* Helper to load session and create API *) 15 21 let with_api env f = 16 22 Eio.Switch.run @@ fun sw -> ··· 231 237 232 238 (* Update command *) 233 239 240 + let update_rkey_arg = 241 + let doc = "Record key of the document. If not provided, use --path to find the document." in 242 + Arg.(value & pos 0 (some string) None & info [] ~docv:"RKEY" ~doc) 243 + 244 + let update_path_arg = 245 + 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 246 + Arg.(value & opt (some string) None & info [ "path"; "p" ] ~docv:"PATH" ~doc) 247 + 234 248 let update_action ~rkey ~title ~site ~path ~description ~content ~tags ~bsky_post env = 235 249 with_api env @@ fun api -> 236 250 let did = Standard_site.Api.get_did api in 237 - (* First get the existing document to preserve published_at *) 238 - match Standard_site.Api.get_document api ~did ~rkey with 239 - | None -> 240 - Fmt.epr "Document not found: %s@." rkey; 241 - exit 1 242 - | Some existing -> 243 - let site = 244 - match site with 245 - | Some s -> resolve_site_uri ~did s 246 - | None -> existing.site 247 - in 248 - let tags = 249 - Option.map (String.split_on_char ',') tags 250 - |> Option.map (List.map String.trim) 251 - in 252 - let bsky_post_ref = 253 - match bsky_post with 254 - | Some url -> Some (Standard_site.Api.resolve_bsky_post api url) 255 - | None -> existing.bsky_post_ref 256 - in 257 - let updated_at = now_rfc3339 () in 258 - Standard_site.Api.update_document api ~rkey ~site 259 - ~title:(Option.value ~default:existing.title title) 260 - ~published_at:existing.published_at 261 - ?path:(if Option.is_some path then path else existing.path) 262 - ?description: 263 - (if Option.is_some description then description 264 - else existing.description) 265 - ?text_content: 266 - (if Option.is_some content then content else existing.text_content) 267 - ?tags:(if Option.is_some tags then tags else existing.tags) 268 - ?bsky_post_ref 269 - ~updated_at (); 270 - Fmt.pr "Updated document: %s@." rkey 251 + (* Track whether we looked up by path (to preserve path vs update it) *) 252 + let looked_up_by_path = Option.is_none rkey in 253 + (* Find the document - either by rkey or by path *) 254 + let rkey, existing = 255 + match rkey with 256 + | Some rkey -> ( 257 + match Standard_site.Api.get_document api ~did ~rkey with 258 + | None -> 259 + Fmt.epr "Document not found: %s@." rkey; 260 + exit 1 261 + | Some doc -> (rkey, doc)) 262 + | None -> ( 263 + (* Look up by path *) 264 + match path with 265 + | None -> 266 + Fmt.epr "Error: Either RKEY or --path must be provided.@."; 267 + exit 1 268 + | Some search_path -> 269 + let docs = Standard_site.Api.list_documents api ~did () in 270 + let matching = 271 + List.find_opt 272 + (fun (_, (d : Document.main)) -> d.path = Some search_path) 273 + docs 274 + in 275 + match matching with 276 + | None -> 277 + Fmt.epr "No document found with path: %s@." search_path; 278 + exit 1 279 + | Some (rkey, doc) -> (rkey, doc)) 280 + in 281 + let site = 282 + match site with 283 + | Some s -> resolve_site_uri ~did s 284 + | None -> existing.site 285 + in 286 + let tags = 287 + Option.map (String.split_on_char ',') tags 288 + |> Option.map (List.map String.trim) 289 + in 290 + let bsky_post_ref = 291 + match bsky_post with 292 + | Some url -> Some (Standard_site.Api.resolve_bsky_post api url) 293 + | None -> existing.bsky_post_ref 294 + in 295 + let updated_at = now_rfc3339 () in 296 + (* When looking up by path, preserve existing path; otherwise use new path if provided *) 297 + let new_path = 298 + if looked_up_by_path then existing.path 299 + else if Option.is_some path then path 300 + else existing.path 301 + in 302 + Standard_site.Api.update_document api ~rkey ~site 303 + ~title:(Option.value ~default:existing.title title) 304 + ~published_at:(normalize_datetime existing.published_at) 305 + ?path:new_path 306 + ?description: 307 + (if Option.is_some description then description else existing.description) 308 + ?text_content: 309 + (if Option.is_some content then content else existing.text_content) 310 + ?tags:(if Option.is_some tags then tags else existing.tags) 311 + ?bsky_post_ref ~updated_at (); 312 + Fmt.pr "Updated document: %s@." rkey 271 313 272 314 let update_title_arg = 273 315 let doc = "New document title." in ··· 278 320 Arg.(value & opt (some string) None & info [ "site"; "s" ] ~docv:"SITE" ~doc) 279 321 280 322 let update_cmd = 281 - let doc = "Update an existing document." in 323 + let doc = "Update an existing document. Specify RKEY as a positional argument, or use --path to find the document by its URL path." in 282 324 let info = Cmd.info "update" ~doc in 283 325 let update' rkey title site path description content tags bsky_post = 284 326 Eio_main.run @@ fun env -> ··· 287 329 in 288 330 Cmd.v info 289 331 Term.( 290 - const update' $ rkey_arg $ update_title_arg $ update_site_arg $ path_arg 291 - $ description_arg $ content_arg $ tags_arg $ bsky_post_arg) 332 + const update' $ update_rkey_arg $ update_title_arg $ update_site_arg 333 + $ update_path_arg $ description_arg $ content_arg $ tags_arg $ bsky_post_arg) 292 334 293 335 (* Delete command *) 294 336
+42 -1
bin/standard-site/cli/cmd_publication.ml
··· 102 102 Cmd.v info 103 103 Term.(const create' $ name_arg $ url_arg $ description_arg $ rkey_arg) 104 104 105 + (* Update command *) 106 + 107 + let update_rkey_arg = 108 + let doc = "Record key of publication to update." in 109 + Arg.(required & pos 0 (some string) None & info [] ~docv:"RKEY" ~doc) 110 + 111 + let update_name_arg = 112 + let doc = "New publication name." in 113 + Arg.(value & opt (some string) None & info [ "name"; "n" ] ~docv:"NAME" ~doc) 114 + 115 + let update_url_arg = 116 + let doc = "New publication base URL." in 117 + Arg.(value & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc) 118 + 119 + let update_action ~rkey ~name ~url ~description env = 120 + with_api env @@ fun api -> 121 + let did = Standard_site.Api.get_did api in 122 + (* First get the existing publication to preserve unchanged fields *) 123 + match Standard_site.Api.get_publication api ~did ~rkey with 124 + | None -> 125 + Fmt.epr "Publication not found: %s@." rkey; 126 + exit 1 127 + | Some existing -> 128 + Standard_site.Api.update_publication api ~rkey 129 + ~name:(Option.value ~default:existing.name name) 130 + ~url:(Option.value ~default:existing.url url) 131 + ?description: 132 + (if Option.is_some description then description 133 + else existing.description) 134 + (); 135 + Fmt.pr "Updated publication: %s@." rkey 136 + 137 + let update_cmd = 138 + let doc = "Update an existing publication." in 139 + let info = Cmd.info "update" ~doc in 140 + let update' rkey name url description = 141 + Eio_main.run @@ fun env -> update_action ~rkey ~name ~url ~description env 142 + in 143 + Cmd.v info 144 + Term.(const update' $ update_rkey_arg $ update_name_arg $ update_url_arg $ description_arg) 145 + 105 146 (* Delete command *) 106 147 107 148 let delete_rkey_arg = ··· 185 226 let cmd = 186 227 let doc = "Publication commands." in 187 228 let info = Cmd.info "publication" ~doc in 188 - Cmd.group info [ list_cmd; create_cmd; delete_cmd; uri_cmd ] 229 + Cmd.group info [ list_cmd; create_cmd; update_cmd; delete_cmd; uri_cmd ]
+31
bin/standard-site/lib/standard_site_api.ml
··· 154 154 | rkey :: _ -> rkey 155 155 | [] -> resp.uri 156 156 157 + let update_publication t ~rkey ~name ~url ?description () = 158 + let client = Xrpc_auth.Client.get_client t in 159 + let did = get_did t in 160 + let record : Standard.Publication.main = 161 + { 162 + name; 163 + url; 164 + description; 165 + icon = None; 166 + basic_theme = None; 167 + preferences = None; 168 + } 169 + in 170 + let input : Atproto.Repo.PutRecord.input = 171 + { 172 + repo = did; 173 + collection = "site.standard.publication"; 174 + rkey; 175 + validate = Some false; 176 + record = encode_to_json Standard.Publication.main_jsont record; 177 + swap_record = None; 178 + swap_commit = None; 179 + } 180 + in 181 + let _ = 182 + Xrpc.Client.procedure client ~nsid:"com.atproto.repo.putRecord" ~params:[] 183 + ~input:(Some Atproto.Repo.PutRecord.input_jsont) ~input_data:(Some input) 184 + ~decoder:Atproto.Repo.PutRecord.output_jsont 185 + in 186 + () 187 + 157 188 let delete_publication t ~rkey = 158 189 let client = Xrpc_auth.Client.get_client t in 159 190 let did = get_did t in
+11
bin/standard-site/lib/standard_site_api.mli
··· 86 86 (** [create_publication api ~name ~url ?description ?rkey ()] creates a new 87 87 publication. Returns the rkey. *) 88 88 89 + val update_publication : 90 + t -> 91 + rkey:string -> 92 + name:string -> 93 + url:string -> 94 + ?description:string -> 95 + unit -> 96 + unit 97 + (** [update_publication api ~rkey ~name ~url ?description ()] updates an 98 + existing publication. *) 99 + 89 100 val delete_publication : t -> rkey:string -> unit 90 101 (** [delete_publication api ~rkey] deletes a publication. *) 91 102