Commit b43f4f95 authored by samuel.mortenson's avatar samuel.mortenson Committed by Samuel Mortenson

Issue #3049979 by samuel.mortenson: Add an abstract service for handling file exports

parent fc72404d
......@@ -110,3 +110,24 @@ uncached paths when cron runs and works through them until the cache is full.
To use tome_static_cron, install the module then visit
/admin/config/services/tome_static_cron/settings to enter the base URL to use
for static cron generations.
MODIFYING TOME SYNC FILE HANDLING
=================================
By default, when file entities are exported, imported, or deleted, Tome Sync
will keep the file export directory and your public file directory in sync by
performing copies or deletes.
This may not be desirable if you prefer to just symlink your files directory
to a directory tracked by Git, or do not want to track files in Git at all and
prefer to use persistent storage.
In those cases, you can override the file handling service to use an alternate
class that does nothing on file sync operations. To do this, add this block of
code to your per-site services.yml file:
```
services:
tome_sync.file_sync:
class: Drupal\tome_sync\NullFileSync
```
......@@ -6,7 +6,8 @@ use Drupal\Core\Config\StorageInterface;
use Drupal\tome_base\CommandBase;
use Drupal\tome_base\PathTrait;
use Drupal\tome_sync\ContentIndexerTrait;
use Drupal\tome_sync\FileTrait;
use Drupal\tome_sync\FileSync;
use Drupal\tome_sync\FileSyncInterface;
use Drupal\tome_sync\TomeSyncHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
......@@ -18,7 +19,6 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
class CleanFilesCommand extends CommandBase {
use FileTrait;
use PathTrait;
use ContentIndexerTrait;
......@@ -36,6 +36,13 @@ class CleanFilesCommand extends CommandBase {
*/
protected $configStorage;
/**
* The file sync service.
*
* @var \Drupal\tome_sync\FileSyncInterface
*/
protected $fileSync;
/**
* Creates a CleanFilesCommand object.
*
......@@ -43,11 +50,14 @@ class CleanFilesCommand extends CommandBase {
* The target content storage.
* @param \Drupal\Core\Config\StorageInterface $config_storage
* The target config storage.
* @param \Drupal\tome_sync\FileSyncInterface $file_sync
* The file sync service.
*/
public function __construct(StorageInterface $content_storage, StorageInterface $config_storage) {
public function __construct(StorageInterface $content_storage, StorageInterface $config_storage, FileSyncInterface $file_sync) {
parent::__construct();
$this->contentStorage = $content_storage;
$this->configStorage = $config_storage;
$this->fileSync = $file_sync;
}
/**
......@@ -72,11 +82,10 @@ class CleanFilesCommand extends CommandBase {
if (!$this->io()->confirm('The files listed above will be deleted.', FALSE)) {
return 0;
}
$file_directory = $this->getFileDirectory();
foreach ($files as $uuid => $filename) {
$this->contentStorage->delete("file.$uuid");
$this->unIndexContentByName("file.$uuid");
file_unmanaged_delete($this->joinPaths($file_directory, $filename));
$this->fileSync->deleteFile($filename);
}
$this->io()->success('Deleted all unused files.');
}
......
......@@ -20,7 +20,6 @@ use Symfony\Component\Serializer\Serializer;
*/
class Exporter implements ExporterInterface {
use FileTrait;
use PathTrait;
use ContentIndexerTrait;
use AccountSwitcherTrait;
......@@ -53,6 +52,13 @@ class Exporter implements ExporterInterface {
*/
protected $eventDispatcher;
/**
* The file sync service.
*
* @var \Drupal\tome_sync\FileSyncInterface
*/
protected $fileSync;
/**
* An array of excluded entity types.
*
......@@ -75,13 +81,16 @@ class Exporter implements ExporterInterface {
* The event dispatcher.
* @param \Drupal\Core\Session\AccountSwitcherInterface $account_switcher
* The account switcher.
* @param \Drupal\tome_sync\FileSyncInterface $file_sync
* The file sync service.
*/
public function __construct(StorageInterface $content_storage, Serializer $serializer, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher, AccountSwitcherInterface $account_switcher) {
public function __construct(StorageInterface $content_storage, Serializer $serializer, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher, AccountSwitcherInterface $account_switcher, FileSyncInterface $file_sync) {
$this->contentStorage = $content_storage;
$this->serializer = $serializer;
$this->entityTypeManager = $entity_type_manager;
$this->eventDispatcher = $event_dispatcher;
$this->accountSwitcher = $account_switcher;
$this->fileSync = $file_sync;
}
/**
......@@ -104,14 +113,10 @@ class Exporter implements ExporterInterface {
*/
public function deleteExportDirectories() {
$this->contentStorage->deleteAll();
$file_directory = $this->getFileDirectory();
$this->deleteContentIndex();
if (file_exists($file_directory)) {
if (!file_unmanaged_delete_recursive($file_directory)) {
return FALSE;
}
if (!$this->fileSync->deleteExportDirectory()) {
return FALSE;
}
$this->ensureFileDirectory();
return TRUE;
}
......@@ -127,30 +132,13 @@ class Exporter implements ExporterInterface {
$this->contentStorage->write(TomeSyncHelper::getContentName($entity), $data);
$this->indexContent($entity);
if ($entity instanceof FileInterface) {
$this->exportFile($entity);
$this->fileSync->exportFile($entity);
}
$event = new ContentCrudEvent($entity);
$this->eventDispatcher->dispatch(TomeSyncEvents::EXPORT_CONTENT, $event);
$this->switchBack();
}
/**
* Exports a file to the export directory.
*
* @param \Drupal\file\FileInterface $file
* The file entity.
*/
protected function exportFile(FileInterface $file) {
$this->ensureFileDirectory();
$file_directory = $this->getFileDirectory();
if (strpos($file->getFileUri(), 'public://') === 0 && file_exists($file->getFileUri())) {
$destination = $this->joinPaths($file_directory, file_uri_target($file->getFileUri()));
$directory = dirname($destination);
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
file_unmanaged_copy($file->getFileUri(), $destination, FILE_EXISTS_REPLACE);
}
}
/**
* {@inheritdoc}
*/
......@@ -162,26 +150,10 @@ class Exporter implements ExporterInterface {
$this->unIndexContent($entity);
}
if ($entity instanceof FileInterface) {
$this->deleteFileExport($entity);
$this->fileSync->deleteFileExport($entity);
}
$event = new ContentCrudEvent($entity);
$this->eventDispatcher->dispatch(TomeSyncEvents::DELETE_CONTENT, $event);
}
/**
* Deletes an exported file.
*
* @param \Drupal\file\FileInterface $file
* The file entity.
*/
protected function deleteFileExport(FileInterface $file) {
$file_directory = $this->getFileDirectory();
if (strpos($file->getFileUri(), 'public://') === 0) {
$path = $this->joinPaths($file_directory, file_uri_target($file->getFileUri()));
if (file_exists($path)) {
file_unmanaged_delete($path);
}
}
}
}
......@@ -19,6 +19,9 @@ interface ExporterInterface {
/**
* Deletes all content and files from the export directories.
*
* @return bool
* Whether or not the deletion was successful.
*/
public function deleteExportDirectories();
......
<?php
namespace Drupal\tome_sync;
use Drupal\Core\Config\StorageException;
use Drupal\Core\Site\Settings;
use Drupal\file\FileInterface;
use Drupal\tome_base\PathTrait;
/**
* Handles file import and exports by keeping a file export directory in sync.
*
* @internal
*/
class FileSync implements FileSyncInterface {
use PathTrait;
/**
* {@inheritdoc}
*/
public function importFiles() {
$file_directory = $this->getFileDirectory();
/** @var \Drupal\file\FileInterface $file */
foreach (file_scan_directory($file_directory, '/.*/') as $file) {
$destination = 'public://' . ltrim(str_replace($file_directory, '', $file->uri), '/');
$directory = dirname($destination);
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
file_unmanaged_copy($file->uri, $destination, FILE_EXISTS_REPLACE);
}
}
/**
* {@inheritdoc}
*/
public function deleteExportDirectory() {
$file_directory = $this->getFileDirectory();
if (file_exists($file_directory)) {
if (!file_unmanaged_delete_recursive($file_directory)) {
return FALSE;
}
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function exportFile(FileInterface $file) {
$this->ensureFileDirectory();
$file_directory = $this->getFileDirectory();
if (strpos($file->getFileUri(), 'public://') === 0 && file_exists($file->getFileUri())) {
$destination = $this->joinPaths($file_directory, file_uri_target($file->getFileUri()));
$directory = dirname($destination);
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
file_unmanaged_copy($file->getFileUri(), $destination, FILE_EXISTS_REPLACE);
}
}
/**
* {@inheritdoc}
*/
public function deleteFileExport(FileInterface $file) {
$file_directory = $this->getFileDirectory();
if (strpos($file->getFileUri(), 'public://') === 0) {
$path = $this->joinPaths($file_directory, file_uri_target($file->getFileUri()));
if (file_exists($path)) {
file_unmanaged_delete($path);
}
}
}
/**
* {@inheritdoc}
*/
public function deleteFile($filename) {
$path = $this->joinPaths($this->getFileDirectory(), $filename);
if (file_exists($path)) {
file_unmanaged_delete($path);
}
}
/**
* Gets the file directory.
*
* @return string
* The file directory.
*/
protected function getFileDirectory() {
return Settings::get('tome_files_directory', '../files') . '/public';
}
/**
* Ensures that the file directory exists.
*/
protected function ensureFileDirectory() {
$file_directory = $this->getFileDirectory();
file_prepare_directory($file_directory, FILE_CREATE_DIRECTORY);
file_save_htaccess($file_directory);
if (!file_exists($file_directory)) {
throw new StorageException('Failed to create config directory ' . $file_directory);
}
}
}
<?php
namespace Drupal\tome_sync;
use Drupal\file\FileInterface;
/**
* Abstractly handles file import and exports.
*/
interface FileSyncInterface {
/**
* Imports all files from the file directory.
*/
public function importFiles();
/**
* Deletes the file export directory.
*
* @return bool
* Whether or not the deletion was successful.
*/
public function deleteExportDirectory();
/**
* Exports a file to the export directory.
*
* @param \Drupal\file\FileInterface $file
* The file entity.
*/
public function exportFile(FileInterface $file);
/**
* Deletes an exported file by entity.
*
* @param \Drupal\file\FileInterface $file
* The file entity.
*/
public function deleteFileExport(FileInterface $file);
/**
* Deletes an exported file by name.
*
* @param string $filename
* The file name.
*/
public function deleteFile($filename);
}
<?php
namespace Drupal\tome_sync;
use Drupal\Core\Config\StorageException;
use Drupal\Core\Site\Settings;
/**
* Contains shared functionality for dealing with files.
*
* @internal
*/
trait FileTrait {
/**
* Gets the file directory.
*
* @return string
* The file directory.
*/
protected function getFileDirectory() {
return Settings::get('tome_files_directory', '../files') . '/public';
}
/**
* Ensures that the file directory exists.
*/
protected function ensureFileDirectory() {
$file_directory = $this->getFileDirectory();
file_prepare_directory($file_directory, FILE_CREATE_DIRECTORY);
file_save_htaccess($file_directory);
if (!file_exists($file_directory)) {
throw new StorageException('Failed to create config directory ' . $file_directory);
}
}
}
......@@ -21,7 +21,6 @@ use Symfony\Component\Serializer\Serializer;
*/
class Importer implements ImporterInterface {
use FileTrait;
use ContentIndexerTrait;
use AccountSwitcherTrait;
......@@ -60,6 +59,13 @@ class Importer implements ImporterInterface {
*/
protected $eventDispatcher;
/**
* The file sync service.
*
* @var \Drupal\tome_sync\FileSyncInterface
*/
protected $fileSync;
/**
* Creates an Importer object.
*
......@@ -73,14 +79,17 @@ class Importer implements ImporterInterface {
* The event dispatcher.
* @param \Drupal\Core\Session\AccountSwitcherInterface $account_switcher
* The account switcher.
* @param \Drupal\tome_sync\FileSyncInterface $file_sync
* The file sync service.
*/
public function __construct(StorageInterface $content_storage, Serializer $serializer, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher, AccountSwitcherInterface $account_switcher) {
public function __construct(StorageInterface $content_storage, Serializer $serializer, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher, AccountSwitcherInterface $account_switcher, FileSyncInterface $file_sync) {
$this->contentStorage = $content_storage;
$this->serializer = $serializer;
$this->entityTypeManager = $entity_type_manager;
$this->isImporting = FALSE;
$this->eventDispatcher = $event_dispatcher;
$this->accountSwitcher = $account_switcher;
$this->fileSync = $file_sync;
}
/**
......@@ -135,14 +144,7 @@ class Importer implements ImporterInterface {
* {@inheritdoc}
*/
public function importFiles() {
$file_directory = $this->getFileDirectory();
/** @var \Drupal\file\FileInterface $file */
foreach (file_scan_directory($file_directory, '/.*/') as $file) {
$destination = 'public://' . ltrim(str_replace($file_directory, '', $file->uri), '/');
$directory = dirname($destination);
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
file_unmanaged_copy($file->uri, $destination, FILE_EXISTS_REPLACE);
}
$this->fileSync->importFiles();
}
/**
......
<?php
namespace Drupal\tome_sync;
use Drupal\file\FileInterface;
/**
* Implements all file sync methods as no-ops.
*
* This is useful for sites that do not store files in Git, or want to
* implement their own syncing strategy for files.
*
* @internal
*/
class NullFileSync implements FileSyncInterface {
/**
* {@inheritdoc}
*/
public function importFiles() {}
/**
* {@inheritdoc}
*/
public function deleteExportDirectory() {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function exportFile(FileInterface $file) {}
/**
* {@inheritdoc}
*/
public function deleteFileExport(FileInterface $file) {}
/**
* {@inheritdoc}
*/
public function deleteFile($filename) {}
}
......@@ -3,7 +3,6 @@
namespace Drupal\Tests\tome_sync\Kernel;
use Drupal\Core\Site\Settings;
use Drupal\file\Entity\File;
use Drupal\node\Entity\Node;
use Drupal\Tests\tome_base\Kernel\TestBase;
use Drupal\tome_sync\TomeSyncHelper;
......@@ -85,19 +84,6 @@ class ExporterTest extends TestBase {
$this->assertContains($user_name, $index[$article_name]);
}
/**
* @covers \Drupal\tome_sync\Exporter::exportFile
*/
public function testExportFile() {
file_put_contents('public://example.txt', $this->randomMachineName());
$file = File::create([
'uri' => 'public://example.txt',
]);
$file->save();
$this->assertTrue(file_exists(Settings::get('tome_files_directory') . '/public/example.txt'));
}
/**
* @covers \Drupal\tome_sync\Exporter::deleteContentExport
*/
......@@ -134,21 +120,6 @@ class ExporterTest extends TestBase {
$this->assertArrayNotHasKey($article_name, $index);
}
/**
* @covers \Drupal\tome_sync\Exporter::deleteFileExport
*/
public function testDeleteFileExport() {
file_put_contents('public://example.txt', $this->randomMachineName());
$file = File::create([
'uri' => 'public://example.txt',
]);
$file->save();
$this->assertTrue(file_exists(Settings::get('tome_files_directory') . '/public/example.txt'));
$file->delete();
$this->assertFalse(file_exists(Settings::get('tome_files_directory') . '/public/example.txt'));
}
/**
* @covers \Drupal\tome_sync\EventSubscriber\ConfigEventSubscriber::configDelete
* @covers \Drupal\tome_sync\EventSubscriber\LanguageConfigEventSubscriber::configDelete
......
<?php
namespace Drupal\Tests\tome_sync\Kernel;
use Drupal\Core\Site\Settings;
use Drupal\file\Entity\File;
use Drupal\Tests\tome_base\Kernel\TestBase;
/**
* Tests that the file sync works.
*
* @group tome_sync
*/
class FileSyncTest extends TestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'tome_sync',
];
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$this->installSchema('tome_sync', ['tome_sync_content_hash']);
}
/**
* @covers \Drupal\tome_sync\FileSync::importFiles
*/
public function testImportFiles() {
$directory = Settings::get('tome_files_directory') . '/public/foo';
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
touch(Settings::get('tome_files_directory') . '/public/foo/example.txt');
touch(Settings::get('tome_files_directory') . '/public/example.txt');
\Drupal::service('tome_sync.file_sync')->importFiles();
$this->assertTrue(file_exists('public://foo/example.txt'));
$this->assertTrue(file_exists('public://example.txt'));
}
/**
* @covers \Drupal\tome_sync\FileSync::deleteExportDirectory
*/
public function testDeleteExportDirectory() {
$directory = Settings::get('tome_files_directory') . '/public';
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
touch($directory . '/example.txt');
\Drupal::service('tome_sync.file_sync')->deleteExportDirectory();
$this->assertFalse(file_exists($directory));
}
/**
* @covers \Drupal\tome_sync\FileSync::deleteFile
*/
public function testDeleteFile() {
$directory = Settings::get('tome_files_directory') . '/public';
file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
touch($directory . '/example.txt');
\Drupal::service('tome_sync.file_sync')->deleteFile('example.txt');
$this->assertFalse(file_exists($directory . '/example.txt'));
}
/**
* @covers \Drupal\tome_sync\FileSync::exportFile
*/
public function testExportFile() {
touch('public://example.txt');
$file = File::create([
'uri' => 'public://example.txt',
]);
$file->save();
$this->assertTrue(file_exists(Settings::get('tome_files_directory') . '/public/example.txt'));
}
/**
* @covers \Drupal\tome_sync\FileSync::deleteFileExport
*/
public function testDeleteFileExport() {
touch('public://example.txt');
$file = File::create([
'uri' => 'public://example.txt',
]);
$file->save();
$this->assertTrue(file_exists(Settings::get('tome_files_directory') . '/public/example.txt'));