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 @@
-
-
\ 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));
}
}
-