mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Choose a profile picture through the compose file picker (#65)
Settings → Profile opens the same file picker as attaching to a mail, in an avatar mode: albums, people, search and paging, one picture at a time, files that cannot be a picture greyed out. The choice is posted straight back to the profile form, and the connection is matched against the user's own. From Paperless-ngx the original is taken rather than the archived PDF. The setup wizard keeps its small grid for now. 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:
26 files changed
+851
-108
No files matched your search
@@ -33,6 +33,17 @@ a row beside Drafts for every account together, and a row at the top of each acc
|
|||||||
that account alone. Archiving or snoozing from it leaves the conversation in the list. A
|
that account alone. Archiving or snoozing from it leaves the conversation in the list. A
|
||||||
conversation with several labels is listed once. No migration.
|
conversation with several labels is listed once. No migration.
|
||||||
|
|
||||||
|
**Choosing a profile picture from Immich, Paperless-ngx or another connected service opens the
|
||||||
|
same picker as attaching to a mail.** Settings → Profile showed a small grid of its own: the first
|
||||||
|
couple of dozen images at the top of the service, with no albums, no people, no search, no way to
|
||||||
|
scroll on. Each connection's button now opens the full picker, with "Use as profile picture" where
|
||||||
|
a mail has "Attach", and one picture can be chosen at a time. A PDF, or a file the service says is
|
||||||
|
over 4 MB, is shown greyed out rather than hidden; Immich and Google Photos do not report sizes, so
|
||||||
|
a large photo from them is still refused after it is chosen, with the reason under the picture
|
||||||
|
field. A scanned photograph from Paperless-ngx is now taken as the original — it used to arrive as
|
||||||
|
the archived PDF and be refused. The setup wizard's profile step still has the small grid, because
|
||||||
|
the picker would replace the wizard it is inside. No migration.
|
||||||
|
|
||||||
**Remote images can be shown in every message without asking (#124).**
|
**Remote images can be shown in every message without asking (#124).**
|
||||||
Settings → Mail → Images has one switch, **Always show remote images**, off until you turn it on.
|
Settings → Mail → Images has one switch, **Always show remote images**, off until you turn it on.
|
||||||
With it on, no message asks before loading its pictures; they still come through plMail's image
|
With it on, no message asks before loading its pictures; they still come through plMail's image
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export default class extends Controller {
|
|||||||
url: String,
|
url: String,
|
||||||
draft: Number,
|
draft: Number,
|
||||||
token: String,
|
token: String,
|
||||||
|
/**
|
||||||
|
* 'attach' (compose) or 'avatar' (Settings → Profile). In avatar mode one
|
||||||
|
* file is chosen and handed back rather than attached to a draft.
|
||||||
|
*/
|
||||||
|
mode: { type: String, default: "attach" },
|
||||||
|
/** The connection being browsed, for the avatar hand-back. */
|
||||||
|
integration: String,
|
||||||
/** The status line's words, translated in _picker.html.twig. */
|
/** The status line's words, translated in _picker.html.twig. */
|
||||||
i18n: { type: Object, default: {} },
|
i18n: { type: Object, default: {} },
|
||||||
};
|
};
|
||||||
@@ -168,6 +175,26 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One file only, in avatar mode.
|
||||||
|
*
|
||||||
|
* Rows are a radio group each, so the browser keeps just one *per file*
|
||||||
|
* and nothing stops a second tick; tiles are checkboxes. A profile has one
|
||||||
|
* picture, so choosing another lets go of the last — and it has to hold
|
||||||
|
* across pages, which the paging script appends into the same container.
|
||||||
|
*/
|
||||||
|
changed(event) {
|
||||||
|
if ("avatar" !== this.modeValue || true !== event.target.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modeTargets.forEach((input) => {
|
||||||
|
if (input !== event.target) {
|
||||||
|
input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async attach() {
|
async attach() {
|
||||||
const chosen = this.modeTargets.filter((input) => input.checked);
|
const chosen = this.modeTargets.filter((input) => input.checked);
|
||||||
|
|
||||||
@@ -177,6 +204,12 @@ export default class extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("avatar" === this.modeValue) {
|
||||||
|
this._chooseAvatar(chosen[0]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (0 === this.draftValue) {
|
if (0 === this.draftValue) {
|
||||||
// The draft is force-saved before the picker opens, so this means
|
// The draft is force-saved before the picker opens, so this means
|
||||||
// that save failed rather than that the user was too quick.
|
// that save failed rather than that the user was too quick.
|
||||||
@@ -239,6 +272,27 @@ export default class extends Controller {
|
|||||||
this.dispatch("close", { prefix: "ui--modal" });
|
this.dispatch("close", { prefix: "ui--modal" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the chosen picture to the profile form, which posts it.
|
||||||
|
*
|
||||||
|
* Nothing is saved here: the picker and the form sit in different Turbo
|
||||||
|
* frames, so the event is the seam, as it is for attachments. The file id
|
||||||
|
* is read back out of the input's name (`mode[<id>]`) rather than through a
|
||||||
|
* Stimulus param, for the reason destination_picker_controller gives — a
|
||||||
|
* service's id is an opaque string and a typecast would change it.
|
||||||
|
*/
|
||||||
|
_chooseAvatar(input) {
|
||||||
|
const fileId = input.name.replace(/^mode\[(.*)\]$/, "$1");
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("plmail:avatar-picked", {
|
||||||
|
detail: { integrationId: this.integrationValue, fileId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dispatch("close", { prefix: "ui--modal" });
|
||||||
|
}
|
||||||
|
|
||||||
/** A translated word, or the key itself — visibly wrong rather than English. */
|
/** A translated word, or the key itself — visibly wrong rather than English. */
|
||||||
_t(key) {
|
_t(key) {
|
||||||
return this.i18nValue[key] ?? key;
|
return this.i18nValue[key] ?? key;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the profile form with a picture chosen in the file picker.
|
||||||
|
*
|
||||||
|
* The picker renders in the body-level modal frame and the form is in the page,
|
||||||
|
* so they cannot address each other through the DOM; the picker announces its
|
||||||
|
* choice on `document` (integration_picker_controller.js) and this is what
|
||||||
|
* listens. It adds the two fields ProfileType expects for a picture from a
|
||||||
|
* service — they are not in the form until there is a choice, so an ordinary
|
||||||
|
* save posts nothing about it — and submits.
|
||||||
|
*
|
||||||
|
* Submitting, rather than filling the fields and waiting for Save: choosing and
|
||||||
|
* saving have always been one action here, and a picture picked in a dialog
|
||||||
|
* that then needs a second click somewhere behind it reads as a pick that did
|
||||||
|
* nothing.
|
||||||
|
*
|
||||||
|
* The server does not trust either field. ProfileUpdater matches the
|
||||||
|
* integration id against the user's own connections and AvatarFromIntegration
|
||||||
|
* decides what the bytes are.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
/** The form's name, which prefixes its fields: `profile[avatarFileId]`. */
|
||||||
|
name: String,
|
||||||
|
};
|
||||||
|
|
||||||
|
picked(event) {
|
||||||
|
const { integrationId, fileId } = event.detail ?? {};
|
||||||
|
|
||||||
|
if (undefined === integrationId || undefined === fileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._set("avatarIntegrationId", integrationId);
|
||||||
|
this._set("avatarFileId", fileId);
|
||||||
|
|
||||||
|
this.element.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hidden field, replaced rather than duplicated on a second pick. */
|
||||||
|
_set(field, value) {
|
||||||
|
const name = `${this.nameValue}[${field}]`;
|
||||||
|
|
||||||
|
this.element
|
||||||
|
.querySelectorAll("input[type=hidden]")
|
||||||
|
.forEach((input) => {
|
||||||
|
if (input.name === name) {
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = name;
|
||||||
|
input.value = value;
|
||||||
|
this.element.append(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Integration;
|
namespace App\Controller\Integration;
|
||||||
|
|
||||||
use App\Controller\ChecksCsrf;
|
use App\Controller\ChecksCsrf;
|
||||||
|
use App\Domain\Helper\AvatarStorage;
|
||||||
use App\Domain\Helper\InlineDisposition;
|
use App\Domain\Helper\InlineDisposition;
|
||||||
use App\Controller\Mail\ComposeAttachmentController;
|
use App\Controller\Mail\ComposeAttachmentController;
|
||||||
use App\Domain\Enum\Integration\Capability;
|
use App\Domain\Enum\Integration\Capability;
|
||||||
@@ -14,6 +15,7 @@ use App\Entity\Mail\Message;
|
|||||||
use App\Entity\Mail\MessagePart;
|
use App\Entity\Mail\MessagePart;
|
||||||
use App\Security\Voter\OwnershipVoter;
|
use App\Security\Voter\OwnershipVoter;
|
||||||
use App\Service\Integration\IntegrationFilePicker;
|
use App\Service\Integration\IntegrationFilePicker;
|
||||||
|
use App\Service\User\AvatarFromIntegration;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -67,6 +69,7 @@ final class FilePickerController extends AbstractController
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly IntegrationFilePicker $picker,
|
private readonly IntegrationFilePicker $picker,
|
||||||
|
private readonly AvatarFromIntegration $avatars,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -90,6 +93,18 @@ final class FilePickerController extends AbstractController
|
|||||||
|
|
||||||
$this->assertUsable($integration, Capability::Browse);
|
$this->assertUsable($integration, Capability::Browse);
|
||||||
|
|
||||||
|
// Choosing a profile picture is the third way in. It is this same
|
||||||
|
// picker — albums, people, search, the date bar and paging — because a
|
||||||
|
// second, smaller one only ever showed the first page of the library
|
||||||
|
// root. What differs is the end of it: nothing is attached to a draft,
|
||||||
|
// the choice is handed back to the profile form (see
|
||||||
|
// integration_picker_controller.js), and that form posts it.
|
||||||
|
$forAvatar = 'avatar' === $request->query->get('mode');
|
||||||
|
|
||||||
|
if (true === $forAvatar) {
|
||||||
|
$this->assertUsable($integration, Capability::Download);
|
||||||
|
}
|
||||||
|
|
||||||
$folderId = $request->query->get('folder');
|
$folderId = $request->query->get('folder');
|
||||||
$draftId = $request->query->getInt('draft');
|
$draftId = $request->query->getInt('draft');
|
||||||
$cursor = $this->blankToNull($request->query->get('cursor'));
|
$cursor = $this->blankToNull($request->query->get('cursor'));
|
||||||
@@ -104,8 +119,14 @@ final class FilePickerController extends AbstractController
|
|||||||
'folderId' => $folderId,
|
'folderId' => $folderId,
|
||||||
'draftId' => $draftId,
|
'draftId' => $draftId,
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
'maxBytes' => ComposeAttachmentController::MAX_ATTACHMENT_BYTES,
|
'mode' => $forAvatar ? 'avatar' : 'attach',
|
||||||
'canLink' => $integration->supports(Capability::ShareLink),
|
// The avatar store's own cap, so a photo it would refuse shows as
|
||||||
|
// too large rather than being chosen and then bounced.
|
||||||
|
'maxBytes' => $forAvatar ? AvatarStorage::MAX_BYTES : ComposeAttachmentController::MAX_ATTACHMENT_BYTES,
|
||||||
|
// Files on this page that cannot be a picture: shown, not choosable.
|
||||||
|
'refusedIds' => $forAvatar ? $this->avatars->refusedIds($view->listing) : [],
|
||||||
|
// A share link is no use as a profile picture.
|
||||||
|
'canLink' => false === $forAvatar && $integration->supports(Capability::ShareLink),
|
||||||
'canThumb' => $integration->supports(Capability::Thumbnail),
|
'canThumb' => $integration->supports(Capability::Thumbnail),
|
||||||
'canSearch' => $view->canSearch,
|
'canSearch' => $view->canSearch,
|
||||||
'buckets' => $view->buckets,
|
'buckets' => $view->buckets,
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ final class SettingsController extends AbstractController
|
|||||||
// comes back whole with the errors on it — see ProfileController.
|
// comes back whole with the errors on it — see ProfileController.
|
||||||
...$this->profileSection->build(
|
...$this->profileSection->build(
|
||||||
$this->getUser(),
|
$this->getUser(),
|
||||||
$request,
|
|
||||||
$request->attributes->get('profileForm') instanceof FormInterface
|
$request->attributes->get('profileForm') instanceof FormInterface
|
||||||
? $request->attributes->get('profileForm')
|
? $request->attributes->get('profileForm')
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Interface;
|
||||||
|
|
||||||
|
use App\Domain\DTO\Integration\RemoteFile;
|
||||||
|
use App\Domain\Exception\IntegrationException;
|
||||||
|
use App\Entity\Integration\Integration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A driver whose download() is not the file the listing described.
|
||||||
|
*
|
||||||
|
* Paperless-ngx is the one: its listing reports the original's type, while
|
||||||
|
* download() answers with the archived PDF it made from it, which is the copy
|
||||||
|
* worth attaching to a mail. For something that has to be the original — a
|
||||||
|
* profile picture taken from a scanned photograph — that PDF is the wrong file,
|
||||||
|
* under a type the listing never promised.
|
||||||
|
*
|
||||||
|
* Separate from IntegrationDriverInterface for the same reason
|
||||||
|
* SearchableDriverInterface is: every other driver has one file per id, and
|
||||||
|
* folding this in would make six of them carry a method that repeats download().
|
||||||
|
*/
|
||||||
|
interface OriginalFileDriverInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The file as it was put into the service, not as the service rendered it.
|
||||||
|
*
|
||||||
|
* @throws IntegrationException
|
||||||
|
*/
|
||||||
|
public function downloadOriginal(Integration $integration, string $fileId): RemoteFile;
|
||||||
|
}
|
||||||
@@ -55,10 +55,12 @@ final class ProfileType extends AbstractType
|
|||||||
'attr' => ['accept' => implode(',', AvatarStorage::ALLOWED_MIME)],
|
'attr' => ['accept' => implode(',', AvatarStorage::ALLOWED_MIME)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Present only while pictures from a connected service are on screen.
|
// Present only when a picture from a connected service is being posted.
|
||||||
// The file id is set by the thumbnail the user clicks — each is a submit
|
// In the setup wizard the file id is set by the thumbnail the user
|
||||||
// button carrying this field's name — so the choice and the submission
|
// clicks — each is a submit button carrying this field's name. In
|
||||||
// are one action rather than two.
|
// Settings the file picker's dialog announces its choice and
|
||||||
|
// settings/avatar_from_service_controller.js adds both fields and
|
||||||
|
// submits. Either way the choice and the submission are one action.
|
||||||
if (null !== $options['avatar_source']) {
|
if (null !== $options['avatar_source']) {
|
||||||
$builder
|
$builder
|
||||||
->add('avatarIntegrationId', HiddenType::class, [
|
->add('avatarIntegrationId', HiddenType::class, [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Domain\Enum\Integration\Provider;
|
|||||||
use App\Domain\Exception\IntegrationException;
|
use App\Domain\Exception\IntegrationException;
|
||||||
use App\Domain\Interface\DestinationDriverInterface;
|
use App\Domain\Interface\DestinationDriverInterface;
|
||||||
use App\Domain\Interface\IntegrationDriverInterface;
|
use App\Domain\Interface\IntegrationDriverInterface;
|
||||||
|
use App\Domain\Interface\OriginalFileDriverInterface;
|
||||||
use App\Domain\Interface\SearchableDriverInterface;
|
use App\Domain\Interface\SearchableDriverInterface;
|
||||||
use App\Entity\Integration\Integration;
|
use App\Entity\Integration\Integration;
|
||||||
use App\Repository\Integration\IntegrationProviderConfigRepository;
|
use App\Repository\Integration\IntegrationProviderConfigRepository;
|
||||||
@@ -62,7 +63,7 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
|
|||||||
* so neither needs a prefix to tell it from the other. The one id that *is*
|
* so neither needs a prefix to tell it from the other. The one id that *is*
|
||||||
* prefixed is the one upload() hands back; see there for why.
|
* prefixed is the one upload() hands back; see there for why.
|
||||||
*/
|
*/
|
||||||
final readonly class PaperlessDriver implements IntegrationDriverInterface, SearchableDriverInterface, DestinationDriverInterface
|
final readonly class PaperlessDriver implements IntegrationDriverInterface, SearchableDriverInterface, DestinationDriverInterface, OriginalFileDriverInterface
|
||||||
{
|
{
|
||||||
private const string API = '/api';
|
private const string API = '/api';
|
||||||
|
|
||||||
@@ -204,14 +205,31 @@ final readonly class PaperlessDriver implements IntegrationDriverInterface, Sear
|
|||||||
|
|
||||||
public function download(Integration $integration, string $fileId): RemoteFile
|
public function download(Integration $integration, string $fileId): RemoteFile
|
||||||
{
|
{
|
||||||
$id = $this->documentFrom($fileId);
|
|
||||||
|
|
||||||
// No ?original=true: /download/ answers with the archived PDF where
|
// No ?original=true: /download/ answers with the archived PDF where
|
||||||
// Paperless made one and falls back to the original by itself where it
|
// Paperless made one and falls back to the original by itself where it
|
||||||
// could not. That is the copy the user saw when they filed the document
|
// could not. That is the copy the user saw when they filed the document
|
||||||
// — searchable, with the OCR layer baked in — and asking for the
|
// — searchable, with the OCR layer baked in — and asking for the
|
||||||
// original would attach the unreadable scan instead.
|
// original would attach the unreadable scan instead.
|
||||||
$response = $this->request($integration, 'GET', $this->url($integration, '/documents/'.$id.'/download/'));
|
return $this->fetch($integration, $fileId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a profile picture, which has to be the picture: a scanned photograph's
|
||||||
|
* archived copy is a PDF, and the listing it was chosen from said JPEG.
|
||||||
|
*/
|
||||||
|
public function downloadOriginal(Integration $integration, string $fileId): RemoteFile
|
||||||
|
{
|
||||||
|
return $this->fetch($integration, $fileId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetch(Integration $integration, string $fileId, bool $original): RemoteFile
|
||||||
|
{
|
||||||
|
$id = $this->documentFrom($fileId);
|
||||||
|
|
||||||
|
$response = $this->request($integration, 'GET', $this->url(
|
||||||
|
$integration,
|
||||||
|
'/documents/'.$id.'/download/'.(true === $original ? '?original=true' : ''),
|
||||||
|
));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$contents = $response->getContent();
|
$contents = $response->getContent();
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\User;
|
namespace App\Service\User;
|
||||||
|
|
||||||
use App\Domain\DTO\Integration\Entry;
|
use App\Domain\DTO\Integration\Entry;
|
||||||
|
use App\Domain\DTO\Integration\Listing;
|
||||||
use App\Domain\Enum\Integration\Capability;
|
use App\Domain\Enum\Integration\Capability;
|
||||||
use App\Domain\Exception\AvatarRefusedException;
|
use App\Domain\Exception\AvatarRefusedException;
|
||||||
use App\Domain\Exception\IntegrationException;
|
use App\Domain\Exception\IntegrationException;
|
||||||
use App\Domain\Helper\AvatarStorage;
|
use App\Domain\Helper\AvatarStorage;
|
||||||
|
use App\Domain\Interface\OriginalFileDriverInterface;
|
||||||
use App\Entity\Integration\Integration;
|
use App\Entity\Integration\Integration;
|
||||||
use App\Entity\User\User;
|
use App\Entity\User\User;
|
||||||
use App\Repository\Integration\IntegrationRepository;
|
use App\Repository\Integration\IntegrationRepository;
|
||||||
@@ -23,10 +25,13 @@ use finfo;
|
|||||||
* asking someone to download one and upload it again is asking them to do the
|
* asking someone to download one and upload it again is asking them to do the
|
||||||
* computer's job.
|
* computer's job.
|
||||||
*
|
*
|
||||||
* Deliberately not the compose file picker: that one is built around attaching
|
* Settings → Profile chooses through the compose file picker, in its avatar
|
||||||
* to a draft, and its browse endpoint takes a draft id. What is needed here is
|
* mode (FilePickerController::browse), and this class only decides what may be
|
||||||
* narrower — one image, no folders to speak of, no selection modes — so it
|
* chosen and keeps the result. That replaced a smaller grid of its own, which
|
||||||
* borrows the drivers rather than the picker.
|
* listed the first page of a service's root and had no albums, no search and no
|
||||||
|
* paging — it showed whatever happened to be newest and nothing else. browse()
|
||||||
|
* below is that grid's data and is now used only by the setup wizard, whose
|
||||||
|
* profile step is itself inside the modal frame the picker opens in.
|
||||||
*/
|
*/
|
||||||
final readonly class AvatarFromIntegration
|
final readonly class AvatarFromIntegration
|
||||||
{
|
{
|
||||||
@@ -84,6 +89,29 @@ final readonly class AvatarFromIntegration
|
|||||||
return array_slice($images, 0, self::LIMIT);
|
return array_slice($images, 0, self::LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files on a picker page that cannot become a picture, so the picker can
|
||||||
|
* show them without letting them be chosen.
|
||||||
|
*
|
||||||
|
* The compose picker lists whatever the service holds — a Paperless library
|
||||||
|
* is mostly PDFs — and hiding those would leave an empty-looking page with a
|
||||||
|
* working "show more" under it. Greyed out, it is plain why nothing there
|
||||||
|
* can be picked.
|
||||||
|
*
|
||||||
|
* @return list<string> entry ids
|
||||||
|
*/
|
||||||
|
public function refusedIds(?Listing $listing): array
|
||||||
|
{
|
||||||
|
if (null === $listing) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (Entry $entry): string => $entry->id,
|
||||||
|
array_filter($listing->files(), static fn (Entry $entry): bool => false === self::looksLikeAnImage($entry)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only what the avatar store will take — PNG, JPEG, GIF, WebP — so nothing
|
* Only what the avatar store will take — PNG, JPEG, GIF, WebP — so nothing
|
||||||
* is offered that is then refused. A HEIC photograph is an image, but not
|
* is offered that is then refused. A HEIC photograph is an image, but not
|
||||||
@@ -125,7 +153,14 @@ final readonly class AvatarFromIntegration
|
|||||||
public function apply(User $user, Integration $integration, string $fileId): void
|
public function apply(User $user, Integration $integration, string $fileId): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$file = $this->drivers->forIntegration($integration)->download($integration, $fileId);
|
$driver = $this->drivers->forIntegration($integration);
|
||||||
|
|
||||||
|
// The original where the service keeps one apart from what it
|
||||||
|
// serves: the picker listed the file by the original's type, and
|
||||||
|
// the rendition — Paperless's archived PDF — is never a picture.
|
||||||
|
$file = $driver instanceof OriginalFileDriverInterface
|
||||||
|
? $driver->downloadOriginal($integration, $fileId)
|
||||||
|
: $driver->download($integration, $fileId);
|
||||||
} catch (IntegrationException $e) {
|
} catch (IntegrationException $e) {
|
||||||
throw new AvatarRefusedException(AvatarRefusedException::UNAVAILABLE, $e);
|
throw new AvatarRefusedException(AvatarRefusedException::UNAVAILABLE, $e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service\User;
|
namespace App\Service\User;
|
||||||
|
|
||||||
use App\Entity\Integration\Integration;
|
|
||||||
use App\Entity\User\User;
|
use App\Entity\User\User;
|
||||||
use App\Form\User\ProfileType;
|
use App\Form\User\ProfileType;
|
||||||
use Symfony\Component\Form\FormFactoryInterface;
|
use Symfony\Component\Form\FormFactoryInterface;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +18,10 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|||||||
* avatar picker's variables in both places — the save endpoint rendering it
|
* avatar picker's variables in both places — the save endpoint rendering it
|
||||||
* with only the form is how picking a picture used to crash with
|
* with only the form is how picking a picture used to crash with
|
||||||
* "Variable avatarSources does not exist".
|
* "Variable avatarSources does not exist".
|
||||||
|
*
|
||||||
|
* The picture is chosen in the file picker's dialog, so what is passed here is
|
||||||
|
* only which connections to offer. It used to carry the chosen connection's
|
||||||
|
* first page of images as well; that grid is gone from Settings.
|
||||||
*/
|
*/
|
||||||
final readonly class ProfileSectionViewData
|
final readonly class ProfileSectionViewData
|
||||||
{
|
{
|
||||||
@@ -36,55 +38,17 @@ final readonly class ProfileSectionViewData
|
|||||||
*
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function build(User $user, Request $request, ?FormInterface $form = null): array
|
public function build(User $user, ?FormInterface $form = null): array
|
||||||
{
|
{
|
||||||
$sources = $this->avatarSources->availableFor($user);
|
|
||||||
$picking = $this->picking($sources, $request);
|
|
||||||
|
|
||||||
$pickUrls = [];
|
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
|
||||||
$pickUrls[(string) $source->id] = $this->sectionUrl((string) $source->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$form ??= $this->formFactory->create(ProfileType::class, $user, [
|
$form ??= $this->formFactory->create(ProfileType::class, $user, [
|
||||||
'action' => $this->urlGenerator->generate('app_settings_profile_save'),
|
'action' => $this->urlGenerator->generate('app_settings_profile_save'),
|
||||||
'avatar_source' => null === $picking ? null : (string) $picking->id,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'profileForm' => $form->createView(),
|
'profileForm' => $form->createView(),
|
||||||
'avatarSources' => $sources,
|
// Which connections to offer; the pictures themselves are browsed in
|
||||||
'avatarPicking' => $picking,
|
// the file picker's dialog, not rendered here.
|
||||||
'avatarEntries' => null === $picking ? [] : $this->avatarSources->browse($picking),
|
'avatarSources' => $this->avatarSources->availableFor($user),
|
||||||
'avatarPickUrls' => $pickUrls,
|
|
||||||
'avatarCloseUrl' => $this->sectionUrl(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The connection whose pictures are on screen, if any.
|
|
||||||
*
|
|
||||||
* @param list<Integration> $sources
|
|
||||||
*/
|
|
||||||
private function picking(array $sources, Request $request): ?Integration
|
|
||||||
{
|
|
||||||
foreach ($sources as $source) {
|
|
||||||
// Matched against the user's own connections rather than looked up
|
|
||||||
// by id, so a borrowed id cannot browse somebody else's photos.
|
|
||||||
if ((string) $source->id === (string) $request->query->get('pick', '')) {
|
|
||||||
return $source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sectionUrl(?string $pick = null): string
|
|
||||||
{
|
|
||||||
return $this->urlGenerator->generate('app_settings_index', array_filter([
|
|
||||||
'section' => 'profile',
|
|
||||||
'pick' => $pick,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,21 @@ the same thing. Only the URLs differ, so the caller passes them:
|
|||||||
closeUrl string where "stop browsing" goes
|
closeUrl string where "stop browsing" goes
|
||||||
fileField string full_name of the form field the choice binds to
|
fileField string full_name of the form field the choice binds to
|
||||||
current string|null URL of the picture in use, if there is one
|
current string|null URL of the picture in use, if there is one
|
||||||
|
modalPicker bool open the file picker in a dialog (see below)
|
||||||
|
|
||||||
A thumbnail *is* the submit: choosing and saving are one action, and the button
|
Two ways to choose, because the two callers differ in where they live.
|
||||||
carries the field name the form binds it to. Browsing is a navigation rather
|
|
||||||
than a client-side toggle because these fields belong to the surrounding form,
|
Settings passes `modalPicker`: each connection opens the same dialog the compose
|
||||||
and HTML has no nested forms.
|
window attaches files with, in its avatar mode, and the choice comes back through
|
||||||
|
settings/avatar_from_service_controller.js on the form. `picking`, `entries`,
|
||||||
|
`pickUrls`, `closeUrl` and `fileField` are not used.
|
||||||
|
|
||||||
|
The setup wizard does not, and still gets the grid below. Its profile step is
|
||||||
|
itself rendered in the modal frame, which the file picker would replace — the
|
||||||
|
wizard would vanish behind it. A thumbnail *is* the submit there: choosing and
|
||||||
|
saving are one action, and the button carries the field name the form binds it
|
||||||
|
to. Browsing is a navigation rather than a client-side toggle because these
|
||||||
|
fields belong to the surrounding form, and HTML has no nested forms.
|
||||||
#}
|
#}
|
||||||
{% if current is defined and current is not null %}
|
{% if current is defined and current is not null %}
|
||||||
{# Shown wherever the profile is edited. A file input reports a filename at
|
{# Shown wherever the profile is edited. A file input reports a filename at
|
||||||
@@ -36,23 +46,40 @@ and HTML has no nested forms.
|
|||||||
|
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{% for source in sources %}
|
{% for source in sources %}
|
||||||
{% set isOpen = picking is not null and picking.id == source.id %}
|
{% if modalPicker|default(false) %}
|
||||||
<a href="{{ isOpen ? closeUrl : pickUrls[source.id] }}"
|
{# type=button: this sits inside the profile form, where the
|
||||||
data-turbo-frame="{{ frame|default('_self') }}"
|
default would submit it. #}
|
||||||
id="avatar-source-{{ source.id }}"
|
<button type="button"
|
||||||
class="inline-flex items-center gap-2 h-8 px-3 rounded-lg text-sm font-medium border transition-colors
|
id="avatar-source-{{ source.id }}"
|
||||||
{% if isOpen %}
|
data-controller="ui--modal"
|
||||||
border-accent bg-accent/10 text-accent
|
data-ui--modal-title-value="{{ source.name }}"
|
||||||
{% else %}
|
data-ui--modal-size-value="{{ source.provider.isMediaLibrary ? 'full' : 'wide' }}"
|
||||||
border-field bg-field text-ink-soft hover:bg-hover
|
data-ui--modal-src-value="{{ path('app_integration_browse', { id: source.id, mode: 'avatar' }) }}"
|
||||||
{% endif %}">
|
data-action="click->ui--modal#open"
|
||||||
<i class="{{ source.provider.icon }} text-xs" aria-hidden="true"></i>
|
class="inline-flex items-center gap-2 h-8 px-3 rounded-lg text-sm font-medium border transition-colors cursor-pointer
|
||||||
{{ source.name }}
|
border-field bg-field text-ink-soft hover:bg-hover">
|
||||||
</a>
|
<i class="{{ source.provider.icon }} text-xs" aria-hidden="true"></i>
|
||||||
|
{{ source.name }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
{% set isOpen = picking is not null and picking.id == source.id %}
|
||||||
|
<a href="{{ isOpen ? closeUrl : pickUrls[source.id] }}"
|
||||||
|
data-turbo-frame="{{ frame|default('_self') }}"
|
||||||
|
id="avatar-source-{{ source.id }}"
|
||||||
|
class="inline-flex items-center gap-2 h-8 px-3 rounded-lg text-sm font-medium border transition-colors
|
||||||
|
{% if isOpen %}
|
||||||
|
border-accent bg-accent/10 text-accent
|
||||||
|
{% else %}
|
||||||
|
border-field bg-field text-ink-soft hover:bg-hover
|
||||||
|
{% endif %}">
|
||||||
|
<i class="{{ source.provider.icon }} text-xs" aria-hidden="true"></i>
|
||||||
|
{{ source.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if picking is not null %}
|
{% if not modalPicker|default(false) and picking is not null %}
|
||||||
{% if entries|length %}
|
{% if entries|length %}
|
||||||
<ul class="mt-4 grid grid-cols-4 sm:grid-cols-6 gap-2">
|
<ul class="mt-4 grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ rendered it: an album is a pile of pictures, a folder is a folder.
|
|||||||
404 the navigation; presence of the field is what tells the two apart. See
|
404 the navigation; presence of the field is what tells the two apart. See
|
||||||
FilePickerController::browseDestination(). #}
|
FilePickerController::browseDestination(). #}
|
||||||
{% set browseArgs = { id: integration.id, folder: entry.id, draft: draftId } %}
|
{% set browseArgs = { id: integration.id, folder: entry.id, draft: draftId } %}
|
||||||
{% if mode|default('attach') == 'destination' %}
|
{% if mode|default('attach') == 'avatar' %}
|
||||||
|
{% set browseArgs = { id: integration.id, folder: entry.id, mode: 'avatar' } %}
|
||||||
|
{% elseif mode|default('attach') == 'destination' %}
|
||||||
{% set browseArgs = { id: integration.id, folder: entry.id, mode: 'destination' } %}
|
{% set browseArgs = { id: integration.id, folder: entry.id, mode: 'destination' } %}
|
||||||
{% if part|default(null) is not null %}
|
{% if part|default(null) is not null %}
|
||||||
{% set browseArgs = browseArgs|merge({ part: part }) %}
|
{% set browseArgs = browseArgs|merge({ part: part }) %}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ are still listed, because "who is this" is a reasonable thing to go looking for.
|
|||||||
|
|
||||||
Navigates rather than selects: a person is a place, not something attachable.
|
Navigates rather than selects: a person is a place, not something attachable.
|
||||||
#}
|
#}
|
||||||
<a href="{{ path('app_integration_browse', { id: integration.id, folder: entry.id, draft: draftId }) }}"
|
{# The mode travels with the navigation — see FilePickerController::browse(). #}
|
||||||
|
{% set navArgs = mode|default('attach') == 'avatar'
|
||||||
|
? { id: integration.id, folder: entry.id, mode: 'avatar' }
|
||||||
|
: { id: integration.id, folder: entry.id, draft: draftId } %}
|
||||||
|
<a href="{{ path('app_integration_browse', navArgs) }}"
|
||||||
class="group flex flex-col items-center gap-2 p-2 rounded-xl hover:bg-hover transition-colors"
|
class="group flex flex-col items-center gap-2 p-2 rounded-xl hover:bg-hover transition-colors"
|
||||||
title="{{ entry.name }}">
|
title="{{ entry.name }}">
|
||||||
<span class="relative block w-24 h-24 rounded-full overflow-hidden bg-sunken ring-2 ring-transparent group-hover:ring-accent/40 transition-[box-shadow]">
|
<span class="relative block w-24 h-24 rounded-full overflow-hidden bg-sunken ring-2 ring-transparent group-hover:ring-accent/40 transition-[box-shadow]">
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ gap under the shortcuts row.
|
|||||||
{% set isGrid = canThumb and integration.provider.isMediaLibrary %}
|
{% set isGrid = canThumb and integration.provider.isMediaLibrary %}
|
||||||
{% set isPeople = listing is not null and listing.people is not empty %}
|
{% set isPeople = listing is not null and listing.people is not empty %}
|
||||||
|
|
||||||
|
{# Which way out of the picker. 'avatar' chooses one picture for the profile form
|
||||||
|
instead of attaching files to a draft, and every link below is a navigation
|
||||||
|
inside the frame: one that forgot the mode would quietly turn the next click
|
||||||
|
back into an attach picker. They all start from navArgs for that reason. #}
|
||||||
|
{% set isAvatar = mode|default('attach') == 'avatar' %}
|
||||||
|
{% set navArgs = isAvatar ? { id: integration.id, mode: 'avatar' } : { id: integration.id, draft: draftId } %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<turbo-frame id="modal">
|
<turbo-frame id="modal">
|
||||||
<template data-ui--modal-frame-title>{{ integration.name }}</template>
|
<template data-ui--modal-frame-title>{{ integration.name }}</template>
|
||||||
@@ -37,6 +44,9 @@ gap under the shortcuts row.
|
|||||||
data-ui--modal-keep-open
|
data-ui--modal-keep-open
|
||||||
data-integration--integration-picker-url-value="{{ path('app_integration_attach', { id: integration.id }) }}"
|
data-integration--integration-picker-url-value="{{ path('app_integration_attach', { id: integration.id }) }}"
|
||||||
data-integration--integration-picker-draft-value="{{ draftId }}"
|
data-integration--integration-picker-draft-value="{{ draftId }}"
|
||||||
|
data-integration--integration-picker-mode-value="{{ isAvatar ? 'avatar' : 'attach' }}"
|
||||||
|
data-integration--integration-picker-integration-value="{{ integration.id }}"
|
||||||
|
data-action="change->integration--integration-picker#changed"
|
||||||
data-integration--integration-picker-token-value="{{ csrf_token('integration-picker') }}"
|
data-integration--integration-picker-token-value="{{ csrf_token('integration-picker') }}"
|
||||||
data-integration--integration-picker-i18n-value="{{ {
|
data-integration--integration-picker-i18n-value="{{ {
|
||||||
loading: 'integration.picker.status.loading'|trans,
|
loading: 'integration.picker.status.loading'|trans,
|
||||||
@@ -60,7 +70,11 @@ gap under the shortcuts row.
|
|||||||
{% if canSearch %}
|
{% if canSearch %}
|
||||||
<form method="get" action="{{ path('app_integration_browse', { id: integration.id }) }}"
|
<form method="get" action="{{ path('app_integration_browse', { id: integration.id }) }}"
|
||||||
class="flex items-center gap-2 px-6 py-3 shrink-0">
|
class="flex items-center gap-2 px-6 py-3 shrink-0">
|
||||||
<input type="hidden" name="draft" value="{{ draftId }}">
|
{% if isAvatar %}
|
||||||
|
<input type="hidden" name="mode" value="avatar">
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="draft" value="{{ draftId }}">
|
||||||
|
{% endif %}
|
||||||
<div class="relative flex-1 min-w-0">
|
<div class="relative flex-1 min-w-0">
|
||||||
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-[11px] text-ink-faint" aria-hidden="true"></i>
|
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-[11px] text-ink-faint" aria-hidden="true"></i>
|
||||||
<input type="search" name="q" value="{{ query }}"
|
<input type="search" name="q" value="{{ query }}"
|
||||||
@@ -84,7 +98,7 @@ gap under the shortcuts row.
|
|||||||
<nav class="flex items-center gap-1 flex-wrap text-xs text-ink-muted">
|
<nav class="flex items-center gap-1 flex-wrap text-xs text-ink-muted">
|
||||||
{% for crumb in listing.breadcrumb %}
|
{% for crumb in listing.breadcrumb %}
|
||||||
{% if not loop.last %}
|
{% if not loop.last %}
|
||||||
<a href="{{ path('app_integration_browse', { id: integration.id, folder: crumb.id, draft: draftId }) }}"
|
<a href="{{ path('app_integration_browse', navArgs|merge({ folder: crumb.id })) }}"
|
||||||
class="hover:text-ink transition-colors">{{ crumb.name }}</a>
|
class="hover:text-ink transition-colors">{{ crumb.name }}</a>
|
||||||
<i class="fa-solid fa-chevron-right text-[8px] text-ink-faint" aria-hidden="true"></i>
|
<i class="fa-solid fa-chevron-right text-[8px] text-ink-faint" aria-hidden="true"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -94,7 +108,7 @@ gap under the shortcuts row.
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{% for shortcut in listing.shortcuts %}
|
{% for shortcut in listing.shortcuts %}
|
||||||
<a href="{{ path('app_integration_browse', { id: integration.id, folder: shortcut.id, draft: draftId }) }}"
|
<a href="{{ path('app_integration_browse', navArgs|merge({ folder: shortcut.id })) }}"
|
||||||
class="{{ loop.first ? 'ml-auto' : '' }} inline-flex items-center h-7 px-2.5 rounded-lg
|
class="{{ loop.first ? 'ml-auto' : '' }} inline-flex items-center h-7 px-2.5 rounded-lg
|
||||||
text-xs font-medium text-ink-soft bg-field border border-field
|
text-xs font-medium text-ink-soft bg-field border border-field
|
||||||
hover:bg-hover transition-colors">
|
hover:bg-hover transition-colors">
|
||||||
@@ -141,6 +155,7 @@ gap under the shortcuts row.
|
|||||||
integration: integration,
|
integration: integration,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
draftId: draftId,
|
draftId: draftId,
|
||||||
|
mode: mode|default('attach'),
|
||||||
} only %}
|
} only %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +169,7 @@ gap under the shortcuts row.
|
|||||||
integration: integration,
|
integration: integration,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
draftId: draftId,
|
draftId: draftId,
|
||||||
|
mode: mode|default('attach'),
|
||||||
isMediaLibrary: true,
|
isMediaLibrary: true,
|
||||||
} only %}
|
} only %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -177,6 +193,7 @@ gap under the shortcuts row.
|
|||||||
integration: integration,
|
integration: integration,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
maxBytes: maxBytes,
|
maxBytes: maxBytes,
|
||||||
|
refusedIds: refusedIds|default([]),
|
||||||
} only %}
|
} only %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +205,9 @@ gap under the shortcuts row.
|
|||||||
integration: integration,
|
integration: integration,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
draftId: draftId,
|
draftId: draftId,
|
||||||
|
mode: mode|default('attach'),
|
||||||
maxBytes: maxBytes,
|
maxBytes: maxBytes,
|
||||||
|
refusedIds: refusedIds|default([]),
|
||||||
canLink: canLink,
|
canLink: canLink,
|
||||||
canThumb: canThumb,
|
canThumb: canThumb,
|
||||||
isMediaLibrary: integration.provider.isMediaLibrary,
|
isMediaLibrary: integration.provider.isMediaLibrary,
|
||||||
@@ -204,13 +223,11 @@ gap under the shortcuts row.
|
|||||||
if that script never runs. #}
|
if that script never runs. #}
|
||||||
{% if listing.nextCursor %}
|
{% if listing.nextCursor %}
|
||||||
<div class="pt-3 text-center" data-integration--integration-picker-target="sentinel">
|
<div class="pt-3 text-center" data-integration--integration-picker-target="sentinel">
|
||||||
<a href="{{ path('app_integration_browse', {
|
<a href="{{ path('app_integration_browse', navArgs|merge({
|
||||||
id: integration.id,
|
|
||||||
folder: folderId,
|
folder: folderId,
|
||||||
draft: draftId,
|
|
||||||
q: query,
|
q: query,
|
||||||
cursor: listing.nextCursor,
|
cursor: listing.nextCursor,
|
||||||
}) }}"
|
})) }}"
|
||||||
data-action="integration--integration-picker#loadMore"
|
data-action="integration--integration-picker#loadMore"
|
||||||
class="inline-flex items-center gap-2 h-8 px-3.5 rounded-lg text-sm font-medium
|
class="inline-flex items-center gap-2 h-8 px-3.5 rounded-lg text-sm font-medium
|
||||||
text-ink-soft bg-field border border-field hover:bg-hover transition-colors">
|
text-ink-soft bg-field border border-field hover:bg-hover transition-colors">
|
||||||
@@ -226,6 +243,7 @@ gap under the shortcuts row.
|
|||||||
integration: integration,
|
integration: integration,
|
||||||
buckets: buckets,
|
buckets: buckets,
|
||||||
draftId: draftId,
|
draftId: draftId,
|
||||||
|
mode: mode|default('attach'),
|
||||||
} only %}
|
} only %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,7 +265,7 @@ gap under the shortcuts row.
|
|||||||
data-integration--integration-picker-target="submit"
|
data-integration--integration-picker-target="submit"
|
||||||
class="inline-flex items-center justify-center h-8 px-3.5 rounded-lg text-sm font-medium text-accent-ink
|
class="inline-flex items-center justify-center h-8 px-3.5 rounded-lg text-sm font-medium text-accent-ink
|
||||||
bg-accent hover:bg-accent-strong transition-colors disabled:opacity-50">
|
bg-accent hover:bg-accent-strong transition-colors disabled:opacity-50">
|
||||||
{{ 'integration.picker.attach'|trans }}
|
{{ (isAvatar ? 'integration.picker.use_as_avatar' : 'integration.picker.attach')|trans }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ like it needed a decision.
|
|||||||
integration: integration,
|
integration: integration,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
draftId: draftId,
|
draftId: draftId,
|
||||||
|
mode: mode|default('attach'),
|
||||||
isMediaLibrary: isMediaLibrary|default(false),
|
isMediaLibrary: isMediaLibrary|default(false),
|
||||||
} only %}
|
} only %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Only a *known* oversize blocks a copy — see the picker's docblock. #}
|
{# Only a *known* oversize blocks a copy — see the picker's docblock. #}
|
||||||
{% set knownTooBig = entry.size is not null and entry.size > maxBytes %}
|
{% set knownTooBig = entry.size is not null and entry.size > maxBytes %}
|
||||||
{% set canCopy = not knownTooBig %}
|
{# Profile-picture mode only: a file that cannot become a picture. Shown, so
|
||||||
|
the page does not look empty, but not choosable. #}
|
||||||
|
{% set notAnImage = entry.id in refusedIds|default([]) %}
|
||||||
|
{% set canCopy = not knownTooBig and not notAnImage %}
|
||||||
{% set unattachable = not canCopy and not canLink %}
|
{% set unattachable = not canCopy and not canLink %}
|
||||||
|
|
||||||
<li class="group relative flex items-center gap-3 px-3 py-2.5 -mx-2 rounded-xl
|
<li class="group relative flex items-center gap-3 px-3 py-2.5 -mx-2 rounded-xl
|
||||||
@@ -105,7 +109,7 @@ like it needed a decision.
|
|||||||
|
|
||||||
{% if unattachable %}
|
{% if unattachable %}
|
||||||
<span class="relative shrink-0 text-[11px] text-ink-faint">
|
<span class="relative shrink-0 text-[11px] text-ink-faint">
|
||||||
{{ 'integration.picker.too_big'|trans }}
|
{{ (notAnImage ? 'integration.picker.not_an_image' : 'integration.picker.too_big')|trans }}
|
||||||
</span>
|
</span>
|
||||||
{% elseif canLink %}
|
{% elseif canLink %}
|
||||||
{# Above the row label, so the chip wins the click it is under. #}
|
{# Above the row label, so the chip wins the click it is under. #}
|
||||||
|
|||||||
@@ -45,12 +45,11 @@ year is skipped if it would land too close to the last one drawn.
|
|||||||
{% set offset = running / total * 100 %}
|
{% set offset = running / total * 100 %}
|
||||||
{% set running = running + bucket.count %}
|
{% set running = running + bucket.count %}
|
||||||
|
|
||||||
<a href="{{ path('app_integration_browse', {
|
{# The mode travels with the navigation — see FilePickerController::browse(). #}
|
||||||
id: integration.id,
|
{% set segmentArgs = mode|default('attach') == 'avatar'
|
||||||
folder: 'timeline',
|
? { id: integration.id, folder: 'timeline', mode: 'avatar', cursor: bucket.cursor }
|
||||||
draft: draftId,
|
: { id: integration.id, folder: 'timeline', draft: draftId, cursor: bucket.cursor } %}
|
||||||
cursor: bucket.cursor,
|
<a href="{{ path('app_integration_browse', segmentArgs) }}"
|
||||||
}) }}"
|
|
||||||
data-integration--picker-scrubber-target="segment"
|
data-integration--picker-scrubber-target="segment"
|
||||||
data-title="{{ bucket.title }}"
|
data-title="{{ bucket.title }}"
|
||||||
data-action="mouseenter->integration--picker-scrubber#show mouseleave->integration--picker-scrubber#hide"
|
data-action="mouseenter->integration--picker-scrubber#show mouseleave->integration--picker-scrubber#hide"
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ selection, which pulled its corners away from the ring.
|
|||||||
{% set knownTooBig = entry.size is not null and entry.size > maxBytes %}
|
{% set knownTooBig = entry.size is not null and entry.size > maxBytes %}
|
||||||
{% set thumbUrl = path('app_integration_thumbnail', { id: integration.id, file: entry.id }) %}
|
{% set thumbUrl = path('app_integration_thumbnail', { id: integration.id, file: entry.id }) %}
|
||||||
|
|
||||||
{% if knownTooBig %}
|
{# Profile-picture mode only: not something that can become a picture. #}
|
||||||
|
{% set notAnImage = entry.id in refusedIds|default([]) %}
|
||||||
|
|
||||||
|
{% if knownTooBig or notAnImage %}
|
||||||
<div class="relative aspect-square rounded-lg overflow-hidden bg-sunken opacity-40"
|
<div class="relative aspect-square rounded-lg overflow-hidden bg-sunken opacity-40"
|
||||||
title="{{ 'integration.picker.too_big'|trans }}">
|
title="{{ (notAnImage ? 'integration.picker.not_an_image' : 'integration.picker.too_big')|trans }}">
|
||||||
<img src="{{ thumbUrl }}" alt="" loading="lazy" class="w-full h-full object-cover">
|
<img src="{{ thumbUrl }}" alt="" loading="lazy" class="w-full h-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -22,17 +22,19 @@ find.
|
|||||||
} %}
|
} %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
{{ form_start(profileForm) }}
|
{# The picker dialog hands its choice to this form — see
|
||||||
|
settings/avatar_from_service_controller.js. #}
|
||||||
|
{{ form_start(profileForm, { attr: {
|
||||||
|
'data-controller': 'settings--avatar-from-service',
|
||||||
|
'data-settings--avatar-from-service-name-value': profileForm.vars.full_name,
|
||||||
|
'data-action': 'plmail:avatar-picked@document->settings--avatar-from-service#picked',
|
||||||
|
} }) }}
|
||||||
{{ form_widget(profileForm) }}
|
{{ form_widget(profileForm) }}
|
||||||
|
|
||||||
{% include '_partials/_avatar_picker.html.twig' with {
|
{% include '_partials/_avatar_picker.html.twig' with {
|
||||||
sources: avatarSources,
|
sources: avatarSources,
|
||||||
picking: avatarPicking,
|
modalPicker: true,
|
||||||
entries: avatarEntries,
|
current: app.user.avatar ? path('app_settings_profile_avatar', { filename: app.user.avatar }) : null,
|
||||||
pickUrls: avatarPickUrls,
|
|
||||||
closeUrl: avatarCloseUrl,
|
|
||||||
fileField: avatarPicking is null ? '' : profileForm.avatarFileId.vars.full_name,
|
|
||||||
current: app.user.avatar ? path('app_settings_profile_avatar', { filename: app.user.avatar }) : null,
|
|
||||||
} only %}
|
} only %}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Integration;
|
||||||
|
|
||||||
|
use App\Domain\DTO\Integration\Entry;
|
||||||
|
use App\Domain\DTO\Integration\Listing;
|
||||||
|
use App\Domain\DTO\Integration\RemoteFile;
|
||||||
|
use App\Domain\DTO\Integration\TimelineBucket;
|
||||||
|
use App\Domain\Enum\Integration\Provider;
|
||||||
|
use App\Domain\Interface\IntegrationDriverInterface;
|
||||||
|
use App\Domain\Interface\SearchableDriverInterface;
|
||||||
|
use App\Domain\Interface\TimelineDriverInterface;
|
||||||
|
use App\Entity\Integration\Integration;
|
||||||
|
use App\Entity\User\User;
|
||||||
|
use App\Service\Integration\IntegrationDriverRegistry;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choosing a profile picture goes through the compose file picker.
|
||||||
|
*
|
||||||
|
* Settings → Profile used to have a grid of its own — the first page of a
|
||||||
|
* service's root, capped, with no albums, search or paging — so the picture
|
||||||
|
* could only ever be one of the newest. It opens the real picker now, in an
|
||||||
|
* avatar mode of the same browse route, and the mode has to survive every click
|
||||||
|
* inside the dialog: breadcrumbs, shortcuts, folders, people, the date bar,
|
||||||
|
* search and the next page are all navigations in the same frame, and a link
|
||||||
|
* that forgot it turns the following click back into "attach to a draft".
|
||||||
|
* That is what is pinned here, from the markup the picker renders.
|
||||||
|
*/
|
||||||
|
final class AvatarPickerModeTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private Connection $connection;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (isset($this->connection) && true === $this->connection->isTransactionActive()) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every way of leaving this page of a photo library — crumb, shortcut,
|
||||||
|
* album, date bar, next page — stays in the avatar picker.
|
||||||
|
*
|
||||||
|
* The presence checks come first on purpose: a page with no links would
|
||||||
|
* satisfy "none of them lacks the mode" just as well.
|
||||||
|
*/
|
||||||
|
public function testEveryNavigationInAPhotoLibraryKeepsTheAvatarMode(): void
|
||||||
|
{
|
||||||
|
$client = $this->signIn();
|
||||||
|
$integration = $this->seedIntegration(Provider::Immich);
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
// crumb "Library", shortcut "Albums", album "Holiday", scrubber month,
|
||||||
|
// next page.
|
||||||
|
$links = $this->browseLinks($crawler);
|
||||||
|
self::assertGreaterThanOrEqual(5, count($links), 'the page should offer five ways onward');
|
||||||
|
|
||||||
|
foreach ($links as $href) {
|
||||||
|
self::assertStringContainsString('mode=avatar', $href);
|
||||||
|
self::assertStringNotContainsString('draft=', $href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The search box is a GET form, so its mode is a hidden field.
|
||||||
|
self::assertSame('avatar', $crawler->filter('form input[name="mode"]')->attr('value'));
|
||||||
|
self::assertCount(0, $crawler->filter('form input[name="draft"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people grid has its own link partial, which is the easy one to miss.
|
||||||
|
*/
|
||||||
|
public function testAPersonInThePeopleViewKeepsTheAvatarMode(): void
|
||||||
|
{
|
||||||
|
$client = $this->signIn();
|
||||||
|
$integration = $this->seedIntegration(Provider::Immich);
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar&folder=people');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$person = $crawler->filter('a[href*="folder=person"]');
|
||||||
|
self::assertCount(1, $person, 'the person should be a link');
|
||||||
|
self::assertStringContainsString('mode=avatar', (string) $person->attr('href'));
|
||||||
|
self::assertStringNotContainsString('draft=', (string) $person->attr('href'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presence companion to the two above: the same pages in attach mode carry
|
||||||
|
* the draft and not the avatar mode, so those assertions are about the mode
|
||||||
|
* rather than about a picker that happens to render no draft.
|
||||||
|
*/
|
||||||
|
public function testAttachModeStillCarriesTheDraft(): void
|
||||||
|
{
|
||||||
|
$client = $this->signIn();
|
||||||
|
$integration = $this->seedIntegration(Provider::Immich);
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?draft=7');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$links = $this->browseLinks($crawler);
|
||||||
|
self::assertGreaterThanOrEqual(5, count($links));
|
||||||
|
|
||||||
|
foreach ($links as $href) {
|
||||||
|
self::assertStringContainsString('draft=7', $href);
|
||||||
|
self::assertStringNotContainsString('mode=avatar', $href);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertCount(1, $crawler->filter('form input[name="draft"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file store lists rows rather than tiles, and a library of scans is
|
||||||
|
* mostly PDFs. They are shown — hiding them would leave a page that looks
|
||||||
|
* empty with "show more" under it — but cannot be chosen, and nothing
|
||||||
|
* offers a share link, which is no use as a profile picture.
|
||||||
|
*/
|
||||||
|
public function testAFileStoreShowsNonImagesButDoesNotLetThemBeChosen(): void
|
||||||
|
{
|
||||||
|
$client = $this->signIn();
|
||||||
|
$integration = $this->seedIntegration(Provider::Nextcloud);
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
self::assertCount(1, $crawler->filter('input[name="mode[p1]"]'), 'the photo can be chosen');
|
||||||
|
self::assertCount(0, $crawler->filter('input[name="mode[d1]"]'), 'the PDF cannot');
|
||||||
|
self::assertStringContainsString('scan.pdf', (string) $client->getResponse()->getContent());
|
||||||
|
self::assertCount(0, $crawler->filter('input[value="link"]'));
|
||||||
|
|
||||||
|
// The avatar store's cap, not the attachment one.
|
||||||
|
self::assertCount(0, $crawler->filter('input[name="mode[b1]"]'), 'a photo over 4 MB cannot');
|
||||||
|
|
||||||
|
foreach ($this->browseLinks($crawler) as $href) {
|
||||||
|
self::assertStringContainsString('mode=avatar', $href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the other side: asked to attach, the same page lets the PDF be
|
||||||
|
// chosen, so the refusal above is the mode's doing.
|
||||||
|
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?draft=7');
|
||||||
|
|
||||||
|
self::assertCount(1, $crawler->filter('input[name="mode[b1]"][value="copy"]'));
|
||||||
|
self::assertCount(1, $crawler->filter('input[name="mode[d1]"][value="copy"]'));
|
||||||
|
self::assertCount(1, $crawler->filter('input[name="mode[d1]"][value="link"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker never needs a draft in this mode, but it is still somebody's
|
||||||
|
* connection: another user's id is refused like it is when attaching.
|
||||||
|
*/
|
||||||
|
public function testAnotherUsersConnectionIsRefused(): void
|
||||||
|
{
|
||||||
|
$client = $this->signIn();
|
||||||
|
$integration = $this->seedIntegration(Provider::Immich);
|
||||||
|
|
||||||
|
$client->loginUser($this->seedUser('stranger'));
|
||||||
|
$client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fixtures ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> the href of every navigation inside the picker
|
||||||
|
*/
|
||||||
|
private function browseLinks(Crawler $crawler): array
|
||||||
|
{
|
||||||
|
return $crawler->filter('a[href*="/browse"]')->each(
|
||||||
|
static fn (Crawler $a): string => (string) $a->attr('href'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedIntegration(Provider $provider): Integration
|
||||||
|
{
|
||||||
|
$integration = new Integration($this->user, $provider, 'Photos');
|
||||||
|
$integration->baseUrl = 'https://photos.example.test';
|
||||||
|
|
||||||
|
$this->em->persist($integration);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $integration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function signIn(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->disableReboot();
|
||||||
|
|
||||||
|
$container = static::getContainer();
|
||||||
|
$this->em = $container->get(EntityManagerInterface::class);
|
||||||
|
$this->connection = $container->get(Connection::class);
|
||||||
|
|
||||||
|
// The service this exercises is the one place the application talks to
|
||||||
|
// somebody else's server, so it is replaced; everything between the
|
||||||
|
// request and it is real.
|
||||||
|
$container->set(IntegrationDriverRegistry::class, new IntegrationDriverRegistry([new AvatarPickerFakeDriver()]));
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
$this->user = $this->seedUser('owner');
|
||||||
|
$client->loginUser($this->user);
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedUser(string $tag): User
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$user->email = 'avatar-picker-' . $tag . '-' . uniqid('', true) . '@example.test';
|
||||||
|
$user->nameFirst = 'Avatar';
|
||||||
|
$user->nameLast = 'Picker';
|
||||||
|
$user->roles = ['ROLE_USER'];
|
||||||
|
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
|
||||||
|
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service with one page of photos, an album, a person and a date bar.
|
||||||
|
*
|
||||||
|
* Not a mock: what matters is the markup the picker draws from what it is told,
|
||||||
|
* which is easier to state as data.
|
||||||
|
*/
|
||||||
|
final class AvatarPickerFakeDriver implements IntegrationDriverInterface, SearchableDriverInterface, TimelineDriverInterface
|
||||||
|
{
|
||||||
|
public function supports(Provider $provider): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(Integration $integration): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(Integration $integration, ?string $folderId = null, ?string $cursor = null): Listing
|
||||||
|
{
|
||||||
|
if ('people' === $folderId) {
|
||||||
|
return new Listing([Entry::person('person:1', 'Ann')], [Entry::folder('', 'Library'), Entry::folder('people', 'People')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Listing(
|
||||||
|
[
|
||||||
|
Entry::folder('album-1', 'Holiday'),
|
||||||
|
new Entry('p1', 'beach.jpg', false, 1_000, 'image/jpeg'),
|
||||||
|
new Entry('d1', 'scan.pdf', false, 1_000, 'application/pdf'),
|
||||||
|
// Fine to attach to a mail, over what a profile picture may be.
|
||||||
|
new Entry('b1', 'panorama.jpg', false, 5 * 1024 * 1024, 'image/jpeg'),
|
||||||
|
],
|
||||||
|
[Entry::folder('', 'Library'), Entry::folder('timeline', 'All photos')],
|
||||||
|
'page-2',
|
||||||
|
[Entry::folder('albums', 'Albums')],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(Integration $integration, string $query, ?string $folderId = null, ?string $cursor = null): Listing
|
||||||
|
{
|
||||||
|
return $this->list($integration, $folderId, $cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function timelineBuckets(Integration $integration): array
|
||||||
|
{
|
||||||
|
return [new TimelineBucket('cursor-1', '2026', 12, 'March 2026')];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(Integration $integration, string $fileId): RemoteFile
|
||||||
|
{
|
||||||
|
throw new \LogicException('Browsing downloads nothing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(Integration $integration, string $absolutePath, string $filename, string $mime, ?string $folderId = null): string
|
||||||
|
{
|
||||||
|
throw new \LogicException('Browsing uploads nothing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shareLink(Integration $integration, string $fileId): string
|
||||||
|
{
|
||||||
|
return 'https://share.example.test/' . $fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function thumbnail(Integration $integration, string $fileId): ?RemoteFile
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,6 +279,27 @@ final class PaperlessDriverTest extends TestCase
|
|||||||
self::assertStringEndsWith('/api/documents/91/download/', $this->requests[0]['url']);
|
self::assertStringEndsWith('/api/documents/91/download/', $this->requests[0]['url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listing says image/jpeg for a scanned photograph and the default
|
||||||
|
* download is the archived PDF, so something that has to be the picture asks
|
||||||
|
* for the original explicitly.
|
||||||
|
*/
|
||||||
|
public function testDownloadOriginalAsksForTheFileAsItWasPutIn(): void
|
||||||
|
{
|
||||||
|
$driver = $this->driver([
|
||||||
|
new MockResponse('jpeg-bytes', ['response_headers' => [
|
||||||
|
'content-type' => 'image/jpeg',
|
||||||
|
'content-disposition' => 'attachment; filename="holiday.jpg"',
|
||||||
|
]]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $driver->downloadOriginal($this->integration(), '91');
|
||||||
|
|
||||||
|
self::assertSame('holiday.jpg', $file->filename);
|
||||||
|
self::assertSame('image/jpeg', $file->mime);
|
||||||
|
self::assertStringEndsWith('/api/documents/91/download/?original=true', $this->requests[0]['url']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testDownloadAsksForTheNameWhenTheHeaderWasStripped(): void
|
public function testDownloadAsksForTheNameWhenTheHeaderWasStripped(): void
|
||||||
{
|
{
|
||||||
$driver = $this->driver([
|
$driver = $this->driver([
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Domain\Exception\AvatarRefusedException;
|
|||||||
use App\Domain\Exception\IntegrationException;
|
use App\Domain\Exception\IntegrationException;
|
||||||
use App\Domain\Helper\AvatarStorage;
|
use App\Domain\Helper\AvatarStorage;
|
||||||
use App\Domain\Interface\IntegrationDriverInterface;
|
use App\Domain\Interface\IntegrationDriverInterface;
|
||||||
|
use App\Domain\Interface\OriginalFileDriverInterface;
|
||||||
use App\Entity\Integration\Integration;
|
use App\Entity\Integration\Integration;
|
||||||
use App\Entity\User\User;
|
use App\Entity\User\User;
|
||||||
use App\Repository\Integration\IntegrationRepository;
|
use App\Repository\Integration\IntegrationRepository;
|
||||||
@@ -79,6 +80,28 @@ final class AvatarFromIntegrationTest extends TestCase
|
|||||||
self::assertNull($user->avatar);
|
self::assertNull($user->avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paperless lists a scanned photograph as image/jpeg and serves the archived
|
||||||
|
* PDF it made from it. A picture taken from it has to be the photograph, so
|
||||||
|
* a driver that keeps the original apart is asked for that one — the
|
||||||
|
* rendition would be refused as "not an image" every time.
|
||||||
|
*/
|
||||||
|
public function testADriverThatKeepsAnOriginalIsAskedForIt(): void
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$driver = new OriginalKeepingDriver(
|
||||||
|
rendition: new RemoteFile('scan.pdf', 'application/pdf', "%PDF-1.7
|
||||||
|
"),
|
||||||
|
original: new RemoteFile('scan.png', 'image/png', self::PNG),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pickerFor($driver)->apply($user, $this->integration(), 'f1');
|
||||||
|
|
||||||
|
self::assertNotNull($user->avatar);
|
||||||
|
self::assertStringEndsWith('.png', $user->avatar);
|
||||||
|
self::assertSame(['downloadOriginal'], $driver->calls);
|
||||||
|
}
|
||||||
|
|
||||||
public function testADownloadThatFailsIsRefusedRatherThanThrown(): void
|
public function testADownloadThatFailsIsRefusedRatherThanThrown(): void
|
||||||
{
|
{
|
||||||
$this->expectExceptionObject(new AvatarRefusedException(AvatarRefusedException::UNAVAILABLE));
|
$this->expectExceptionObject(new AvatarRefusedException(AvatarRefusedException::UNAVAILABLE));
|
||||||
@@ -128,6 +151,11 @@ final class AvatarFromIntegrationTest extends TestCase
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return $this->pickerFor($driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickerFor(IntegrationDriverInterface $driver): AvatarFromIntegration
|
||||||
|
{
|
||||||
return new AvatarFromIntegration(
|
return new AvatarFromIntegration(
|
||||||
$this->createStub(IntegrationRepository::class),
|
$this->createStub(IntegrationRepository::class),
|
||||||
new IntegrationDriverRegistry([$driver]),
|
new IntegrationDriverRegistry([$driver]),
|
||||||
@@ -141,3 +169,62 @@ final class AvatarFromIntegrationTest extends TestCase
|
|||||||
return new Integration(new User(), Provider::Nextcloud, 'Nextcloud');
|
return new Integration(new User(), Provider::Nextcloud, 'Nextcloud');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service whose download() is a rendition and whose original is a different
|
||||||
|
* file, recording which one it was asked for.
|
||||||
|
*/
|
||||||
|
final class OriginalKeepingDriver implements IntegrationDriverInterface, OriginalFileDriverInterface
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $calls = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RemoteFile $rendition,
|
||||||
|
private readonly RemoteFile $original,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(Provider $provider): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(Integration $integration): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(Integration $integration, ?string $folderId = null, ?string $cursor = null): Listing
|
||||||
|
{
|
||||||
|
return new Listing([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(Integration $integration, string $fileId): RemoteFile
|
||||||
|
{
|
||||||
|
$this->calls[] = 'download';
|
||||||
|
|
||||||
|
return $this->rendition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadOriginal(Integration $integration, string $fileId): RemoteFile
|
||||||
|
{
|
||||||
|
$this->calls[] = 'downloadOriginal';
|
||||||
|
|
||||||
|
return $this->original;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(Integration $integration, string $absolutePath, string $filename, string $mime, ?string $folderId = null): string
|
||||||
|
{
|
||||||
|
return 'unused';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shareLink(Integration $integration, string $fileId): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function thumbnail(Integration $integration, string $fileId): ?RemoteFile
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -522,6 +522,76 @@ test.describe("compose integration picker", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → Profile chose a picture from a grid of its own — the first page
|
||||||
|
* of the service's root, with no albums, search or paging — so only the
|
||||||
|
* newest photos were ever on offer. It opens the picker compose uses.
|
||||||
|
*
|
||||||
|
* The service is unreachable here, so what is asserted is that the dialog
|
||||||
|
* is the picker (its own error, not an empty grid), that it was asked in
|
||||||
|
* avatar mode, and that choosing hands the file back to the form, which
|
||||||
|
* posts it. With no service there is no photo to click, so the choice is a
|
||||||
|
* ticked radio put where the picker's own markup puts one — the rest, from
|
||||||
|
* the picker's button to the form's request, is the real thing.
|
||||||
|
*/
|
||||||
|
test("Settings → Profile picks a picture through the same picker", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enableNextcloudAsAdmin(page);
|
||||||
|
await connectAsMailUser(page, "Picture cloud");
|
||||||
|
|
||||||
|
await page.goto("/settings?section=profile");
|
||||||
|
|
||||||
|
const browse = page.waitForRequest((request) =>
|
||||||
|
/\/integrations\/\d+\/browse\?mode=avatar$/.test(request.url()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Picture cloud" }).click();
|
||||||
|
await browse;
|
||||||
|
|
||||||
|
const modal = page.locator("#modal");
|
||||||
|
|
||||||
|
await expect(modal).toContainText(/Could not reach the Nextcloud server/i);
|
||||||
|
|
||||||
|
// Nothing chosen yet: the button must not post an empty choice.
|
||||||
|
await modal.getByRole("button", { name: "Use as profile picture" }).click();
|
||||||
|
await expect(modal).toContainText("Nothing selected");
|
||||||
|
|
||||||
|
await modal
|
||||||
|
.locator('[data-controller="integration--integration-picker"]')
|
||||||
|
.evaluate((root) => {
|
||||||
|
// Two files, the second chosen after the first — a profile has
|
||||||
|
// one picture, so only the second may be left standing.
|
||||||
|
for (const [file, checked] of [["Photos/old.jpg", true], ["Photos/me.jpg", false]] as const) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "radio";
|
||||||
|
input.name = `mode[${file}]`;
|
||||||
|
input.value = "copy";
|
||||||
|
input.checked = checked;
|
||||||
|
input.setAttribute("data-integration--integration-picker-target", "mode");
|
||||||
|
root.append(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const second = root.querySelector<HTMLInputElement>('input[name="mode[Photos/me.jpg]"]')!;
|
||||||
|
second.checked = true;
|
||||||
|
second.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(modal.locator('input[name="mode[Photos/old.jpg]"]')).not.toBeChecked();
|
||||||
|
|
||||||
|
const save = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
"POST" === request.method() && /\/settings\/profile$/.test(request.url()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.getByRole("button", { name: "Use as profile picture" }).click();
|
||||||
|
|
||||||
|
// Both fields, under the form's own name, carrying what was chosen.
|
||||||
|
const body = (await save).postData() ?? "";
|
||||||
|
expect(body).toMatch(/name="profile\[avatarIntegrationId\]"\s+\d+/);
|
||||||
|
expect(body).toMatch(/name="profile\[avatarFileId\]"\s+Photos\/me\.jpg/);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attachment chip gained a "Save to…" half and was extracted into a
|
* The attachment chip gained a "Save to…" half and was extracted into a
|
||||||
* shared partial, so both of its call sites had to keep working. This
|
* shared partial, so both of its call sites had to keep working. This
|
||||||
|
|||||||
@@ -2200,6 +2200,7 @@ integration:
|
|||||||
link: Link
|
link: Link
|
||||||
more: 'Mehr anzeigen'
|
more: 'Mehr anzeigen'
|
||||||
no_results: 'Nichts gefunden für „%query%“.'
|
no_results: 'Nichts gefunden für „%query%“.'
|
||||||
|
not_an_image: 'Keine Bilddatei'
|
||||||
search: Suchen
|
search: Suchen
|
||||||
search_placeholder:
|
search_placeholder:
|
||||||
dropbox: 'Dropbox durchsuchen'
|
dropbox: 'Dropbox durchsuchen'
|
||||||
@@ -2219,6 +2220,7 @@ integration:
|
|||||||
skipped: 'Übersprungen: %files%'
|
skipped: 'Übersprungen: %files%'
|
||||||
timeline: 'Zu einem Datum springen'
|
timeline: 'Zu einem Datum springen'
|
||||||
too_big: 'Zu groß zum Anhängen'
|
too_big: 'Zu groß zum Anhängen'
|
||||||
|
use_as_avatar: 'Als Profilbild verwenden'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
button:
|
button:
|
||||||
|
|||||||
@@ -2199,6 +2199,7 @@ integration:
|
|||||||
link: Link
|
link: Link
|
||||||
more: 'Show more'
|
more: 'Show more'
|
||||||
no_results: 'Nothing found for “%query%”.'
|
no_results: 'Nothing found for “%query%”.'
|
||||||
|
not_an_image: 'Not a picture'
|
||||||
search: Search
|
search: Search
|
||||||
search_placeholder:
|
search_placeholder:
|
||||||
dropbox: 'Search Dropbox'
|
dropbox: 'Search Dropbox'
|
||||||
@@ -2218,6 +2219,7 @@ integration:
|
|||||||
skipped: 'Skipped: %files%'
|
skipped: 'Skipped: %files%'
|
||||||
timeline: 'Jump to a date'
|
timeline: 'Jump to a date'
|
||||||
too_big: 'Too large to attach'
|
too_big: 'Too large to attach'
|
||||||
|
use_as_avatar: 'Use as profile picture'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
button:
|
button:
|
||||||
|
|||||||
@@ -2079,6 +2079,7 @@ integration:
|
|||||||
link: Link
|
link: Link
|
||||||
more: 'Show more'
|
more: 'Show more'
|
||||||
no_results: 'Naught found for “%query%”.'
|
no_results: 'Naught found for “%query%”.'
|
||||||
|
not_an_image: 'Not a picture'
|
||||||
search: Search
|
search: Search
|
||||||
search_placeholder:
|
search_placeholder:
|
||||||
dropbox: 'Search Dropbox'
|
dropbox: 'Search Dropbox'
|
||||||
@@ -2098,6 +2099,7 @@ integration:
|
|||||||
skipped: 'Left on the dock: %files%'
|
skipped: 'Left on the dock: %files%'
|
||||||
timeline: 'Jump to a date'
|
timeline: 'Jump to a date'
|
||||||
too_big: 'Too heavy to haul'
|
too_big: 'Too heavy to haul'
|
||||||
|
use_as_avatar: 'Hoist as me flag'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
button:
|
button:
|
||||||
|
|||||||
@@ -2210,6 +2210,7 @@ integration:
|
|||||||
link: Lien
|
link: Lien
|
||||||
more: 'Afficher plus'
|
more: 'Afficher plus'
|
||||||
no_results: 'Rien trouvé pour « %query% ».'
|
no_results: 'Rien trouvé pour « %query% ».'
|
||||||
|
not_an_image: 'Pas une image'
|
||||||
search: Rechercher
|
search: Rechercher
|
||||||
search_placeholder:
|
search_placeholder:
|
||||||
dropbox: 'Rechercher dans Dropbox'
|
dropbox: 'Rechercher dans Dropbox'
|
||||||
@@ -2229,6 +2230,7 @@ integration:
|
|||||||
skipped: 'Ignorés : %files%'
|
skipped: 'Ignorés : %files%'
|
||||||
timeline: 'Aller à une date'
|
timeline: 'Aller à une date'
|
||||||
too_big: 'Trop volumineux pour être joint'
|
too_big: 'Trop volumineux pour être joint'
|
||||||
|
use_as_avatar: 'Utiliser comme photo de profil'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
button:
|
button:
|
||||||
|
|||||||
Reference in new issue
Block a user