nhance/app/Libraries/GoogleSheetLib.php
2026-03-23 18:33:11 +05:30

518 lines
15 KiB
PHP

<?php
namespace App\Libraries;
use Google_Client;
use Google_Service_Drive;
use Google_Service_Sheets;
use Google_Service_Sheets_BatchUpdateSpreadsheetRequest;
use Google_Service_Sheets_ValueRange;
class GoogleSheetLib
{
protected Google_Client $client;
protected Google_Service_Sheets $sheets;
protected Google_Service_Drive $drive;
public function __construct()
{
$this->client = new Google_Client();
// Service account JSON
$this->client->setAuthConfig(
ROOTPATH . 'nhance-ee8d1-e3c5269b1ec7.json'
);
// IMPORTANT for service account
$this->client->useApplicationDefaultCredentials();
// Required scopes
$this->client->addScope([
Google_Service_Drive::DRIVE,
Google_Service_Sheets::SPREADSHEETS,
]);
// Init services
$this->sheets = new Google_Service_Sheets($this->client);
$this->drive = new Google_Service_Drive($this->client);
}
/* ===================== READ ===================== */
public function read(string $spreadsheetId, string $range = 'RFQ Page')
{
$response = $this->sheets
->spreadsheets_values
->get($spreadsheetId, $range);
return $response->getValues() ?? [];
}
/* ===================== WRITE ===================== */
public function write(string $spreadsheetId, array $values, string $range = 'Sheet1')
{
$body = new Google_Service_Sheets_ValueRange([
'values' => $values,
]);
$this->sheets
->spreadsheets_values
->update(
$spreadsheetId,
$range,
$body,
['valueInputOption' => 'RAW']
);
return true;
}
/* ===================== DOWNLOAD ===================== */
public function downloadExcel(string $spreadsheetId)
{
$response = $this->drive->files->export(
$spreadsheetId,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
['alt' => 'media']
);
return $response->getBody()->getContents();
}
/* ================= LIST FOLDER SHEETS ===================== */
/**
* Lists files inside a Google Drive folder and returns JSON with
* filename and sheet id (Drive file id / spreadsheet id).
*/
public function listFolderSheetIdsAsJson(string $folderId = ''): string
{
if ($folderId === '') {
$folderId = '19uySI-PSFQfFpZvMSnBtirXbwfCTXFBL';
}
$results = [];
$pageToken = null;
$folderIdEsc = str_replace("'", "\\'", $folderId);
$query = "'{$folderIdEsc}' in parents and trashed = false";
$listParams = [
'q' => $query,
'fields' => 'nextPageToken, files(id,name,mimeType)',
'pageSize' => 1000,
'supportsAllDrives' => true,
'includeItemsFromAllDrives' => true,
];
do {
if ($pageToken) {
$listParams['pageToken'] = $pageToken;
} else {
unset($listParams['pageToken']);
}
$response = $this->drive->files->listFiles($listParams);
foreach (($response->getFiles() ?? []) as $file) {
// "sheetId" here is the Drive file id (spreadsheetId for Google Sheets)
$results[] = [
'name' => (string) $file->getName(),
'sheetId' => (string) $file->getId(),
];
}
$pageToken = $response->getNextPageToken();
} while (! empty($pageToken));
return json_encode($results, JSON_UNESCAPED_SLASHES);
}
/* ================= COPY TEMPLATE ================= */
public function copyTemplate(string $templateId, string $name, string $folderId): string
{
$file = $this->drive->files->copy(
$templateId,
new \Google_Service_Drive_DriveFile([
'name' => $name,
'parents' => [$folderId],
]), [
'supportsAllDrives' => true,
'fields' => 'id, name, parents',
]
);
return $file->id;
}
/**
* Find/replace text across all sheets in the spreadsheet (batchUpdate).
*
* @param array<string, string> $replacements placeholder => replacement (empty string allowed)
*/
public function findAndReplaceAllSheets(string $spreadsheetId, array $replacements): void
{
if ($replacements === []) {
return;
}
$requests = [];
foreach ($replacements as $find => $replacement) {
if ($find === '') {
continue;
}
$requests[] = [
'findReplace' => [
'find' => (string) $find,
'replacement' => (string) $replacement,
'allSheets' => true,
'matchCase' => false,
'matchEntireCell' => false,
'includeFormulas' => true,
],
];
}
if ($requests === []) {
return;
}
$this->sheets->spreadsheets->batchUpdate(
$spreadsheetId,
new Google_Service_Sheets_BatchUpdateSpreadsheetRequest([
'requests' => $requests,
])
);
}
/**
* Quote a sheet title for A1 notation ranges.
*/
private function quoteSheetTitleForRange(string $title): string
{
return "'" . str_replace("'", "''", $title) . "'";
}
/**
* 1-based column index to Excel column letters (A, B, …, Z, AA, …).
*/
private function columnNumberToLetters(int $n): string
{
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = intdiv($n - 1, 26);
}
return $s;
}
/**
* Sheet title from an A1 range like 'RFQ Page'!A1:ZZ3000 or Sheet1!A1.
*/
private function parseSheetTitleFromBatchRange(string $rangeStr): string
{
$parts = explode('!', $rangeStr, 2);
$sheet = $parts[0] ?? '';
if ($sheet === '') {
return '';
}
if (str_starts_with($sheet, "'")) {
return str_replace("''", "'", trim($sheet, "'"));
}
return $sheet;
}
/**
* Locate a placeholder in any sheet and write a 2D table starting at that cell.
* First row of $tableValues is treated as headers.
*
* @param list<list<string|int|float>> $tableValues
*/
public function writeTableAtPlaceholder(string $spreadsheetId, string $placeholder, array $tableValues): bool
{
if ($placeholder === '' || $tableValues === []) {
return false;
}
$meta = $this->sheets->spreadsheets->get(
$spreadsheetId,
['fields' => 'sheets(properties(title))']
);
$titles = [];
foreach ($meta->getSheets() ?? [] as $sheet) {
$props = $sheet->getProperties();
if ($props !== null) {
$titles[] = (string) $props->getTitle();
}
}
if ($titles === []) {
return false;
}
$ranges = [];
// Use row-based range so API returns row 1 == index 0 (leading empty rows are not dropped).
foreach ($titles as $title) {
$ranges[] = $this->quoteSheetTitleForRange($title) . '!1:3000';
}
$batch = $this->sheets->spreadsheets_values->batchGet($spreadsheetId, [
'ranges' => $ranges,
'majorDimension' => 'ROWS',
]);
$valueRanges = $batch->getValueRanges() ?? [];
foreach ($valueRanges as $vr) {
$title = $this->parseSheetTitleFromBatchRange((string) ($vr->getRange() ?? ''));
if ($title === '') {
continue;
}
$values = $vr->getValues() ?? [];
foreach ($values as $r => $row) {
if (! is_array($row)) {
continue;
}
foreach ($row as $c => $cell) {
if (! is_string($cell) && ! is_numeric($cell)) {
continue;
}
$text = (string) $cell;
if (! str_contains($text, $placeholder)) {
continue;
}
$startCol = $this->columnNumberToLetters((int) $c + 1);
$startRow = (int) $r + 1;
$q = $this->quoteSheetTitleForRange($title);
$range = "{$q}!{$startCol}{$startRow}";
$body = new Google_Service_Sheets_ValueRange([
'values' => $tableValues,
]);
$this->sheets->spreadsheets_values->update(
$spreadsheetId,
$range,
$body,
['valueInputOption' => 'USER_ENTERED']
);
return true;
}
}
}
return false;
}
/* ================= PERMISSIONS ================= */
public function applyPermissions(string $fileId, array $permissions)
{
foreach ($permissions['editors'] as $email) {
$this->createPermission($fileId, $email, 'writer');
}
foreach ($permissions['viewers'] as $email) {
$this->createPermission($fileId, $email, 'reader');
}
}
private function createPermission(string $fileId, string $email, string $role)
{
$type = str_starts_with($email, 'group:') ? 'group' : 'user';
$email = str_replace('group:', '', $email);
$this->drive->permissions->create(
$fileId,
new \Google_Service_Drive_Permission([
'type' => $type,
'role' => $role,
'emailAddress' => $email,
]),
['sendNotificationEmail' => false, 'supportsAllDrives' => true]
);
}
/* ================= PROTECTIONS ================= */
public function applyProtectionsold(string $spreadsheetId, array $ranges)
{
$spreadsheet = $this->sheets->spreadsheets->get($spreadsheetId);
$sheetId = $spreadsheet->getSheets()[0]->getProperties()->getSheetId();
$requests = [];
foreach ($ranges as $range) {
[$sheetName, $a1] = explode('!', $range);
$requests[] = [
'addProtectedRange' => [
'protectedRange' => [
'range' => [
'sheetId' => $sheetId,
],
'warningOnly' => false,
],
],
];
}
$this->sheets->spreadsheets->batchUpdate(
$spreadsheetId,
new Google_Service_Sheets_BatchUpdateSpreadsheetRequest([
'requests' => $requests,
])
);
}
public function applyProtections(string $spreadsheetId, array $protections)
{
// Fetch spreadsheet metadata
$spreadsheet = $this->sheets->spreadsheets->get(
$spreadsheetId,
['fields' => 'sheets(properties(sheetId,title,gridProperties))']
);
// Map sheet names
$sheetMap = [];
foreach ($spreadsheet->getSheets() as $sheet) {
$props = $sheet->getProperties();
$sheetMap[$props->getTitle()] = [
'sheetId' => $props->getSheetId(),
'rowCount' => $props->getGridProperties()->getRowCount(),
'colCount' => $props->getGridProperties()->getColumnCount(),
];
}
$requests = [];
foreach ($protections as $protection) {
$rangeStr = $protection['range'];
if (! str_contains($rangeStr, '!')) {
throw new \Exception("Invalid range format: {$rangeStr}");
}
[$sheetName, $a1] = explode('!', $rangeStr, 2);
if (! isset($sheetMap[$sheetName])) {
throw new \Exception("Sheet not found: {$sheetName}");
}
$sheetMeta = $sheetMap[$sheetName];
$gridRange = $this->convertA1ToGridRange(
$a1,
$sheetMeta['sheetId'],
$sheetMeta['rowCount'],
$sheetMeta['colCount']
);
$protectedRange = [
'range' => $gridRange,
'description' => 'RFQ Protected Area',
'warningOnly' => false,
'editors' => [
'users' => $protection['users'] ?? [],
'groups' => $protection['groups'] ?? [],
],
];
$requests[] = [
'addProtectedRange' => [
'protectedRange' => $protectedRange,
],
];
}
if (! empty($requests)) {
$batch = new \Google_Service_Sheets_BatchUpdateSpreadsheetRequest([
'requests' => $requests,
]);
$this->sheets->spreadsheets->batchUpdate($spreadsheetId, $batch);
}
return true;
}
/* ================= FILE NAME ================= */
public function getFileName(string $fileId): string
{
$file = $this->drive->files->get($fileId, [
'supportsAllDrives' => true,
'fields' => 'name',
]);
return $file->getName() ?? '';
}
/* ================= URL ================= */
public function sheetUrl(string $sheetId): string
{
return "https://docs.google.com/spreadsheets/d/{$sheetId}/edit";
}
private function convertA1ToGridRange($a1, $sheetId, $maxRows, $maxCols)
{
if (preg_match('/^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$/i', $a1, $m)) {
$startCol = $this->colToIndex($m[1]);
$startRow = intval($m[2]) - 1;
if (! empty($m[3])) {
$endCol = $this->colToIndex($m[3]) + 1;
$endRow = intval($m[4]);
} else {
$endCol = $startCol + 1;
$endRow = $startRow + 1;
}
return [
'sheetId' => $sheetId,
'startRowIndex' => $startRow,
'endRowIndex' => $endRow,
'startColumnIndex' => $startCol,
'endColumnIndex' => $endCol,
];
}
throw new \Exception("Unsupported A1 format: {$a1}");
}
private function colToIndex($letters)
{
$letters = strtoupper($letters);
$index = 0;
for ($i = 0; $i < strlen($letters); $i++) {
$index = $index * 26 + (ord($letters[$i]) - 64);
}
return $index - 1;
}
}