diff --git a/front/js/helpers.js b/front/js/helpers.js index 666fda6..4814bd8 100644 --- a/front/js/helpers.js +++ b/front/js/helpers.js @@ -69,7 +69,7 @@ export function apiPost(url, body, options = {}) { "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content, ...options.headers, }, - }); + }).then((r) => r.json()); } export function apiGet(url, options = {}) { diff --git a/front/js/svelte/content/ActionsOnSelected.svelte b/front/js/svelte/content/ActionsOnSelected.svelte index e9e08ff..3208034 100644 --- a/front/js/svelte/content/ActionsOnSelected.svelte +++ b/front/js/svelte/content/ActionsOnSelected.svelte @@ -1,5 +1,6 @@ - -
{#if !isCreateMode} - +
- +
Create newCreate new {#if !isCreateMode} Clone {/if} - (activeContentTab = "_info")} - class="dropdown-item" - href="{channel.lucentUrl}">Revisions (activeContentTab = "_info")} + class="dropdown-item" + href={channel.lucentUrl}>Revisions
{/if} - -
\ No newline at end of file + + diff --git a/src/Commands/Export.php b/src/Commands/Export.php new file mode 100644 index 0000000..aa3e8f4 --- /dev/null +++ b/src/Commands/Export.php @@ -0,0 +1,96 @@ +format("Y_m_d_His"); + $sqlFile = $exportDir . "/dump_{$stamp}.sql"; + $zipFile = $exportDir . "/export_{$stamp}.zip"; + + // Dump database + $tableArgs = collect($tables)->map(fn($t) => "-t {$t}")->join(" "); + $command = sprintf( + "PGPASSWORD=%s pg_dump -h %s -p %s -U %s -d %s %s --no-owner --no-acl > %s", + $db["password"], + $db["host"], + $db["port"], + $db["username"], + $db["database"], + $tableArgs, + $sqlFile, + ); + + exec($command, result_code: $code); + + if ($code !== 0) { + $this->error("pg_dump failed"); + return; + } + + $this->info("Database dumped."); + + // Zip SQL + files + $filesDir = Storage::disk(config("lucent.disk"))->path("files"); + + $zip = new ZipArchive(); + if ( + $zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== + true + ) { + $this->error("Could not create zip archive."); + return; + } + + $zip->addFile($sqlFile, "dump_{$stamp}.sql"); + + if (is_dir($filesDir)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $filesDir, + \FilesystemIterator::SKIP_DOTS, + ), + ); + foreach ($iterator as $file) { + $relativePath = + "files/" . + ltrim( + str_replace($filesDir, "", $file->getRealPath()), + DIRECTORY_SEPARATOR, + ); + $zip->addFile($file->getRealPath(), $relativePath); + } + } + + $zip->close(); + + // Clean up originals + unlink($sqlFile); + + $this->info("Exported to {$zipFile}"); + } +} diff --git a/src/Commands/Import.php b/src/Commands/Import.php new file mode 100644 index 0000000..c177a86 --- /dev/null +++ b/src/Commands/Import.php @@ -0,0 +1,155 @@ +error("No exports directory found at {$exportDir}"); + return; + } + + $zips = glob($exportDir . "/export_*.zip"); + + if (empty($zips)) { + $this->error("No export archives found in {$exportDir}"); + return; + } + + rsort($zips); + $choices = array_map(fn($p) => basename($p), $zips); + + $chosen = $this->choice("Select an export to import", $choices, 0); + $zipFile = $exportDir . "/" . $chosen; + + $this->warn("This will REPLACE all records, revisions, edges, files data and uploaded files."); + + if (!$this->confirm("Import {$chosen}?", false)) { + $this->info("Aborted."); + return; + } + + // Extract to temp directory + $tempDir = storage_path("exports/.import_tmp_" . uniqid()); + mkdir($tempDir, 0755, true); + + $zip = new ZipArchive(); + if ($zip->open($zipFile) !== true) { + $this->error("Could not open zip archive."); + $this->cleanup($tempDir); + return; + } + + $zip->extractTo($tempDir); + $zip->close(); + + // Restore database + $sqlFiles = glob($tempDir . "/*.sql"); + if (empty($sqlFiles)) { + $this->error("No SQL dump found inside the archive."); + $this->cleanup($tempDir); + return; + } + + $db = config("database.connections.pgsql"); + $tables = [ + "lucent_records", + "lucent_revisions", + "lucent_files", + "lucent_edges", + ]; + + // Truncate existing tables before restore + $truncate = collect($tables) + ->map(fn($t) => "TRUNCATE TABLE {$t} CASCADE;") + ->join(" "); + + $truncateCmd = sprintf( + "PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -c \"%s\"", + $db["password"], + $db["host"], + $db["port"], + $db["username"], + $db["database"], + $truncate, + ); + + exec($truncateCmd, result_code: $truncateCode); + + if ($truncateCode !== 0) { + $this->error("Failed to truncate existing tables."); + $this->cleanup($tempDir); + return; + } + + $restoreCmd = sprintf( + "PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -f %s", + $db["password"], + $db["host"], + $db["port"], + $db["username"], + $db["database"], + $sqlFiles[0], + ); + + exec($restoreCmd, result_code: $restoreCode); + + if ($restoreCode !== 0) { + $this->error("Database restore failed."); + $this->cleanup($tempDir); + return; + } + + $this->info("Database restored."); + + // Replace files + $srcFilesDir = $tempDir . "/files"; + + if (is_dir($srcFilesDir)) { + $disk = Storage::disk(config("lucent.disk")); + $destFilesDir = $disk->path("files"); + + // Remove existing files directory + if (is_dir($destFilesDir)) { + exec("rm -rf " . escapeshellarg($destFilesDir)); + } + + exec( + sprintf("cp -R %s %s", escapeshellarg($srcFilesDir), escapeshellarg($destFilesDir)), + result_code: $copyCode, + ); + + if ($copyCode !== 0) { + $this->error("Failed to restore files."); + $this->cleanup($tempDir); + return; + } + + $this->info("Files restored."); + } else { + $this->warn("No files directory found in archive — skipping file restore."); + } + + $this->cleanup($tempDir); + $this->info("Import complete."); + } + + private function cleanup(string $dir): void + { + if (is_dir($dir)) { + exec("rm -rf " . escapeshellarg($dir)); + } + } +} diff --git a/src/Http/Controller/RecordController.php b/src/Http/Controller/RecordController.php index 50ca041..e7f74de 100644 --- a/src/Http/Controller/RecordController.php +++ b/src/Http/Controller/RecordController.php @@ -84,8 +84,8 @@ class RecordController extends Controller ->childrenFields($schema?->visible ?? []) ->childrenDepth(1) ->parentsDepth(0) - ->runWithCount(); + $records = $graph->getRootRecords()->toArray(); $data = [ diff --git a/src/LucentServiceProvider.php b/src/LucentServiceProvider.php index 6702c32..1417a7d 100644 --- a/src/LucentServiceProvider.php +++ b/src/LucentServiceProvider.php @@ -20,6 +20,8 @@ use Lucent\Commands\LiveLink; use Lucent\Commands\RebuildThumbnails; use Lucent\Commands\RemoveOrphanEdges; use Lucent\Commands\SetupDatabase; +use Lucent\Commands\Export; +use Lucent\Commands\Import; use Lucent\Data\ChannelAuth; use Lucent\File\FileService; use Lucent\Query\DatabaseGraph\DatabaseGraph; @@ -96,6 +98,8 @@ class LucentServiceProvider extends ServiceProvider RemoveOrphanEdges::class, SetupDatabase::class, GenerateCollectionSchema::class, + Export::class, + Import::class, ]); } diff --git a/src/Query/Query.php b/src/Query/Query.php index 47347a5..d31edcf 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -57,6 +57,7 @@ final class Query $resultChildrenEdges, ); } + $resultParentSourceTargetIds = []; $resultParentEdges = []; if ($this->options->parentsDepth > 0 && !empty($ids)) { @@ -126,6 +127,7 @@ final class Query $queryRecord->isRoot = data_get($recordData, "isRoot") === true; return $queryRecord; }) + ->toArray(); $queryEdges = collect($edges) @@ -222,7 +224,9 @@ final class Query $query = Database::make()->table("lucent_records"); $query = $this->parseFilters($query); $query = $this->findNotLinked($query); + $graph = $this->run(); + $graph->total = $query->count(); return $graph; } diff --git a/src/Record/InputFormatter.php b/src/Record/InputFormatter.php index 1975f4f..12c1a91 100644 --- a/src/Record/InputFormatter.php +++ b/src/Record/InputFormatter.php @@ -7,22 +7,20 @@ use Lucent\Schema\FieldInterface; class InputFormatter { - - public function __construct( - public ChannelService $channelService, - ) - { - } + public function __construct(public ChannelService $channelService) {} public function fill(string $schemaName, RecordData $input): RecordData { - $schema = $this->channelService->getSchema($schemaName)->get(); - $data = $schema->fields->reduce(fn(array $carry, FieldInterface $field) => $field->format($input->toArray(), $carry), []); + $data = $schema->fields->reduce( + fn(array $carry, FieldInterface $field) => $field->format( + $input->toArray(), + $carry, + ), + [], + ); return new RecordData($data); } - - } diff --git a/src/Schema/Ui/Text.php b/src/Schema/Ui/Text.php index 0fe88ec..0551049 100644 --- a/src/Schema/Ui/Text.php +++ b/src/Schema/Ui/Text.php @@ -15,34 +15,37 @@ class Text implements FieldInterface, RequiredInterface public function __construct( public string $name, public string $label, - public bool $required = false, - public bool $nullable = false, - public ?int $min = null, - public ?int $max = null, + public bool $required = false, + public bool $nullable = false, + public ?int $min = null, + public ?int $max = null, public string $help = "", public string $default = "", - public bool $readonly = false, + public bool $readonly = false, public string $optionsFrom = "", public string $optionsField = "", - public bool $optionsSuggest = false, + public bool $optionsSuggest = false, public ?array $selectOptions = null, public string $group = "", - ) - { + ) { $this->info = new FieldInfo("text", "Text", FieldType::STRING); } public function format(array $input, array $output): array { - $value = !empty($input[$this->name]) ? (string)$input[$this->name] : null; - $output[$this->name] = (new Nullable($this->nullable, $value, ""))->value(); + $value = !empty($input[$this->name]) + ? (string) $input[$this->name] + : null; + $output[$this->name] = Nullable::make( + $this->nullable, + $value, + "", + )->value(); return $output; } - public function failRequired(mixed $value): bool { return empty(trim($value)); } } -