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;
}