Split attachment paths (#848)

* fixed attachment statistics for sqlite

* Split attachment path into internal and external path, so the external source URL can be retained after a file is downloaded

* Make internal and external path for attachments nullable, to make clear that they have no internal or external path

* Added migrations for nullable columns for postgres and mysql

* Added migration for nullable internal and external pathes for sqlite

* Added translations

* Fixed upload error

* Restrict length of filename badge in attachment edit view

* Improved margins with badges in attachment edit

* Added a link to view external version from attachment edit

* Let media_url  stay in API attachments responses for backward compatibility

---------

Co-authored-by: jona <a@b.c>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
Treeed 2025-02-22 17:29:14 +01:00 committed by GitHub
parent ebb977e99f
commit 29f92d9bd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 561 additions and 371 deletions

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250220215048 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Split $path property for attachments into $internal_path and $external_path';
}
public function mySQLUp(Schema $schema): void
{
//Create the new columns as nullable (that is easier modifying them)
$this->addSql('ALTER TABLE attachments ADD internal_path VARCHAR(255) DEFAULT NULL, ADD external_path VARCHAR(255) DEFAULT NULL');
//Copy the data from path to external_path and remove the path column
$this->addSql('UPDATE attachments SET external_path=path');
$this->addSql('ALTER TABLE attachments DROP path');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%MEDIA#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%BASE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%SECURE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS3D#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET external_path=NULL WHERE internal_path IS NOT NULL');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('UPDATE attachments SET external_path=internal_path WHERE internal_path IS NOT NULL');
$this->addSql('ALTER TABLE attachments DROP internal_path');
$this->addSql('ALTER TABLE attachments RENAME COLUMN external_path TO path');
}
public function postgreSQLUp(Schema $schema): void
{
//We can use the same SQL for PostgreSQL as for MySQL
$this->mySQLUp($schema);
}
public function postgreSQLDown(Schema $schema): void
{
//We can use the same SQL for PostgreSQL as for MySQL
$this->mySQLDown($schema);
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__attachments AS SELECT id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, path FROM attachments');
$this->addSql('DROP TABLE attachments');
$this->addSql('CREATE TABLE attachments (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, type_id INTEGER NOT NULL, original_filename VARCHAR(255) DEFAULT NULL, show_in_table BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, class_name VARCHAR(255) NOT NULL, element_id INTEGER NOT NULL, internal_path VARCHAR(255) DEFAULT NULL, external_path VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_47C4FAD6C54C8C93 FOREIGN KEY (type_id) REFERENCES attachment_types (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO attachments (id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, external_path) SELECT id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, path FROM __temp__attachments');
$this->addSql('DROP TABLE __temp__attachments');
$this->addSql('CREATE INDEX attachment_element_idx ON attachments (class_name, element_id)');
$this->addSql('CREATE INDEX attachment_name_idx ON attachments (name)');
$this->addSql('CREATE INDEX attachments_idx_class_name_id ON attachments (class_name, id)');
$this->addSql('CREATE INDEX attachments_idx_id_element_id_class_name ON attachments (id, element_id, class_name)');
$this->addSql('CREATE INDEX IDX_47C4FAD6C54C8C93 ON attachments (type_id)');
$this->addSql('CREATE INDEX IDX_47C4FAD61F1F2A24 ON attachments (element_id)');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%MEDIA#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%BASE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%SECURE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS3D#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET external_path=NULL WHERE internal_path IS NOT NULL');
}
public function sqLiteDown(Schema $schema): void
{
//Reuse the MySQL down migration:
$this->mySQLDown($schema);
}
}

View file

@ -51,15 +51,15 @@ class AttachmentFileController extends AbstractController
$this->denyAccessUnlessGranted('show_private', $attachment); $this->denyAccessUnlessGranted('show_private', $attachment);
} }
if ($attachment->isExternal()) { if (!$attachment->hasInternal()) {
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!'); throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
} }
if (!$helper->isFileExisting($attachment)) { if (!$helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!'); throw $this->createNotFoundException('The file associated with the attachment is not existing!');
} }
$file_path = $helper->toAbsoluteFilePath($attachment); $file_path = $helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path); $response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded //Set header content disposition, so that the file will be downloaded
@ -80,15 +80,15 @@ class AttachmentFileController extends AbstractController
$this->denyAccessUnlessGranted('show_private', $attachment); $this->denyAccessUnlessGranted('show_private', $attachment);
} }
if ($attachment->isExternal()) { if (!$attachment->hasInternal()) {
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!'); throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
} }
if (!$helper->isFileExisting($attachment)) { if (!$helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!'); throw $this->createNotFoundException('The file associated with the attachment is not existing!');
} }
$file_path = $helper->toAbsoluteFilePath($attachment); $file_path = $helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path); $response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded //Set header content disposition, so that the file will be downloaded

View file

@ -131,7 +131,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$attachment->setName('Test2'); $attachment->setName('Test2');
$attachment->setPath('invalid'); $attachment->setInternalPath('invalid');
$attachment->setShowInTable(true); $attachment->setShowInTable(true);
$attachment->setAttachmentType($manager->find(AttachmentType::class, 1)); $attachment->setAttachmentType($manager->find(AttachmentType::class, 1));
$part->addAttachment($attachment); $part->addAttachment($attachment);

View file

@ -50,8 +50,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
{ {
$dataTable->add('dont_matter', RowClassColumn::class, [ $dataTable->add('dont_matter', RowClassColumn::class, [
'render' => function ($value, Attachment $context): string { 'render' => function ($value, Attachment $context): string {
//Mark attachments with missing files yellow //Mark attachments yellow which have an internal file linked that doesn't exist
if(!$this->attachmentHelper->isFileExisting($context)){ if($context->hasInternal() && !$this->attachmentHelper->isInternalFileExisting($context)){
return 'table-warning'; return 'table-warning';
} }
@ -64,8 +64,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
'className' => 'no-colvis', 'className' => 'no-colvis',
'render' => function ($value, Attachment $context): string { 'render' => function ($value, Attachment $context): string {
if ($context->isPicture() if ($context->isPicture()
&& !$context->isExternal() && $this->attachmentHelper->isInternalFileExisting($context)) {
&& $this->attachmentHelper->isFileExisting($context)) {
$title = htmlspecialchars($context->getName()); $title = htmlspecialchars($context->getName());
if ($context->getFilename()) { if ($context->getFilename()) {
$title .= ' ('.htmlspecialchars($context->getFilename()).')'; $title .= ' ('.htmlspecialchars($context->getFilename()).')';
@ -93,26 +93,6 @@ final class AttachmentDataTable implements DataTableTypeInterface
$dataTable->add('name', TextColumn::class, [ $dataTable->add('name', TextColumn::class, [
'label' => 'attachment.edit.name', 'label' => 'attachment.edit.name',
'orderField' => 'NATSORT(attachment.name)', 'orderField' => 'NATSORT(attachment.name)',
'render' => function ($value, Attachment $context) {
//Link to external source
if ($context->isExternal()) {
return sprintf(
'<a href="%s" class="link-external">%s</a>',
htmlspecialchars((string) $context->getURL()),
htmlspecialchars($value)
);
}
if ($this->attachmentHelper->isFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
);
}
return $value;
},
]); ]);
$dataTable->add('attachment_type', TextColumn::class, [ $dataTable->add('attachment_type', TextColumn::class, [
@ -136,25 +116,57 @@ final class AttachmentDataTable implements DataTableTypeInterface
), ),
]); ]);
$dataTable->add('filename', TextColumn::class, [ $dataTable->add('internal_link', TextColumn::class, [
'label' => $this->translator->trans('attachment.table.filename'), 'label' => 'attachment.table.internal_file',
'propertyPath' => 'filename', 'propertyPath' => 'filename',
'render' => function ($value, Attachment $context) {
if ($this->attachmentHelper->isInternalFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
);
}
return $value;
}
]);
$dataTable->add('external_link', TextColumn::class, [
'label' => 'attachment.table.external_link',
'propertyPath' => 'host',
'render' => function ($value, Attachment $context) {
if ($context->hasExternal()) {
return sprintf(
'<a href="%s" class="link-external">%s</a>',
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars($value)
);
}
return $value;
}
]); ]);
$dataTable->add('filesize', TextColumn::class, [ $dataTable->add('filesize', TextColumn::class, [
'label' => $this->translator->trans('attachment.table.filesize'), 'label' => $this->translator->trans('attachment.table.filesize'),
'render' => function ($value, Attachment $context) { 'render' => function ($value, Attachment $context) {
if ($context->isExternal()) { if (!$context->hasInternal()) {
return sprintf( return sprintf(
'<span class="badge bg-primary"> '<span class="badge bg-primary">
<i class="fas fa-globe fa-fw"></i>%s <i class="fas fa-globe fa-fw"></i>%s
</span>', </span>',
$this->translator->trans('attachment.external') $this->translator->trans('attachment.external_only')
); );
} }
if ($this->attachmentHelper->isFileExisting($context)) { if ($this->attachmentHelper->isInternalFileExisting($context)) {
return $this->attachmentHelper->getHumanFileSize($context); return sprintf(
'<span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> %s
</span>',
$this->attachmentHelper->getHumanFileSize($context)
);
} }
return sprintf( return sprintf(

View file

@ -78,11 +78,16 @@ use LogicException;
denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'], denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
processor: HandleAttachmentsUploadsProcessor::class, processor: HandleAttachmentsUploadsProcessor::class,
)] )]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true, //This property is added by the denormalizer in order to resolve the placeholder
description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.', #[DocumentedAPIProperty(
example: '/media/part/2/bc547-6508afa5a79c8.pdf')] schemaName: 'Attachment-Read', property: 'internal_path', type: 'string', nullable: false,
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true, description: 'The URL to the internally saved copy of the file, if one exists',
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.')] example: '/media/part/2/bc547-6508afa5a79c8.pdf'
)]
#[DocumentedAPIProperty(
schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.'
)]
#[ApiFilter(LikeFilter::class, properties: ["name"])] #[ApiFilter(LikeFilter::class, properties: ["name"])]
#[ApiFilter(EntityFilter::class, properties: ["attachment_type"])] #[ApiFilter(EntityFilter::class, properties: ["attachment_type"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
@ -119,10 +124,6 @@ abstract class Attachment extends AbstractNamedDBElement
*/ */
final public const MODEL_EXTS = ['x3d']; final public const MODEL_EXTS = ['x3d'];
/**
* When the path begins with one of the placeholders.
*/
final public const INTERNAL_PLACEHOLDER = ['%BASE%', '%MEDIA%', '%SECURE%'];
/** /**
* @var array placeholders for attachments which using built in files * @var array placeholders for attachments which using built in files
@ -152,10 +153,21 @@ abstract class Attachment extends AbstractNamedDBElement
protected ?string $original_filename = null; protected ?string $original_filename = null;
/** /**
* @var string The path to the file relative to a placeholder path like %MEDIA% * @var string|null If a copy of the file is stored internally, the path to the file relative to a placeholder
* path like %MEDIA%
*/ */
#[ORM\Column(name: 'path', type: Types::STRING)] #[ORM\Column(type: Types::STRING, nullable: true)]
protected string $path = ''; protected ?string $internal_path = null;
/**
* @var string|null The path to the external source if the file is stored externally or was downloaded from an
* external source. Null if there is no external source.
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['attachment:read'])]
#[ApiProperty(example: 'http://example.com/image.jpg')]
protected ?string $external_path = null;
/** /**
* @var string the name of this element * @var string the name of this element
@ -237,7 +249,7 @@ abstract class Attachment extends AbstractNamedDBElement
/** /**
* Check if this attachment is a picture (analyse the file's extension). * Check if this attachment is a picture (analyse the file's extension).
* If the link is external, it is assumed that this is true. * If the link is only external and doesn't contain an extension, it is assumed that this is true.
* *
* @return bool * true if the file extension is a picture extension * @return bool * true if the file extension is a picture extension
* * otherwise false * * otherwise false
@ -245,54 +257,67 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
public function isPicture(): bool public function isPicture(): bool
{ {
if ($this->isExternal()) { if($this->hasInternal()){
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
}
if ($this->hasExternal()) {
//Check if we can extract a file extension from the URL //Check if we can extract a file extension from the URL
$extension = pathinfo(parse_url($this->path, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION); $extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
//If no extension is found or it is known picture extension, we assume that this is a picture extension //If no extension is found or it is known picture extension, we assume that this is a picture extension
return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true); return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true);
} }
//File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture...
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION); return false;
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
} }
/** /**
* Check if this attachment is a 3D model and therefore can be directly shown to user. * Check if this attachment is a 3D model and therefore can be directly shown to user.
* If the attachment is external, false is returned (3D Models must be internal). * If no internal copy exists, false is returned (3D Models must be internal).
*/ */
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
#[SerializedName('3d_model')] #[SerializedName('3d_model')]
public function is3DModel(): bool public function is3DModel(): bool
{ {
//We just assume that 3D Models are internally saved, otherwise we get problems loading them. //We just assume that 3D Models are internally saved, otherwise we get problems loading them.
if ($this->isExternal()) { if (!$this->hasInternal()) {
return false; return false;
} }
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION); $extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::MODEL_EXTS, true); return in_array(strtolower($extension), static::MODEL_EXTS, true);
} }
/** /**
* Checks if the attachment file is externally saved (the database saves an URL). * Checks if this attachment has a path to an external file
* *
* @return bool true, if the file is saved externally * @return bool true, if there is a path to an external file
* @phpstan-assert-if-true non-empty-string $this->external_path
* @phpstan-assert-if-true non-empty-string $this->getExternalPath())
*/ */
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
public function isExternal(): bool public function hasExternal(): bool
{ {
//When path is empty, this attachment can not be external return $this->external_path !== null && $this->external_path !== '';
if ($this->path === '') { }
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode /**
$tmp = explode('/', $this->path); * Checks if this attachment has a path to an internal file.
* Does not check if the file exists.
return !in_array($tmp[0], array_merge(static::INTERNAL_PLACEHOLDER, static::BUILTIN_PLACEHOLDER), true); *
* @return bool true, if there is a path to an internal file
* @phpstan-assert-if-true non-empty-string $this->internal_path
* @phpstan-assert-if-true non-empty-string $this->getInternalPath())
*/
#[Groups(['attachment:read'])]
public function hasInternal(): bool
{
return $this->internal_path !== null && $this->internal_path !== '';
} }
/** /**
@ -305,8 +330,12 @@ abstract class Attachment extends AbstractNamedDBElement
#[SerializedName('private')] #[SerializedName('private')]
public function isSecure(): bool public function isSecure(): bool
{ {
if ($this->internal_path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode //After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $this->path); $tmp = explode('/', $this->internal_path);
return '%SECURE%' === $tmp[0]; return '%SECURE%' === $tmp[0];
} }
@ -320,7 +349,11 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
public function isBuiltIn(): bool public function isBuiltIn(): bool
{ {
return static::checkIfBuiltin($this->path); if ($this->internal_path === null) {
return false;
}
return static::checkIfBuiltin($this->internal_path);
} }
/******************************************************************************** /********************************************************************************
@ -332,13 +365,13 @@ abstract class Attachment extends AbstractNamedDBElement
/** /**
* Returns the extension of the file referenced via the attachment. * Returns the extension of the file referenced via the attachment.
* For a path like %BASE/path/foo.bar, bar will be returned. * For a path like %BASE/path/foo.bar, bar will be returned.
* If this attachment is external null is returned. * If this attachment is only external null is returned.
* *
* @return string|null the file extension in lower case * @return string|null the file extension in lower case
*/ */
public function getExtension(): ?string public function getExtension(): ?string
{ {
if ($this->isExternal()) { if (!$this->hasInternal()) {
return null; return null;
} }
@ -346,7 +379,7 @@ abstract class Attachment extends AbstractNamedDBElement
return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION)); return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION));
} }
return strtolower(pathinfo($this->getPath(), PATHINFO_EXTENSION)); return strtolower(pathinfo($this->getInternalPath(), PATHINFO_EXTENSION));
} }
/** /**
@ -361,52 +394,54 @@ abstract class Attachment extends AbstractNamedDBElement
} }
/** /**
* The URL to the external file, or the path to the built-in file. * The URL to the external file, or the path to the built-in file, but not paths to uploaded files.
* Returns null, if the file is not external (and not builtin). * Returns null, if the file is not external (and not builtin).
* The output of this function is such, that no changes occur when it is fed back into setURL().
* Required for the Attachment form field.
*/ */
#[Groups(['attachment:read'])]
#[SerializedName('url')]
public function getURL(): ?string public function getURL(): ?string
{ {
if (!$this->isExternal() && !$this->isBuiltIn()) { if($this->hasExternal()){
return null; return $this->getExternalPath();
} }
if($this->isBuiltIn()){
return $this->path; return $this->getInternalPath();
}
return null;
} }
/** /**
* Returns the hostname where the external file is stored. * Returns the hostname where the external file is stored.
* Returns null, if the file is not external. * Returns null, if there is no external path.
*/ */
public function getHost(): ?string public function getHost(): ?string
{ {
if (!$this->isExternal()) { if (!$this->hasExternal()) {
return null; return null;
} }
return parse_url((string) $this->getURL(), PHP_URL_HOST); return parse_url($this->getExternalPath(), PHP_URL_HOST);
} }
/** public function getInternalPath(): ?string
* Get the filepath, relative to %BASE%.
*
* @return string A string like %BASE/path/foo.bar
*/
public function getPath(): string
{ {
return $this->path; return $this->internal_path;
}
public function getExternalPath(): ?string
{
return $this->external_path;
} }
/** /**
* Returns the filename of the attachment. * Returns the filename of the attachment.
* For a path like %BASE/path/foo.bar, foo.bar will be returned. * For a path like %BASE/path/foo.bar, foo.bar will be returned.
* *
* If the path is a URL (can be checked via isExternal()), null will be returned. * If there is no internal copy of the file, null will be returned.
*/ */
public function getFilename(): ?string public function getFilename(): ?string
{ {
if ($this->isExternal()) { if (!$this->hasInternal()) {
return null; return null;
} }
@ -415,7 +450,7 @@ abstract class Attachment extends AbstractNamedDBElement
return $this->original_filename; return $this->original_filename;
} }
return pathinfo($this->getPath(), PATHINFO_BASENAME); return pathinfo($this->getInternalPath(), PATHINFO_BASENAME);
} }
/** /**
@ -488,15 +523,12 @@ abstract class Attachment extends AbstractNamedDBElement
} }
/** /**
* Sets the filepath (with relative placeholder) for this attachment. * Sets the path to a file hosted internally. If you set this path to a file that was not downloaded from the
* * external source in external_path, make sure to reset external_path.
* @param string $path the new filepath of the attachment
*
* @return Attachment
*/ */
public function setPath(string $path): self public function setInternalPath(?string $internal_path): self
{ {
$this->path = $path; $this->internal_path = $internal_path;
return $this; return $this;
} }
@ -512,34 +544,60 @@ abstract class Attachment extends AbstractNamedDBElement
} }
/** /**
* Sets the url associated with this attachment. * Sets up the paths using a user provided string which might contain an external path or a builtin path. Allows
* If the url is empty nothing is changed, to not override the file path. * resetting the external path if an internal path exists. Resets any other paths if a (nonempty) new path is set.
*
* @return Attachment
*/ */
#[Groups(['attachment:write'])] #[Groups(['attachment:write'])]
#[SerializedName('url')] #[SerializedName('url')]
#[ApiProperty(description: 'Set the path of the attachment here.
Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
string if the attachment has an internal file associated and you\'d like to reset the external source.
If you set a new (nonempty) file path any associated internal file will be removed!')]
public function setURL(?string $url): self public function setURL(?string $url): self
{ {
//Do nothing if the URL is empty //Don't allow the user to set an empty external path if the internal path is empty already
if ($url === null || $url === '') { if (($url === null || $url === "") && !$this->hasInternal()) {
return $this; return $this;
} }
$url = trim($url); //The URL field can also contain the special builtin internal paths, so we need to distinguish here
//Escape spaces in URL if ($this::checkIfBuiltin($url)) {
$url = str_replace(' ', '%20', $url); $this->setInternalPath($url);
//make sure the external path isn't still pointing to something unrelated
//Only set if the URL is not empty $this->setExternalPath(null);
if ($url !== '') { } else {
if (str_contains($url, '%BASE%') || str_contains($url, '%MEDIA%')) { $this->setExternalPath($url);
throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!');
}
$this->path = $url;
//Reset internal filename
$this->original_filename = null;
} }
return $this;
}
/**
* Sets the path to a file hosted on an external server. Setting the external path to a (nonempty) value different
* from the the old one _clears_ the internal path, so that the external path reflects where any associated internal
* file came from.
*/
public function setExternalPath(?string $external_path): self
{
//If we only clear the external path, don't reset the internal path, since that could be confusing
if($external_path === null || $external_path === '') {
$this->external_path = null;
return $this;
}
$external_path = trim($external_path);
//Escape spaces in URL
$external_path = str_replace(' ', '%20', $external_path);
if($this->external_path === $external_path) {
//Nothing changed, nothing to do
return $this;
}
$this->external_path = $external_path;
$this->internal_path = null;
//Reset internal filename
$this->original_filename = null;
return $this; return $this;
} }
@ -551,12 +609,17 @@ abstract class Attachment extends AbstractNamedDBElement
/** /**
* Checks if the given path is a path to a builtin resource. * Checks if the given path is a path to a builtin resource.
* *
* @param string $path The path that should be checked * @param string|null $path The path that should be checked
* *
* @return bool true if the path is pointing to a builtin resource * @return bool true if the path is pointing to a builtin resource
*/ */
public static function checkIfBuiltin(string $path): bool public static function checkIfBuiltin(?string $path): bool
{ {
//An empty path can't be a builtin
if ($path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode //After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $path); $tmp = explode('/', $path);
//Builtins must have a %PLACEHOLDER% construction //Builtins must have a %PLACEHOLDER% construction

View file

@ -52,8 +52,8 @@ class AttachmentDeleteListener
#[PreUpdate] #[PreUpdate]
public function preUpdateHandler(Attachment $attachment, PreUpdateEventArgs $event): void public function preUpdateHandler(Attachment $attachment, PreUpdateEventArgs $event): void
{ {
if ($event->hasChangedField('path')) { if ($event->hasChangedField('internal_path')) {
$old_path = $event->getOldValue('path'); $old_path = $event->getOldValue('internal_path');
//Dont delete file if the attachment uses a builtin ressource: //Dont delete file if the attachment uses a builtin ressource:
if (Attachment::checkIfBuiltin($old_path)) { if (Attachment::checkIfBuiltin($old_path)) {

View file

@ -58,7 +58,7 @@ class AttachmentRepository extends DBElementRepository
{ {
$qb = $this->createQueryBuilder('attachment'); $qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)') $qb->select('COUNT(attachment)')
->where('attachment.path LIKE :like ESCAPE \'#\''); ->where('attachment.internal_path LIKE :like ESCAPE \'#\'');
$qb->setParameter('like', '#%SECURE#%%'); $qb->setParameter('like', '#%SECURE#%%');
$query = $qb->getQuery(); $query = $qb->getQuery();
@ -66,7 +66,7 @@ class AttachmentRepository extends DBElementRepository
} }
/** /**
* Gets the count of all external attachments (attachments only containing a URL). * Gets the count of all external attachments (attachments containing an external path).
* *
* @throws NoResultException * @throws NoResultException
* @throws NonUniqueResultException * @throws NonUniqueResultException
@ -75,17 +75,15 @@ class AttachmentRepository extends DBElementRepository
{ {
$qb = $this->createQueryBuilder('attachment'); $qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)') $qb->select('COUNT(attachment)')
->where('ILIKE(attachment.path, :http) = TRUE') ->andWhere('attaachment.internal_path IS NULL')
->orWhere('ILIKE(attachment.path, :https) = TRUE'); ->where('attachment.external_path IS NOT NULL');
$qb->setParameter('http', 'http://%');
$qb->setParameter('https', 'https://%');
$query = $qb->getQuery(); $query = $qb->getQuery();
return (int) $query->getSingleScalarResult(); return (int) $query->getSingleScalarResult();
} }
/** /**
* Gets the count of all attachments where a user uploaded a file. * Gets the count of all attachments where a user uploaded a file or a file was downloaded from an external source.
* *
* @throws NoResultException * @throws NoResultException
* @throws NonUniqueResultException * @throws NonUniqueResultException
@ -94,9 +92,9 @@ class AttachmentRepository extends DBElementRepository
{ {
$qb = $this->createQueryBuilder('attachment'); $qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)') $qb->select('COUNT(attachment)')
->where('attachment.path LIKE :base ESCAPE \'#\'') ->where('attachment.internal_path LIKE :base ESCAPE \'#\'')
->orWhere('attachment.path LIKE :media ESCAPE \'#\'') ->orWhere('attachment.internal_path LIKE :media ESCAPE \'#\'')
->orWhere('attachment.path LIKE :secure ESCAPE \'#\''); ->orWhere('attachment.internal_path LIKE :secure ESCAPE \'#\'');
$qb->setParameter('secure', '#%SECURE#%%'); $qb->setParameter('secure', '#%SECURE#%%');
$qb->setParameter('base', '#%BASE#%%'); $qb->setParameter('base', '#%BASE#%%');
$qb->setParameter('media', '#%MEDIA#%%'); $qb->setParameter('media', '#%MEDIA#%%');

View file

@ -52,11 +52,15 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
$context[self::ALREADY_CALLED] = true; $context[self::ALREADY_CALLED] = true;
$data = $this->normalizer->normalize($object, $format, $context); $data = $this->normalizer->normalize($object, $format, $context);
$data['internal_path'] = $this->attachmentURLGenerator->getInternalViewURL($object);
$data['media_url'] = $this->attachmentURLGenerator->getViewURL($object);
//Add thumbnail url if the attachment is a picture //Add thumbnail url if the attachment is a picture
$data['thumbnail_url'] = $object->isPicture() ? $this->attachmentURLGenerator->getThumbnailURL($object) : null; $data['thumbnail_url'] = $object->isPicture() ? $this->attachmentURLGenerator->getThumbnailURL($object) : null;
//For backwards compatibility reasons
//Deprecated: Use internal_path and external_path instead
$data['media_url'] = $data['internal_path'] ?? $object->getExternalPath();
return $data; return $data;
} }

View file

@ -44,35 +44,31 @@ class AttachmentManager
* *
* @param Attachment $attachment The attachment for which the file should be generated * @param Attachment $attachment The attachment for which the file should be generated
* *
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is external or has * @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is only external or has
* invalid file. * invalid file.
*/ */
public function attachmentToFile(Attachment $attachment): ?SplFileInfo public function attachmentToFile(Attachment $attachment): ?SplFileInfo
{ {
if ($attachment->isExternal() || !$this->isFileExisting($attachment)) { if (!$this->isInternalFileExisting($attachment)) {
return null; return null;
} }
return new SplFileInfo($this->toAbsoluteFilePath($attachment)); return new SplFileInfo($this->toAbsoluteInternalFilePath($attachment));
} }
/** /**
* Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved, * Returns the absolute filepath to the internal copy of the attachment. Null is returned, if the attachment is
* or is not existing. * only externally saved, or is not existing.
* *
* @param Attachment $attachment The attachment for which the filepath should be determined * @param Attachment $attachment The attachment for which the filepath should be determined
*/ */
public function toAbsoluteFilePath(Attachment $attachment): ?string public function toAbsoluteInternalFilePath(Attachment $attachment): ?string
{ {
if ($attachment->getPath() === '') { if (!$attachment->hasInternal()){
return null; return null;
} }
if ($attachment->isExternal()) { $path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
return null;
}
$path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
//realpath does not work with null as argument //realpath does not work with null as argument
if (null === $path) { if (null === $path) {
@ -89,8 +85,8 @@ class AttachmentManager
} }
/** /**
* Checks if the file in this attachement is existing. This works for files on the HDD, and for URLs * Checks if the file in this attachment is existing. This works for files on the HDD, and for URLs
* (it's not checked if the ressource behind the URL is really existing, so for every external attachment true is returned). * (it's not checked if the resource behind the URL is really existing, so for every external attachment true is returned).
* *
* @param Attachment $attachment The attachment for which the existence should be checked * @param Attachment $attachment The attachment for which the existence should be checked
* *
@ -98,15 +94,23 @@ class AttachmentManager
*/ */
public function isFileExisting(Attachment $attachment): bool public function isFileExisting(Attachment $attachment): bool
{ {
if ($attachment->getPath() === '') { if($attachment->hasExternal()){
return false;
}
if ($attachment->isExternal()) {
return true; return true;
} }
return $this->isInternalFileExisting($attachment);
}
$absolute_path = $this->toAbsoluteFilePath($attachment); /**
* Checks if the internal file in this attachment is existing. Returns false if the attachment doesn't have an
* internal file.
*
* @param Attachment $attachment The attachment for which the existence should be checked
*
* @return bool true if the file is existing
*/
public function isInternalFileExisting(Attachment $attachment): bool
{
$absolute_path = $this->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) { if (null === $absolute_path) {
return false; return false;
@ -117,21 +121,17 @@ class AttachmentManager
/** /**
* Returns the filesize of the attachments in bytes. * Returns the filesize of the attachments in bytes.
* For external attachments or not existing attachments, null is returned. * For purely external attachments or inexistent attachments, null is returned.
* *
* @param Attachment $attachment the filesize for which the filesize should be calculated * @param Attachment $attachment the filesize for which the filesize should be calculated
*/ */
public function getFileSize(Attachment $attachment): ?int public function getFileSize(Attachment $attachment): ?int
{ {
if ($attachment->isExternal()) { if (!$this->isInternalFileExisting($attachment)) {
return null; return null;
} }
if (!$this->isFileExisting($attachment)) { $tmp = filesize($this->toAbsoluteInternalFilePath($attachment));
return null;
}
$tmp = filesize($this->toAbsoluteFilePath($attachment));
return false !== $tmp ? $tmp : null; return false !== $tmp ? $tmp : null;
} }

View file

@ -115,12 +115,16 @@ class AttachmentPathResolver
* Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk. * Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk.
* The directory separator is always /. Relative pathes are not realy possible (.. is striped). * The directory separator is always /. Relative pathes are not realy possible (.. is striped).
* *
* @param string $placeholder_path the filepath with placeholder for which the real path should be determined * @param string|null $placeholder_path the filepath with placeholder for which the real path should be determined
* *
* @return string|null The absolute real path of the file, or null if the placeholder path is invalid * @return string|null The absolute real path of the file, or null if the placeholder path is invalid
*/ */
public function placeholderToRealPath(string $placeholder_path): ?string public function placeholderToRealPath(?string $placeholder_path): ?string
{ {
if (null === $placeholder_path) {
return null;
}
//The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory //The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory
//Older path entries are given via %BASE% which was the project root //Older path entries are given via %BASE% which was the project root

View file

@ -55,7 +55,7 @@ class AttachmentReverseSearch
$repo = $this->em->getRepository(Attachment::class); $repo = $this->em->getRepository(Attachment::class);
return $repo->findBy([ return $repo->findBy([
'path' => [$relative_path_new, $relative_path_old], 'internal_path' => [$relative_path_new, $relative_path_old],
]); ]);
} }

View file

@ -207,7 +207,7 @@ class AttachmentSubmitHandler
if ($file instanceof UploadedFile) { if ($file instanceof UploadedFile) {
$this->upload($attachment, $file, $secure_attachment); $this->upload($attachment, $file, $secure_attachment);
} elseif ($upload->downloadUrl && $attachment->isExternal()) { } elseif ($upload->downloadUrl && $attachment->hasExternal()) {
$this->downloadURL($attachment, $secure_attachment); $this->downloadURL($attachment, $secure_attachment);
} }
@ -244,12 +244,12 @@ class AttachmentSubmitHandler
protected function renameBlacklistedExtensions(Attachment $attachment): Attachment protected function renameBlacklistedExtensions(Attachment $attachment): Attachment
{ {
//We can not do anything on builtins or external ressources //We can not do anything on builtins or external ressources
if ($attachment->isBuiltIn() || $attachment->isExternal()) { if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment; return $attachment;
} }
//Determine the old filepath //Determine the old filepath
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath()); $old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
if ($old_path === null || $old_path === '' || !file_exists($old_path)) { if ($old_path === null || $old_path === '' || !file_exists($old_path)) {
return $attachment; return $attachment;
} }
@ -267,7 +267,7 @@ class AttachmentSubmitHandler
$fs->rename($old_path, $new_path); $fs->rename($old_path, $new_path);
//Update the attachment //Update the attachment
$attachment->setPath($this->pathResolver->realPathToPlaceholder($new_path)); $attachment->setInternalPath($this->pathResolver->realPathToPlaceholder($new_path));
} }
@ -275,17 +275,17 @@ class AttachmentSubmitHandler
} }
/** /**
* Move the given attachment to secure location (or back to public folder) if needed. * Move the internal copy of the given attachment to a secure location (or back to public folder) if needed.
* *
* @param Attachment $attachment the attachment for which the file should be moved * @param Attachment $attachment the attachment for which the file should be moved
* @param bool $secure_location this value determines, if the attachment is moved to the secure or public folder * @param bool $secure_location this value determines, if the attachment is moved to the secure or public folder
* *
* @return Attachment The attachment with the updated filepath * @return Attachment The attachment with the updated internal filepath
*/ */
protected function moveFile(Attachment $attachment, bool $secure_location): Attachment protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
{ {
//We can not do anything on builtins or external ressources //We can not do anything on builtins or external ressources
if ($attachment->isBuiltIn() || $attachment->isExternal()) { if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment; return $attachment;
} }
@ -295,7 +295,7 @@ class AttachmentSubmitHandler
} }
//Determine the old filepath //Determine the old filepath
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath()); $old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
if (!file_exists($old_path)) { if (!file_exists($old_path)) {
return $attachment; return $attachment;
} }
@ -319,7 +319,7 @@ class AttachmentSubmitHandler
//Save info to attachment entity //Save info to attachment entity
$new_path = $this->pathResolver->realPathToPlaceholder($new_path); $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
$attachment->setPath($new_path); $attachment->setInternalPath($new_path);
return $attachment; return $attachment;
} }
@ -329,7 +329,7 @@ class AttachmentSubmitHandler
* *
* @param bool $secureAttachment True if the file should be moved to the secure attachment storage * @param bool $secureAttachment True if the file should be moved to the secure attachment storage
* *
* @return Attachment The attachment with the new filepath * @return Attachment The attachment with the downloaded copy
*/ */
protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
{ {
@ -338,7 +338,7 @@ class AttachmentSubmitHandler
throw new RuntimeException('Download of attachments is not allowed!'); throw new RuntimeException('Download of attachments is not allowed!');
} }
$url = $attachment->getURL(); $url = $attachment->getExternalPath();
$fs = new Filesystem(); $fs = new Filesystem();
$attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment); $attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment);
@ -399,7 +399,7 @@ class AttachmentSubmitHandler
//Make our file path relative to %BASE% //Make our file path relative to %BASE%
$new_path = $this->pathResolver->realPathToPlaceholder($new_path); $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
//Save the path to the attachment //Save the path to the attachment
$attachment->setPath($new_path); $attachment->setInternalPath($new_path);
} catch (TransportExceptionInterface) { } catch (TransportExceptionInterface) {
throw new AttachmentDownloadException('Transport error!'); throw new AttachmentDownloadException('Transport error!');
} }
@ -427,7 +427,9 @@ class AttachmentSubmitHandler
//Make our file path relative to %BASE% //Make our file path relative to %BASE%
$file_path = $this->pathResolver->realPathToPlaceholder($file_path); $file_path = $this->pathResolver->realPathToPlaceholder($file_path);
//Save the path to the attachment //Save the path to the attachment
$attachment->setPath($file_path); $attachment->setInternalPath($file_path);
//reset any external paths the attachment might have had
$attachment->setExternalPath(null);
//And save original filename //And save original filename
$attachment->setFilename($file->getClientOriginalName()); $attachment->setFilename($file->getClientOriginalName());

View file

@ -92,9 +92,9 @@ class AttachmentURLGenerator
* Returns a URL under which the attachment file can be viewed. * Returns a URL under which the attachment file can be viewed.
* @return string|null The URL or null if the attachment file is not existing * @return string|null The URL or null if the attachment file is not existing
*/ */
public function getViewURL(Attachment $attachment): ?string public function getInternalViewURL(Attachment $attachment): ?string
{ {
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment); $absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) { if (null === $absolute_path) {
return null; return null;
} }
@ -111,6 +111,7 @@ class AttachmentURLGenerator
/** /**
* Returns a URL to a thumbnail of the attachment file. * Returns a URL to a thumbnail of the attachment file.
* For external files the original URL is returned.
* @return string|null The URL or null if the attachment file is not existing * @return string|null The URL or null if the attachment file is not existing
*/ */
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
@ -119,11 +120,14 @@ class AttachmentURLGenerator
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!'); throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
} }
if ($attachment->isExternal() && ($attachment->getURL() !== null && $attachment->getURL() !== '')) { if (!$attachment->hasInternal()){
return $attachment->getURL(); if($attachment->hasExternal()) {
return $attachment->getExternalPath();
}
return null;
} }
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment); $absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) { if (null === $absolute_path) {
return null; return null;
} }
@ -137,7 +141,7 @@ class AttachmentURLGenerator
//GD can not work with SVG, so serve it directly... //GD can not work with SVG, so serve it directly...
//We can not use getExtension here, because it uses the original filename and not the real extension //We can not use getExtension here, because it uses the original filename and not the real extension
//Instead we use the logic, which is also used to determine if the attachment is a picture //Instead we use the logic, which is also used to determine if the attachment is a picture
$extension = pathinfo(parse_url($attachment->getPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION); $extension = pathinfo(parse_url($attachment->getInternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
if ('svg' === $extension) { if ('svg' === $extension) {
return $this->assets->getUrl($asset_path); return $this->assets->getUrl($asset_path);
} }
@ -157,7 +161,7 @@ class AttachmentURLGenerator
/** /**
* Returns a download link to the file associated with the attachment. * Returns a download link to the file associated with the attachment.
*/ */
public function getDownloadURL(Attachment $attachment): string public function getInternalDownloadURL(Attachment $attachment): string
{ {
//Redirect always to download controller, which sets the correct headers for downloading: //Redirect always to download controller, which sets the correct headers for downloading:
return $this->urlGenerator->generate('attachment_download', ['id' => $attachment->getID()]); return $this->urlGenerator->generate('attachment_download', ['id' => $attachment->getID()]);

View file

@ -247,7 +247,8 @@ trait EntityMergerHelperTrait
{ {
return $this->mergeCollections($target, $other, 'attachments', fn(Attachment $t, Attachment $o): bool => $t->getName() === $o->getName() return $this->mergeCollections($target, $other, 'attachments', fn(Attachment $t, Attachment $o): bool => $t->getName() === $o->getName()
&& $t->getAttachmentType() === $o->getAttachmentType() && $t->getAttachmentType() === $o->getAttachmentType()
&& $t->getPath() === $o->getPath()); && $t->getExternalPath() === $o->getExternalPath()
&& $t->getInternalPath() === $o->getInternalPath());
} }
/** /**

View file

@ -156,25 +156,32 @@ class EntityURLGenerator
public function viewURL(Attachment $entity): string public function viewURL(Attachment $entity): string
{ {
if ($entity->isExternal()) { //For external attachments, return the link to external path if ($entity->hasInternal()) {
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!'); return $this->attachmentURLGenerator->getInternalViewURL($entity);
} }
//return $this->urlGenerator->generate('attachment_view', ['id' => $entity->getID()]);
return $this->attachmentURLGenerator->getViewURL($entity) ?? ''; if($entity->hasExternal()) {
return $entity->getExternalPath();
}
throw new \RuntimeException('Attachment has no internal nor external path!');
} }
public function downloadURL($entity): string public function downloadURL($entity): string
{ {
if ($entity instanceof Attachment) { if (!($entity instanceof Attachment)) {
if ($entity->isExternal()) { //For external attachments, return the link to external path throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
}
return $this->attachmentURLGenerator->getDownloadURL($entity);
} }
//Otherwise throw an error if ($entity->hasInternal()) {
throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class)); return $this->attachmentURLGenerator->getInternalDownloadURL($entity);
}
if($entity->hasExternal()) {
return $entity->getExternalPath();
}
throw new \RuntimeException('Attachment has not internal or external path!');
} }
/** /**

View file

@ -105,7 +105,7 @@ trait PKImportHelperTrait
//Next comes the filename plus extension //Next comes the filename plus extension
$path .= '/'.$attachment_row['filename'].'.'.$attachment_row['extension']; $path .= '/'.$attachment_row['filename'].'.'.$attachment_row['extension'];
$attachment->setPath($path); $attachment->setInternalPath($path);
return $attachment; return $attachment;
} }

View file

@ -122,8 +122,8 @@ final class SandboxedTwigFactory
'getFullPath', 'getPathArray', 'getSubelements', 'getChildren', 'isNotSelectable', ], 'getFullPath', 'getPathArray', 'getSubelements', 'getChildren', 'isNotSelectable', ],
AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite', 'getAutoProductUrl'], AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite', 'getAutoProductUrl'],
AttachmentContainingDBElement::class => ['getAttachments', 'getMasterPictureAttachment'], AttachmentContainingDBElement::class => ['getAttachments', 'getMasterPictureAttachment'],
Attachment::class => ['isPicture', 'is3DModel', 'isExternal', 'isSecure', 'isBuiltIn', 'getExtension', Attachment::class => ['isPicture', 'is3DModel', 'hasExternal', 'hasInternal', 'isSecure', 'isBuiltIn', 'getExtension',
'getElement', 'getURL', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable', ], 'getElement', 'getExternalPath', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable'],
AbstractParameter::class => ['getFormattedValue', 'getGroup', 'getSymbol', 'getValueMin', 'getValueMax', AbstractParameter::class => ['getFormattedValue', 'getGroup', 'getSymbol', 'getValueMin', 'getValueMax',
'getValueTypical', 'getUnit', 'getValueText', ], 'getValueTypical', 'getUnit', 'getValueText', ],
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'], MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],

View file

@ -152,35 +152,32 @@
</button> </button>
{% set attach = form.vars.value %} {% set attach = form.vars.value %}
{# @var \App\Entity\Attachments\Attachment attach #}
{% if attach is not null %} {% if attach is not null %}
{% if attachment_manager.fileExisting(attach) %} {% if not attach.hasInternal() and attach.external %}
{% if not attach.external %} <div class="mt-2">
<br><br>
<h6>
<span class="badge bg-primary"> <span class="badge bg-primary">
<i class="fas fa-fw {{ ext_to_fa_icon(attach.extension) }}"></i> {{ attach.filename }} <i class="fas fa-fw fa-globe"></i> {% trans %}attachment.external_only{% endtrans %}
</span> </span>
<br> </div>
<span class="badge bg-secondary"> {% elseif attachment_manager.isInternalFileExisting(attach) %}
<div class="mt-2">
<div class="badge bg-primary mt-2" title="{{ attach.filename }}">
<i class="fas fa-fw {{ ext_to_fa_icon(attach.extension) }}"></i> {{ attach.filename|u.truncate(25, ' ...') }}
</div>
<br>
<div class="badge bg-secondary mt-1">
<i class="fas fa-hdd fa-fw"></i> {{ attachment_manager.humanFileSize(attach) }} <i class="fas fa-hdd fa-fw"></i> {{ attachment_manager.humanFileSize(attach) }}
</span> </div>
</h6> </div>
{% else %}
<br><br>
<h6>
<span class="badge bg-primary">
<i class="fas fa-fw fa-globe"></i> {% trans %}attachment.external{% endtrans %}
</span>
</h6>
{% endif %}
{% if attach.secure %} {% if attach.secure %}
<h6> <div>
<span class="badge bg-success"> <span class="badge bg-success">
<i class="fas fa-fw fa-shield-alt"></i> {% trans %}attachment.secure{% endtrans %} <i class="fas fa-fw fa-shield-alt"></i> {% trans %}attachment.secure{% endtrans %}
</span> </span>
</h6> </div>
{% endif %} {% endif %}
{% if attach.secure and not is_granted('show_private', attach) %} {% if attach.secure and not is_granted('show_private', attach) %}
@ -190,16 +187,21 @@
<img class="img-fluid img-thumbnail thumbnail-sm" src="{{ attachment_thumbnail(attach, 'thumbnail_md') }}" alt="{% trans %}attachment.preview.alt{% endtrans %}" /> <img class="img-fluid img-thumbnail thumbnail-sm" src="{{ attachment_thumbnail(attach, 'thumbnail_md') }}" alt="{% trans %}attachment.preview.alt{% endtrans %}" />
</a> </a>
{% else %} {% else %}
<a href="{{ entity_url(attach, 'file_view') }}" rel="noopener" target="_blank" data-turbo="false" class="link-external">{% trans %}attachment.view{% endtrans %}</a> <a href="{{ entity_url(attach, 'file_view') }}" rel="noopener" target="_blank" data-turbo="false" class="link-external">{% trans %}attachment.view_local{% endtrans %}</a>
{% endif %} {% endif %}
{% else %} {% else %}
<br><br> <div class="mt-2">
<h6>
<span class="badge bg-warning"> <span class="badge bg-warning">
<i class="fas fa-exclamation-circle fa-fw"></i> {% trans %}attachment.file_not_found{% endtrans %} <i class="fas fa-exclamation-circle fa-fw"></i> {% trans %}attachment.file_not_found{% endtrans %}
</span> </span>
</h6> </div>
{% endif %}
{% if attach.external %}
<div>
<a href="{{ attach.externalPath }}" rel="noopener" target="_blank" data-turbo="false" class="link-external"
title="{% trans with {"%host%": attach.host} %}attachment.view_external.view_at{% endtrans %}">{% trans %}attachment.view_external{% endtrans %}</a>
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -24,18 +24,16 @@
<td class="align-middle">{{ attachment.name }}</td> <td class="align-middle">{{ attachment.name }}</td>
<td class="align-middle">{{ attachment.attachmentType.fullPath }}</td> <td class="align-middle">{{ attachment.attachmentType.fullPath }}</td>
<td class="align-middle"> <td class="align-middle">
{% if attachment.external %} {% if attachment.hasInternal() %}
<a href="{{ attachment.uRL }}" rel="noopener" target="_blank" class="link-external">{{ attachment.host }}</a>
{% else %}
{{ attachment.filename }} {{ attachment.filename }}
{% endif %} {% endif %}
</td> </td>
<td class="align-middle h6"> <td class="align-middle h6">
{% if attachment.external %} {% if not attachment.hasInternal() %}
<span class="badge bg-primary"> <span class="badge bg-primary">
<i class="fas fa-fw fa-globe"></i> {% trans %}attachment.external{% endtrans %} <i class="fas fa-fw fa-globe"></i> {% trans %}attachment.external_only{% endtrans %}
</span> </span>
{% elseif attachment_manager.fileExisting(attachment) %} {% elseif attachment_manager.internalFileExisting(attachment) %}
<span class="badge bg-secondary"> <span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> {{ attachment_manager.humanFileSize(attachment) }} <i class="fas fa-hdd fa-fw"></i> {{ attachment_manager.humanFileSize(attachment) }}
</span> </span>
@ -58,14 +56,19 @@
</td> </td>
<td><div class="btn-group" role="group" aria-label=""> <td><div class="btn-group" role="group" aria-label="">
<a {% if attachment_manager.fileExisting(attachment) %}href="{{ entity_url(attachment, 'file_view') }}"{% endif %} target="_blank" <a {% if attachment.hasExternal() %}href="{{ attachment.externalPath }}"{% endif %} target="_blank"
class="btn btn-secondary {% if not attachment_manager.fileExisting(attachment) or (attachment.secure and not is_granted("show_private", attachment)) %}disabled{% endif %}" class="btn btn-secondary {% if not attachment.hasExternal() %}disabled{% endif %}"
data-turbo="false" title="{% trans %}attachment.view{% endtrans %}" rel="noopener"> data-turbo="false" title="{% trans with {"%host%": attachment.host} %}attachment.view_external.view_at{% endtrans %}" rel="noopener">
<i class="fas fa-globe fa-fw"></i>
</a>
<a {% if attachment_manager.isInternalFileExisting(attachment) %}href="{{ entity_url(attachment, 'file_view') }}"{% endif %} target="_blank"
class="btn btn-secondary {% if not attachment_manager.isInternalFileExisting(attachment) or (attachment.secure and not is_granted("show_private", attachment)) %}disabled{% endif %}"
data-turbo="false" title="{% trans %}attachment.view_local{% endtrans %}" rel="noopener">
<i class="fas fa-eye fa-fw"></i> <i class="fas fa-eye fa-fw"></i>
</a> </a>
<a {% if attachment_manager.fileExisting(attachment) %}href="{{ entity_url(attachment, 'file_download') }}"{% endif %} data-turbo="false" <a {% if attachment_manager.isInternalFileExisting(attachment) %}href="{{ entity_url(attachment, 'file_download') }}"{% endif %} data-turbo="false"
class="btn btn-secondary {% if not attachment_manager.fileExisting(attachment) or (attachment.secure and not is_granted("show_private", attachment)) %}disabled{% endif %}" class="btn btn-secondary {% if not attachment_manager.isInternalFileExisting(attachment) or (attachment.secure and not is_granted("show_private", attachment)) %}disabled{% endif %}"
title="{% trans %}attachment.download{% endtrans %}"> title="{% trans %}attachment.download_local{% endtrans %}">
<i class="fas fa-download fa-fw"></i> <i class="fas fa-download fa-fw"></i>
</a> </a>

View file

@ -91,7 +91,7 @@ class AttachmentsEndpointTest extends AuthenticatedApiTestCase
//Attachment must be set (not null) //Attachment must be set (not null)
$array = json_decode($response->getContent(), true); $array = json_decode($response->getContent(), true);
self::assertNotNull($array['media_url']); self::assertNotNull($array['internal_path']);
//Attachment must be private //Attachment must be private
self::assertJsonContains([ self::assertJsonContains([

View file

@ -59,14 +59,15 @@ class AttachmentTest extends TestCase
$this->assertNull($attachment->getAttachmentType()); $this->assertNull($attachment->getAttachmentType());
$this->assertFalse($attachment->isPicture()); $this->assertFalse($attachment->isPicture());
$this->assertFalse($attachment->isExternal()); $this->assertFalse($attachment->hasExternal());
$this->assertFalse($attachment->hasInternal());
$this->assertFalse($attachment->isSecure()); $this->assertFalse($attachment->isSecure());
$this->assertFalse($attachment->isBuiltIn()); $this->assertFalse($attachment->isBuiltIn());
$this->assertFalse($attachment->is3DModel()); $this->assertFalse($attachment->is3DModel());
$this->assertFalse($attachment->getShowInTable()); $this->assertFalse($attachment->getShowInTable());
$this->assertEmpty($attachment->getPath()); $this->assertEmpty($attachment->getInternalPath());
$this->assertEmpty($attachment->getExternalPath());
$this->assertEmpty($attachment->getName()); $this->assertEmpty($attachment->getName());
$this->assertEmpty($attachment->getURL());
$this->assertEmpty($attachment->getExtension()); $this->assertEmpty($attachment->getExtension());
$this->assertNull($attachment->getElement()); $this->assertNull($attachment->getElement());
$this->assertEmpty($attachment->getFilename()); $this->assertEmpty($attachment->getFilename());
@ -119,82 +120,63 @@ class AttachmentTest extends TestCase
$attachment->setElement($element); $attachment->setElement($element);
} }
public function externalDataProvider(): \Iterator
{
yield ['', false];
yield ['%MEDIA%/foo/bar.txt', false];
yield ['%BASE%/foo/bar.jpg', false];
yield ['%FOOTPRINTS%/foo/bar.jpg', false];
yield ['%FOOTPRINTS3D%/foo/bar.jpg', false];
yield ['%SECURE%/test.txt', false];
yield ['%test%/foo/bar.ghp', true];
yield ['foo%MEDIA%/foo.jpg', true];
yield ['foo%MEDIA%/%BASE%foo.jpg', true];
}
/** public static function extensionDataProvider(): \Iterator
* @dataProvider externalDataProvider
*/
public function testIsExternal($path, $expected): void
{ {
$attachment = new PartAttachment(); yield ['%MEDIA%/foo/bar.txt', 'http://google.de', null, 'txt'];
$this->setProtectedProperty($attachment, 'path', $path); yield ['%MEDIA%/foo/bar.JPeg', 'https://foo.bar', null, 'jpeg'];
$this->assertSame($expected, $attachment->isExternal()); yield ['%MEDIA%/foo/bar.JPeg', null, 'test.txt', 'txt'];
} yield ['%MEDIA%/foo/bar', 'https://foo.bar/test.jpeg', null, ''];
yield ['%MEDIA%/foo.bar', 'test.txt', 'bar', ''];
public function extensionDataProvider(): \Iterator yield [null, 'http://google.de', null, null];
{ yield [null, 'https://foo.bar', null, null];
yield ['%MEDIA%/foo/bar.txt', null, 'txt']; yield [null, ',https://foo.bar/test.jpeg', null, null];
yield ['%MEDIA%/foo/bar.JPeg', null, 'jpeg']; yield [null, 'test', null, null];
yield ['%MEDIA%/foo/bar.JPeg', 'test.txt', 'txt']; yield [null, 'test.txt', null, null];
yield ['%MEDIA%/foo/bar', null, ''];
yield ['%MEDIA%/foo.bar', 'bar', ''];
yield ['http://google.de', null, null];
yield ['https://foo.bar', null, null];
yield ['https://foo.bar/test.jpeg', null, null];
yield ['test', null, null];
yield ['test.txt', null, null];
} }
/** /**
* @dataProvider extensionDataProvider * @dataProvider extensionDataProvider
*/ */
public function testGetExtension($path, $originalFilename, $expected): void public function testGetExtension(?string $internal_path, ?string $external_path, ?string $originalFilename, ?string $expected): void
{ {
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$this->setProtectedProperty($attachment, 'path', $path); $this->setProtectedProperty($attachment, 'internal_path', $internal_path);
$this->setProtectedProperty($attachment, 'external_path', $external_path);
$this->setProtectedProperty($attachment, 'original_filename', $originalFilename); $this->setProtectedProperty($attachment, 'original_filename', $originalFilename);
$this->assertSame($expected, $attachment->getExtension()); $this->assertSame($expected, $attachment->getExtension());
} }
public function pictureDataProvider(): \Iterator public static function pictureDataProvider(): \Iterator
{ {
yield ['%MEDIA%/foo/bar.txt', false]; yield [null, '%MEDIA%/foo/bar.txt', false];
yield ['https://test.de/picture.jpeg', true]; yield [null, 'https://test.de/picture.jpeg', true];
yield ['https://test.de/picture.png?test=fdsj&width=34', true]; yield [null, 'https://test.de/picture.png?test=fdsj&width=34', true];
yield ['https://invalid.invalid/file.txt', false]; yield [null, 'https://invalid.invalid/file.txt', false];
yield ['http://infsf.inda/file.zip?test', false]; yield [null, 'http://infsf.inda/file.zip?test', false];
yield ['https://test.de', true]; yield [null, 'https://test.de', true];
yield ['https://invalid.com/invalid/pic', true]; yield [null, 'https://invalid.com/invalid/pic', true];
yield ['%MEDIA%/foo/bar.jpeg', true]; yield ['%MEDIA%/foo/bar.jpeg', 'https://invalid.invalid/file.txt', true];
yield ['%MEDIA%/foo/bar.webp', true]; yield ['%MEDIA%/foo/bar.webp', '', true];
yield ['%MEDIA%/foo', false]; yield ['%MEDIA%/foo', '', false];
yield ['%SECURE%/foo.txt/test', false]; yield ['%SECURE%/foo.txt/test', 'https://test.de/picture.jpeg', false];
} }
/** /**
* @dataProvider pictureDataProvider * @dataProvider pictureDataProvider
*/ */
public function testIsPicture($path, $expected): void public function testIsPicture(?string $internal_path, ?string $external_path, bool $expected): void
{ {
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$this->setProtectedProperty($attachment, 'path', $path); $this->setProtectedProperty($attachment, 'internal_path', $internal_path);
$this->setProtectedProperty($attachment, 'external_path', $external_path);
$this->assertSame($expected, $attachment->isPicture()); $this->assertSame($expected, $attachment->isPicture());
} }
public function builtinDataProvider(): \Iterator public static function builtinDataProvider(): \Iterator
{ {
yield ['', false]; yield ['', false];
yield [null, false];
yield ['%MEDIA%/foo/bar.txt', false]; yield ['%MEDIA%/foo/bar.txt', false];
yield ['%BASE%/foo/bar.txt', false]; yield ['%BASE%/foo/bar.txt', false];
yield ['/', false]; yield ['/', false];
@ -205,14 +187,14 @@ class AttachmentTest extends TestCase
/** /**
* @dataProvider builtinDataProvider * @dataProvider builtinDataProvider
*/ */
public function testIsBuiltIn($path, $expected): void public function testIsBuiltIn(?string $path, $expected): void
{ {
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$this->setProtectedProperty($attachment, 'path', $path); $this->setProtectedProperty($attachment, 'internal_path', $path);
$this->assertSame($expected, $attachment->isBuiltIn()); $this->assertSame($expected, $attachment->isBuiltIn());
} }
public function hostDataProvider(): \Iterator public static function hostDataProvider(): \Iterator
{ {
yield ['%MEDIA%/foo/bar.txt', null]; yield ['%MEDIA%/foo/bar.txt', null];
yield ['https://www.google.de/test.txt', 'www.google.de']; yield ['https://www.google.de/test.txt', 'www.google.de'];
@ -222,55 +204,60 @@ class AttachmentTest extends TestCase
/** /**
* @dataProvider hostDataProvider * @dataProvider hostDataProvider
*/ */
public function testGetHost($path, $expected): void public function testGetHost(?string $path, ?string $expected): void
{ {
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$this->setProtectedProperty($attachment, 'path', $path); $this->setProtectedProperty($attachment, 'external_path', $path);
$this->assertSame($expected, $attachment->getHost()); $this->assertSame($expected, $attachment->getHost());
} }
public function filenameProvider(): \Iterator public static function filenameProvider(): \Iterator
{ {
yield ['%MEDIA%/foo/bar.txt', null, 'bar.txt']; yield ['%MEDIA%/foo/bar.txt', 'https://www.google.de/test.txt', null, 'bar.txt'];
yield ['%MEDIA%/foo/bar.JPeg', 'test.txt', 'test.txt']; yield ['%MEDIA%/foo/bar.JPeg', 'https://www.google.de/foo.txt', 'test.txt', 'test.txt'];
yield ['https://www.google.de/test.txt', null, null]; yield ['', 'https://www.google.de/test.txt', null, null];
yield [null, 'https://www.google.de/test.txt', null, null];
} }
/** /**
* @dataProvider filenameProvider * @dataProvider filenameProvider
*/ */
public function testGetFilename($path, $original_filename, $expected): void public function testGetFilename(?string $internal_path, ?string $external_path, ?string $original_filename, ?string $expected): void
{ {
$attachment = new PartAttachment(); $attachment = new PartAttachment();
$this->setProtectedProperty($attachment, 'path', $path); $this->setProtectedProperty($attachment, 'internal_path', $internal_path);
$this->setProtectedProperty($attachment, 'external_path', $external_path);
$this->setProtectedProperty($attachment, 'original_filename', $original_filename); $this->setProtectedProperty($attachment, 'original_filename', $original_filename);
$this->assertSame($expected, $attachment->getFilename()); $this->assertSame($expected, $attachment->getFilename());
} }
public function testSetURL(): void public function testSetExternalPath(): void
{ {
$attachment = new PartAttachment(); $attachment = new PartAttachment();
//Set URL //Set URL
$attachment->setURL('https://google.de'); $attachment->setExternalPath('https://google.de');
$this->assertSame('https://google.de', $attachment->getURL()); $this->assertSame('https://google.de', $attachment->getExternalPath());
//Ensure that an empty url does not overwrite the existing one //Ensure that changing the external path does reset the internal one
$attachment->setPath('%MEDIA%/foo/bar.txt'); $attachment->setInternalPath('%MEDIA%/foo/bar.txt');
$attachment->setURL(' '); $attachment->setExternalPath('https://example.de');
$this->assertSame('%MEDIA%/foo/bar.txt', $attachment->getPath()); $this->assertSame(null, $attachment->getInternalPath());
//Ensure that setting the same value to the external path again doesn't reset the internal one
$attachment->setExternalPath('https://google.de');
$attachment->setInternalPath('%MEDIA%/foo/bar.txt');
$attachment->setExternalPath('https://google.de');
$this->assertSame('%MEDIA%/foo/bar.txt', $attachment->getInternalPath());
//Ensure that resetting the external path doesn't reset the internal one
$attachment->setInternalPath('%MEDIA%/foo/bar.txt');
$attachment->setExternalPath('');
$this->assertSame('%MEDIA%/foo/bar.txt', $attachment->getInternalPath());
//Ensure that spaces get replaced by %20 //Ensure that spaces get replaced by %20
$attachment->setURL('https://google.de/test file.txt'); $attachment->setExternalPath('https://example.de/test file.txt');
$this->assertSame('https://google.de/test%20file.txt', $attachment->getURL()); $this->assertSame('https://example.de/test%20file.txt', $attachment->getExternalPath());
}
public function testSetURLForbiddenURL(): void
{
$attachment = new PartAttachment();
$this->expectException(InvalidArgumentException::class);
$attachment->setURL('%MEDIA%/foo/bar.txt');
} }
public function testIsURL(): void public function testIsURL(): void

View file

@ -242,7 +242,7 @@
</notes> </notes>
<segment state="final"> <segment state="final">
<source>part.info.timetravel_hint</source> <source>part.info.timetravel_hint</source>
<target>This is how the part appeared before %timestamp%. &lt;i&gt;Please note that this feature is experimental, so the info may not be correct.&lt;/i&gt;</target> <target><![CDATA[This is how the part appeared before %timestamp%. <i>Please note that this feature is experimental, so the info may not be correct.</i>]]></target>
</segment> </segment>
</unit> </unit>
<unit id="3exvSpl" name="standard.label"> <unit id="3exvSpl" name="standard.label">
@ -731,10 +731,10 @@
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>user.edit.tfa.disable_tfa_message</source> <source>user.edit.tfa.disable_tfa_message</source>
<target>This will disable &lt;b&gt;all active two-factor authentication methods of the user&lt;/b&gt; and delete the &lt;b&gt;backup codes&lt;/b&gt;! <target><![CDATA[This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>!
&lt;br&gt; <br>
The user will have to set up all two-factor authentication methods again and print new backup codes! &lt;br&gt;&lt;br&gt; The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br>
&lt;b&gt;Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!&lt;/b&gt;</target> <b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b>]]></target>
</segment> </segment>
</unit> </unit>
<unit id="APsHYu0" name="user.edit.tfa.disable_tfa.btn"> <unit id="APsHYu0" name="user.edit.tfa.disable_tfa.btn">
@ -780,18 +780,10 @@ The user will have to set up all two-factor authentication methods again and pri
<target>Delete</target> <target>Delete</target>
</segment> </segment>
</unit> </unit>
<unit id="W80Gv6o" name="attachment.external"> <unit id="FtktoBj" name="attachment.external_only">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\_attachments.html.twig:41</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:38</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:35</note>
<note category="file-source" priority="1">Part-DB1\src\DataTables\AttachmentDataTable.php:159</note>
<note priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:38</note>
<note priority="1">Part-DB1\src\DataTables\AttachmentDataTable.php:159</note>
</notes>
<segment state="translated"> <segment state="translated">
<source>attachment.external</source> <source>attachment.external_only</source>
<target>External</target> <target>External only</target>
</segment> </segment>
</unit> </unit>
<unit id="JES0hrm" name="attachment.preview.alt"> <unit id="JES0hrm" name="attachment.preview.alt">
@ -806,7 +798,7 @@ The user will have to set up all two-factor authentication methods again and pri
<target>Attachment thumbnail</target> <target>Attachment thumbnail</target>
</segment> </segment>
</unit> </unit>
<unit id="fCQby7u" name="attachment.view"> <unit id="I_HDnsL" name="attachment.view_local">
<notes> <notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\_attachments.html.twig:52</note> <note category="file-source" priority="1">Part-DB1\templates\AdminPages\_attachments.html.twig:52</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:50</note> <note category="file-source" priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:50</note>
@ -816,8 +808,8 @@ The user will have to set up all two-factor authentication methods again and pri
<note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:45</note> <note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:45</note>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>attachment.view</source> <source>attachment.view_local</source>
<target>View</target> <target>View Local Copy</target>
</segment> </segment>
</unit> </unit>
<unit id="mEHEYM6" name="attachment.file_not_found"> <unit id="mEHEYM6" name="attachment.file_not_found">
@ -893,9 +885,9 @@ The user will have to set up all two-factor authentication methods again and pri
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>entity.delete.message</source> <source>entity.delete.message</source>
<target>This can not be undone! <target><![CDATA[This can not be undone!
&lt;br&gt; <br>
Sub elements will be moved upwards.</target> Sub elements will be moved upwards.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="2tKAqHw" name="entity.delete"> <unit id="2tKAqHw" name="entity.delete">
@ -1449,7 +1441,7 @@ Sub elements will be moved upwards.</target>
</notes> </notes>
<segment state="final"> <segment state="final">
<source>homepage.github.text</source> <source>homepage.github.text</source>
<target>Source, downloads, bug reports, to-do-list etc. can be found on &lt;a href="%href%" class="link-external" target="_blank"&gt;GitHub project page&lt;/a&gt;</target> <target><![CDATA[Source, downloads, bug reports, to-do-list etc. can be found on <a href="%href%" class="link-external" target="_blank">GitHub project page</a>]]></target>
</segment> </segment>
</unit> </unit>
<unit id="D5OKsgU" name="homepage.help.caption"> <unit id="D5OKsgU" name="homepage.help.caption">
@ -1471,7 +1463,7 @@ Sub elements will be moved upwards.</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>homepage.help.text</source> <source>homepage.help.text</source>
<target>Help and tips can be found in Wiki the &lt;a href="%href%" class="link-external" target="_blank"&gt;GitHub page&lt;/a&gt;</target> <target><![CDATA[Help and tips can be found in Wiki the <a href="%href%" class="link-external" target="_blank">GitHub page</a>]]></target>
</segment> </segment>
</unit> </unit>
<unit id="dnirx4v" name="homepage.forum.caption"> <unit id="dnirx4v" name="homepage.forum.caption">
@ -1713,7 +1705,7 @@ Sub elements will be moved upwards.</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>email.pw_reset.fallback</source> <source>email.pw_reset.fallback</source>
<target>If this does not work for you, go to &lt;a href="%url%"&gt;%url%&lt;/a&gt; and enter the following info</target> <target><![CDATA[If this does not work for you, go to <a href="%url%">%url%</a> and enter the following info]]></target>
</segment> </segment>
</unit> </unit>
<unit id="DduL9Hu" name="email.pw_reset.username"> <unit id="DduL9Hu" name="email.pw_reset.username">
@ -1743,7 +1735,7 @@ Sub elements will be moved upwards.</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>email.pw_reset.valid_unit %date%</source> <source>email.pw_reset.valid_unit %date%</source>
<target>The reset token will be valid until &lt;i&gt;%date%&lt;/i&gt;.</target> <target><![CDATA[The reset token will be valid until <i>%date%</i>.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="8sBnjRy" name="orderdetail.delete"> <unit id="8sBnjRy" name="orderdetail.delete">
@ -2119,14 +2111,14 @@ Sub elements will be moved upwards.</target>
<target>Preview picture</target> <target>Preview picture</target>
</segment> </segment>
</unit> </unit>
<unit id="O2kBcDz" name="attachment.download"> <unit id="Uuy6Ntl" name="attachment.download_local">
<notes> <notes>
<note category="file-source" priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:67</note> <note category="file-source" priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:67</note>
<note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:50</note> <note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:50</note>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>attachment.download</source> <source>attachment.download_local</source>
<target>Download</target> <target>Download Local Copy</target>
</segment> </segment>
</unit> </unit>
<unit id="mPK9Iyq" name="user.creating_user"> <unit id="mPK9Iyq" name="user.creating_user">
@ -3586,8 +3578,8 @@ Sub elements will be moved upwards.</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>tfa_google.disable.confirm_message</source> <source>tfa_google.disable.confirm_message</source>
<target>If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.&lt;br&gt; <target><![CDATA[If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.<br>
Also note that without two-factor authentication, your account is no longer as well protected against attackers!</target> Also note that without two-factor authentication, your account is no longer as well protected against attackers!]]></target>
</segment> </segment>
</unit> </unit>
<unit id="yu9MSt5" name="tfa_google.disabled_message"> <unit id="yu9MSt5" name="tfa_google.disabled_message">
@ -3607,7 +3599,7 @@ Also note that without two-factor authentication, your account is no longer as w
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>tfa_google.step.download</source> <source>tfa_google.step.download</source>
<target>Download an authenticator app (e.g. &lt;a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2"&gt;Google Authenticator&lt;/a&gt; oder &lt;a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp"&gt;FreeOTP Authenticator&lt;/a&gt;)</target> <target><![CDATA[Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)]]></target>
</segment> </segment>
</unit> </unit>
<unit id="eriwJoR" name="tfa_google.step.scan"> <unit id="eriwJoR" name="tfa_google.step.scan">
@ -3849,8 +3841,8 @@ Also note that without two-factor authentication, your account is no longer as w
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>tfa_trustedDevices.explanation</source> <source>tfa_trustedDevices.explanation</source>
<target>When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed. <target><![CDATA[When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed.
If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of &lt;i&gt;all &lt;/i&gt;computers here.</target> If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of <i>all </i>computers here.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="FZINq8z" name="tfa_trustedDevices.invalidate.confirm_title"> <unit id="FZINq8z" name="tfa_trustedDevices.invalidate.confirm_title">
@ -5321,7 +5313,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>label_options.lines_mode.help</source> <source>label_options.lines_mode.help</source>
<target>If you select Twig here, the content field is interpreted as Twig template. See &lt;a href="https://twig.symfony.com/doc/3.x/templates.html"&gt;Twig documentation&lt;/a&gt; and &lt;a href="https://docs.part-db.de/usage/labels.html#twig-mode"&gt;Wiki&lt;/a&gt; for more information.</target> <target><![CDATA[If you select Twig here, the content field is interpreted as Twig template. See <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig documentation</a> and <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a> for more information.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="isvxbiX" name="label_options.page_size.label"> <unit id="isvxbiX" name="label_options.page_size.label">
@ -9396,25 +9388,25 @@ Element 3</target>
<unit id="r4vDLAt" name="filter.parameter_value_constraint.operator.&lt;"> <unit id="r4vDLAt" name="filter.parameter_value_constraint.operator.&lt;">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&lt;</source> <source>filter.parameter_value_constraint.operator.&lt;</source>
<target>Typ. Value &lt;</target> <target><![CDATA[Typ. Value <]]></target>
</segment> </segment>
</unit> </unit>
<unit id="X9SA3UP" name="filter.parameter_value_constraint.operator.&gt;"> <unit id="X9SA3UP" name="filter.parameter_value_constraint.operator.&gt;">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&gt;</source> <source>filter.parameter_value_constraint.operator.&gt;</source>
<target>Typ. Value &gt;</target> <target><![CDATA[Typ. Value >]]></target>
</segment> </segment>
</unit> </unit>
<unit id="BQGaoQS" name="filter.parameter_value_constraint.operator.&lt;="> <unit id="BQGaoQS" name="filter.parameter_value_constraint.operator.&lt;=">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&lt;=</source> <source>filter.parameter_value_constraint.operator.&lt;=</source>
<target>Typ. Value &lt;=</target> <target><![CDATA[Typ. Value <=]]></target>
</segment> </segment>
</unit> </unit>
<unit id="2ha3P6g" name="filter.parameter_value_constraint.operator.&gt;="> <unit id="2ha3P6g" name="filter.parameter_value_constraint.operator.&gt;=">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&gt;=</source> <source>filter.parameter_value_constraint.operator.&gt;=</source>
<target>Typ. Value &gt;=</target> <target><![CDATA[Typ. Value >=]]></target>
</segment> </segment>
</unit> </unit>
<unit id="4DaBace" name="filter.parameter_value_constraint.operator.BETWEEN"> <unit id="4DaBace" name="filter.parameter_value_constraint.operator.BETWEEN">
@ -9522,7 +9514,7 @@ Element 3</target>
<unit id="4tHhDtU" name="parts_list.search.searching_for"> <unit id="4tHhDtU" name="parts_list.search.searching_for">
<segment state="translated"> <segment state="translated">
<source>parts_list.search.searching_for</source> <source>parts_list.search.searching_for</source>
<target>Searching parts with keyword &lt;b&gt;%keyword%&lt;/b&gt;</target> <target><![CDATA[Searching parts with keyword <b>%keyword%</b>]]></target>
</segment> </segment>
</unit> </unit>
<unit id="4vomKLa" name="parts_list.search_options.caption"> <unit id="4vomKLa" name="parts_list.search_options.caption">
@ -10182,13 +10174,13 @@ Element 3</target>
<unit id="NdZ1t7a" name="project.builds.number_of_builds_possible"> <unit id="NdZ1t7a" name="project.builds.number_of_builds_possible">
<segment state="translated"> <segment state="translated">
<source>project.builds.number_of_builds_possible</source> <source>project.builds.number_of_builds_possible</source>
<target>You have enough stocked to build &lt;b&gt;%max_builds%&lt;/b&gt; builds of this project.</target> <target><![CDATA[You have enough stocked to build <b>%max_builds%</b> builds of this project.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="iuSpPbg" name="project.builds.check_project_status"> <unit id="iuSpPbg" name="project.builds.check_project_status">
<segment state="translated"> <segment state="translated">
<source>project.builds.check_project_status</source> <source>project.builds.check_project_status</source>
<target>The current project status is &lt;b&gt;"%project_status%"&lt;/b&gt;. You should check if you really want to build the project with this status!</target> <target><![CDATA[The current project status is <b>"%project_status%"</b>. You should check if you really want to build the project with this status!]]></target>
</segment> </segment>
</unit> </unit>
<unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n"> <unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n">
@ -10290,7 +10282,7 @@ Element 3</target>
<unit id="GzqIwHH" name="entity.select.add_hint"> <unit id="GzqIwHH" name="entity.select.add_hint">
<segment state="translated"> <segment state="translated">
<source>entity.select.add_hint</source> <source>entity.select.add_hint</source>
<target>Use -&gt; to create nested structures, e.g. "Node 1-&gt;Node 1.1"</target> <target><![CDATA[Use -> to create nested structures, e.g. "Node 1->Node 1.1"]]></target>
</segment> </segment>
</unit> </unit>
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB"> <unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
@ -10314,13 +10306,13 @@ Element 3</target>
<unit id="XLnXtsR" name="homepage.first_steps.introduction"> <unit id="XLnXtsR" name="homepage.first_steps.introduction">
<segment state="translated"> <segment state="translated">
<source>homepage.first_steps.introduction</source> <source>homepage.first_steps.introduction</source>
<target>Your database is still empty. You might want to read the &lt;a href="%url%"&gt;documentation&lt;/a&gt; or start to creating the following data structures:</target> <target><![CDATA[Your database is still empty. You might want to read the <a href="%url%">documentation</a> or start to creating the following data structures:]]></target>
</segment> </segment>
</unit> </unit>
<unit id="Q79MOIk" name="homepage.first_steps.create_part"> <unit id="Q79MOIk" name="homepage.first_steps.create_part">
<segment state="translated"> <segment state="translated">
<source>homepage.first_steps.create_part</source> <source>homepage.first_steps.create_part</source>
<target>Or you can directly &lt;a href="%url%"&gt;create a new part&lt;/a&gt;.</target> <target><![CDATA[Or you can directly <a href="%url%">create a new part</a>.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="vplYq4f" name="homepage.first_steps.hide_hint"> <unit id="vplYq4f" name="homepage.first_steps.hide_hint">
@ -10332,7 +10324,7 @@ Element 3</target>
<unit id="MJoZl4f" name="homepage.forum.text"> <unit id="MJoZl4f" name="homepage.forum.text">
<segment state="translated"> <segment state="translated">
<source>homepage.forum.text</source> <source>homepage.forum.text</source>
<target>For questions about Part-DB use the &lt;a href="%href%" class="link-external" target="_blank"&gt;discussion forum&lt;/a&gt;</target> <target><![CDATA[For questions about Part-DB use the <a href="%href%" class="link-external" target="_blank">discussion forum</a>]]></target>
</segment> </segment>
</unit> </unit>
<unit id="YsukbnK" name="log.element_edited.changed_fields.category"> <unit id="YsukbnK" name="log.element_edited.changed_fields.category">
@ -10986,7 +10978,7 @@ Element 3</target>
<unit id="p_IxB9K" name="parts.import.help_documentation"> <unit id="p_IxB9K" name="parts.import.help_documentation">
<segment state="translated"> <segment state="translated">
<source>parts.import.help_documentation</source> <source>parts.import.help_documentation</source>
<target>See the &lt;a href="%link%"&gt;documentation&lt;/a&gt; for more information on the file format.</target> <target><![CDATA[See the <a href="%link%">documentation</a> for more information on the file format.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="awbvhVq" name="parts.import.help"> <unit id="awbvhVq" name="parts.import.help">
@ -11166,7 +11158,7 @@ Element 3</target>
<unit id="o5u.Nnz" name="part.filter.lessThanDesired"> <unit id="o5u.Nnz" name="part.filter.lessThanDesired">
<segment state="translated"> <segment state="translated">
<source>part.filter.lessThanDesired</source> <source>part.filter.lessThanDesired</source>
<target>In stock less than desired (total amount &lt; min. amount)</target> <target><![CDATA[In stock less than desired (total amount < min. amount)]]></target>
</segment> </segment>
</unit> </unit>
<unit id="YN9eLcZ" name="part.filter.lotOwner"> <unit id="YN9eLcZ" name="part.filter.lotOwner">
@ -11978,13 +11970,13 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="i68lU5x" name="part.merge.confirm.title"> <unit id="i68lU5x" name="part.merge.confirm.title">
<segment state="translated"> <segment state="translated">
<source>part.merge.confirm.title</source> <source>part.merge.confirm.title</source>
<target>Do you really want to merge &lt;b&gt;%other%&lt;/b&gt; into &lt;b&gt;%target%&lt;/b&gt;?</target> <target><![CDATA[Do you really want to merge <b>%other%</b> into <b>%target%</b>?]]></target>
</segment> </segment>
</unit> </unit>
<unit id="k0anzYV" name="part.merge.confirm.message"> <unit id="k0anzYV" name="part.merge.confirm.message">
<segment state="translated"> <segment state="translated">
<source>part.merge.confirm.message</source> <source>part.merge.confirm.message</source>
<target>&lt;b&gt;%other%&lt;/b&gt; will be deleted, and the part will be saved with the shown information.</target> <target><![CDATA[<b>%other%</b> will be deleted, and the part will be saved with the shown information.]]></target>
</segment> </segment>
</unit> </unit>
<unit id="mmW5Yl1" name="part.info.merge_modal.title"> <unit id="mmW5Yl1" name="part.info.merge_modal.title">
@ -12329,5 +12321,29 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>There are no entities to export!</target> <target>There are no entities to export!</target>
</segment> </segment>
</unit> </unit>
<unit id="0B3_rob" name="attachment.table.internal_file">
<segment>
<source>attachment.table.internal_file</source>
<target>Internal file</target>
</segment>
</unit>
<unit id="uhfLnkB" name="attachment.table.external_link">
<segment>
<source>attachment.table.external_link</source>
<target>External link</target>
</segment>
</unit>
<unit id="2WKNZAm" name="attachment.view_external.view_at">
<segment>
<source>attachment.view_external.view_at</source>
<target>View at %host%</target>
</segment>
</unit>
<unit id="nwO78O_" name="attachment.view_external">
<segment>
<source>attachment.view_external</source>
<target>View external version</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>