create_gdoc.php 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. <?php
  2. declare(strict_types=1);
  3. header('Content-Type: application/json');
  4. ini_set('display_errors','0');
  5. error_reporting(E_ALL);
  6. ob_start(); // capture any stray output
  7. require __DIR__ . '/vendor/autoload.php';
  8. require_once __DIR__ . '/gapi_bootstrap.php';
  9. use Google\Service\Drive;
  10. use Google\Service\Drive\DriveFile;
  11. use Google\Service\Drive\Permission;
  12. use Google\Service\Docs;
  13. use Google\Service\Docs\BatchUpdateDocumentRequest;
  14. /* ---------- helpers ---------- */
  15. function md_to_html(string $md): string {
  16. if (class_exists('Parsedown')) {
  17. $p = new \Parsedown(); $p->setBreaksEnabled(true);
  18. return $p->text($md);
  19. }
  20. // tiny fallback (headings + lists + paragraphs)
  21. $h = htmlspecialchars($md, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8');
  22. $h = preg_replace('/^######\s*(.+)$/m','<h6>$1</h6>',$h);
  23. $h = preg_replace('/^#####\s*(.+)$/m','<h5>$1</h5>',$h);
  24. $h = preg_replace('/^####\s*(.+)$/m','<h4>$1</h4>',$h);
  25. $h = preg_replace('/^###\s*(.+)$/m','<h3>$1</h3>',$h);
  26. $h = preg_replace('/^##\s*(.+)$/m','<h2>$1</h2>',$h);
  27. $h = preg_replace('/^#\s*(.+)$/m','<h1>$1</h1>',$h);
  28. $h = preg_replace('/^\s*[-*]\s+(.+)$/m','<li>$1</li>',$h);
  29. if (preg_match_all('/(<li>.*<\/li>)/sU',$h,$m)) {
  30. foreach ($m[0] as $block) $h = str_replace($block,"<ul>$block</ul>",$h);
  31. }
  32. $h = preg_replace('/(?:\r?\n){2,}/', "</p>\n<p>", $h);
  33. return "<p>{$h}</p>";
  34. }
  35. function build_cover_and_body_html(array $ctx, string $bodyHtml): string {
  36. $address = htmlspecialchars($ctx['address'] ?? 'Planning Report', ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');
  37. $preparedFor = htmlspecialchars($ctx['prepared_for'] ?? '—', ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');
  38. $preparedBy = htmlspecialchars($ctx['prepared_by'] ?? 'Modulos Design', ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');
  39. $when = date('j F Y');
  40. $intent = empty($ctx['project_intent']) ? '' :
  41. '<p><strong>Project intent:</strong> '.htmlspecialchars($ctx['project_intent'], ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8').'</p>';
  42. return <<<HTML
  43. <html><head><meta charset="utf-8"></head>
  44. <body style="font-family: Arial, sans-serif;">
  45. <div style="page-break-after:always">
  46. <h1>Supporting Planning Report</h1>
  47. <div><strong>Address:</strong> {$address}</div>
  48. <div>Prepared for: <strong>{$preparedFor}</strong></div>
  49. <div>Prepared by: <strong>{$preparedBy}</strong></div>
  50. <div>Date: <strong>{$when}</strong></div>
  51. {$intent}
  52. </div>
  53. {$bodyHtml}
  54. </body></html>
  55. HTML;
  56. }
  57. function plain_from_html(string $html): string {
  58. $t = html_entity_decode(strip_tags($html), ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8');
  59. $t = preg_replace("/\r\n|\r/","\n",$t);
  60. $t = preg_replace("/\n{3,}/","\n\n",$t);
  61. return trim($t)."\n";
  62. }
  63. function json_fail(int $code, string $msg, array $extra = []): void {
  64. http_response_code($code);
  65. $noise = trim(ob_get_clean());
  66. $out = ['ok'=>false,'error'=>$msg] + $extra;
  67. if ($noise) $out['_noise'] = $noise;
  68. echo json_encode($out); exit;
  69. }
  70. /* ---------- main ---------- */
  71. try {
  72. $in = json_decode(file_get_contents('php://input'), true) ?: [];
  73. $markdown = (string)($in['markdown'] ?? '');
  74. $ctx = $in['context'] ?? [];
  75. $useTpl = !empty($in['use_template']);
  76. if ($markdown === '') json_fail(400, 'Missing markdown');
  77. // Auth (your bootstrap decides OAuth vs Service Account)
  78. [$client, $who] = google_client();
  79. $isSA = stripos($who, 'iam.gserviceaccount.com') !== false;
  80. $drive = new Drive($client);
  81. $docs = new Docs($client);
  82. $parentId = getenv('GDOC_PARENT_ID') ?: null;
  83. $parents = $parentId ? [$parentId] : [];
  84. $title = 'Supporting Planning Report';
  85. if (!empty($ctx['address'])) $title .= ' — '.$ctx['address'];
  86. $templateId = getenv('GDOC_TEMPLATE_ID') ?: null;
  87. // If using template, verify we can read it before doing anything else
  88. if ($useTpl) {
  89. if (!$templateId) {
  90. json_fail(400, 'Template requested but GDOC_TEMPLATE_ID is not set', [
  91. 'owner'=>$who,'hint'=>'Set GDOC_TEMPLATE_ID env var or uncheck “Use template”.'
  92. ]);
  93. }
  94. try {
  95. // quick permission probe
  96. $tplMeta = $drive->files->get($templateId, ['fields'=>'id,name,owners,driveId,parents']);
  97. } catch (\Google\Service\Exception $e) {
  98. json_fail(500, 'Cannot access template (check GDOC_TEMPLATE_ID and sharing)', [
  99. 'owner'=>$who,
  100. 'google_error'=>$e->getMessage(),
  101. 'template_id'=>$templateId,
  102. 'is_service_account'=>$isSA,
  103. ]);
  104. }
  105. }
  106. /* ---------- TEMPLATE FLOW ---------- */
  107. if ($useTpl && $templateId) {
  108. // Copy template to target folder
  109. $copy = $drive->files->copy(
  110. $templateId,
  111. new DriveFile(['name'=>$title, 'parents'=>$parents]),
  112. ['supportsAllDrives'=>true, 'fields'=>'id,webViewLink']
  113. );
  114. $docId = $copy->id;
  115. // Build content and replace placeholders; if {{BODY}} not found -> append
  116. $htmlBody = build_cover_and_body_html($ctx, md_to_html($markdown));
  117. $plain = plain_from_html($htmlBody);
  118. $resp = $docs->documents->batchUpdate($docId, new BatchUpdateDocumentRequest([
  119. 'requests' => [
  120. ['replaceAllText' => ['containsText'=>['text'=>'{{BODY}}','matchCase'=>true], 'replaceText'=>$plain]],
  121. ['replaceAllText' => ['containsText'=>['text'=>'{{ADDRESS}}','matchCase'=>true], 'replaceText'=>(string)($ctx['address'] ?? '')]],
  122. ['replaceAllText' => ['containsText'=>['text'=>'{{DATE}}','matchCase'=>true], 'replaceText'=>date('j F Y')]],
  123. ['replaceAllText' => ['containsText'=>['text'=>'{{PREPARED_FOR}}','matchCase'=>true], 'replaceText'=>(string)($ctx['prepared_for'] ?? '—')]],
  124. ['replaceAllText' => ['containsText'=>['text'=>'{{PREPARED_BY}}','matchCase'=>true], 'replaceText'=>(string)($ctx['prepared_by'] ?? 'Modulos Design')]],
  125. ['replaceAllText' => ['containsText'=>['text'=>'{{INTENT}}','matchCase'=>true], 'replaceText'=>(string)($ctx['project_intent'] ?? '')]],
  126. ]
  127. ]));
  128. // Did {{BODY}} exist?
  129. $occ = 0;
  130. $replies = $resp->getReplies();
  131. if ($replies && isset($replies[0]) && method_exists($replies[0],'getReplaceAllText') && $replies[0]->getReplaceAllText()) {
  132. $occ = (int)$replies[0]->getReplaceAllText()->getOccurrencesChanged();
  133. }
  134. if ($occ === 0) {
  135. $meta = $docs->documents->get($docId, ['fields'=>'body/content']);
  136. $content = $meta->getBody()->getContent();
  137. $endIndex = $content ? end($content)->getEndIndex() : 1;
  138. $docs->documents->batchUpdate($docId, new BatchUpdateDocumentRequest([
  139. 'requests' => [[ 'insertText' => ['location'=>['index'=>max(1, $endIndex-1)], 'text'=>$plain ] ]]
  140. ]));
  141. }
  142. // Optional: share to a user when SA is used (or always if you prefer)
  143. if ($email = getenv('GDOC_SHARE_EMAIL')) {
  144. try {
  145. $perm = new Permission(['type'=>'user','role'=>'writer','emailAddress'=>$email]);
  146. $drive->permissions->create($docId, $perm, [
  147. 'sendNotificationEmail'=>false, 'supportsAllDrives'=>true
  148. ]);
  149. } catch (\Google\Service\Exception $e) {
  150. // don’t fail the whole op for a sharing hiccup—just include detail
  151. $shareErr = $e->getMessage();
  152. }
  153. }
  154. $noise = trim(ob_get_clean());
  155. echo json_encode([
  156. 'ok'=>true, 'id'=>$docId, 'url'=>$copy->webViewLink,
  157. 'template'=>true, 'owner'=>$who, '_noise'=>$noise ?: null,
  158. 'share_error'=>$shareErr ?? null
  159. ]);
  160. exit;
  161. }
  162. /* ---------- BLANK (HTML IMPORT) FLOW ---------- */
  163. $fullHtml = build_cover_and_body_html($ctx, md_to_html($markdown));
  164. $fileMeta = new DriveFile([
  165. 'name'=>$title, 'mimeType'=>'application/vnd.google-apps.document', 'parents'=>$parents
  166. ]);
  167. try {
  168. // Attempt HTML import (best formatting)
  169. $created = $drive->files->create(
  170. $fileMeta,
  171. [
  172. 'data' => $fullHtml,
  173. 'mimeType' => 'text/html',
  174. 'uploadType' => 'multipart',
  175. 'supportsAllDrives' => true,
  176. 'fields' => 'id,webViewLink'
  177. ]
  178. );
  179. } catch (\Google\Service\Exception $importErr) {
  180. // Fallback: create blank doc then insert plain text via Docs API
  181. $created = $drive->files->create(
  182. new DriveFile(['name'=>$title,'mimeType'=>'application/vnd.google-apps.document','parents'=>$parents]),
  183. ['supportsAllDrives'=>true, 'fields'=>'id,webViewLink']
  184. );
  185. $plain = plain_from_html($fullHtml);
  186. $docs->documents->batchUpdate($created->id, new BatchUpdateDocumentRequest([
  187. 'requests' => [[ 'insertText' => ['location'=>['index'=>1], 'text'=>$plain ] ]]
  188. ]));
  189. $importErrorMessage = $importErr->getMessage();
  190. }
  191. if ($email = getenv('GDOC_SHARE_EMAIL')) {
  192. try {
  193. $perm = new Permission(['type'=>'user','role'=>'writer','emailAddress'=>$email]);
  194. $drive->permissions->create($created->id, $perm, [
  195. 'sendNotificationEmail'=>false, 'supportsAllDrives'=>true
  196. ]);
  197. } catch (\Google\Service\Exception $e) {
  198. $shareErr = $e->getMessage();
  199. }
  200. }
  201. $noise = trim(ob_get_clean());
  202. echo json_encode([
  203. 'ok'=>true, 'id'=>$created->id, 'url'=>$created->webViewLink,
  204. 'template'=>false, 'owner'=>$who, '_noise'=>$noise ?: null,
  205. 'import_fallback_used'=> isset($importErrorMessage),
  206. 'import_error'=> $importErrorMessage ?? null,
  207. 'share_error'=> $shareErr ?? null
  208. ]);
  209. exit;
  210. } catch (\Google\Service\Exception $e) {
  211. $noise = trim(ob_get_clean());
  212. $out = ['ok'=>false,'error'=>$e->getMessage(),'_noise'=>$noise ?: null];
  213. if (method_exists($e,'getErrors')) $out['errors'] = $e->getErrors();
  214. http_response_code(500); echo json_encode($out); exit;
  215. } catch (\Throwable $e) {
  216. $noise = trim(ob_get_clean());
  217. http_response_code(500);
  218. echo json_encode(['ok'=>false,'error'=>$e->getMessage(),'_noise'=>$noise ?: null]); exit;
  219. }