mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Save to Paperless-ngx: tags are choices, and the upload goes through (#78)
The Save to… chooser drew Paperless tags as folders to open, and clicking one did nothing. A DestinationKind (folder, album, tag) now decides how the chooser is drawn: a tag is the choice itself, with a No tag row. The upload is sent as a string with a Content-Length instead of streamed, which Paperless read as an empty form ("No file was submitted", #79).
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+390
-21
No files matched your search
@@ -8,6 +8,16 @@ The published image tags: `latest` follows the most recent release below,
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Saving an attachment to Paperless-ngx works.** The "Save to…" chooser listed your Paperless tags
|
||||
as folders to open, but a tag has nothing inside it, so clicking one only reloaded the same list and
|
||||
the dialog seemed to do nothing. A tag is now the choice itself: click it and the attachment is filed
|
||||
under it, or pick **No tag** to file it under none. The same goes for the folder a filter saves to.
|
||||
The upload itself was also refused ("No file was submitted") because it was sent in a form
|
||||
Paperless could not read; it now goes through. Paperless still turns down file types it cannot
|
||||
consume, a ZIP for one, and says so. Nextcloud, Google Drive, OneDrive, Dropbox, Immich and Google Photos choose exactly as before. No
|
||||
migration.
|
||||
[#78](https://github.com/karatektus/pl_mail/pull/78), by @Flouss.
|
||||
|
||||
**A message's header no longer breaks into pieces in a narrow pane.** On a phone, or with the
|
||||
reading pane on the right and dragged narrow, the sender's address was cut mid-word across three
|
||||
lines while the date and the buttons took half the width. Below 36rem of pane width the date, the
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Integration;
|
||||
|
||||
/**
|
||||
* What a "Save to…" lands in, which decides how the destination chooser is drawn
|
||||
* and what it calls the things it lists.
|
||||
*
|
||||
* Separate from Provider::isMediaLibrary() because the two questions only
|
||||
* coincided until Paperless arrived: that flag says "this holds photos", and the
|
||||
* chooser had been reading it as "this has no folder tree". Paperless holds
|
||||
* documents and has no tree either, so it was drawn as a tree its driver could
|
||||
* not walk.
|
||||
*/
|
||||
enum DestinationKind: string
|
||||
{
|
||||
/** A tree: a folder can hold folders, so rows navigate and the footer saves "here". */
|
||||
case Folder = 'folder';
|
||||
|
||||
/** A flat list of albums in a photo library. Each row is itself the choice. */
|
||||
case Album = 'album';
|
||||
|
||||
/** A flat list of tags in a document archive. Each row is itself the choice. */
|
||||
case Tag = 'tag';
|
||||
|
||||
/**
|
||||
* Whether there is nothing to descend into, so every listed container is a
|
||||
* choice rather than a place to browse to.
|
||||
*/
|
||||
public function isFlat(): bool
|
||||
{
|
||||
return self::Folder !== $this;
|
||||
}
|
||||
}
|
||||
@@ -472,6 +472,22 @@ enum Provider: string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a save is filed into — a folder, an album or a tag.
|
||||
*
|
||||
* Not derived from isMediaLibrary(): that is about what the service holds,
|
||||
* this is about the shape of the containers, and a document archive has
|
||||
* the second without the first.
|
||||
*/
|
||||
public function destinationKind(): DestinationKind
|
||||
{
|
||||
return match ($this) {
|
||||
self::Immich, self::GooglePhotos => DestinationKind::Album,
|
||||
self::Paperless => DestinationKind::Tag,
|
||||
default => DestinationKind::Folder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a "Save to…" of this content type could possibly land.
|
||||
*
|
||||
|
||||
@@ -266,7 +266,11 @@ final readonly class PaperlessDriver implements IntegrationDriverInterface, Sear
|
||||
|
||||
$response = $this->request($integration, 'POST', $this->url($integration, '/documents/post_document/'), [
|
||||
'headers' => $form->getPreparedHeaders()->toArray(),
|
||||
'body' => $form->bodyToIterable(),
|
||||
// A string, not bodyToIterable(): a streamed body leaves chunked with
|
||||
// no Content-Length, and Paperless's server then reads the form as
|
||||
// empty and answers 400 "No file was submitted." to a valid PDF. The
|
||||
// whole file is held in memory for it; attachments are mail-sized.
|
||||
'body' => $form->toString(),
|
||||
]);
|
||||
|
||||
return self::TASK_PREFIX.$this->taskId($response);
|
||||
|
||||
@@ -16,6 +16,13 @@ Two shapes, decided by the taxonomy rather than by provider name:
|
||||
tree to descend, so each album is itself the button, and a "Library" row
|
||||
saves with no album at all.
|
||||
|
||||
A document archive (Paperless) gets its tags the same way, flat: a tag is a
|
||||
label a document is filed under, not a place that holds anything. Each tag is
|
||||
itself the button, and a "No tag" row files under none. What decides between
|
||||
this and a tree is Provider::destinationKind(), not whether the service holds
|
||||
photos — a flat list drawn as a tree is a list of links that all lead back to
|
||||
the same list.
|
||||
|
||||
And two MODES, decided by whether there is a part:
|
||||
|
||||
With a part, a choice is a save: the row or the footer posts the upload and
|
||||
@@ -36,7 +43,11 @@ a successful save is exactly when the modal should close.
|
||||
#}
|
||||
{% extends '_layout/app.html.twig' %}
|
||||
|
||||
{% set isMediaLibrary = integration.provider.isMediaLibrary %}
|
||||
{# Folder, album or tag: the shape of the containers, and so what they are
|
||||
called. `isFlat` is what changes the markup; `kind.value` completes the
|
||||
translation keys, so a fourth kind is one enum case and its strings. #}
|
||||
{% set kind = integration.provider.destinationKind %}
|
||||
{% set isFlat = kind.isFlat %}
|
||||
|
||||
{# Pick mode is the absence of something to upload, not a separate flag — see
|
||||
FilePickerController::renderDestination(). #}
|
||||
@@ -107,9 +118,7 @@ drift into looking like different features.
|
||||
the folder a rule will use for whatever arrives. #}
|
||||
{{ 'integration.destination.choose_for_filter'|trans }}
|
||||
{% else %}
|
||||
{{ (isMediaLibrary
|
||||
? 'integration.destination.choose_album'
|
||||
: 'integration.destination.choose_folder')|trans({ '%file%': part.filename ?: 'attachment' }) }}
|
||||
{{ ('integration.destination.choose_' ~ kind.value)|trans({ '%file%': part.filename ?: 'attachment' }) }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -138,7 +147,7 @@ drift into looking like different features.
|
||||
{% else %}
|
||||
|
||||
{# ── Breadcrumb — a folder tree only ── #}
|
||||
{% if not isMediaLibrary %}
|
||||
{% if not isFlat %}
|
||||
<nav class="flex items-center gap-1 flex-wrap px-6 pb-3 shrink-0 text-xs text-ink-muted">
|
||||
{% for crumb in listing.breadcrumb %}
|
||||
{% if not loop.last %}
|
||||
@@ -155,18 +164,24 @@ drift into looking like different features.
|
||||
{# ── The containers ── #}
|
||||
<div class="flex-1 min-h-[14rem] min-w-0 px-6 overflow-y-auto overscroll-contain">
|
||||
<div class="py-2">
|
||||
{% if isMediaLibrary %}
|
||||
{% if isFlat %}
|
||||
|
||||
{# What the two flat kinds differ in: the row that files
|
||||
under no container at all, and the icons. #}
|
||||
{% set flat = {
|
||||
album: { rootLabel: 'integration.destination.library', rootIcon: 'fa-photo-film', icon: 'fa-images' },
|
||||
tag: { rootLabel: 'integration.destination.no_tag', rootIcon: 'fa-box-archive', icon: 'fa-tag' },
|
||||
}[kind.value] %}
|
||||
<ul class="space-y-0.5">
|
||||
{# Always available: the library itself, no album. #}
|
||||
{{ dest.chooseRow(integration, part, isPick, '', 'integration.destination.library'|trans, 'fa-photo-film') }}
|
||||
{# Always available: no album, no tag. #}
|
||||
{{ dest.chooseRow(integration, part, isPick, '', flat.rootLabel|trans, flat.rootIcon) }}
|
||||
{% for entry in listing.folders %}
|
||||
{{ dest.chooseRow(integration, part, isPick, entry.id, entry.name, 'fa-images') }}
|
||||
{{ dest.chooseRow(integration, part, isPick, entry.id, entry.name, flat.icon) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if listing.folders is empty %}
|
||||
<p class="py-6 text-center text-sm text-ink-faint italic">
|
||||
{{ 'integration.destination.no_albums'|trans }}
|
||||
{{ ('integration.destination.no_' ~ kind.value ~ 's')|trans }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -226,9 +241,7 @@ drift into looking like different features.
|
||||
text-xs font-medium text-ink-soft hover:bg-hover transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i>
|
||||
{{ (isMediaLibrary
|
||||
? 'integration.destination.new_album'
|
||||
: 'integration.destination.new_folder')|trans }}
|
||||
{{ ('integration.destination.new_' ~ kind.value)|trans }}
|
||||
</summary>
|
||||
<form method="post" data-ui--modal-keep-open
|
||||
action="{{ path('app_integration_create_destination', { id: integration.id, part: part.id }) }}"
|
||||
@@ -236,14 +249,10 @@ drift into looking like different features.
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('integration-destination-' ~ part.id) }}">
|
||||
<input type="hidden" name="parent" value="{{ folderId }}">
|
||||
<label class="sr-only" for="new-destination-name">
|
||||
{{ (isMediaLibrary
|
||||
? 'integration.destination.album_name'
|
||||
: 'integration.destination.folder_name')|trans }}
|
||||
{{ ('integration.destination.' ~ kind.value ~ '_name')|trans }}
|
||||
</label>
|
||||
<input type="text" id="new-destination-name" name="name" required
|
||||
placeholder="{{ (isMediaLibrary
|
||||
? 'integration.destination.album_name'
|
||||
: 'integration.destination.folder_name')|trans }}"
|
||||
placeholder="{{ ('integration.destination.' ~ kind.value ~ '_name')|trans }}"
|
||||
class="flex-1 min-w-0 h-9 px-3 rounded-lg text-sm bg-field border border-field text-ink
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
<button type="submit"
|
||||
@@ -273,7 +282,7 @@ drift into looking like different features.
|
||||
Offered even when the listing failed: the top level is always
|
||||
a valid target, so a user is never left unable to save at all
|
||||
because a subfolder would not load. #}
|
||||
{% if not isMediaLibrary %}
|
||||
{% if not isFlat %}
|
||||
{# The readable half of the answer. Folder ids are opaque for
|
||||
most services — a Drive id is a random string — so the
|
||||
trail the driver built is what a person is shown once the
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Tests\Domain\Enum\Integration;
|
||||
|
||||
use App\Domain\Enum\Integration\AuthKind;
|
||||
use App\Domain\Enum\Integration\Capability;
|
||||
use App\Domain\Enum\Integration\DestinationKind;
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Domain\Enum\Integration\ServiceKind;
|
||||
use App\Domain\Interface\CalendarSyncDriverInterface;
|
||||
@@ -351,6 +352,42 @@ final class ProviderTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a "Save to…" lands in, which decides whether the destination chooser
|
||||
* draws a tree to walk or a flat list to pick from.
|
||||
*
|
||||
* Paperless is the case this exists for: it is not a photo library, but it
|
||||
* has no tree either — a save files under a tag, and nothing is beneath a
|
||||
* tag. Deriving "flat" from isMediaLibrary() drew its tags as folders to
|
||||
* navigate into, and clicking one went nowhere.
|
||||
*/
|
||||
#[DataProvider('destinationKinds')]
|
||||
public function testEachFileProviderSaysWhatItsDestinationsAre(Provider $provider, DestinationKind $expected): void
|
||||
{
|
||||
self::assertSame($expected, $provider->destinationKind());
|
||||
}
|
||||
|
||||
public function testOnlyAFolderTreeIsNavigable(): void
|
||||
{
|
||||
self::assertFalse(DestinationKind::Folder->isFlat());
|
||||
self::assertTrue(DestinationKind::Album->isFlat());
|
||||
self::assertTrue(DestinationKind::Tag->isFlat());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string,array{Provider,DestinationKind}>
|
||||
*/
|
||||
public static function destinationKinds(): iterable
|
||||
{
|
||||
yield 'nextcloud' => [Provider::Nextcloud, DestinationKind::Folder];
|
||||
yield 'googleDrive' => [Provider::GoogleDrive, DestinationKind::Folder];
|
||||
yield 'oneDrive' => [Provider::OneDrive, DestinationKind::Folder];
|
||||
yield 'dropbox' => [Provider::Dropbox, DestinationKind::Folder];
|
||||
yield 'immich' => [Provider::Immich, DestinationKind::Album];
|
||||
yield 'googlePhotos' => [Provider::GooglePhotos, DestinationKind::Album];
|
||||
yield 'paperless' => [Provider::Paperless, DestinationKind::Tag];
|
||||
}
|
||||
|
||||
public function testEveryProviderHasALabelAndAnIcon(): void
|
||||
{
|
||||
foreach (Provider::cases() as $provider) {
|
||||
|
||||
@@ -345,6 +345,38 @@ final class PaperlessDriverTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A streamed multipart body leaves as `Transfer-Encoding: chunked` with no
|
||||
* Content-Length, and Paperless's server then reads it as an empty form: it
|
||||
* answers 400 "No file was submitted." to a perfectly good PDF that curl gets
|
||||
* a 200 for. So the body must be a whole string, which the client sends with
|
||||
* a Content-Length. Verified against a real paperless-ngx 2026-10-11.
|
||||
*/
|
||||
public function testUploadSendsTheFileWithAContentLengthRatherThanChunked(): void
|
||||
{
|
||||
$path = $this->scratchFile('%PDF-1.4 scan-bytes');
|
||||
|
||||
try {
|
||||
$driver = $this->driver([new MockResponse('"3f2a1b8c-0000-4000-8000-000000000003"')]);
|
||||
|
||||
$driver->upload($this->integration(), $path, 'bill.pdf', 'application/pdf');
|
||||
|
||||
$headers = $this->requests[0]['options']['normalized_headers'];
|
||||
|
||||
// Presence: the file really is in the body, so the two absences
|
||||
// below are not an empty form passing for correct behaviour.
|
||||
self::assertStringContainsString('%PDF-1.4 scan-bytes', $this->requests[0]['body']);
|
||||
self::assertStringContainsString('filename="bill.pdf"', $this->requests[0]['body']);
|
||||
self::assertSame(
|
||||
['Content-Length: '.strlen($this->requests[0]['body'])],
|
||||
$headers['content-length'] ?? [],
|
||||
);
|
||||
self::assertArrayNotHasKey('transfer-encoding', $headers);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function testNothingCanMistakeATaskIdForADocument(): void
|
||||
{
|
||||
$driver = $this->driver([]);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Twig;
|
||||
|
||||
use App\Domain\DTO\Integration\Entry;
|
||||
use App\Domain\DTO\Integration\Listing;
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Entity\Integration\Integration;
|
||||
use App\Entity\Mail\MessagePart;
|
||||
use App\Entity\User\User;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\ArrayLoader;
|
||||
use Twig\Loader\ChainLoader;
|
||||
|
||||
/**
|
||||
* What the destination chooser draws for each kind of container.
|
||||
*
|
||||
* Paperless has no folders — a save files under a TAG, and the driver offers the
|
||||
* tags as a flat list with nothing beneath any of them. The chooser used to
|
||||
* decide "flat or tree" by asking whether the provider was a photo library, so
|
||||
* Paperless fell into the tree branch: every tag was drawn as a link that
|
||||
* navigates INTO it, the driver answered with the very same flat list, and the
|
||||
* dialog appeared to do nothing when a tag was clicked. Nothing in the markup
|
||||
* could save anywhere except the footer's "Save here", which files under no tag.
|
||||
*
|
||||
* Asserted on the rendered HTML rather than on the driver, because the driver
|
||||
* was right all along; the fault was in what the template did with a flat list.
|
||||
*
|
||||
* The page layout is replaced by an empty shell: it needs a whole request's
|
||||
* worth of appearance and session state that is nothing to do with this
|
||||
* template, and the chooser's own markup is all that is under test.
|
||||
*/
|
||||
final class DestinationPickerRenderTest extends KernelTestCase
|
||||
{
|
||||
private Environment $twig;
|
||||
private TranslatorInterface $translator;
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = self::getContainer();
|
||||
$this->twig = $container->get(Environment::class);
|
||||
$this->translator = $container->get(TranslatorInterface::class);
|
||||
|
||||
// The save controls carry CSRF tokens, and the token manager reads the
|
||||
// session off the request stack — empty outside a real request.
|
||||
$request = new Request();
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$container->get('request_stack')->push($request);
|
||||
|
||||
$this->twig->setLoader(new ChainLoader([
|
||||
new ArrayLoader(['_layout/app.html.twig' => '{% block body %}{% endblock %}']),
|
||||
$this->twig->getLoader(),
|
||||
]));
|
||||
|
||||
$this->user = new User();
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression itself. A tag is a destination: clicking it must be the
|
||||
* choice, not a trip to a list that looks identical.
|
||||
*/
|
||||
public function testAPaperlessTagIsAChoiceRatherThanALinkIntoItself(): void
|
||||
{
|
||||
$html = $this->render(Provider::Paperless, [
|
||||
Entry::folder('7', 'Taxes'),
|
||||
Entry::folder('9', 'Invoices'),
|
||||
]);
|
||||
|
||||
self::assertStringContainsString('data-destination-folder="7"', $html);
|
||||
self::assertStringContainsString('data-destination-label="Taxes"', $html);
|
||||
self::assertStringContainsString('data-destination-folder="9"', $html);
|
||||
|
||||
// The failing shape: a link that browses to the tag and re-renders the
|
||||
// same flat list. Neither id may appear in a browse URL.
|
||||
self::assertStringNotContainsString('folder=7', $html);
|
||||
self::assertStringNotContainsString('folder=9', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence companion to the test above. "No link into a tag" would also
|
||||
* hold for an empty chooser, so this pins that the other half — filing
|
||||
* under no tag at all — is still on offer, and is the only thing at the
|
||||
* root that is.
|
||||
*/
|
||||
public function testPaperlessOffersNoTagAsTheRootChoiceAndNoFooterToSaveHere(): void
|
||||
{
|
||||
$html = $this->render(Provider::Paperless, [Entry::folder('7', 'Taxes')]);
|
||||
|
||||
self::assertStringContainsString('data-destination-folder=""', $html);
|
||||
self::assertStringContainsString($this->translator->trans('integration.destination.no_tag'), $html);
|
||||
|
||||
// A flat list has no "here" to browse to; the footer's save-here would
|
||||
// be a second, unlabelled way to file under no tag.
|
||||
self::assertStringNotContainsString(
|
||||
$this->translator->trans('integration.destination.choose_here'),
|
||||
$html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In the save flow — the one the reading pane starts — a tag row is a form
|
||||
* that posts the upload with the tag as its destination, the way an Immich
|
||||
* album does. Pick mode shares the row's markup and differs only in the
|
||||
* wrapper, so this is the half of it that actually moves a file.
|
||||
*/
|
||||
public function testAPaperlessTagPostsASaveWhenThereIsAnAttachmentInHand(): void
|
||||
{
|
||||
$html = $this->render(Provider::Paperless, [Entry::folder('7', 'Taxes')], withPart: true);
|
||||
|
||||
self::assertStringContainsString('name="destination" value="7"', $html);
|
||||
self::assertStringContainsString('/save-attachment/', $html);
|
||||
self::assertStringNotContainsString('folder=7', $html);
|
||||
}
|
||||
|
||||
/** Called tags, not albums: Paperless has neither folders nor albums. */
|
||||
public function testPaperlessSpeaksOfTagsNotAlbums(): void
|
||||
{
|
||||
$html = $this->render(Provider::Paperless, [], withPart: true);
|
||||
|
||||
self::assertStringContainsString(
|
||||
$this->translator->trans('integration.destination.no_tags'),
|
||||
$html,
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
$this->translator->trans('integration.destination.choose_tag', ['%file%' => 'invoice.pdf']),
|
||||
$html,
|
||||
);
|
||||
self::assertStringNotContainsString(
|
||||
$this->translator->trans('integration.destination.no_albums'),
|
||||
$html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two kinds this change must not move. A file store still navigates
|
||||
* into a folder, and a photo library's album is still a choice — if the
|
||||
* flat/tree decision were inverted for everyone, the Paperless tests above
|
||||
* would pass for the wrong reason.
|
||||
*/
|
||||
public function testAFileStoreStillNavigatesIntoAFolder(): void
|
||||
{
|
||||
$html = $this->render(Provider::Nextcloud, [Entry::folder('Mail/Invoices', 'Invoices')]);
|
||||
|
||||
self::assertStringContainsString('browse?folder=Mail/Invoices&mode=destination', $html);
|
||||
self::assertStringNotContainsString('data-destination-label="Invoices"', $html);
|
||||
self::assertStringContainsString(
|
||||
$this->translator->trans('integration.destination.choose_here'),
|
||||
$html,
|
||||
);
|
||||
}
|
||||
|
||||
public function testAPhotoLibraryAlbumIsStillAChoice(): void
|
||||
{
|
||||
$html = $this->render(Provider::Immich, [Entry::folder('a1', 'Holiday')]);
|
||||
|
||||
self::assertStringContainsString('data-destination-folder="a1"', $html);
|
||||
self::assertStringNotContainsString('folder=a1', $html);
|
||||
self::assertStringContainsString($this->translator->trans('integration.destination.library'), $html);
|
||||
}
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param list<Entry> $folders
|
||||
*/
|
||||
private function render(Provider $provider, array $folders, bool $withPart = false): string
|
||||
{
|
||||
$integration = new Integration($this->user, $provider, 'Test connection');
|
||||
$this->setId($integration, 5);
|
||||
|
||||
$part = null;
|
||||
|
||||
if (true === $withPart) {
|
||||
$part = new MessagePart();
|
||||
$part->filename = 'invoice.pdf';
|
||||
$this->setId($part, 42);
|
||||
}
|
||||
|
||||
return $this->twig->render('integration/_destination.html.twig', [
|
||||
'integration' => $integration,
|
||||
'part' => $part,
|
||||
'listing' => new Listing($folders, [Entry::folder('', $provider->label())]),
|
||||
'error' => null,
|
||||
'createError' => null,
|
||||
'folderId' => null,
|
||||
'canCreate' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function setId(object $entity, int $id): void
|
||||
{
|
||||
(new ReflectionProperty($entity::class, 'id'))->setValue($entity, $id);
|
||||
}
|
||||
}
|
||||
@@ -2177,16 +2177,21 @@ integration:
|
||||
# den er später speichert — für Mail, die noch gar nicht da ist.
|
||||
choose_for_filter: 'Wähle, wohin dieser Filter Anhänge speichert.'
|
||||
choose_here: 'Diesen Ordner wählen'
|
||||
choose_tag: 'Tag für %file% wählen'
|
||||
choose_this: Wählen
|
||||
create: Erstellen
|
||||
folder_name: 'Ordnername'
|
||||
library: 'Mediathek (kein Album)'
|
||||
new_album: 'Neues Album'
|
||||
new_folder: 'Neuer Ordner'
|
||||
new_tag: 'Neuer Tag'
|
||||
no_albums: 'Noch keine Alben.'
|
||||
no_subfolders: 'Keine Ordner hier.'
|
||||
no_tag: 'Kein Tag'
|
||||
no_tags: 'Noch keine Tags.'
|
||||
save_here: 'Hier speichern'
|
||||
start_over: 'Von vorn beginnen'
|
||||
tag_name: 'Tag-Name'
|
||||
picker:
|
||||
attach: Anhängen
|
||||
attach_from: 'Aus %name% anhängen'
|
||||
|
||||
@@ -2176,16 +2176,21 @@ integration:
|
||||
# folder it will save to, for mail that has not arrived yet.
|
||||
choose_for_filter: 'Choose where this filter saves attachments.'
|
||||
choose_here: 'Choose this folder'
|
||||
choose_tag: 'Choose a tag for %file%'
|
||||
choose_this: Choose
|
||||
create: Create
|
||||
folder_name: 'Folder name'
|
||||
library: 'Library (no album)'
|
||||
new_album: 'New album'
|
||||
new_folder: 'New folder'
|
||||
new_tag: 'New tag'
|
||||
no_albums: 'No albums yet.'
|
||||
no_subfolders: 'No folders here.'
|
||||
no_tag: 'No tag'
|
||||
no_tags: 'No tags yet.'
|
||||
save_here: 'Save here'
|
||||
start_over: 'Start over'
|
||||
tag_name: 'Tag name'
|
||||
picker:
|
||||
attach: Attach
|
||||
attach_from: 'Attach from %name%'
|
||||
|
||||
@@ -2056,16 +2056,21 @@ integration:
|
||||
choose_folder: 'Pick a hold fer %file%'
|
||||
choose_for_filter: "Pick where this sieve stows its cargo."
|
||||
choose_here: 'Take this hold'
|
||||
choose_tag: 'Pick a tag fer %file%'
|
||||
choose_this: Take it
|
||||
create: Make
|
||||
folder_name: 'Hold name'
|
||||
library: 'Whole library (no album)'
|
||||
new_album: 'New album'
|
||||
new_folder: 'New hold'
|
||||
new_tag: 'New tag'
|
||||
no_albums: 'No albums yet, matey.'
|
||||
no_subfolders: 'No holds here.'
|
||||
no_tag: 'No tag'
|
||||
no_tags: 'No tags yet, matey.'
|
||||
save_here: 'Stow here'
|
||||
start_over: 'Start afresh'
|
||||
tag_name: 'Tag name'
|
||||
picker:
|
||||
attach: 'Haul aboard'
|
||||
attach_from: 'Haul from %name%'
|
||||
|
||||
@@ -2187,16 +2187,21 @@ integration:
|
||||
# folder it will save to, for mail that has not arrived yet.
|
||||
choose_for_filter: 'Choisissez où ce filtre enregistre les pièces jointes.'
|
||||
choose_here: 'Choisir ce dossier'
|
||||
choose_tag: 'Choisir une étiquette pour %file%'
|
||||
choose_this: Choisir
|
||||
create: Créer
|
||||
folder_name: 'Nom du dossier'
|
||||
library: 'Bibliothèque (sans album)'
|
||||
new_album: 'Nouvel album'
|
||||
new_folder: 'Nouveau dossier'
|
||||
new_tag: 'Nouvelle étiquette'
|
||||
no_albums: 'Aucun album pour l’instant.'
|
||||
no_subfolders: 'Aucun dossier ici.'
|
||||
no_tag: 'Sans étiquette'
|
||||
no_tags: 'Aucune étiquette pour l’instant.'
|
||||
save_here: 'Enregistrer ici'
|
||||
start_over: 'Recommencer'
|
||||
tag_name: 'Nom de l’étiquette'
|
||||
picker:
|
||||
attach: Joindre
|
||||
attach_from: 'Joindre depuis %name%'
|
||||
|
||||
Reference in new issue
Block a user