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