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
This commit is contained in:
parent
e0df9c6b1d
commit
329e4e6725
42
app/Commands/SyncFigmaData.php
Normal file
42
app/Commands/SyncFigmaData.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\FigmaSyncService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class SyncFigmaData extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Figma';
|
||||||
|
protected $name = 'figma:sync';
|
||||||
|
protected $description = 'Full sync single Figma file snapshots and comments into local database.';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
CLI::write('Starting Figma full sync...', 'yellow');
|
||||||
|
|
||||||
|
$service = new FigmaSyncService();
|
||||||
|
$result = $service->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Commands/SyncFigmaIncremental.php
Normal file
49
app/Commands/SyncFigmaIncremental.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\FigmaSyncService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class SyncFigmaIncremental extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Figma';
|
||||||
|
protected $name = 'figma:sync-incremental';
|
||||||
|
protected $description = 'Incremental sync from single Figma file (default last 1 day). Usage: php spark figma:sync-incremental [days]';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$days = isset($params[0]) ? (int) $params[0] : 1;
|
||||||
|
if ($days < 1) {
|
||||||
|
$days = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('Starting Figma incremental sync (last ' . $days . ' day)...', 'yellow');
|
||||||
|
|
||||||
|
$service = new FigmaSyncService();
|
||||||
|
$result = $service->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -275,6 +275,14 @@ $routes->group('api/gitea', function($routes) {
|
|||||||
$routes->get('getcommits/(:segment)/(:segment)', 'Gitea::getCommits/$1/$2');
|
$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->group('api/git', function($routes) {
|
||||||
$routes->get('summary', 'Api\GitApi::summary');
|
$routes->get('summary', 'Api\GitApi::summary');
|
||||||
$routes->get('users', 'Api\GitApi::users');
|
$routes->get('users', 'Api\GitApi::users');
|
||||||
|
|||||||
189
app/Controllers/Api/FigmaApi.php
Normal file
189
app/Controllers/Api/FigmaApi.php
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Libraries\FigmaSyncService;
|
||||||
|
use CodeIgniter\API\ResponseTrait;
|
||||||
|
|
||||||
|
class FigmaApi extends BaseController
|
||||||
|
{
|
||||||
|
use ResponseTrait;
|
||||||
|
|
||||||
|
private function ensureLoggedIn()
|
||||||
|
{
|
||||||
|
if (!session()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Controllers/Figma.php
Normal file
14
app/Controllers/Figma.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
class Figma extends BaseController
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$level = (int) session()->get('level');
|
||||||
|
$data['isAdmin'] = in_array($level, [0, 1, 2], true);
|
||||||
|
|
||||||
|
return view('figma_dashboard', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php
Normal file
176
app/Database/Migrations/2026-04-27-000001_CreateFigmaTables.php
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateFigmaTables extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddLabelDescriptionToFigmaVersions extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
|
||||||
|
if (!$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class RemoveFileUrlAndThumbnailFromFigmaTables extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if ($this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
439
app/Libraries/FigmaSyncService.php
Normal file
439
app/Libraries/FigmaSyncService.php
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
28
app/Models/FigmaCommentsModel.php
Normal file
28
app/Models/FigmaCommentsModel.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class FigmaCommentsModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'figma_comments';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'file_id',
|
||||||
|
'figma_comment_id',
|
||||||
|
'user_name',
|
||||||
|
'message',
|
||||||
|
'is_resolved',
|
||||||
|
'resolved_at',
|
||||||
|
'created_at_figma',
|
||||||
|
'client_meta_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
29
app/Models/FigmaFileVersionsModel.php
Normal file
29
app/Models/FigmaFileVersionsModel.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class FigmaFileVersionsModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'figma_file_versions';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'file_id',
|
||||||
|
'figma_version_id',
|
||||||
|
'version',
|
||||||
|
'label',
|
||||||
|
'description',
|
||||||
|
'name',
|
||||||
|
'editor_type',
|
||||||
|
'last_modified_figma',
|
||||||
|
'created_at_figma',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
26
app/Models/FigmaFilesModel.php
Normal file
26
app/Models/FigmaFilesModel.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class FigmaFilesModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'figma_files';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'file_key',
|
||||||
|
'name',
|
||||||
|
'version',
|
||||||
|
'last_modified',
|
||||||
|
'editor_type',
|
||||||
|
'last_synced_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
378
app/Views/figma_dashboard.php
Normal file
378
app/Views/figma_dashboard.php
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
<?= $this->extend('layouts/main.php') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row page-titles">
|
||||||
|
<div class="col-md-6 align-self-center">
|
||||||
|
<h4 class="text-themecolor">Figma Dashboard (DB)</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<?php if (!empty($isAdmin)): ?>
|
||||||
|
<button id="btnSync" class="btn btn-primary">Sync Now</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Start Date</label>
|
||||||
|
<input type="date" id="filterStart" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">End Date</label>
|
||||||
|
<input type="date" id="filterEnd" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
|
<button id="btnApplyFilter" class="btn btn-success w-100">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3 g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="small text-muted">Current File</div>
|
||||||
|
<div id="currentFileName" class="fw-bold">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="small text-muted">Current Version</div>
|
||||||
|
<div id="currentFileVersion" class="fw-bold">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="small text-muted">Snapshots</div>
|
||||||
|
<div id="totalSnapshots" class="fw-bold">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="small text-muted">Comments</div>
|
||||||
|
<div id="totalComments" class="fw-bold">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">Latest File Snapshots</h5>
|
||||||
|
<div><strong>Total Snapshots:</strong> <span id="totalVersionRows">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered" id="tableVersions">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Editor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<button id="versionPrev" class="btn btn-outline-secondary btn-sm">Prev</button>
|
||||||
|
<div id="versionPageInfo" class="small text-muted">Page 0 of 0</div>
|
||||||
|
<button id="versionNext" class="btn btn-outline-secondary btn-sm">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">Latest Comments</h5>
|
||||||
|
<div><strong>Total Comments:</strong> <span id="totalCommentRows">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered" id="tableComments">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Comment</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<button id="commentPrev" class="btn btn-outline-secondary btn-sm">Prev</button>
|
||||||
|
<div id="commentPageInfo" class="small text-muted">Page 0 of 0</div>
|
||||||
|
<button id="commentNext" class="btn btn-outline-secondary btn-sm">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const filterStart = document.getElementById('filterStart');
|
||||||
|
const filterEnd = document.getElementById('filterEnd');
|
||||||
|
const btnApplyFilter = document.getElementById('btnApplyFilter');
|
||||||
|
const btnSync = document.getElementById('btnSync');
|
||||||
|
|
||||||
|
const versionPrev = document.getElementById('versionPrev');
|
||||||
|
const versionNext = document.getElementById('versionNext');
|
||||||
|
const versionPageInfo = document.getElementById('versionPageInfo');
|
||||||
|
|
||||||
|
const commentPrev = document.getElementById('commentPrev');
|
||||||
|
const commentNext = document.getElementById('commentNext');
|
||||||
|
const commentPageInfo = document.getElementById('commentPageInfo');
|
||||||
|
|
||||||
|
const totalSnapshots = document.getElementById('totalSnapshots');
|
||||||
|
const totalComments = document.getElementById('totalComments');
|
||||||
|
const totalVersionRows = document.getElementById('totalVersionRows');
|
||||||
|
const totalCommentRows = document.getElementById('totalCommentRows');
|
||||||
|
const currentFileName = document.getElementById('currentFileName');
|
||||||
|
const currentFileVersion = document.getElementById('currentFileVersion');
|
||||||
|
|
||||||
|
const versionsState = {
|
||||||
|
page: 1,
|
||||||
|
perPage: 25,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const commentsState = {
|
||||||
|
page: 1,
|
||||||
|
perPage: 25,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
setDefaultDateRange();
|
||||||
|
await loadSummary();
|
||||||
|
await loadTables();
|
||||||
|
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
|
||||||
|
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
|
||||||
|
|
||||||
|
btnApplyFilter.addEventListener('click', async () => {
|
||||||
|
versionsState.page = 1;
|
||||||
|
commentsState.page = 1;
|
||||||
|
versionsState.loaded = true;
|
||||||
|
commentsState.loaded = true;
|
||||||
|
await loadTables();
|
||||||
|
});
|
||||||
|
|
||||||
|
versionPrev.addEventListener('click', async () => {
|
||||||
|
if (versionsState.page > 1) {
|
||||||
|
versionsState.page--;
|
||||||
|
await loadVersions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
versionNext.addEventListener('click', async () => {
|
||||||
|
if (versionsState.page < versionsState.totalPages) {
|
||||||
|
versionsState.page++;
|
||||||
|
await loadVersions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
commentPrev.addEventListener('click', async () => {
|
||||||
|
if (commentsState.page > 1) {
|
||||||
|
commentsState.page--;
|
||||||
|
await loadComments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
commentNext.addEventListener('click', async () => {
|
||||||
|
if (commentsState.page < commentsState.totalPages) {
|
||||||
|
commentsState.page++;
|
||||||
|
await loadComments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (btnSync) {
|
||||||
|
btnSync.addEventListener('click', async () => {
|
||||||
|
btnSync.disabled = true;
|
||||||
|
btnSync.innerText = 'Syncing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`<?= base_url('api/figma/sync') ?>`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Sync complete');
|
||||||
|
await loadSummary();
|
||||||
|
await loadTables();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Sync failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Sync request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSync.disabled = false;
|
||||||
|
btnSync.innerText = 'Sync Now';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultDateRange() {
|
||||||
|
const today = new Date();
|
||||||
|
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
||||||
|
filterStart.value = toInputDate(startOfYear);
|
||||||
|
filterEnd.value = toInputDate(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInputDate(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePager(prevBtn, nextBtn, infoEl, state) {
|
||||||
|
const totalPages = state.loaded ? state.totalPages : 0;
|
||||||
|
const page = state.loaded && totalPages > 0 ? state.page : 0;
|
||||||
|
|
||||||
|
prevBtn.disabled = !state.loaded || page <= 1;
|
||||||
|
nextBtn.disabled = !state.loaded || page >= totalPages || totalPages <= 1;
|
||||||
|
infoEl.innerText = `Page ${page} of ${totalPages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseParams(page, perPage) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterStart.value) params.set('start_date', filterStart.value);
|
||||||
|
if (filterEnd.value) params.set('end_date', filterEnd.value);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('per_page', String(perPage));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
const response = await fetch(`<?= base_url('api/figma/summary') ?>`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFileName.innerText = result.data.file?.name || '-';
|
||||||
|
currentFileVersion.innerText = result.data.latest_version_label || result.data.file?.version || '-';
|
||||||
|
totalSnapshots.innerText = String(result.data.versions ?? 0);
|
||||||
|
totalComments.innerText = String(result.data.comments ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTables() {
|
||||||
|
await loadVersions();
|
||||||
|
await loadComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
const tbody = document.querySelector('#tableVersions tbody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Loading...</td></tr>';
|
||||||
|
|
||||||
|
const response = await fetch(`<?= base_url('api/figma/snapshots') ?>?${getBaseParams(versionsState.page, versionsState.perPage)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !Array.isArray(result.data)) {
|
||||||
|
versionsState.total = 0;
|
||||||
|
versionsState.totalPages = 0;
|
||||||
|
totalVersionRows.innerText = '0';
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Failed loading snapshots</td></tr>';
|
||||||
|
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionsState.total = Number(result.meta?.total ?? result.data.length ?? 0);
|
||||||
|
versionsState.totalPages = Number(result.meta?.total_pages ?? 0);
|
||||||
|
versionsState.page = Number(result.meta?.page ?? versionsState.page);
|
||||||
|
versionsState.perPage = Number(result.meta?.per_page ?? versionsState.perPage);
|
||||||
|
versionsState.loaded = true;
|
||||||
|
totalVersionRows.innerText = String(versionsState.total);
|
||||||
|
|
||||||
|
if (!result.data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">No snapshots found</td></tr>';
|
||||||
|
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = result.data.map(item => {
|
||||||
|
return `<tr>
|
||||||
|
<td>${escapeHtml(item.created_at_figma || '')}</td>
|
||||||
|
<td>${escapeHtml(item.label || item.version || item.figma_version_id || '')}</td>
|
||||||
|
<td>${escapeHtml(item.description || '')}</td>
|
||||||
|
<td>${escapeHtml(item.version || item.figma_version_id || '')}</td>
|
||||||
|
<td>${escapeHtml(item.editor_type || '')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
updatePager(versionPrev, versionNext, versionPageInfo, versionsState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
const tbody = document.querySelector('#tableComments tbody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4">Loading...</td></tr>';
|
||||||
|
|
||||||
|
const response = await fetch(`<?= base_url('api/figma/comments') ?>?${getBaseParams(commentsState.page, commentsState.perPage)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !Array.isArray(result.data)) {
|
||||||
|
commentsState.total = 0;
|
||||||
|
commentsState.totalPages = 0;
|
||||||
|
totalCommentRows.innerText = '0';
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4">Failed loading comments</td></tr>';
|
||||||
|
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsState.total = Number(result.meta?.total ?? result.data.length ?? 0);
|
||||||
|
commentsState.totalPages = Number(result.meta?.total_pages ?? 0);
|
||||||
|
commentsState.page = Number(result.meta?.page ?? commentsState.page);
|
||||||
|
commentsState.perPage = Number(result.meta?.per_page ?? commentsState.perPage);
|
||||||
|
commentsState.loaded = true;
|
||||||
|
totalCommentRows.innerText = String(commentsState.total);
|
||||||
|
|
||||||
|
if (!result.data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4">No comments found</td></tr>';
|
||||||
|
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = result.data.map(item => {
|
||||||
|
const comment = escapeHtml((item.message || '').slice(0, 160));
|
||||||
|
const status = item.is_resolved ? 'Resolved' : 'Open';
|
||||||
|
return `<tr>
|
||||||
|
<td>${escapeHtml(item.created_at_figma || '')}</td>
|
||||||
|
<td>${escapeHtml(item.user_name || '')}</td>
|
||||||
|
<td>${comment}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
updatePager(commentPrev, commentNext, commentPageInfo, commentsState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@ -114,6 +114,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li> <a class="waves-effect waves-dark" href='<?=base_url();?>gitea' aria-expanded="false"> <i class="fa-brands fa-git-alt"></i><span class='hide-menu'>Gitea Dashboard</span></a></li>
|
<li> <a class="waves-effect waves-dark" href='<?=base_url();?>gitea' aria-expanded="false"> <i class="fa-brands fa-git-alt"></i><span class='hide-menu'>Gitea Dashboard</span></a></li>
|
||||||
|
<li> <a class="waves-effect waves-dark" href='<?=base_url();?>figma' aria-expanded="false"> <i class="fa-brands fa-figma"></i><span class='hide-menu'>Figma Dashboard</span></a></li>
|
||||||
|
|
||||||
<?php elseif ($isPS): ?>
|
<?php elseif ($isPS): ?>
|
||||||
<!-- <li> <a class="has-arrow waves-effect waves-dark" href="javascript:void(0)" aria-expanded="false"> <i class="fas fa-user"></i><span class="hide-menu">User</span> </a>
|
<!-- <li> <a class="has-arrow waves-effect waves-dark" href="javascript:void(0)" aria-expanded="false"> <i class="fas fa-user"></i><span class="hide-menu">User</span> </a>
|
||||||
|
|||||||
7
scripts/figma-sync-full.sh
Normal file
7
scripts/figma-sync-full.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
php spark figma:sync
|
||||||
8
scripts/figma-sync-incremental.sh
Normal file
8
scripts/figma-sync-incremental.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
DAYS="${1:-1}"
|
||||||
|
php spark figma:sync-incremental "$DAYS"
|
||||||
Loading…
x
Reference in New Issue
Block a user