feat(share) : recherche globale récursive par nom de fichier dans le partage SMB

Endpoint GET /api/share/search?q= parcourant tout le partage en largeur
(garde-fous 200 résultats / 2000 dossiers). Le champ de l'explorateur
déclenche une recherche globale debouncée dès 2 caractères (filtre local
en deçà), avec affichage du dossier parent de chaque résultat.
This commit is contained in:
Matthieu
2026-06-12 15:49:57 +02:00
parent 682b5747b1
commit 1964ea5fb4
8 changed files with 267 additions and 18 deletions
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareSearchController extends AbstractController
{
/** Longueur minimale du terme de recherche (évite un parcours global trop large). */
private const int MIN_QUERY_LENGTH = 2;
public function __construct(
private readonly FileSource $fileSource,
) {}
#[Route('/api/share/search', name: 'share_search', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): JsonResponse
{
$query = trim((string) $request->query->get('q', ''));
if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
return new JsonResponse(['query' => $query, 'entries' => []]);
}
try {
$entries = $this->fileSource->search($query);
} catch (ShareNotConfiguredException) {
return new JsonResponse(['error' => 'Share not configured.'], 409);
} catch (ShareConnectionException) {
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
}
return new JsonResponse([
'query' => $query,
'entries' => array_map(static fn (FileEntry $e): array => [
'name' => $e->name,
'path' => $e->path,
'isDir' => $e->isDir,
'size' => $e->size,
'modifiedAt' => $e->modifiedAt,
'mimeType' => $e->mimeType,
], $entries),
]);
}
}
+7
View File
@@ -11,6 +11,13 @@ interface FileSource
*/
public function dir(string $relativePath): array;
/**
* Recherche récursive, par fragment de nom (insensible à la casse), dans tout le partage.
*
* @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom (limités)
*/
public function search(string $query, int $limit = 200): array;
/**
* @return resource flux binaire en lecture
*/
+71 -7
View File
@@ -16,8 +16,13 @@ use Icewind\SMB\ServerFactory;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
use function count;
final class SmbFileSource implements FileSource
{
/** Garde-fou : nombre maximum de dossiers explorés par recherche (évite de bloquer sur un très gros partage). */
private const int SEARCH_MAX_DIRS = 2000;
public function __construct(
private readonly ShareConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
@@ -38,17 +43,60 @@ final class SmbFileSource implements FileSource
$entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos);
usort($entries, static function (FileEntry $a, FileEntry $b): int {
if ($a->isDir !== $b->isDir) {
return $a->isDir ? -1 : 1;
}
return strcasecmp($a->name, $b->name);
});
$this->sortEntries($entries);
return $entries;
}
public function search(string $query, int $limit = 200): array
{
$needle = trim($query);
if ('' === $needle) {
return [];
}
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$base = (string) $config->getBasePath();
$results = [];
$queue = ['']; // chemins relatifs des dossiers à explorer, racine en premier (parcours en largeur)
$visitedDirs = 0;
while ([] !== $queue && count($results) < $limit && $visitedDirs < self::SEARCH_MAX_DIRS) {
$relative = array_shift($queue);
$full = $this->pathResolver->fullPath($base, $relative);
try {
$infos = $share->dir($full);
} catch (Throwable) {
continue; // dossier illisible (droits, lien mort…) : on l'ignore et on poursuit
}
++$visitedDirs;
foreach ($infos as $info) {
$entry = $this->toEntry($info, $relative);
if ($entry->isDir) {
$queue[] = $entry->path;
}
if (false !== mb_stripos($entry->name, $needle)) {
$results[] = $entry;
if (count($results) >= $limit) {
break;
}
}
}
}
$this->sortEntries($results);
return $results;
}
public function read(string $relativePath)
{
$config = $this->requireUsableConfig();
@@ -108,6 +156,22 @@ final class SmbFileSource implements FileSource
}
}
/**
* Trie en place : dossiers d'abord, puis tri alphabétique insensible à la casse.
*
* @param FileEntry[] $entries
*/
private function sortEntries(array &$entries): void
{
usort($entries, static function (FileEntry $a, FileEntry $b): int {
if ($a->isDir !== $b->isDir) {
return $a->isDir ? -1 : 1;
}
return strcasecmp($a->name, $b->name);
});
}
private function toEntry(IFileInfo $info, string $parentRelative): FileEntry
{
$parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/';