1use kb::parser::parse_document;
27
28use crate::comments_meta::{self, CommentState};
29use crate::custom_id::ensure_section_ids;
30use crate::envelope;
31use crate::error::{Error, Result};
32use crate::google::client::GoogleClient;
33use crate::google::docs::{self, DocumentRef};
34use crate::orgfile;
35use crate::project;
36use crate::sexp::Sexp;
37use crate::syncstate::{PostedReply, SyncState};
38
39const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";
41
42#[derive(Debug, Clone)]
45pub struct PushOutcome {
46 pub document: DocumentRef,
48 pub created: bool,
50 pub element_count: usize,
52 pub resolved: Vec<String>,
54 pub replied: usize,
56 pub body_updated: bool,
60 pub new_content: String,
62}
63
64pub async fn push(
74 client: &GoogleClient,
75 content: &str,
76 default_title: &str,
77 now: &str,
78) -> Result<PushOutcome> {
79 let (body, machine) = orgfile::split(content);
80 let machine = machine.unwrap_or("");
81
82 let body_with_ids = ensure_section_ids(body);
84
85 let document =
87 parse_document(&body_with_ids).map_err(|err| Error::OrgParse(err.to_string()))?;
88 let projection = project::project(&document);
89 let element_count = projection.positions.len();
90 let fingerprint = projection.fingerprint();
91 let existing = SyncState::parse_block(machine)?;
92
93 let (document_ref, created) = if let Some(id) = orgfile::read_keyword(content, "GDOC_ID") {
95 (DocumentRef::from_id(id), false)
96 } else {
97 let title =
98 orgfile::read_keyword(content, "TITLE").unwrap_or_else(|| default_title.to_owned());
99 (client.create_document(&title).await?, true)
100 };
101
102 let body_updated = created || existing.projection_hash.as_deref() != Some(fingerprint.as_str());
109 if body_updated {
110 let batch =
111 full_replace_batch(client, &document_ref.id, created, projection.requests).await?;
112 client.batch_update(&document_ref.id, batch).await?;
113 }
114
115 let resolved = resolve_done(client, &document_ref.id, machine).await;
117
118 let (mut state, replied) = post_replies(client, &document_ref.id, machine, existing).await;
121
122 state.positions = projection.positions;
126 state.projection_hash = Some(fingerprint);
127 let new_content = write_back(&body_with_ids, machine, &document_ref, &state, now);
128
129 Ok(PushOutcome {
130 document: document_ref,
131 created,
132 element_count,
133 resolved,
134 replied,
135 body_updated,
136 new_content,
137 })
138}
139
140async fn post_replies(
145 client: &GoogleClient,
146 document_id: &str,
147 machine: &str,
148 mut state: SyncState,
149) -> (SyncState, usize) {
150 let mut replied = 0;
151 for reply in comments_meta::pending_replies(machine) {
152 if state.reply_posted(&reply.comment_id, &reply.content) {
153 continue;
154 }
155 if client
156 .create_reply(document_id, &reply.comment_id, &reply.content)
157 .await
158 .is_ok()
159 {
160 state.posted_replies.push(PostedReply {
161 comment_id: reply.comment_id,
162 content: reply.content,
163 });
164 replied += 1;
165 }
166 }
167 (state, replied)
168}
169
170async fn full_replace_batch(
173 client: &GoogleClient,
174 document_id: &str,
175 created: bool,
176 requests: Vec<google_docs1::api::Request>,
177) -> Result<Vec<google_docs1::api::Request>> {
178 let mut batch = Vec::with_capacity(requests.len() + 1);
179 if !created {
180 let end_index = client.document_end_index(document_id).await?;
181 if let Some(delete) = docs::delete_body_request(end_index) {
182 batch.push(delete);
183 }
184 }
185 batch.extend(requests);
186 Ok(batch)
187}
188
189async fn resolve_done(client: &GoogleClient, document_id: &str, machine: &str) -> Vec<String> {
192 let done: Vec<String> = comments_meta::parse_entries(machine)
193 .into_iter()
194 .filter(|entry| entry.state == CommentState::Done)
195 .map(|entry| entry.id)
196 .collect();
197 client
198 .resolve_comments(document_id, &done)
199 .await
200 .into_iter()
201 .filter_map(|(id, outcome)| outcome.is_ok().then_some(id))
202 .collect()
203}
204
205fn write_back(
209 body_with_ids: &str,
210 machine: &str,
211 document: &DocumentRef,
212 sync: &SyncState,
213 now: &str,
214) -> String {
215 let active = comments_meta::render_section(machine, &[]);
217 let region = format!("{METADATA_HEADING}\n{}{active}", sync.render_block());
218
219 let with_keywords = write_keywords(body_with_ids, document, now);
220 orgfile::replace_metadata(&with_keywords, ®ion)
221}
222
223fn write_keywords(body: &str, document: &DocumentRef, now: &str) -> String {
225 let with_id = orgfile::upsert_keyword(body, "GDOC_ID", &document.id);
226 let with_url = orgfile::upsert_keyword(&with_id, "GDOC_URL", &document.url);
227 orgfile::upsert_keyword(&with_url, "GDOC_LAST_PUSH", now)
228}
229
230#[must_use]
232pub fn envelope(outcome: &PushOutcome) -> Sexp {
233 let resolved = outcome
234 .resolved
235 .iter()
236 .map(|id| Sexp::string(id.clone()))
237 .collect();
238 envelope::ok(
239 "push",
240 vec![
241 ("document-id", Sexp::string(outcome.document.id.clone())),
242 ("document-url", Sexp::string(outcome.document.url.clone())),
243 ("created", Sexp::symbol(envelope::flag(outcome.created))),
244 (
245 "elements",
246 Sexp::int(i64::try_from(outcome.element_count).unwrap_or(i64::MAX)),
247 ),
248 ("resolved", Sexp::list(resolved)),
249 (
250 "replied",
251 Sexp::int(i64::try_from(outcome.replied).unwrap_or(i64::MAX)),
252 ),
253 (
254 "body-updated",
255 Sexp::symbol(envelope::flag(outcome.body_updated)),
256 ),
257 ],
258 )
259}
260
261#[cfg(test)]
262mod tests {
263 use super::push;
264 use crate::custom_id::ensure_section_ids;
265 use crate::google::client::GoogleClient;
266 use crate::orgfile;
267 use mockito::{Matcher, Server};
268
269 const NOW: &str = "2026-06-10T00:00:00+00:00";
270
271 fn test_client(server: &Server) -> GoogleClient {
272 let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
273 client.set_base_url(&server.url());
274 client
275 }
276
277 async fn mock_create(server: &mut Server, id: &str) -> mockito::Mock {
278 server
279 .mock("POST", "/v1/documents")
280 .match_query(Matcher::Any)
281 .with_status(200)
282 .with_header("content-type", "application/json")
283 .with_body(format!(r#"{{"documentId":"{id}"}}"#))
284 .create_async()
285 .await
286 }
287
288 async fn mock_batch(server: &mut Server, id: &str) -> mockito::Mock {
289 server
290 .mock("POST", format!("/v1/documents/{id}:batchUpdate").as_str())
291 .match_query(Matcher::Any)
292 .with_status(200)
293 .with_header("content-type", "application/json")
294 .with_body(format!(r#"{{"documentId":"{id}"}}"#))
295 .create_async()
296 .await
297 }
298
299 #[tokio::test]
300 async fn push_creates_doc_and_rebuilds_machine_region() {
301 let mut server = Server::new_async().await;
302 let create = mock_create(&mut server, "DOC1").await;
303 let batch = mock_batch(&mut server, "DOC1").await;
304
305 let content = "#+TITLE: Spec\n* Intro\nHello world.\n";
306 let outcome = push(&test_client(&server), content, "fallback", NOW)
307 .await
308 .expect("push succeeds");
309
310 assert!(outcome.created);
311 assert_eq!(outcome.element_count, 2); let written = &outcome.new_content;
313 assert!(written.contains("Hello world.\n"));
314 assert!(written.contains(":CUSTOM_ID: sec-intro\n"));
315 assert!(written.contains("#+GDOC_ID: DOC1\n"));
316 assert!(written.contains("#+GDOC_URL: https://docs.google.com/document/d/DOC1/edit\n"));
317 assert!(written.contains(&format!("#+GDOC_LAST_PUSH: {NOW}\n")));
318 assert!(written.contains("* GDOC_METADATA :noexport:\n** Sync State\n"));
319 assert!(written.contains("(pos \"sec-intro\" 1 heading)"));
320 assert!(written.contains("** Active Comments\n"));
321
322 create.assert_async().await;
323 batch.assert_async().await;
324 }
325
326 #[tokio::test]
327 async fn push_preserves_body_prose_byte_for_byte() {
328 let mut server = Server::new_async().await;
329 let _create = mock_create(&mut server, "DOC1").await;
330 let _batch = mock_batch(&mut server, "DOC1").await;
331
332 let content = "* Intro\nHello world.\n\n* Details\nMore text.\n";
333 let outcome = push(&test_client(&server), content, "fallback", NOW)
334 .await
335 .expect("push succeeds");
336
337 let expected = {
340 let body = ensure_section_ids(content);
341 let body = orgfile::upsert_keyword(&body, "GDOC_ID", "DOC1");
342 let body = orgfile::upsert_keyword(
343 &body,
344 "GDOC_URL",
345 "https://docs.google.com/document/d/DOC1/edit",
346 );
347 orgfile::upsert_keyword(&body, "GDOC_LAST_PUSH", NOW)
348 };
349 let pushed_body = orgfile::body(&outcome.new_content);
350 assert_eq!(
351 pushed_body.trim_end_matches('\n'),
352 expected.trim_end_matches('\n')
353 );
354 }
355
356 #[tokio::test]
357 async fn push_existing_doc_full_replaces_without_create() {
358 let mut server = Server::new_async().await;
359 let get = server
361 .mock("GET", "/v1/documents/EXISTING")
362 .match_query(Matcher::Any)
363 .with_status(200)
364 .with_header("content-type", "application/json")
365 .with_body(r#"{"body":{"content":[{"endIndex":50}]}}"#)
366 .create_async()
367 .await;
368 let batch = server
370 .mock("POST", "/v1/documents/EXISTING:batchUpdate")
371 .match_query(Matcher::Any)
372 .match_body(Matcher::Regex("deleteContentRange".to_owned()))
373 .with_status(200)
374 .with_header("content-type", "application/json")
375 .with_body(r#"{"documentId":"EXISTING"}"#)
376 .create_async()
377 .await;
378
379 let content = "#+GDOC_ID: EXISTING\n#+TITLE: Spec\n* Intro\nHi.\n";
380 let outcome = push(&test_client(&server), content, "fallback", NOW)
381 .await
382 .expect("push succeeds");
383
384 assert!(!outcome.created);
385 get.assert_async().await;
387 batch.assert_async().await;
388 }
389
390 #[tokio::test]
391 async fn push_resolves_done_comments() {
392 let mut server = Server::new_async().await;
393 let _create = mock_create(&mut server, "DOC1").await;
394 let _batch = mock_batch(&mut server, "DOC1").await;
395 let resolve = server
396 .mock("PATCH", "/files/DOC1/comments/CDONE")
397 .match_query(Matcher::Any)
398 .match_body(Matcher::PartialJsonString(
399 r#"{"resolved":true}"#.to_owned(),
400 ))
401 .with_status(200)
402 .with_header("content-type", "application/json")
403 .with_body(r#"{"id":"CDONE","resolved":true}"#)
404 .create_async()
405 .await;
406
407 let content = "#+GDOC_ID: DOC1\n* Intro\nHi.\n\n\
408 * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
409 ** Active Comments\n*** DONE Alice: fixed\n:PROPERTIES:\n:COMMENT_ID: CDONE\n:END:\n";
410 let _get = server
412 .mock("GET", "/v1/documents/DOC1")
413 .match_query(Matcher::Any)
414 .with_status(200)
415 .with_header("content-type", "application/json")
416 .with_body(r#"{"body":{"content":[{"endIndex":10}]}}"#)
417 .create_async()
418 .await;
419
420 let outcome = push(&test_client(&server), content, "fallback", NOW)
421 .await
422 .expect("push succeeds");
423
424 assert_eq!(outcome.resolved, vec!["CDONE".to_owned()]);
425 assert!(outcome.new_content.contains(":COMMENT_ID: CDONE\n"));
428 resolve.assert_async().await;
429 }
430
431 #[tokio::test]
432 async fn unchanged_reprojection_skips_the_full_replace() {
433 let mut server = Server::new_async().await;
434 let get = server
436 .mock("GET", "/v1/documents/DOC1")
437 .match_query(Matcher::Any)
438 .with_status(200)
439 .with_header("content-type", "application/json")
440 .with_body(r#"{"body":{"content":[{"endIndex":20}]}}"#)
441 .expect(1)
442 .create_async()
443 .await;
444 let batch = server
445 .mock("POST", "/v1/documents/DOC1:batchUpdate")
446 .match_query(Matcher::Any)
447 .with_status(200)
448 .with_header("content-type", "application/json")
449 .with_body(r#"{"documentId":"DOC1"}"#)
450 .expect(1)
451 .create_async()
452 .await;
453
454 let content = "#+GDOC_ID: DOC1\n* Intro\nHello world.\n";
456 let first = push(&test_client(&server), content, "fallback", NOW)
457 .await
458 .expect("first push succeeds");
459 assert!(first.body_updated);
460 assert!(first.new_content.contains("(projection-hash "));
461
462 let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
465 .await
466 .expect("second push succeeds");
467 assert!(!second.body_updated);
468
469 get.assert_async().await; batch.assert_async().await;
471 }
472
473 #[tokio::test]
474 async fn push_posts_pending_replies_then_records_them_for_idempotence() {
475 let mut server = Server::new_async().await;
476 let _create = mock_create(&mut server, "DOC1").await;
477 let _batch = mock_batch(&mut server, "DOC1").await;
478 let _get = server
479 .mock("GET", "/v1/documents/DOC1")
480 .match_query(Matcher::Any)
481 .with_status(200)
482 .with_header("content-type", "application/json")
483 .with_body(r#"{"body":{"content":[{"endIndex":10}]}}"#)
484 .create_async()
485 .await;
486 let reply = server
488 .mock("POST", "/files/DOC1/comments/C1/replies")
489 .match_query(Matcher::Any)
490 .match_body(Matcher::PartialJsonString(
491 r#"{"content":"Clarified."}"#.to_owned(),
492 ))
493 .with_status(200)
494 .with_header("content-type", "application/json")
495 .with_body(r#"{"id":"R1"}"#)
496 .expect(1)
497 .create_async()
498 .await;
499
500 let content = "#+GDOC_ID: DOC1\n* Intro\nHi.\n\n\
501 * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n\
502 ** Active Comments\n*** TODO Alice: please clarify\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n**** REPLY\nClarified.\n";
503
504 let first = push(&test_client(&server), content, "fallback", NOW)
505 .await
506 .expect("first push succeeds");
507 assert_eq!(first.replied, 1);
508 assert!(
510 first
511 .new_content
512 .contains("(posted-replies (reply \"C1\" \"Clarified.\")")
513 );
514
515 let second = push(&test_client(&server), &first.new_content, "fallback", NOW)
517 .await
518 .expect("second push succeeds");
519 assert_eq!(second.replied, 0);
520 reply.assert_async().await;
521 }
522}