setBreaksEnabled(true); return $p->text($md); } // tiny fallback (headings + lists + paragraphs) $h = htmlspecialchars($md, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); $h = preg_replace('/^######\s*(.+)$/m','
$1
',$h); $h = preg_replace('/^#####\s*(.+)$/m','
$1
',$h); $h = preg_replace('/^####\s*(.+)$/m','

$1

',$h); $h = preg_replace('/^###\s*(.+)$/m','

$1

',$h); $h = preg_replace('/^##\s*(.+)$/m','

$1

',$h); $h = preg_replace('/^#\s*(.+)$/m','

$1

',$h); $h = preg_replace('/^\s*[-*]\s+(.+)$/m','
  • $1
  • ',$h); if (preg_match_all('/(
  • .*<\/li>)/sU',$h,$m)) { foreach ($m[0] as $block) $h = str_replace($block,"",$h); } $h = preg_replace('/(?:\r?\n){2,}/', "

    \n

    ", $h); return "

    {$h}

    "; } function build_cover_and_body_html(array $ctx, string $bodyHtml): string { $address = htmlspecialchars($ctx['address'] ?? 'Planning Report', ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $preparedFor = htmlspecialchars($ctx['prepared_for'] ?? '—', ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $preparedBy = htmlspecialchars($ctx['prepared_by'] ?? 'Modulos Design', ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $when = date('j F Y'); $intent = empty($ctx['project_intent']) ? '' : '

    Project intent: '.htmlspecialchars($ctx['project_intent'], ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8').'

    '; return <<

    Supporting Planning Report

    Address: {$address}
    Prepared for: {$preparedFor}
    Prepared by: {$preparedBy}
    Date: {$when}
    {$intent}
    {$bodyHtml} HTML; } function plain_from_html(string $html): string { $t = html_entity_decode(strip_tags($html), ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $t = preg_replace("/\r\n|\r/","\n",$t); $t = preg_replace("/\n{3,}/","\n\n",$t); return trim($t)."\n"; } function json_fail(int $code, string $msg, array $extra = []): void { http_response_code($code); $noise = trim(ob_get_clean()); $out = ['ok'=>false,'error'=>$msg] + $extra; if ($noise) $out['_noise'] = $noise; echo json_encode($out); exit; } /* ---------- main ---------- */ try { $in = json_decode(file_get_contents('php://input'), true) ?: []; $markdown = (string)($in['markdown'] ?? ''); $ctx = $in['context'] ?? []; $useTpl = !empty($in['use_template']); if ($markdown === '') json_fail(400, 'Missing markdown'); // Auth (your bootstrap decides OAuth vs Service Account) [$client, $who] = google_client(); $isSA = stripos($who, 'iam.gserviceaccount.com') !== false; $drive = new Drive($client); $docs = new Docs($client); $parentId = getenv('GDOC_PARENT_ID') ?: null; $parents = $parentId ? [$parentId] : []; $title = 'Supporting Planning Report'; if (!empty($ctx['address'])) $title .= ' — '.$ctx['address']; $templateId = getenv('GDOC_TEMPLATE_ID') ?: null; // If using template, verify we can read it before doing anything else if ($useTpl) { if (!$templateId) { json_fail(400, 'Template requested but GDOC_TEMPLATE_ID is not set', [ 'owner'=>$who,'hint'=>'Set GDOC_TEMPLATE_ID env var or uncheck “Use template”.' ]); } try { // quick permission probe $tplMeta = $drive->files->get($templateId, ['fields'=>'id,name,owners,driveId,parents']); } catch (\Google\Service\Exception $e) { json_fail(500, 'Cannot access template (check GDOC_TEMPLATE_ID and sharing)', [ 'owner'=>$who, 'google_error'=>$e->getMessage(), 'template_id'=>$templateId, 'is_service_account'=>$isSA, ]); } } /* ---------- TEMPLATE FLOW ---------- */ if ($useTpl && $templateId) { // Copy template to target folder $copy = $drive->files->copy( $templateId, new DriveFile(['name'=>$title, 'parents'=>$parents]), ['supportsAllDrives'=>true, 'fields'=>'id,webViewLink'] ); $docId = $copy->id; // Build content and replace placeholders; if {{BODY}} not found -> append $htmlBody = build_cover_and_body_html($ctx, md_to_html($markdown)); $plain = plain_from_html($htmlBody); $resp = $docs->documents->batchUpdate($docId, new BatchUpdateDocumentRequest([ 'requests' => [ ['replaceAllText' => ['containsText'=>['text'=>'{{BODY}}','matchCase'=>true], 'replaceText'=>$plain]], ['replaceAllText' => ['containsText'=>['text'=>'{{ADDRESS}}','matchCase'=>true], 'replaceText'=>(string)($ctx['address'] ?? '')]], ['replaceAllText' => ['containsText'=>['text'=>'{{DATE}}','matchCase'=>true], 'replaceText'=>date('j F Y')]], ['replaceAllText' => ['containsText'=>['text'=>'{{PREPARED_FOR}}','matchCase'=>true], 'replaceText'=>(string)($ctx['prepared_for'] ?? '—')]], ['replaceAllText' => ['containsText'=>['text'=>'{{PREPARED_BY}}','matchCase'=>true], 'replaceText'=>(string)($ctx['prepared_by'] ?? 'Modulos Design')]], ['replaceAllText' => ['containsText'=>['text'=>'{{INTENT}}','matchCase'=>true], 'replaceText'=>(string)($ctx['project_intent'] ?? '')]], ] ])); // Did {{BODY}} exist? $occ = 0; $replies = $resp->getReplies(); if ($replies && isset($replies[0]) && method_exists($replies[0],'getReplaceAllText') && $replies[0]->getReplaceAllText()) { $occ = (int)$replies[0]->getReplaceAllText()->getOccurrencesChanged(); } if ($occ === 0) { $meta = $docs->documents->get($docId, ['fields'=>'body/content']); $content = $meta->getBody()->getContent(); $endIndex = $content ? end($content)->getEndIndex() : 1; $docs->documents->batchUpdate($docId, new BatchUpdateDocumentRequest([ 'requests' => [[ 'insertText' => ['location'=>['index'=>max(1, $endIndex-1)], 'text'=>$plain ] ]] ])); } // Optional: share to a user when SA is used (or always if you prefer) if ($email = getenv('GDOC_SHARE_EMAIL')) { try { $perm = new Permission(['type'=>'user','role'=>'writer','emailAddress'=>$email]); $drive->permissions->create($docId, $perm, [ 'sendNotificationEmail'=>false, 'supportsAllDrives'=>true ]); } catch (\Google\Service\Exception $e) { // don’t fail the whole op for a sharing hiccup—just include detail $shareErr = $e->getMessage(); } } $noise = trim(ob_get_clean()); echo json_encode([ 'ok'=>true, 'id'=>$docId, 'url'=>$copy->webViewLink, 'template'=>true, 'owner'=>$who, '_noise'=>$noise ?: null, 'share_error'=>$shareErr ?? null ]); exit; } /* ---------- BLANK (HTML IMPORT) FLOW ---------- */ $fullHtml = build_cover_and_body_html($ctx, md_to_html($markdown)); $fileMeta = new DriveFile([ 'name'=>$title, 'mimeType'=>'application/vnd.google-apps.document', 'parents'=>$parents ]); try { // Attempt HTML import (best formatting) $created = $drive->files->create( $fileMeta, [ 'data' => $fullHtml, 'mimeType' => 'text/html', 'uploadType' => 'multipart', 'supportsAllDrives' => true, 'fields' => 'id,webViewLink' ] ); } catch (\Google\Service\Exception $importErr) { // Fallback: create blank doc then insert plain text via Docs API $created = $drive->files->create( new DriveFile(['name'=>$title,'mimeType'=>'application/vnd.google-apps.document','parents'=>$parents]), ['supportsAllDrives'=>true, 'fields'=>'id,webViewLink'] ); $plain = plain_from_html($fullHtml); $docs->documents->batchUpdate($created->id, new BatchUpdateDocumentRequest([ 'requests' => [[ 'insertText' => ['location'=>['index'=>1], 'text'=>$plain ] ]] ])); $importErrorMessage = $importErr->getMessage(); } if ($email = getenv('GDOC_SHARE_EMAIL')) { try { $perm = new Permission(['type'=>'user','role'=>'writer','emailAddress'=>$email]); $drive->permissions->create($created->id, $perm, [ 'sendNotificationEmail'=>false, 'supportsAllDrives'=>true ]); } catch (\Google\Service\Exception $e) { $shareErr = $e->getMessage(); } } $noise = trim(ob_get_clean()); echo json_encode([ 'ok'=>true, 'id'=>$created->id, 'url'=>$created->webViewLink, 'template'=>false, 'owner'=>$who, '_noise'=>$noise ?: null, 'import_fallback_used'=> isset($importErrorMessage), 'import_error'=> $importErrorMessage ?? null, 'share_error'=> $shareErr ?? null ]); exit; } catch (\Google\Service\Exception $e) { $noise = trim(ob_get_clean()); $out = ['ok'=>false,'error'=>$e->getMessage(),'_noise'=>$noise ?: null]; if (method_exists($e,'getErrors')) $out['errors'] = $e->getErrors(); http_response_code(500); echo json_encode($out); exit; } catch (\Throwable $e) { $noise = trim(ob_get_clean()); http_response_code(500); echo json_encode(['ok'=>false,'error'=>$e->getMessage(),'_noise'=>$noise ?: null]); exit; }