From 1964ea5fb4d096c60f87793cf7ca461edb77be5b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 15:49:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(share)=20:=20recherche=20globale=20r=C3=A9?= =?UTF-8?q?cursive=20par=20nom=20de=20fichier=20dans=20le=20partage=20SMB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/i18n/locales/fr.json | 3 +- frontend/pages/documents.vue | 75 ++++++++++++++++-- frontend/services/dto/share.ts | 5 ++ frontend/services/share.ts | 8 +- .../Share/ShareSearchController.php | 56 +++++++++++++ src/Service/Share/FileSource.php | 7 ++ src/Service/Share/SmbFileSource.php | 78 +++++++++++++++++-- .../Functional/Controller/ShareSearchTest.php | 53 +++++++++++++ 8 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 src/Controller/Share/ShareSearchController.php create mode 100644 tests/Functional/Controller/ShareSearchTest.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index bbdb21f..3b3fbff 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -439,7 +439,8 @@ "title": "Documents", "root": "Racine", "empty": "Ce dossier est vide.", - "filterPlaceholder": "Filtrer ce dossier…", + "noResults": "Aucun document ne correspond à votre recherche.", + "searchPlaceholder": "Rechercher dans tout le partage…", "download": "Télécharger", "reload": "Recharger", "previewError": "Aperçu impossible. Téléchargez le fichier pour l'ouvrir.", diff --git a/frontend/pages/documents.vue b/frontend/pages/documents.vue index 3962e85..05fec23 100644 --- a/frontend/pages/documents.vue +++ b/frontend/pages/documents.vue @@ -16,7 +16,7 @@
@@ -32,11 +32,13 @@ -
+
-

{{ error }}

-

{{ $t('sharedFiles.empty') }}

+

{{ error || searchError }}

+

+ {{ isSearchMode ? $t('sharedFiles.noResults') : $t('sharedFiles.empty') }} +

@@ -55,8 +57,11 @@ @click="onEntryClick(entry)" > @@ -82,9 +87,11 @@ import { formatFileSize } from '~/utils/format' useHead({ title: 'Documents' }) -const { browse } = useShareService() +const { browse, search } = useShareService() const { enabled, ensureLoaded } = useShareStatus() +const MIN_SEARCH_LENGTH = 2 + const currentPath = ref('') const breadcrumb = ref([]) const entries = ref([]) @@ -92,12 +99,64 @@ const filter = ref('') const loading = ref(false) const error = ref(null) +const searchResults = ref([]) +const searching = ref(false) +const searchError = ref(null) + const previewEntry = ref(null) +// Recherche globale (récursive sur tout le partage) dès 2 caractères ; +// en deçà, simple filtre local sur le dossier courant. +const isSearchMode = computed(() => filter.value.trim().length >= MIN_SEARCH_LENGTH) + const visibleEntries = computed(() => { const f = filter.value.trim().toLowerCase() if (!f) return entries.value - return entries.value.filter((e) => e.name.toLowerCase().includes(f)) + if (f.length < MIN_SEARCH_LENGTH) { + return entries.value.filter((e) => e.name.toLowerCase().includes(f)) + } + return searchResults.value +}) + +function parentDir(entry: FileEntry): string { + const idx = entry.path.lastIndexOf('/') + return idx === -1 ? '' : entry.path.slice(0, idx) +} + +let searchTimer: ReturnType | null = null +let searchSeq = 0 + +async function runSearch(query: string) { + const seq = ++searchSeq + searching.value = true + searchError.value = null + try { + const result = await search(query) + if (seq !== searchSeq) return // une frappe plus récente a pris le relais + searchResults.value = result.entries + } catch (e: unknown) { + if (seq !== searchSeq) return + searchError.value = (e as Error)?.message ?? 'Erreur' + searchResults.value = [] + } finally { + if (seq === searchSeq) searching.value = false + } +} + +watch(filter, (value) => { + if (searchTimer) clearTimeout(searchTimer) + + const q = value.trim() + if (q.length < MIN_SEARCH_LENGTH) { + searchSeq++ // invalide toute recherche en vol + searching.value = false + searchError.value = null + searchResults.value = [] + return + } + + searching.value = true + searchTimer = setTimeout(() => runSearch(q), 300) }) const fileEntries = computed(() => visibleEntries.value.filter((e) => !e.isDir)) diff --git a/frontend/services/dto/share.ts b/frontend/services/dto/share.ts index e053ce4..1bcb716 100644 --- a/frontend/services/dto/share.ts +++ b/frontend/services/dto/share.ts @@ -18,6 +18,11 @@ export type ShareBrowseResult = { entries: FileEntry[] } +export type ShareSearchResult = { + query: string + entries: FileEntry[] +} + export type ShareStatus = { enabled: boolean } diff --git a/frontend/services/share.ts b/frontend/services/share.ts index 2ce7399..b1be61f 100644 --- a/frontend/services/share.ts +++ b/frontend/services/share.ts @@ -1,4 +1,4 @@ -import type { ShareBrowseResult, ShareStatus } from './dto/share' +import type { ShareBrowseResult, ShareSearchResult, ShareStatus } from './dto/share' export function useShareService() { const api = useApi() @@ -9,6 +9,10 @@ export function useShareService() { return api.get(`/share/browse${query}`) } + async function search(query: string): Promise { + return api.get(`/share/search?q=${encodeURIComponent(query)}`) + } + async function getStatus(): Promise { return api.get('/share/status') } @@ -18,5 +22,5 @@ export function useShareService() { return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}` } - return { browse, getStatus, getDownloadUrl } + return { browse, search, getStatus, getDownloadUrl } } diff --git a/src/Controller/Share/ShareSearchController.php b/src/Controller/Share/ShareSearchController.php new file mode 100644 index 0000000..aa5512b --- /dev/null +++ b/src/Controller/Share/ShareSearchController.php @@ -0,0 +1,56 @@ +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), + ]); + } +} diff --git a/src/Service/Share/FileSource.php b/src/Service/Share/FileSource.php index c848ecf..4143f6a 100644 --- a/src/Service/Share/FileSource.php +++ b/src/Service/Share/FileSource.php @@ -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 */ diff --git a/src/Service/Share/SmbFileSource.php b/src/Service/Share/SmbFileSource.php index a66eff1..d4f29fd 100644 --- a/src/Service/Share/SmbFileSource.php +++ b/src/Service/Share/SmbFileSource.php @@ -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, '/').'/'; diff --git a/tests/Functional/Controller/ShareSearchTest.php b/tests/Functional/Controller/ShareSearchTest.php new file mode 100644 index 0000000..62e9bbd --- /dev/null +++ b/tests/Functional/Controller/ShareSearchTest.php @@ -0,0 +1,53 @@ +request('GET', '/api/share/search?q=rapport'); + + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testSearchReturnsEmptyForShortQuery(): void + { + $client = self::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/search?q=a'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertSame('a', $data['query']); + self::assertSame([], $data['entries']); + } + + public function testSearchReturns409WhenNotConfigured(): void + { + $client = self::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/search?q=rapport'); + + self::assertSame(409, $client->getResponse()->getStatusCode()); + } + + private function login(KernelBrowser $client): void + { + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + } +}
- - {{ entry.name }} + + + {{ entry.name }} + {{ parentDir(entry) }} + {{ entry.isDir ? '—' : formatFileSize(entry.size) }} {{ formatDate(entry.modifiedAt) }}