Skip to content

Commit a6a7e88

Browse files
committed
export object to zip
1 parent 9886981 commit a6a7e88

7 files changed

Lines changed: 218 additions & 2 deletions

File tree

‎config/routes/export.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
$group->get('/collections/{collection}[/json]', Export\ExportJsonAction::class)->setName('export-json')->add(CollectionAccessMiddleware::class);
1313
$group->get('/collections/{collection}/csv', Export\ExportCsvAction::class)->setName('export-csv')->add(CollectionAccessMiddleware::class);
1414
$group->get('/collections/{collection}/zip', Export\ExportZipAction::class)->setName('export-zip')->add(CollectionAccessMiddleware::class);
15+
$group->get('/collections/{collection}/{id}/zip', Export\ExportObjectZipAction::class)->setName('export-object-zip')->add(CollectionAccessMiddleware::class);
1516
$group->get('/schemas/{schema}', Export\ExportSchemaAction::class)->setName('export-schema')->add(SchemaAccessMiddleware::class);
1617
$group->get('/jumpstart', Export\ExportJumpStartAction::class)->setName('export-jumpstart');
1718
$group->get('/jumpstart/demo', Export\ExportJumpStartDemoAction::class)->setName('export-jumpstart-demo');

‎css/admin-dashboard/buttons.scss‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ nav[popover] {
7575
&.link { --action-icon : var(--icon-link) }
7676
&.delete { --action-icon : var(--icon-trash-o) }
7777
&.clone { --action-icon : var(--icon-duplicate) }
78+
&.download { --action-icon : var(--icon-download) }
79+
&.object-download { --action-icon : var(--icon-download) }
7880
}
7981
a {
8082
display : flex;

‎resources/templates/admin/collection.twig‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
{% if cms.canAccessCollection(collection, 'create') %}
4040
<li class="duplicate" style="display:none"><a href="#" onclick="duplicateObject(event)">Duplicate</a></li>
4141
{% endif %}
42+
{# Show download zip only when editing an object (css in object.twig) #}
43+
<li class="object-download" style="display:none">
44+
<a href="{{ cms.api }}/export/collections/{{ collection }}/{{ page }}/zip">Download Zip</a>
45+
</li>
4246
<li class="export"><a href="collections/{{ collection }}/-export">Export</a></li>
4347
{% if cms.canAccessCollection(collection, 'create') %}
4448
<li class="import"><a href="collections/{{ collection }}/-import">Import</a></li>

‎resources/templates/admin/collection/object.twig‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ function duplicateObject(event) {
8383
</script>
8484
<style>
8585
#collection-actions .object-link,
86-
#collection-actions .duplicate {
86+
#collection-actions .duplicate,
87+
#collection-actions .object-download {
8788
display: list-item !important;
8889
}
8990
</style>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace TotalCMS\Action\Export;
4+
5+
use Nyholm\Psr7\Stream;
6+
use Psr\Http\Message\ResponseInterface;
7+
use Psr\Http\Message\ServerRequestInterface;
8+
use TotalCMS\Domain\Export\Service\ObjectZipper;
9+
use TotalCMS\Domain\Object\Repository\ObjectRepository;
10+
11+
readonly class ExportObjectZipAction
12+
{
13+
public function __construct(
14+
private ObjectZipper $objectZipper,
15+
private ObjectRepository $objectRepository,
16+
) {
17+
}
18+
19+
/** @param array<string,string> $args The arguments */
20+
public function __invoke(
21+
ServerRequestInterface $request,
22+
ResponseInterface $response,
23+
array $args,
24+
): ResponseInterface {
25+
$collection = $args['collection'];
26+
$id = $args['id'];
27+
28+
// Verify object exists
29+
if (!$this->objectRepository->existsObject($collection, $id)) {
30+
$response = $response->withStatus(404);
31+
$response->getBody()->write('Object not found');
32+
33+
return $response;
34+
}
35+
36+
try {
37+
$zipPath = $this->objectZipper->createObjectZip($collection, $id);
38+
$filename = $this->objectZipper->getZipFilename($collection, $id);
39+
40+
if (!file_exists($zipPath)) {
41+
$response = $response->withStatus(500);
42+
$response->getBody()->write('Failed to create zip file');
43+
44+
return $response;
45+
}
46+
47+
$zipContent = file_get_contents($zipPath);
48+
49+
// Clean up temporary file
50+
unlink($zipPath);
51+
52+
if ($zipContent === false) {
53+
$response = $response->withStatus(500);
54+
$response->getBody()->write('Failed to read zip file');
55+
56+
return $response;
57+
}
58+
59+
$response = $response->withHeader('Content-Type', 'application/zip')
60+
->withHeader('Content-Disposition', sprintf('attachment; filename="%s"', $filename))
61+
->withHeader('Content-Length', (string)strlen($zipContent));
62+
63+
return $response->withBody(Stream::create($zipContent));
64+
} catch (\RuntimeException $e) {
65+
$response = $response->withStatus(500);
66+
$response->getBody()->write('Error creating zip: ' . $e->getMessage());
67+
68+
return $response;
69+
}
70+
}
71+
}

‎src/Domain/Admin/CollectionTable.php‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,19 @@ private function buildObjectActionButton(array $object): string
303303
]);
304304
$clone = HTMLUtils::element('li', $clone, ['class' => 'clone']);
305305

306-
$actions = HTMLUtils::element('ul', $edit . $link . $clone . $delete);
306+
$download = HTMLUtils::element('a', 'Download Zip', [
307+
'href' => implode('/', [
308+
$this->config->api,
309+
'export',
310+
'collections',
311+
$this->collectionData->id,
312+
$id,
313+
'zip',
314+
]),
315+
]);
316+
$download = HTMLUtils::element('li', $download, ['class' => 'download']);
317+
318+
$actions = HTMLUtils::element('ul', $edit . $link . $clone . $download . $delete);
307319
$popover = HTMLUtils::element('nav', $actions, [
308320
'popover' => '',
309321
'class' => 'object-action-popover',
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace TotalCMS\Domain\Export\Service;
4+
5+
use TotalCMS\Infrastructure\Filesystem\PathUtils;
6+
use TotalCMS\Support\Config;
7+
8+
/**
9+
* Service for creating zip files of a single object's data and assets.
10+
*/
11+
readonly class ObjectZipper
12+
{
13+
public function __construct(
14+
private Config $config,
15+
) {
16+
}
17+
18+
/**
19+
* Create a zip file of an object's JSON file and assets folder.
20+
*
21+
* @param string $collection The collection name
22+
* @param string $id The object ID
23+
*
24+
* @throws \RuntimeException If zip creation fails or object not found
25+
*
26+
* @return string The path to the created zip file
27+
*/
28+
public function createObjectZip(string $collection, string $id): string
29+
{
30+
$datadir = $this->config->datadir;
31+
32+
// Paths
33+
$objectFile = PathUtils::buildPath(collection: $collection, filename: $id . '.json');
34+
$assetsPath = PathUtils::buildPath(collection: $collection, filename: $id);
35+
36+
$fullObjectPath = $datadir . DIRECTORY_SEPARATOR . $objectFile;
37+
38+
// Verify object exists
39+
if (!file_exists($fullObjectPath)) {
40+
throw new \RuntimeException("Object not found: {$collection}/{$id}");
41+
}
42+
43+
// Create temp zip
44+
$tempZipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR .
45+
'object-' . $collection . '-' . $id . '-' . time() . '.zip';
46+
47+
$zip = new \ZipArchive();
48+
$result = $zip->open($tempZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
49+
50+
if ($result !== true) {
51+
throw new \RuntimeException(sprintf('Failed to create zip file: %s (Error code: %d)', $tempZipPath, $result));
52+
}
53+
54+
// Add JSON file
55+
$zip->addFile($fullObjectPath, $id . '.json');
56+
57+
// Add assets folder if exists and has non-cache contents
58+
$fullAssetsPath = $datadir . DIRECTORY_SEPARATOR . $assetsPath;
59+
if (is_dir($fullAssetsPath) && $this->hasNonCacheContents($fullAssetsPath)) {
60+
$this->addDirectoryToZip($zip, $fullAssetsPath, $id);
61+
}
62+
63+
$zip->close();
64+
65+
return $tempZipPath;
66+
}
67+
68+
/**
69+
* Check if a directory has any contents that are not .cache directories.
70+
*/
71+
private function hasNonCacheContents(string $path): bool
72+
{
73+
$iterator = new \DirectoryIterator($path);
74+
foreach ($iterator as $file) {
75+
if ($file->isDot()) {
76+
continue;
77+
}
78+
// Skip .cache directories
79+
if ($file->getFilename() === '.cache') {
80+
continue;
81+
}
82+
83+
return true;
84+
}
85+
86+
return false;
87+
}
88+
89+
/**
90+
* Recursively add directory contents to zip, excluding .cache folders.
91+
*/
92+
private function addDirectoryToZip(\ZipArchive $zip, string $realPath, string $zipPath): void
93+
{
94+
$iterator = new \RecursiveIteratorIterator(
95+
new \RecursiveDirectoryIterator($realPath, \RecursiveDirectoryIterator::SKIP_DOTS),
96+
\RecursiveIteratorIterator::SELF_FIRST
97+
);
98+
99+
foreach ($iterator as $file) {
100+
$filePath = $file->getRealPath();
101+
$relativePath = substr((string)$filePath, strlen($realPath) + 1);
102+
103+
// Skip .cache directories and their contents
104+
if (str_contains($relativePath, '.cache')) {
105+
continue;
106+
}
107+
108+
$zipFilePath = $zipPath . DIRECTORY_SEPARATOR . $relativePath;
109+
110+
if ($file->isDir()) {
111+
$zip->addEmptyDir($zipFilePath);
112+
} elseif ($file->isFile()) {
113+
$zip->addFile($filePath, $zipFilePath);
114+
}
115+
}
116+
}
117+
118+
/**
119+
* Get the filename for the zip download.
120+
*/
121+
public function getZipFilename(string $collection, string $id): string
122+
{
123+
return sprintf('%s--%s.zip', $collection, $id);
124+
}
125+
}

0 commit comments

Comments
 (0)