import and export

This commit is contained in:
2026-05-07 17:42:46 +03:00
parent 8cf1dd9bfd
commit 639ee895cd
10 changed files with 343 additions and 86 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ export function apiPost(url, body, options = {}) {
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content, "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content,
...options.headers, ...options.headers,
}, },
}); }).then((r) => r.json());
} }
export function apiGet(url, options = {}) { export function apiGet(url, options = {}) {
@@ -1,5 +1,6 @@
<script> <script>
import {getContext} from "svelte"; import { getContext } from "svelte";
import { apiPost } from "../../helpers";
const channel = getContext("channel"); const channel = getContext("channel");
export let selected; export let selected;
@@ -8,10 +9,9 @@
function deleteRecords(e) { function deleteRecords(e) {
e.preventDefault(); e.preventDefault();
axios apiPost(channel.lucentUrl + "/records/delete", {
.post(channel.lucentUrl + "/records/delete", { ids: selected.map((s) => s.id),
ids: selected.map((s) => s.id), })
})
.then((response) => { .then((response) => {
window.location.reload(); window.location.reload();
}) })
@@ -21,11 +21,10 @@
} }
function changeStatus(e, status) { function changeStatus(e, status) {
axios apiPost(channel.lucentUrl + "/records/status/" + status, {
.post(channel.lucentUrl + "/records/status/" + status, { schemaName: schema.name,
schemaName: schema.name, records: selected,
records: selected })
})
.then((response) => { .then((response) => {
window.location.reload(); window.location.reload();
}) })
@@ -38,44 +37,44 @@
<div style="display: flex;align-items: center; gap: 8px"> <div style="display: flex;align-items: center; gap: 8px">
<span class="me-2">{selected.length} records selected</span> <span class="me-2">{selected.length} records selected</span>
<button <button
on:click|preventDefault={(e) => changeStatus(e, "published")} on:click|preventDefault={(e) => changeStatus(e, "published")}
type="button" type="button"
class="button">Publish class="button"
</button >Publish
> </button>
<button <button
on:click|preventDefault={(e) => changeStatus(e, "draft")} on:click|preventDefault={(e) => changeStatus(e, "draft")}
type="button" type="button"
class="button">Make Draft class="button"
</button >Make Draft
> </button>
{#if filter["status_in"] === "trashed"} {#if filter["status_in"] === "trashed"}
<button <button
on:click|preventDefault={(e) => changeStatus(e, "published")} on:click|preventDefault={(e) => changeStatus(e, "published")}
type="button" type="button"
class="button">Publish class="button"
</button >Publish
> </button>
{#if schema.hasDrafts} {#if schema.hasDrafts}
<button <button
on:click|preventDefault={(e) => changeStatus(e, "draft")} on:click|preventDefault={(e) => changeStatus(e, "draft")}
type="button" type="button"
class="button">Make Draft class="button"
</button >Make Draft
> </button>
{/if} {/if}
<button <button
on:click|preventDefault={deleteRecords} on:click|preventDefault={deleteRecords}
type="button" type="button"
class="button">Delete forever class="button"
</button >Delete forever
> </button>
{:else} {:else}
<button <button
type="button" type="button"
on:click|preventDefault={(e) => changeStatus(e, "trashed")} on:click|preventDefault={(e) => changeStatus(e, "trashed")}
class="button">Move to trash class="button"
</button >Move to trash
> </button>
{/if} {/if}
</div> </div>
@@ -1,8 +1,9 @@
<script> <script>
import {getContext} from "svelte"; import { getContext } from "svelte";
import Icon from "../../common/Icon.svelte"; import Icon from "../../common/Icon.svelte";
import Dropdown from "../../common/Dropdown.svelte"; import Dropdown from "../../common/Dropdown.svelte";
import StatusSelect from "./StatusSelect.svelte"; import StatusSelect from "./StatusSelect.svelte";
import { apiPost } from "../../../helpers";
const channel = getContext("channel"); const channel = getContext("channel");
export let schema; export let schema;
@@ -12,45 +13,42 @@
function clone(e) { function clone(e) {
e.preventDefault(); e.preventDefault();
axios.post(channel.lucentUrl + "/records/clone/" + record.id).then(response => { apiPost(channel.lucentUrl + "/records/clone/" + record.id)
window.location = channel.lucentUrl + "/records/" + response.data.id; .then((response) => {
}).catch(error => { window.location = channel.lucentUrl + "/records/" + response.id;
})
}); .catch((error) => {});
} }
</script> </script>
<div style="display: flex;align-items: center; gap:10px;"> <div style="display: flex;align-items: center; gap:10px;">
{#if !isCreateMode} {#if !isCreateMode}
<Dropdown > <Dropdown>
<div slot="button"> <div slot="button">
<Icon icon="ellipsis"/> <Icon icon="ellipsis" />
</div> </div>
<h6 class="dropdown-header">Record Actions</h6> <h6 class="dropdown-header">Record Actions</h6>
<a <a
class="dropdown-item" class="dropdown-item"
href="{channel.lucentUrl}/records/new?schema={schema.name}" href="{channel.lucentUrl}/records/new?schema={schema.name}"
>Create new</a >Create new</a
> >
{#if !isCreateMode} {#if !isCreateMode}
<a <a
class="dropdown-item" class="dropdown-item"
on:click={clone} on:click={clone}
href={channel.lucentUrl} href={channel.lucentUrl}
> >
Clone Clone
</a> </a>
{/if} {/if}
<a <a
on:click|preventDefault={(e) => on:click|preventDefault={(e) => (activeContentTab = "_info")}
(activeContentTab = "_info")} class="dropdown-item"
class="dropdown-item" href={channel.lucentUrl}>Revisions</a
href="{channel.lucentUrl}">Revisions</a
> >
</Dropdown> </Dropdown>
{/if} {/if}
<StatusSelect bind:status={record.status} {record}></StatusSelect> <StatusSelect bind:status={record.status} {record}></StatusSelect>
</div> </div>
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Lucent\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
class Export extends Command
{
protected $signature = "lucent:export";
protected $prefix = "lucent_";
protected $description = "Export data and files";
public function handle(): void
{
$db = config("database.connections.pgsql");
$tables = [
"lucent_records",
"lucent_revisions",
"lucent_files",
"lucent_edges",
];
$exportDir = storage_path("exports");
if (!is_dir($exportDir)) {
mkdir($exportDir, 0755, true);
}
$stamp = now()->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}");
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace Lucent\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
class Import extends Command
{
protected $signature = "lucent:import";
protected $description = "Import data and files from an export archive";
public function handle(): void
{
$exportDir = storage_path("exports");
if (!is_dir($exportDir)) {
$this->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));
}
}
}
+1 -1
View File
@@ -84,8 +84,8 @@ class RecordController extends Controller
->childrenFields($schema?->visible ?? []) ->childrenFields($schema?->visible ?? [])
->childrenDepth(1) ->childrenDepth(1)
->parentsDepth(0) ->parentsDepth(0)
->runWithCount(); ->runWithCount();
$records = $graph->getRootRecords()->toArray(); $records = $graph->getRootRecords()->toArray();
$data = [ $data = [
+4
View File
@@ -20,6 +20,8 @@ use Lucent\Commands\LiveLink;
use Lucent\Commands\RebuildThumbnails; use Lucent\Commands\RebuildThumbnails;
use Lucent\Commands\RemoveOrphanEdges; use Lucent\Commands\RemoveOrphanEdges;
use Lucent\Commands\SetupDatabase; use Lucent\Commands\SetupDatabase;
use Lucent\Commands\Export;
use Lucent\Commands\Import;
use Lucent\Data\ChannelAuth; use Lucent\Data\ChannelAuth;
use Lucent\File\FileService; use Lucent\File\FileService;
use Lucent\Query\DatabaseGraph\DatabaseGraph; use Lucent\Query\DatabaseGraph\DatabaseGraph;
@@ -96,6 +98,8 @@ class LucentServiceProvider extends ServiceProvider
RemoveOrphanEdges::class, RemoveOrphanEdges::class,
SetupDatabase::class, SetupDatabase::class,
GenerateCollectionSchema::class, GenerateCollectionSchema::class,
Export::class,
Import::class,
]); ]);
} }
+4
View File
@@ -57,6 +57,7 @@ final class Query
$resultChildrenEdges, $resultChildrenEdges,
); );
} }
$resultParentSourceTargetIds = []; $resultParentSourceTargetIds = [];
$resultParentEdges = []; $resultParentEdges = [];
if ($this->options->parentsDepth > 0 && !empty($ids)) { if ($this->options->parentsDepth > 0 && !empty($ids)) {
@@ -126,6 +127,7 @@ final class Query
$queryRecord->isRoot = data_get($recordData, "isRoot") === true; $queryRecord->isRoot = data_get($recordData, "isRoot") === true;
return $queryRecord; return $queryRecord;
}) })
->toArray(); ->toArray();
$queryEdges = collect($edges) $queryEdges = collect($edges)
@@ -222,7 +224,9 @@ final class Query
$query = Database::make()->table("lucent_records"); $query = Database::make()->table("lucent_records");
$query = $this->parseFilters($query); $query = $this->parseFilters($query);
$query = $this->findNotLinked($query); $query = $this->findNotLinked($query);
$graph = $this->run(); $graph = $this->run();
$graph->total = $query->count(); $graph->total = $query->count();
return $graph; return $graph;
} }
+8 -10
View File
@@ -7,22 +7,20 @@ use Lucent\Schema\FieldInterface;
class InputFormatter class InputFormatter
{ {
public function __construct(public ChannelService $channelService) {}
public function __construct(
public ChannelService $channelService,
)
{
}
public function fill(string $schemaName, RecordData $input): RecordData public function fill(string $schemaName, RecordData $input): RecordData
{ {
$schema = $this->channelService->getSchema($schemaName)->get(); $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); return new RecordData($data);
} }
} }
+15 -12
View File
@@ -15,34 +15,37 @@ class Text implements FieldInterface, RequiredInterface
public function __construct( public function __construct(
public string $name, public string $name,
public string $label, public string $label,
public bool $required = false, public bool $required = false,
public bool $nullable = false, public bool $nullable = false,
public ?int $min = null, public ?int $min = null,
public ?int $max = null, public ?int $max = null,
public string $help = "", public string $help = "",
public string $default = "", public string $default = "",
public bool $readonly = false, public bool $readonly = false,
public string $optionsFrom = "", public string $optionsFrom = "",
public string $optionsField = "", public string $optionsField = "",
public bool $optionsSuggest = false, public bool $optionsSuggest = false,
public ?array $selectOptions = null, public ?array $selectOptions = null,
public string $group = "", public string $group = "",
) ) {
{
$this->info = new FieldInfo("text", "Text", FieldType::STRING); $this->info = new FieldInfo("text", "Text", FieldType::STRING);
} }
public function format(array $input, array $output): array public function format(array $input, array $output): array
{ {
$value = !empty($input[$this->name]) ? (string)$input[$this->name] : null; $value = !empty($input[$this->name])
$output[$this->name] = (new Nullable($this->nullable, $value, ""))->value(); ? (string) $input[$this->name]
: null;
$output[$this->name] = Nullable::make(
$this->nullable,
$value,
"",
)->value();
return $output; return $output;
} }
public function failRequired(mixed $value): bool public function failRequired(mixed $value): bool
{ {
return empty(trim($value)); return empty(trim($value));
} }
} }