440 lines
11 KiB
PHP
440 lines
11 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Libraries;
|
||
|
|
|
||
|
|
use App\Models\FigmaCommentsModel;
|
||
|
|
use App\Models\FigmaFileVersionsModel;
|
||
|
|
use App\Models\FigmaFilesModel;
|
||
|
|
|
||
|
|
class FigmaSyncService
|
||
|
|
{
|
||
|
|
private string $baseUrl;
|
||
|
|
private string $token;
|
||
|
|
private string $fileKey;
|
||
|
|
|
||
|
|
private FigmaFilesModel $filesModel;
|
||
|
|
private FigmaFileVersionsModel $versionsModel;
|
||
|
|
private FigmaCommentsModel $commentsModel;
|
||
|
|
|
||
|
|
public function __construct()
|
||
|
|
{
|
||
|
|
$this->baseUrl = rtrim((string) (env('FIGMA_BASE_URL') ?: 'https://api.figma.com/v1'), '/');
|
||
|
|
$this->token = (string) env('FIGMA_TOKEN');
|
||
|
|
$this->fileKey = (string) env('FIGMA_FILE_KEY');
|
||
|
|
|
||
|
|
$this->filesModel = new FigmaFilesModel();
|
||
|
|
$this->versionsModel = new FigmaFileVersionsModel();
|
||
|
|
$this->commentsModel = new FigmaCommentsModel();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function syncAll(): array
|
||
|
|
{
|
||
|
|
return $this->performSync();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function syncIncremental(int $days = 1): array
|
||
|
|
{
|
||
|
|
if ($days < 1) {
|
||
|
|
$days = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
$sinceIso = (new \DateTimeImmutable('now'))
|
||
|
|
->modify('-' . $days . ' days')
|
||
|
|
->format(DATE_ATOM);
|
||
|
|
|
||
|
|
return $this->performSync($sinceIso, $days);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function performSync(?string $sinceIso = null, ?int $days = null): array
|
||
|
|
{
|
||
|
|
if ($this->token === '') {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => 'FIGMA_TOKEN missing in .env',
|
||
|
|
'stats' => [],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($this->fileKey === '') {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => 'FIGMA_FILE_KEY missing in .env',
|
||
|
|
'stats' => [],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$stats = [
|
||
|
|
'mode' => $sinceIso === null ? 'full' : 'incremental',
|
||
|
|
'since' => $sinceIso,
|
||
|
|
'files_synced' => 0,
|
||
|
|
'versions_synced' => 0,
|
||
|
|
'comments_synced' => 0,
|
||
|
|
'errors' => [],
|
||
|
|
];
|
||
|
|
|
||
|
|
$fileId = $this->upsertFile([
|
||
|
|
'key' => $this->fileKey,
|
||
|
|
'name' => env('FIGMA_FILE_NAME') ?: 'Figma File',
|
||
|
|
'version' => null,
|
||
|
|
'lastModified' => null,
|
||
|
|
'editorType' => null,
|
||
|
|
]);
|
||
|
|
|
||
|
|
if ($fileId === null) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => 'Failed upsert Figma file metadata',
|
||
|
|
'stats' => $stats,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$stats['files_synced'] = 1;
|
||
|
|
|
||
|
|
$versionsResult = $this->fetchAllCollection('/files/' . $this->fileKey . '/versions', ['versions', 'data', 'items']);
|
||
|
|
if ($versionsResult['success'] === false) {
|
||
|
|
$stats['errors'][] = $versionsResult['message'];
|
||
|
|
} else {
|
||
|
|
$versions = $this->sortByDateDesc($versionsResult['data'], ['created_at', 'createdAt', 'created_at_figma']);
|
||
|
|
foreach ($versions as $version) {
|
||
|
|
if ($sinceIso !== null && !$this->isRecent($version['created_at'] ?? $version['createdAt'] ?? null, $sinceIso)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($this->upsertVersion($fileId, $version)) {
|
||
|
|
$stats['versions_synced']++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!empty($versions)) {
|
||
|
|
$latestVersion = $versions[0];
|
||
|
|
$this->filesModel->update($fileId, [
|
||
|
|
'name' => (string) (env('FIGMA_FILE_NAME') ?: 'Figma File'),
|
||
|
|
'version' => $latestVersion['label'] ?? $latestVersion['version'] ?? $latestVersion['id'] ?? null,
|
||
|
|
'last_modified' => $this->normalizeDate($latestVersion['created_at'] ?? $latestVersion['createdAt'] ?? null),
|
||
|
|
'last_synced_at' => date('Y-m-d H:i:s'),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$commentsResult = $this->fetchAllCollection('/files/' . $this->fileKey . '/comments', ['comments', 'data', 'items']);
|
||
|
|
if ($commentsResult['success'] === false) {
|
||
|
|
$stats['errors'][] = $commentsResult['message'];
|
||
|
|
} else {
|
||
|
|
$comments = $this->sortByDateDesc($commentsResult['data'], ['created_at', 'createdAt']);
|
||
|
|
foreach ($comments as $comment) {
|
||
|
|
if ($sinceIso !== null && !$this->isRecent($comment['created_at'] ?? $comment['createdAt'] ?? null, $sinceIso)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($this->upsertComment($fileId, $comment)) {
|
||
|
|
$stats['comments_synced']++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->filesModel->update($fileId, [
|
||
|
|
'last_synced_at' => date('Y-m-d H:i:s'),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$message = $sinceIso === null
|
||
|
|
? 'Figma full sync completed'
|
||
|
|
: 'Figma incremental sync completed (last ' . ($days ?? 1) . ' day)';
|
||
|
|
|
||
|
|
return [
|
||
|
|
'success' => true,
|
||
|
|
'message' => $message,
|
||
|
|
'stats' => $stats,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function upsertFile(array $file): ?int
|
||
|
|
{
|
||
|
|
$fileKey = (string) ($file['key'] ?? $this->fileKey);
|
||
|
|
if ($fileKey === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'file_key' => $fileKey,
|
||
|
|
'name' => (string) ($file['name'] ?? 'Figma File'),
|
||
|
|
'version' => $file['version'] ?? null,
|
||
|
|
'last_modified' => $this->normalizeDate($file['lastModified'] ?? null),
|
||
|
|
'editor_type' => $this->normalizeEditorType($file['editorType'] ?? null),
|
||
|
|
'last_synced_at' => date('Y-m-d H:i:s'),
|
||
|
|
];
|
||
|
|
|
||
|
|
$existing = $this->filesModel->where('file_key', $fileKey)->first();
|
||
|
|
if ($existing) {
|
||
|
|
$this->filesModel->update((int) $existing['id'], $data);
|
||
|
|
return (int) $existing['id'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->filesModel->insert($data);
|
||
|
|
return (int) $this->filesModel->getInsertID();
|
||
|
|
}
|
||
|
|
|
||
|
|
private function upsertVersion(int $fileId, array $version): bool
|
||
|
|
{
|
||
|
|
$versionId = (string) ($version['id'] ?? $version['version'] ?? '');
|
||
|
|
if ($versionId === '') {
|
||
|
|
$versionId = sha1((string) json_encode($version));
|
||
|
|
}
|
||
|
|
|
||
|
|
$label = $version['label'] ?? $version['name'] ?? $version['version'] ?? $versionId;
|
||
|
|
$description = $version['description'] ?? $version['notes'] ?? $version['message'] ?? null;
|
||
|
|
$createdAt = $this->normalizeDate($version['created_at'] ?? $version['createdAt'] ?? null);
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'file_id' => $fileId,
|
||
|
|
'figma_version_id' => $versionId,
|
||
|
|
'version' => is_scalar($label) ? (string) $label : $versionId,
|
||
|
|
'label' => is_scalar($label) ? (string) $label : $versionId,
|
||
|
|
'description' => is_scalar($description) ? (string) $description : null,
|
||
|
|
'name' => (string) (env('FIGMA_FILE_NAME') ?: 'Figma File'),
|
||
|
|
'editor_type' => $this->normalizeEditorType($version['editorType'] ?? null),
|
||
|
|
'last_modified_figma' => $createdAt,
|
||
|
|
'created_at_figma' => $createdAt,
|
||
|
|
];
|
||
|
|
|
||
|
|
$existing = $this->versionsModel
|
||
|
|
->where('file_id', $fileId)
|
||
|
|
->where('figma_version_id', $versionId)
|
||
|
|
->first();
|
||
|
|
|
||
|
|
if ($existing) {
|
||
|
|
$this->versionsModel->update((int) $existing['id'], $data);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->versionsModel->insert($data);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function upsertComment(int $fileId, array $comment): bool
|
||
|
|
{
|
||
|
|
$commentId = (string) ($comment['id'] ?? '');
|
||
|
|
if ($commentId === '') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'file_id' => $fileId,
|
||
|
|
'figma_comment_id' => $commentId,
|
||
|
|
'user_name' => $comment['user']['handle'] ?? $comment['user']['name'] ?? $comment['user_name'] ?? null,
|
||
|
|
'message' => $comment['message'] ?? $comment['text'] ?? null,
|
||
|
|
'is_resolved' => !empty($comment['resolved_at']) ? 1 : (int) (!empty($comment['resolved']) ? 1 : 0),
|
||
|
|
'resolved_at' => $this->normalizeDate($comment['resolved_at'] ?? null),
|
||
|
|
'created_at_figma' => $this->normalizeDate($comment['created_at'] ?? $comment['createdAt'] ?? null),
|
||
|
|
'client_meta_json' => isset($comment['client_meta']) ? json_encode($comment['client_meta']) : (isset($comment['client_meta_json']) ? (string) $comment['client_meta_json'] : null),
|
||
|
|
];
|
||
|
|
|
||
|
|
$existing = $this->commentsModel->where('figma_comment_id', $commentId)->first();
|
||
|
|
if ($existing) {
|
||
|
|
$this->commentsModel->update((int) $existing['id'], $data);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->commentsModel->insert($data);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function fetchAllCollection(string $endpoint, array $listKeys): array
|
||
|
|
{
|
||
|
|
$allItems = [];
|
||
|
|
$seenSignatures = [];
|
||
|
|
$page = 1;
|
||
|
|
$limit = 100;
|
||
|
|
$maxPages = 100;
|
||
|
|
|
||
|
|
while ($page <= $maxPages) {
|
||
|
|
$response = $this->request('GET', $endpoint, [
|
||
|
|
'page' => $page,
|
||
|
|
'limit' => $limit,
|
||
|
|
'per_page' => $limit,
|
||
|
|
]);
|
||
|
|
|
||
|
|
if ($response['success'] === false) {
|
||
|
|
return $response;
|
||
|
|
}
|
||
|
|
|
||
|
|
$items = $this->extractList($response['data'], $listKeys);
|
||
|
|
if (empty($items)) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
$newItems = [];
|
||
|
|
foreach ($items as $item) {
|
||
|
|
$signature = $this->itemSignature($item);
|
||
|
|
if ($signature !== '' && isset($seenSignatures[$signature])) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($signature !== '') {
|
||
|
|
$seenSignatures[$signature] = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
$newItems[] = $item;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (empty($newItems)) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
$allItems = array_merge($allItems, $newItems);
|
||
|
|
|
||
|
|
if (count($items) < $limit) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
$page++;
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'success' => true,
|
||
|
|
'message' => 'ok',
|
||
|
|
'data' => $allItems,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function extractList($data, array $listKeys): array
|
||
|
|
{
|
||
|
|
if (is_array($data) && array_is_list($data)) {
|
||
|
|
return $data;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!is_array($data)) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach ($listKeys as $key) {
|
||
|
|
if (isset($data[$key]) && is_array($data[$key])) {
|
||
|
|
return $data[$key];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function sortByDateDesc(array $items, array $dateKeys): array
|
||
|
|
{
|
||
|
|
usort($items, function (array $left, array $right) use ($dateKeys): int {
|
||
|
|
$leftDate = $this->dateValue($left, $dateKeys);
|
||
|
|
$rightDate = $this->dateValue($right, $dateKeys);
|
||
|
|
|
||
|
|
return $rightDate <=> $leftDate;
|
||
|
|
});
|
||
|
|
|
||
|
|
return $items;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function dateValue(array $row, array $dateKeys): int
|
||
|
|
{
|
||
|
|
foreach ($dateKeys as $key) {
|
||
|
|
if (!empty($row[$key])) {
|
||
|
|
try {
|
||
|
|
return (new \DateTimeImmutable((string) $row[$key]))->getTimestamp();
|
||
|
|
} catch (\Throwable $e) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function itemSignature(array $item): string
|
||
|
|
{
|
||
|
|
foreach (['id', 'version', 'created_at', 'createdAt', 'created_at_figma', 'figma_version_id', 'figma_comment_id'] as $key) {
|
||
|
|
if (!empty($item[$key])) {
|
||
|
|
return (string) $item[$key];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return sha1((string) json_encode($item));
|
||
|
|
}
|
||
|
|
|
||
|
|
private function isRecent(?string $value, string $sinceIso): bool
|
||
|
|
{
|
||
|
|
if ($value === null || $value === '') {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
return new \DateTimeImmutable($value) >= new \DateTimeImmutable($sinceIso);
|
||
|
|
} catch (\Throwable $e) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private function request(string $method, string $endpoint, array $query = []): array
|
||
|
|
{
|
||
|
|
$url = $this->baseUrl . $endpoint;
|
||
|
|
if (!empty($query)) {
|
||
|
|
$url .= '?' . http_build_query($query);
|
||
|
|
}
|
||
|
|
|
||
|
|
$client = \Config\Services::curlrequest();
|
||
|
|
|
||
|
|
try {
|
||
|
|
$response = $client->request($method, $url, [
|
||
|
|
'headers' => [
|
||
|
|
'X-Figma-Token' => $this->token,
|
||
|
|
'Accept' => 'application/json',
|
||
|
|
],
|
||
|
|
'http_errors' => false,
|
||
|
|
'timeout' => 120,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$statusCode = $response->getStatusCode();
|
||
|
|
$body = json_decode($response->getBody(), true);
|
||
|
|
|
||
|
|
if ($statusCode < 200 || $statusCode >= 300) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => 'Figma request failed [' . $statusCode . '] ' . $endpoint,
|
||
|
|
'data' => $body,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'success' => true,
|
||
|
|
'message' => 'ok',
|
||
|
|
'data' => $body,
|
||
|
|
];
|
||
|
|
} catch (\Throwable $e) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => 'Figma request exception: ' . $e->getMessage(),
|
||
|
|
'data' => null,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private function normalizeDate(?string $value): ?string
|
||
|
|
{
|
||
|
|
if ($value === null || $value === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
return (new \DateTime($value))->format('Y-m-d H:i:s');
|
||
|
|
} catch (\Throwable $e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private function normalizeEditorType($value): ?string
|
||
|
|
{
|
||
|
|
if ($value === null) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_array($value)) {
|
||
|
|
return implode(',', array_map('strval', $value));
|
||
|
|
}
|
||
|
|
|
||
|
|
return (string) $value;
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|