From 329e4e672592b693910195ffd9b2d3169887a920 Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Mon, 27 Apr 2026 16:55:43 +0700 Subject: [PATCH] feat(figma): add local sync dashboard, API, and CLI tooling Add Figma persistence and sync flow for one file source. - create figma_files, figma_file_versions, and figma_comments tables with supporting migrations - add FigmaSyncService for full and incremental sync, API fetch, pagination, dedupe, and upserts - add CLI commands and shell wrappers for full and incremental sync runs - expose Figma dashboard plus API endpoints for summary, snapshots, comments, and admin sync trigger - wire route and sidebar entry for dashboard access - trim legacy file_url and thumbnail_url fields, add version label/description support --- app/Commands/SyncFigmaData.php | 42 ++ app/Commands/SyncFigmaIncremental.php | 49 ++ app/Config/Routes.php | 8 + app/Controllers/Api/FigmaApi.php | 189 ++++++++ app/Controllers/Figma.php | 14 + .../2026-04-27-000001_CreateFigmaTables.php | 176 +++++++ ...002_AddLabelDescriptionToFigmaVersions.php | 43 ++ ...moveFileUrlAndThumbnailFromFigmaTables.php | 68 +++ app/Libraries/FigmaSyncService.php | 439 ++++++++++++++++++ app/Models/FigmaCommentsModel.php | 28 ++ app/Models/FigmaFileVersionsModel.php | 29 ++ app/Models/FigmaFilesModel.php | 26 ++ app/Views/figma_dashboard.php | 378 +++++++++++++++ app/Views/layouts/_sidebar.php | 1 + scripts/figma-sync-full.sh | 7 + scripts/figma-sync-incremental.sh | 8 + 16 files changed, 1505 insertions(+) create mode 100644 app/Commands/SyncFigmaData.php create mode 100644 app/Commands/SyncFigmaIncremental.php create mode 100644 app/Controllers/Api/FigmaApi.php create mode 100644 app/Controllers/Figma.php create mode 100644 app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php create mode 100644 app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php create mode 100644 app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php create mode 100644 app/Libraries/FigmaSyncService.php create mode 100644 app/Models/FigmaCommentsModel.php create mode 100644 app/Models/FigmaFileVersionsModel.php create mode 100644 app/Models/FigmaFilesModel.php create mode 100644 app/Views/figma_dashboard.php create mode 100644 scripts/figma-sync-full.sh create mode 100644 scripts/figma-sync-incremental.sh diff --git a/app/Commands/SyncFigmaData.php b/app/Commands/SyncFigmaData.php new file mode 100644 index 0000000..70877ba --- /dev/null +++ b/app/Commands/SyncFigmaData.php @@ -0,0 +1,42 @@ +syncAll(); + + if (!($result['success'] ?? false)) { + CLI::error('Sync failed: ' . ($result['message'] ?? 'Unknown error')); + return; + } + + $stats = $result['stats'] ?? []; + CLI::write('Sync done.', 'green'); + CLI::write('Files: ' . ($stats['files_synced'] ?? 0)); + CLI::write('Snapshots: ' . ($stats['versions_synced'] ?? 0)); + CLI::write('Comments: ' . ($stats['comments_synced'] ?? 0)); + + $errors = $stats['errors'] ?? []; + if (!empty($errors)) { + CLI::newLine(); + CLI::write('Warnings / Errors:', 'yellow'); + foreach ($errors as $error) { + CLI::write('- ' . $error, 'light_red'); + } + } + } +} diff --git a/app/Commands/SyncFigmaIncremental.php b/app/Commands/SyncFigmaIncremental.php new file mode 100644 index 0000000..d4e7f5f --- /dev/null +++ b/app/Commands/SyncFigmaIncremental.php @@ -0,0 +1,49 @@ +syncIncremental($days); + + if (!($result['success'] ?? false)) { + CLI::error('Sync failed: ' . ($result['message'] ?? 'Unknown error')); + return; + } + + $stats = $result['stats'] ?? []; + CLI::write('Sync done.', 'green'); + CLI::write('Mode: ' . ($stats['mode'] ?? 'incremental')); + CLI::write('Since: ' . ($stats['since'] ?? '-')); + CLI::write('Files: ' . ($stats['files_synced'] ?? 0)); + CLI::write('Snapshots: ' . ($stats['versions_synced'] ?? 0)); + CLI::write('Comments: ' . ($stats['comments_synced'] ?? 0)); + + $errors = $stats['errors'] ?? []; + if (!empty($errors)) { + CLI::newLine(); + CLI::write('Warnings / Errors:', 'yellow'); + foreach ($errors as $error) { + CLI::write('- ' . $error, 'light_red'); + } + } + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 17bf968..b1ed20f 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -275,6 +275,14 @@ $routes->group('api/gitea', function($routes) { $routes->get('getcommits/(:segment)/(:segment)', 'Gitea::getCommits/$1/$2'); }); +$routes->get('/figma', 'Figma::index'); +$routes->group('api/figma', function($routes) { + $routes->get('summary', 'Api\FigmaApi::summary'); + $routes->get('snapshots', 'Api\FigmaApi::snapshots'); + $routes->get('comments', 'Api\FigmaApi::comments'); + $routes->post('sync', 'Api\FigmaApi::sync'); +}); + $routes->group('api/git', function($routes) { $routes->get('summary', 'Api\GitApi::summary'); $routes->get('users', 'Api\GitApi::users'); diff --git a/app/Controllers/Api/FigmaApi.php b/app/Controllers/Api/FigmaApi.php new file mode 100644 index 0000000..a00b556 --- /dev/null +++ b/app/Controllers/Api/FigmaApi.php @@ -0,0 +1,189 @@ +get('userid')) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Unauthorized', + ], 401); + } + + return null; + } + + private function ensureAdmin() + { + $level = (int) session()->get('level'); + if (!in_array($level, [0, 1, 2], true)) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Forbidden. Admin only.', + ], 403); + } + + return null; + } + + public function summary() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $db = \Config\Database::connect(); + $file = $db->table('figma_files')->orderBy('id', 'DESC')->get()->getRowArray(); + $versionsCount = $db->table('figma_file_versions')->countAllResults(); + $commentsCount = $db->table('figma_comments')->countAllResults(); + $latestVersion = $db->table('figma_file_versions')->orderBy('created_at_figma', 'DESC')->get()->getRowArray(); + $latestComment = $db->table('figma_comments')->selectMax('created_at_figma', 'latest')->get()->getRowArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Summary fetched', + 'data' => [ + 'file' => $file, + 'versions' => $versionsCount, + 'comments' => $commentsCount, + 'latest_version_at' => $latestVersion['created_at_figma'] ?? null, + 'latest_version_label' => $latestVersion['label'] ?? $latestVersion['version'] ?? null, + 'latest_version_description' => $latestVersion['description'] ?? null, + 'latest_comment_at' => $latestComment['latest'] ?? null, + ], + ], 200); + } + + private function getPaginationParams(): array + { + $page = (int) ($this->request->getGet('page') ?? 1); + if ($page <= 0) { + $page = 1; + } + + $perPage = (int) ($this->request->getGet('per_page') ?? $this->request->getGet('limit') ?? 25); + if ($perPage <= 0 || $perPage > 100) { + $perPage = 25; + } + + return [$page, $perPage]; + } + + public function snapshots() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $startDate = $this->request->getGet('start_date'); + $endDate = $this->request->getGet('end_date'); + [$page, $perPage] = $this->getPaginationParams(); + $offset = ($page - 1) * $perPage; + + $db = \Config\Database::connect(); + $baseBuilder = $db->table('figma_file_versions v') + ->join('figma_files f', 'f.id = v.file_id', 'inner'); + + if (!empty($startDate)) { + $baseBuilder->where('v.created_at_figma >=', $startDate . ' 00:00:00'); + } + + if (!empty($endDate)) { + $baseBuilder->where('v.created_at_figma <=', $endDate . ' 23:59:59'); + } + + $total = (int) (clone $baseBuilder)->countAllResults(); + $rows = $baseBuilder + ->select('v.id, v.figma_version_id, v.version, v.label, v.description, v.name, v.editor_type, v.last_modified_figma, v.created_at_figma, f.file_key, f.last_synced_at') + ->orderBy('v.created_at_figma', 'DESC') + ->limit($perPage, $offset) + ->get() + ->getResultArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Snapshots fetched', + 'data' => $rows, + 'meta' => [ + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $perPage > 0 ? (int) ceil($total / $perPage) : 0, + ], + ], 200); + } + + public function comments() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $startDate = $this->request->getGet('start_date'); + $endDate = $this->request->getGet('end_date'); + [$page, $perPage] = $this->getPaginationParams(); + $offset = ($page - 1) * $perPage; + + $db = \Config\Database::connect(); + $baseBuilder = $db->table('figma_comments c') + ->join('figma_files f', 'f.id = c.file_id', 'inner'); + + if (!empty($startDate)) { + $baseBuilder->where('c.created_at_figma >=', $startDate . ' 00:00:00'); + } + + if (!empty($endDate)) { + $baseBuilder->where('c.created_at_figma <=', $endDate . ' 23:59:59'); + } + + $total = (int) (clone $baseBuilder)->countAllResults(); + $rows = $baseBuilder + ->select('c.id, c.figma_comment_id, c.user_name, c.message, c.is_resolved, c.resolved_at, c.created_at_figma, c.client_meta_json, f.file_key') + ->orderBy('c.created_at_figma', 'DESC') + ->limit($perPage, $offset) + ->get() + ->getResultArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Comments fetched', + 'data' => $rows, + 'meta' => [ + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $perPage > 0 ? (int) ceil($total / $perPage) : 0, + ], + ], 200); + } + + public function sync() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + if ($response = $this->ensureAdmin()) { + return $response; + } + + $service = new FigmaSyncService(); + $result = $service->syncAll(); + + $statusCode = $result['success'] ? 200 : 500; + return $this->respond([ + 'status' => $result['success'] ? 'success' : 'error', + 'message' => $result['message'], + 'data' => $result['stats'] ?? [], + ], $statusCode); + } +} diff --git a/app/Controllers/Figma.php b/app/Controllers/Figma.php new file mode 100644 index 0000000..65cdb81 --- /dev/null +++ b/app/Controllers/Figma.php @@ -0,0 +1,14 @@ +get('level'); + $data['isAdmin'] = in_array($level, [0, 1, 2], true); + + return view('figma_dashboard', $data); + } +} diff --git a/app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php b/app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php new file mode 100644 index 0000000..3f4ed02 --- /dev/null +++ b/app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php @@ -0,0 +1,176 @@ +forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'file_key' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'version' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'last_modified' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'editor_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'last_synced_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('file_key'); + $this->forge->createTable('figma_files', true); + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'file_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + ], + 'figma_version_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'version' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'editor_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'last_modified_figma' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at_figma' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['file_id', 'figma_version_id']); + $this->forge->addKey('file_id'); + $this->forge->addKey('created_at_figma'); + $this->forge->addKey('last_modified_figma'); + $this->forge->createTable('figma_file_versions', true); + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'file_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + ], + 'figma_comment_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'user_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'message' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'is_resolved' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'resolved_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at_figma' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'client_meta_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('figma_comment_id'); + $this->forge->addKey('file_id'); + $this->forge->addKey('created_at_figma'); + $this->forge->createTable('figma_comments', true); + } + + public function down() + { + $this->forge->dropTable('figma_comments', true); + $this->forge->dropTable('figma_file_versions', true); + $this->forge->dropTable('figma_files', true); + } +} diff --git a/app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php b/app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php new file mode 100644 index 0000000..274350b --- /dev/null +++ b/app/Database/Migrations/2026-04-27-000002_AddLabelDescriptionToFigmaVersions.php @@ -0,0 +1,43 @@ +db->fieldExists('label', 'figma_file_versions')) { + $fields['label'] = [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ]; + } + + if (!$this->db->fieldExists('description', 'figma_file_versions')) { + $fields['description'] = [ + 'type' => 'LONGTEXT', + 'null' => true, + ]; + } + + if (!empty($fields)) { + $this->forge->addColumn('figma_file_versions', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('description', 'figma_file_versions')) { + $this->forge->dropColumn('figma_file_versions', 'description'); + } + + if ($this->db->fieldExists('label', 'figma_file_versions')) { + $this->forge->dropColumn('figma_file_versions', 'label'); + } + } +} diff --git a/app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php b/app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php new file mode 100644 index 0000000..85370c6 --- /dev/null +++ b/app/Database/Migrations/2026-04-27-000003_RemoveFileUrlAndThumbnailFromFigmaTables.php @@ -0,0 +1,68 @@ +db->fieldExists('file_url', 'figma_files')) { + $this->forge->dropColumn('figma_files', 'file_url'); + } + + if ($this->db->fieldExists('thumbnail_url', 'figma_files')) { + $this->forge->dropColumn('figma_files', 'thumbnail_url'); + } + + if ($this->db->fieldExists('file_url', 'figma_file_versions')) { + $this->forge->dropColumn('figma_file_versions', 'file_url'); + } + + if ($this->db->fieldExists('thumbnail_url', 'figma_file_versions')) { + $this->forge->dropColumn('figma_file_versions', 'thumbnail_url'); + } + } + + public function down() + { + $fieldsFiles = []; + if (!$this->db->fieldExists('file_url', 'figma_files')) { + $fieldsFiles['file_url'] = [ + 'type' => 'TEXT', + 'null' => true, + ]; + } + + if (!$this->db->fieldExists('thumbnail_url', 'figma_files')) { + $fieldsFiles['thumbnail_url'] = [ + 'type' => 'TEXT', + 'null' => true, + ]; + } + + if (!empty($fieldsFiles)) { + $this->forge->addColumn('figma_files', $fieldsFiles); + } + + $fieldsVersions = []; + if (!$this->db->fieldExists('file_url', 'figma_file_versions')) { + $fieldsVersions['file_url'] = [ + 'type' => 'TEXT', + 'null' => true, + ]; + } + + if (!$this->db->fieldExists('thumbnail_url', 'figma_file_versions')) { + $fieldsVersions['thumbnail_url'] = [ + 'type' => 'TEXT', + 'null' => true, + ]; + } + + if (!empty($fieldsVersions)) { + $this->forge->addColumn('figma_file_versions', $fieldsVersions); + } + } +} diff --git a/app/Libraries/FigmaSyncService.php b/app/Libraries/FigmaSyncService.php new file mode 100644 index 0000000..29e9038 --- /dev/null +++ b/app/Libraries/FigmaSyncService.php @@ -0,0 +1,439 @@ +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; + } + +} diff --git a/app/Models/FigmaCommentsModel.php b/app/Models/FigmaCommentsModel.php new file mode 100644 index 0000000..12ab7f2 --- /dev/null +++ b/app/Models/FigmaCommentsModel.php @@ -0,0 +1,28 @@ +extend('layouts/main.php') ?> + +section('content') ?> +
+
+
+
+

Figma Dashboard (DB)

+
+
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
Current File
+
-
+
+
+
+
+
+
+
Current Version
+
-
+
+
+
+
+
+
+
Snapshots
+
0
+
+
+
+
+
+
+
Comments
+
0
+
+
+
+
+ +
+
+
+
+
+
Latest File Snapshots
+
Total Snapshots: 0
+
+
+ + + + + + + + + + + +
DateLabelDescriptionVersionEditor
+
+
+ +
Page 0 of 0
+ +
+
+
+
+ +
+
+
+
+
Latest Comments
+
Total Comments: 0
+
+
+ + + + + + + + + + +
DateUserCommentStatus
+
+
+ +
Page 0 of 0
+ +
+
+
+
+
+
+
+endSection() ?> + +section('script') ?> + +endSection() ?> diff --git a/app/Views/layouts/_sidebar.php b/app/Views/layouts/_sidebar.php index b4e30bd..28d0148 100644 --- a/app/Views/layouts/_sidebar.php +++ b/app/Views/layouts/_sidebar.php @@ -114,6 +114,7 @@
  • +