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:
@@ -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.",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="max-w-sm flex-1">
|
||||
<MalioInputText
|
||||
v-model="filter"
|
||||
:placeholder="$t('sharedFiles.filterPlaceholder')"
|
||||
:placeholder="$t('sharedFiles.searchPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,11 +32,13 @@
|
||||
</div>
|
||||
|
||||
<!-- États -->
|
||||
<div v-if="loading" class="mt-10 flex justify-center">
|
||||
<div v-if="loading || searching" class="mt-10 flex justify-center">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="error" class="mt-10 text-sm text-red-600">{{ error }}</p>
|
||||
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>
|
||||
<p v-else-if="error || searchError" class="mt-10 text-sm text-red-600">{{ error || searchError }}</p>
|
||||
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">
|
||||
{{ isSearchMode ? $t('sharedFiles.noResults') : $t('sharedFiles.empty') }}
|
||||
</p>
|
||||
|
||||
<!-- Tableau -->
|
||||
<table v-else class="mt-6 w-full text-sm">
|
||||
@@ -55,8 +57,11 @@
|
||||
@click="onEntryClick(entry)"
|
||||
>
|
||||
<td class="flex items-center gap-2 py-2">
|
||||
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 text-neutral-400" />
|
||||
<span class="truncate">{{ entry.name }}</span>
|
||||
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate">{{ entry.name }}</span>
|
||||
<span v-if="isSearchMode && parentDir(entry)" class="truncate text-xs text-neutral-400">{{ parentDir(entry) }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 text-neutral-500">{{ entry.isDir ? '—' : formatFileSize(entry.size) }}</td>
|
||||
<td class="py-2 text-neutral-500">{{ formatDate(entry.modifiedAt) }}</td>
|
||||
@@ -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<Breadcrumb[]>([])
|
||||
const entries = ref<FileEntry[]>([])
|
||||
@@ -92,12 +99,64 @@ const filter = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const searchResults = ref<FileEntry[]>([])
|
||||
const searching = ref(false)
|
||||
const searchError = ref<string | null>(null)
|
||||
|
||||
const previewEntry = ref<FileEntry | null>(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<typeof setTimeout> | 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))
|
||||
|
||||
@@ -18,6 +18,11 @@ export type ShareBrowseResult = {
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareSearchResult = {
|
||||
query: string
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareStatus = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
@@ -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<ShareBrowseResult>(`/share/browse${query}`)
|
||||
}
|
||||
|
||||
async function search(query: string): Promise<ShareSearchResult> {
|
||||
return api.get<ShareSearchResult>(`/share/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
async function getStatus(): Promise<ShareStatus> {
|
||||
return api.get<ShareStatus>('/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 }
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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, '/').'/';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ShareSearchTest extends WebTestCase
|
||||
{
|
||||
public function testSearchRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user