mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-05 12:54:33 +00:00
Merge pull request #1809 from veenone/feat/orphan-import-all
Import all matching orphans in a background job
This commit is contained in:
@@ -5,20 +5,24 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Jobs\ImportOrphanFilesJob;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\OrphanFileImporter;
|
||||
use App\Modules\Files\OrphanFileScanner;
|
||||
use App\Modules\Files\OrphanImportProgress;
|
||||
use App\Modules\Files\Scanning\ScanStatus;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Support\Pagination;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -33,7 +37,8 @@ class OrphanFilesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrphanFileScanner $scanner,
|
||||
private readonly StoreUploadedFile $storeFile,
|
||||
private readonly OrphanFileImporter $importer,
|
||||
private readonly OrphanImportProgress $progress,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
@@ -91,6 +96,7 @@ class OrphanFilesController extends Controller
|
||||
'search' => $search,
|
||||
'scanned_disks' => $this->scanner->scannedDisks(),
|
||||
'missing_count' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
|
||||
'import_run' => $this->progress->current(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -138,14 +144,20 @@ class OrphanFilesController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
$validated = $this->validateItems($request);
|
||||
if ($request->boolean('all')) {
|
||||
return $this->importAll($request, $user);
|
||||
}
|
||||
|
||||
// While a background run is adopting files, a second importer
|
||||
// could adopt the same path twice.
|
||||
if ($this->progress->isActive()) {
|
||||
$this->alreadyRunning();
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
$importedFile = null;
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$disk = Storage::disk($item['disk']);
|
||||
|
||||
foreach ($this->validateItems($request)['items'] as $item) {
|
||||
// Re-validate against a fresh scan — never trust a
|
||||
// client-supplied disk/path just because an earlier scan
|
||||
// listed it.
|
||||
@@ -153,18 +165,7 @@ class OrphanFilesController extends Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
$importedFile = $this->storeFile->create(
|
||||
uploader: $user,
|
||||
originalName: basename($item['path']),
|
||||
path: $item['path'],
|
||||
mimeType: $disk->mimeType($item['path']) ?: 'application/octet-stream',
|
||||
size: $disk->size($item['path']),
|
||||
checksum: $this->checksumOf($disk, $item['path']),
|
||||
folderId: null,
|
||||
disk: $item['disk'],
|
||||
action: Action::FileImported,
|
||||
);
|
||||
|
||||
$importedFile = $this->importer->import($user, $item['disk'], $item['path']);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
@@ -182,6 +183,49 @@ class OrphanFilesController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every orphan the search matches, on every page. Handed to a queued
|
||||
* job: thousands of files hashed in full take minutes, and a request
|
||||
* is cut off after 30s of CPU — part-way through, with an error page
|
||||
* for an import that was in fact half done. See ImportOrphanFilesJob.
|
||||
*/
|
||||
private function importAll(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$search = trim($request->validate(['search' => ['nullable', 'string', 'max:255']])['search'] ?? '');
|
||||
$search = $search !== '' ? $search : null;
|
||||
|
||||
$total = count($this->scanner->importable($user, $search));
|
||||
|
||||
if ($total === 0) {
|
||||
return back()->with('success', trans_choice(':count file imported.|:count files imported.', 0, ['count' => '0']));
|
||||
}
|
||||
|
||||
if (! $this->progress->tryStart($total)) {
|
||||
$this->alreadyRunning();
|
||||
}
|
||||
|
||||
ImportOrphanFilesJob::dispatch($user->id, $search);
|
||||
|
||||
return back()->with('success', trans_choice(
|
||||
'Importing :count file in the background.|Importing :count files in the background.',
|
||||
$total,
|
||||
['count' => (string) $total],
|
||||
));
|
||||
}
|
||||
|
||||
private function alreadyRunning(): never
|
||||
{
|
||||
throw ValidationException::withMessages(['items' => __('An import is already running. Wait for it to finish.')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polled by the orphans screen while a background run is going.
|
||||
*/
|
||||
public function importStatus(): JsonResponse
|
||||
{
|
||||
return response()->json($this->progress->current());
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $this->validateItems($request);
|
||||
@@ -218,24 +262,4 @@ class OrphanFilesController extends Controller
|
||||
'items.*.path' => ['required', 'string'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streamed rather than hash_file() on a local path — the only way to
|
||||
* checksum a file that might live on a non-local disk (S3 has no
|
||||
* local filesystem path to hand hash_file()).
|
||||
*/
|
||||
private function checksumOf(Filesystem $disk, string $path): string
|
||||
{
|
||||
$stream = $disk->readStream($path);
|
||||
|
||||
if ($stream === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$context = hash_init('sha256');
|
||||
hash_update_stream($context, $stream);
|
||||
fclose($stream);
|
||||
|
||||
return hash_final($context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Jobs;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\OrphanFileImporter;
|
||||
use App\Modules\Files\OrphanFileScanner;
|
||||
use App\Modules\Files\OrphanImportProgress;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* "Import all" on the orphans screen. Thousands of files, each hashed in
|
||||
* full, run for minutes, which is far past what one web request is
|
||||
* allowed (see OrphanFilesController::import).
|
||||
*
|
||||
* The work is cut into chunks of about $budgetSeconds. Each chunk scans
|
||||
* the disk again and imports what is still orphaned until its time is up,
|
||||
* then queues the next chunk. That keeps every job well inside the default
|
||||
* worker's 60s timeout and the queue's 90s retry_after, so it can share
|
||||
* the default queue (no extra worker to deploy). Mail queued in the
|
||||
* meantime goes out between chunks rather than waiting behind the whole
|
||||
* import. Because each chunk rescans, a file already adopted is never
|
||||
* offered twice, and a run that dies part-way resumes from what is left.
|
||||
*/
|
||||
class ImportOrphanFilesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable;
|
||||
|
||||
/**
|
||||
* Not retried as such: the next "Import all" (or the next chunk)
|
||||
* rescans and carries on from where this one stopped. failed()
|
||||
* records why it stopped.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $userId,
|
||||
private readonly ?string $search,
|
||||
private readonly int $budgetSeconds = 45,
|
||||
) {}
|
||||
|
||||
public function handle(OrphanFileScanner $scanner, OrphanFileImporter $importer, OrphanImportProgress $progress): void
|
||||
{
|
||||
$user = User::query()->find($this->userId);
|
||||
|
||||
if ($user === null) {
|
||||
$progress->fail('The account that started the import no longer exists.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$deadline = microtime(true) + $this->budgetSeconds;
|
||||
|
||||
foreach ($scanner->importable($user, $this->search) as $i => $item) {
|
||||
// At least one file per chunk, so a run always moves forward.
|
||||
if ($i > 0 && microtime(true) >= $deadline) {
|
||||
self::dispatch($this->userId, $this->search, $this->budgetSeconds);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$importer->import($user, $item['disk'], $item['path']);
|
||||
$progress->advance();
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
app(OrphanImportProgress::class)->fail($exception->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Adopts an orphan in place: a File row for bytes already on disk. Shared
|
||||
* by the orphans screen (a ticked selection, in the request) and
|
||||
* ImportOrphanFilesJob ("import all", in the background). Which paths are
|
||||
* importable is OrphanFileScanner's call.
|
||||
*/
|
||||
class OrphanFileImporter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StoreUploadedFile $storeFile,
|
||||
) {}
|
||||
|
||||
public function import(User $user, string $diskName, string $path): File
|
||||
{
|
||||
$disk = Storage::disk($diskName);
|
||||
|
||||
return $this->storeFile->create(
|
||||
uploader: $user,
|
||||
originalName: basename($path),
|
||||
path: $path,
|
||||
mimeType: $disk->mimeType($path) ?: 'application/octet-stream',
|
||||
size: $disk->size($path),
|
||||
checksum: $this->checksumOf($disk, $path),
|
||||
folderId: null,
|
||||
disk: $diskName,
|
||||
action: Action::FileImported,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streamed rather than hash_file() on a local path — the only way to
|
||||
* checksum a file that might live on a non-local disk (S3 has no
|
||||
* local filesystem path to hand hash_file()).
|
||||
*/
|
||||
private function checksumOf(Filesystem $disk, string $path): string
|
||||
{
|
||||
$stream = $disk->readStream($path);
|
||||
|
||||
if ($stream === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$context = hash_init('sha256');
|
||||
hash_update_stream($context, $stream);
|
||||
fclose($stream);
|
||||
|
||||
return hash_final($context);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,26 @@ class OrphanFileScanner
|
||||
return $orphans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every orphan the search matches that $viewer may import: the same
|
||||
* rule as isImportable(), applied to a whole scan. Taken from a fresh
|
||||
* scan, so unlike a client-supplied list it needs no per-path re-check.
|
||||
*
|
||||
* @return list<array{disk: string, path: string}>
|
||||
*/
|
||||
public function importable(User $viewer, ?string $search = null): array
|
||||
{
|
||||
$importable = [];
|
||||
|
||||
foreach ($this->scan($viewer, $search) as $orphan) {
|
||||
if ($orphan['allowed'] && $orphan['size'] > 0) {
|
||||
$importable[] = ['disk' => $orphan['disk'], 'path' => $orphan['path']];
|
||||
}
|
||||
}
|
||||
|
||||
return $importable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validated at import/delete time — never trust a client-supplied
|
||||
* disk/path just because it was in an earlier scan response. $disk
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* The one background "import all" run on the orphans screen: how far it
|
||||
* has got, and whether it is still going. Kept in the cache rather than a
|
||||
* table: it is a progress readout for whoever is watching, not a record
|
||||
* (every imported file is already in the activity log).
|
||||
*
|
||||
* There is only ever one run. Two at once would each scan the same disk
|
||||
* and adopt the same paths twice, so a new one is refused while the
|
||||
* current one is active.
|
||||
*
|
||||
* @phpstan-type Run array{status: string, total: int, imported: int, error: ?string, started_at: int, updated_at: int}
|
||||
*/
|
||||
class OrphanImportProgress
|
||||
{
|
||||
private const KEY = 'orphan-files:import-run';
|
||||
|
||||
/** A run kept for a day, so its outcome is still there after lunch. */
|
||||
private const TTL_SECONDS = 86400;
|
||||
|
||||
/**
|
||||
* Each chunk of the job runs for under a minute, and every imported
|
||||
* file touches updated_at. A run silent for this long has no worker
|
||||
* behind it (crashed, or no worker listening), and must not block
|
||||
* the next one forever.
|
||||
*/
|
||||
private const STALL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* Starts a run unless one is active, under a lock so two people
|
||||
* clicking "Import all" at the same moment cannot both get one.
|
||||
*/
|
||||
public function tryStart(int $total): bool
|
||||
{
|
||||
return (bool) Cache::lock(self::KEY.':start', 10)->get(function () use ($total): bool {
|
||||
if ($this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->put([
|
||||
'status' => 'running',
|
||||
'total' => $total,
|
||||
'imported' => 0,
|
||||
'error' => null,
|
||||
'started_at' => now()->getTimestamp(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function advance(): void
|
||||
{
|
||||
$run = $this->raw();
|
||||
|
||||
if ($run !== null) {
|
||||
$run['imported']++;
|
||||
$this->put($run);
|
||||
}
|
||||
}
|
||||
|
||||
public function finish(): void
|
||||
{
|
||||
$this->end('finished');
|
||||
}
|
||||
|
||||
public function fail(string $error): void
|
||||
{
|
||||
$this->end('failed', $error);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return ($this->current()['status'] ?? null) === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* The run as the page shows it. A running run that has gone quiet is
|
||||
* reported as stalled.
|
||||
*
|
||||
* @return array{status: string, total: int, imported: int, error: ?string, started_at: int}|null
|
||||
*/
|
||||
public function current(): ?array
|
||||
{
|
||||
$run = $this->raw();
|
||||
|
||||
if ($run === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($run['status'] === 'running' && now()->getTimestamp() - $run['updated_at'] > self::STALL_SECONDS) {
|
||||
$run['status'] = 'stalled';
|
||||
}
|
||||
|
||||
unset($run['updated_at']);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function end(string $status, ?string $error = null): void
|
||||
{
|
||||
$run = $this->raw();
|
||||
|
||||
if ($run !== null) {
|
||||
$this->put([...$run, 'status' => $status, 'error' => $error]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Run|null
|
||||
*/
|
||||
private function raw(): ?array
|
||||
{
|
||||
return Cache::get(self::KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $run
|
||||
*/
|
||||
private function put(array $run): void
|
||||
{
|
||||
Cache::put(self::KEY, [...$run, 'updated_at' => now()->getTimestamp()], self::TTL_SECONDS);
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Es mostra fins a 320 × 128 píxels, de manera que hi caben tant un logotip ample com un de quadrat. PNG, JPG, GIF o WebP, fins a 2 MB.",
|
||||
"Show the site name under the logo": "Mostra el nom del lloc sota el logotip",
|
||||
"They will no longer be available to anyone they were shared with.": "Deixaran d'estar disponibles per a tothom amb qui s'havien compartit.",
|
||||
"Uploading into :folder": "Pujant a :folder"
|
||||
"Uploading into :folder": "Pujant a :folder",
|
||||
"Select all :count matching files": "Selecciona els :count fitxers coincidents",
|
||||
"Restricted and empty files are skipped.": "Els fitxers restringits i els buits s’ometen.",
|
||||
"Import all": "Importa-ho tot",
|
||||
"Importing in the background": "S’està important en segon pla",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported de :total fitxers importats. Pots sortir d’aquesta pàgina: la importació continua.",
|
||||
"Import finished": "Importació acabada",
|
||||
":imported of :total files imported.": ":imported de :total fitxers importats.",
|
||||
"The import stopped": "La importació s’ha aturat",
|
||||
"The import stopped making progress": "La importació ha deixat d’avançar",
|
||||
":imported of :total files were imported before it failed: :error": "S’han importat :imported de :total fitxers abans de l’error: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "S’han importat :imported de :total fitxers. Potser el procés en segon pla no s’està executant.",
|
||||
"Choose Import all again to continue with the rest.": "Torna a triar «Importa-ho tot» per continuar amb la resta.",
|
||||
"An import is already running. Wait for it to finish.": "Ja hi ha una importació en curs. Espera que acabi.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "S’està important :count fitxer en segon pla.|S’estan important :count fitxers en segon pla."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Zobrazuje se až v rozměru 320 × 128 pixelů, takže se vejde široké i čtvercové logo. PNG, JPG, GIF nebo WebP, do 2 MB.",
|
||||
"Show the site name under the logo": "Zobrazit název webu pod logem",
|
||||
"They will no longer be available to anyone they were shared with.": "Přestanou být dostupné všem, se kterými byly sdíleny.",
|
||||
"Uploading into :folder": "Nahrávání do: :folder"
|
||||
"Uploading into :folder": "Nahrávání do: :folder",
|
||||
"Select all :count matching files": "Vybrat všech :count odpovídajících souborů",
|
||||
"Restricted and empty files are skipped.": "Soubory s omezenou příponou a prázdné soubory se přeskočí.",
|
||||
"Import all": "Naimportovat vše",
|
||||
"Importing in the background": "Import probíhá na pozadí",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": "Naimportováno :imported z :total souborů. Tuto stránku můžete opustit — import poběží dál.",
|
||||
"Import finished": "Import dokončen",
|
||||
":imported of :total files imported.": "Naimportováno :imported z :total souborů.",
|
||||
"The import stopped": "Import se zastavil",
|
||||
"The import stopped making progress": "Import přestal postupovat",
|
||||
":imported of :total files were imported before it failed: :error": "Před chybou se naimportovalo :imported z :total souborů: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Naimportováno :imported z :total souborů. Proces na pozadí možná neběží.",
|
||||
"Choose Import all again to continue with the rest.": "Pro pokračování se zbytkem zvolte znovu „Naimportovat vše“.",
|
||||
"An import is already running. Wait for it to finish.": "Import už probíhá. Počkejte, až skončí.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Na pozadí se importuje :count soubor.|Na pozadí se importují :count soubory.|Na pozadí se importuje :count souborů."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wird bis zu 320 × 128 Pixel groß angezeigt, sodass breite und quadratische Logos gleichermaßen passen. PNG, JPG, GIF oder WebP, bis 2 MB.",
|
||||
"Show the site name under the logo": "Seitennamen unter dem Logo anzeigen",
|
||||
"They will no longer be available to anyone they were shared with.": "Sie stehen dann niemandem mehr zur Verfügung, mit dem sie geteilt wurden.",
|
||||
"Uploading into :folder": "Hochladen in :folder"
|
||||
"Uploading into :folder": "Hochladen in :folder",
|
||||
"Select all :count matching files": "Alle :count passenden Dateien auswählen",
|
||||
"Restricted and empty files are skipped.": "Dateien mit eingeschränkter Endung und leere Dateien werden übersprungen.",
|
||||
"Import all": "Alle importieren",
|
||||
"Importing in the background": "Import läuft im Hintergrund",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported von :total Dateien importiert. Sie können diese Seite verlassen — der Import läuft weiter.",
|
||||
"Import finished": "Import abgeschlossen",
|
||||
":imported of :total files imported.": ":imported von :total Dateien importiert.",
|
||||
"The import stopped": "Der Import wurde abgebrochen",
|
||||
"The import stopped making progress": "Der Import kommt nicht mehr voran",
|
||||
":imported of :total files were imported before it failed: :error": ":imported von :total Dateien wurden importiert, bevor ein Fehler auftrat: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":imported von :total Dateien wurden importiert. Möglicherweise läuft der Hintergrundprozess nicht.",
|
||||
"Choose Import all again to continue with the rest.": "Wählen Sie erneut „Alle importieren“, um mit den übrigen fortzufahren.",
|
||||
"An import is already running. Wait for it to finish.": "Es läuft bereits ein Import. Warten Sie, bis er abgeschlossen ist.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": ":count Datei wird im Hintergrund importiert.|:count Dateien werden im Hintergrund importiert."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Se muestra hasta 320 × 128 píxeles, así que entran tanto un logo ancho como uno cuadrado. PNG, JPG, GIF o WebP, hasta 2 MB.",
|
||||
"Show the site name under the logo": "Mostrar el nombre del sitio debajo del logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Dejarán de estar disponibles para quienes fueron compartidos.",
|
||||
"Uploading into :folder": "Subiendo a :folder"
|
||||
"Uploading into :folder": "Subiendo a :folder",
|
||||
"Select all :count matching files": "Seleccionar los :count archivos coincidentes",
|
||||
"Restricted and empty files are skipped.": "Se omiten los archivos restringidos y los vacíos.",
|
||||
"Import all": "Importar todo",
|
||||
"Importing in the background": "Importando en segundo plano",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported de :total archivos importados. Puedes salir de esta página: la importación sigue en marcha.",
|
||||
"Import finished": "Importación terminada",
|
||||
":imported of :total files imported.": ":imported de :total archivos importados.",
|
||||
"The import stopped": "La importación se detuvo",
|
||||
"The import stopped making progress": "La importación dejó de avanzar",
|
||||
":imported of :total files were imported before it failed: :error": "Se importaron :imported de :total archivos antes del error: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Se importaron :imported de :total archivos. Puede que el proceso en segundo plano no esté funcionando.",
|
||||
"Choose Import all again to continue with the rest.": "Vuelve a elegir «Importar todo» para seguir con el resto.",
|
||||
"An import is already running. Wait for it to finish.": "Ya hay una importación en curso. Espera a que termine.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Importando :count archivo en segundo plano.|Importando :count archivos en segundo plano."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Affiché jusqu'à 320 × 128 pixels : un logo large comme un logo carré y trouvent leur place. PNG, JPG, GIF ou WebP, jusqu'à 2 Mo.",
|
||||
"Show the site name under the logo": "Afficher le nom du site sous le logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Ils ne seront plus accessibles aux personnes avec qui ils étaient partagés.",
|
||||
"Uploading into :folder": "Envoi dans :folder"
|
||||
"Uploading into :folder": "Envoi dans :folder",
|
||||
"Select all :count matching files": "Sélectionner les :count fichiers correspondants",
|
||||
"Restricted and empty files are skipped.": "Les fichiers à extension restreinte et les fichiers vides sont ignorés.",
|
||||
"Import all": "Tout importer",
|
||||
"Importing in the background": "Importation en arrière-plan",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported fichiers importés sur :total. Vous pouvez quitter cette page — l’importation continue.",
|
||||
"Import finished": "Importation terminée",
|
||||
":imported of :total files imported.": ":imported fichiers importés sur :total.",
|
||||
"The import stopped": "L’importation s’est arrêtée",
|
||||
"The import stopped making progress": "L’importation n’avance plus",
|
||||
":imported of :total files were imported before it failed: :error": ":imported fichiers sur :total ont été importés avant l’échec : :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":imported fichiers sur :total ont été importés. Le processus d’arrière-plan ne tourne peut-être pas.",
|
||||
"Choose Import all again to continue with the rest.": "Choisissez à nouveau « Tout importer » pour continuer avec le reste.",
|
||||
"An import is already running. Wait for it to finish.": "Une importation est déjà en cours. Attendez qu’elle se termine.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Importation de :count fichier en arrière-plan.|Importation de :count fichiers en arrière-plan."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Ditampilkan hingga 320 × 128 piksel, jadi logo lebar maupun persegi sama-sama muat. PNG, JPG, GIF, atau WebP, hingga 2 MB.",
|
||||
"Show the site name under the logo": "Tampilkan nama situs di bawah logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Berkas tersebut tidak akan tersedia lagi bagi siapa pun yang sebelumnya menerimanya.",
|
||||
"Uploading into :folder": "Mengunggah ke :folder"
|
||||
"Uploading into :folder": "Mengunggah ke :folder",
|
||||
"Select all :count matching files": "Pilih semua :count berkas yang cocok",
|
||||
"Restricted and empty files are skipped.": "Berkas dengan ekstensi yang dibatasi dan berkas kosong dilewati.",
|
||||
"Import all": "Impor semua",
|
||||
"Importing in the background": "Mengimpor di latar belakang",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported dari :total berkas diimpor. Anda bisa meninggalkan halaman ini — impor tetap berjalan.",
|
||||
"Import finished": "Impor selesai",
|
||||
":imported of :total files imported.": ":imported dari :total berkas diimpor.",
|
||||
"The import stopped": "Impor berhenti",
|
||||
"The import stopped making progress": "Impor tidak lagi berjalan maju",
|
||||
":imported of :total files were imported before it failed: :error": ":imported dari :total berkas diimpor sebelum gagal: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":imported dari :total berkas diimpor. Proses latar mungkin tidak berjalan.",
|
||||
"Choose Import all again to continue with the rest.": "Pilih “Impor semua” lagi untuk melanjutkan sisanya.",
|
||||
"An import is already running. Wait for it to finish.": "Impor lain sedang berjalan. Tunggu sampai selesai.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Mengimpor :count berkas di latar belakang.|Mengimpor :count berkas di latar belakang."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Mostrato fino a 320 × 128 pixel, così ci stanno sia un logo largo sia uno quadrato. PNG, JPG, GIF o WebP, fino a 2 MB.",
|
||||
"Show the site name under the logo": "Mostra il nome del sito sotto il logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Non saranno più disponibili per le persone con cui erano condivisi.",
|
||||
"Uploading into :folder": "Caricamento in :folder"
|
||||
"Uploading into :folder": "Caricamento in :folder",
|
||||
"Select all :count matching files": "Seleziona tutti i :count file corrispondenti",
|
||||
"Restricted and empty files are skipped.": "I file con estensione limitata e quelli vuoti vengono saltati.",
|
||||
"Import all": "Importa tutto",
|
||||
"Importing in the background": "Importazione in background",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported di :total file importati. Puoi lasciare questa pagina: l’importazione continua.",
|
||||
"Import finished": "Importazione completata",
|
||||
":imported of :total files imported.": ":imported di :total file importati.",
|
||||
"The import stopped": "L’importazione si è interrotta",
|
||||
"The import stopped making progress": "L’importazione non avanza più",
|
||||
":imported of :total files were imported before it failed: :error": "Sono stati importati :imported di :total file prima dell’errore: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Sono stati importati :imported di :total file. Forse il processo in background non è in esecuzione.",
|
||||
"Choose Import all again to continue with the rest.": "Scegli di nuovo «Importa tutto» per continuare con il resto.",
|
||||
"An import is already running. Wait for it to finish.": "C’è già un’importazione in corso. Aspetta che finisca.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Importazione di :count file in background.|Importazione di :count file in background."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大 320 × 128 ピクセルで表示されるため、横長のロゴも正方形のロゴも収まります。PNG、JPG、GIF、WebP、2 MB まで。",
|
||||
"Show the site name under the logo": "ロゴの下にサイト名を表示する",
|
||||
"They will no longer be available to anyone they were shared with.": "共有していた相手全員から利用できなくなります。",
|
||||
"Uploading into :folder": "アップロード先: :folder"
|
||||
"Uploading into :folder": "アップロード先: :folder",
|
||||
"Select all :count matching files": "該当する :count 件のファイルをすべて選択",
|
||||
"Restricted and empty files are skipped.": "制限された拡張子のファイルと空のファイルはスキップされます。",
|
||||
"Import all": "すべてインポート",
|
||||
"Importing in the background": "バックグラウンドでインポート中",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":total 件中 :imported 件をインポートしました。このページを離れても、インポートは続行されます。",
|
||||
"Import finished": "インポートが完了しました",
|
||||
":imported of :total files imported.": ":total 件中 :imported 件をインポートしました。",
|
||||
"The import stopped": "インポートが停止しました",
|
||||
"The import stopped making progress": "インポートが進まなくなりました",
|
||||
":imported of :total files were imported before it failed: :error": "エラーで停止するまでに :total 件中 :imported 件をインポートしました: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":total 件中 :imported 件をインポートしました。バックグラウンド処理が動いていない可能性があります。",
|
||||
"Choose Import all again to continue with the rest.": "残りを続けるには、もう一度「すべてインポート」を選んでください。",
|
||||
"An import is already running. Wait for it to finish.": "すでにインポートを実行中です。終わるまでお待ちください。",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": ":count 件のファイルをバックグラウンドでインポートしています。|:count 件のファイルをバックグラウンドでインポートしています。"
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Getoond tot 320 × 128 pixels, dus zowel een breed als een vierkant logo past. PNG, JPG, GIF of WebP, tot 2 MB.",
|
||||
"Show the site name under the logo": "Sitenaam onder het logo tonen",
|
||||
"They will no longer be available to anyone they were shared with.": "Ze zijn niet langer beschikbaar voor iedereen met wie ze gedeeld waren.",
|
||||
"Uploading into :folder": "Uploaden naar :folder"
|
||||
"Uploading into :folder": "Uploaden naar :folder",
|
||||
"Select all :count matching files": "Alle :count overeenkomende bestanden selecteren",
|
||||
"Restricted and empty files are skipped.": "Bestanden met een beperkte extensie en lege bestanden worden overgeslagen.",
|
||||
"Import all": "Alles importeren",
|
||||
"Importing in the background": "Importeren op de achtergrond",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported van :total bestanden geïmporteerd. Je kunt deze pagina verlaten — het importeren gaat door.",
|
||||
"Import finished": "Importeren voltooid",
|
||||
":imported of :total files imported.": ":imported van :total bestanden geïmporteerd.",
|
||||
"The import stopped": "Het importeren is gestopt",
|
||||
"The import stopped making progress": "Het importeren schiet niet meer op",
|
||||
":imported of :total files were imported before it failed: :error": ":imported van :total bestanden zijn geïmporteerd voordat het misging: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":imported van :total bestanden zijn geïmporteerd. Mogelijk draait het achtergrondproces niet.",
|
||||
"Choose Import all again to continue with the rest.": "Kies opnieuw ‘Alles importeren’ om met de rest verder te gaan.",
|
||||
"An import is already running. Wait for it to finish.": "Er loopt al een import. Wacht tot die klaar is.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": ":count bestand wordt op de achtergrond geïmporteerd.|:count bestanden worden op de achtergrond geïmporteerd."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wyświetlane w rozmiarze do 320 × 128 pikseli, więc zmieści się zarówno szerokie, jak i kwadratowe logo. PNG, JPG, GIF lub WebP, do 2 MB.",
|
||||
"Show the site name under the logo": "Pokaż nazwę witryny pod logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Przestaną być dostępne dla wszystkich, którym je udostępniono.",
|
||||
"Uploading into :folder": "Przesyłanie do: :folder"
|
||||
"Uploading into :folder": "Przesyłanie do: :folder",
|
||||
"Select all :count matching files": "Zaznacz wszystkie pasujące pliki (:count)",
|
||||
"Restricted and empty files are skipped.": "Pliki z ograniczonym rozszerzeniem i puste pliki są pomijane.",
|
||||
"Import all": "Importuj wszystko",
|
||||
"Importing in the background": "Importowanie w tle",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": "Zaimportowano :imported z :total plików. Możesz opuścić tę stronę — import będzie trwał dalej.",
|
||||
"Import finished": "Import zakończony",
|
||||
":imported of :total files imported.": "Zaimportowano :imported z :total plików.",
|
||||
"The import stopped": "Import został przerwany",
|
||||
"The import stopped making progress": "Import przestał postępować",
|
||||
":imported of :total files were imported before it failed: :error": "Przed błędem zaimportowano :imported z :total plików: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Zaimportowano :imported z :total plików. Być może proces w tle nie działa.",
|
||||
"Choose Import all again to continue with the rest.": "Wybierz ponownie „Importuj wszystko”, aby kontynuować z resztą.",
|
||||
"An import is already running. Wait for it to finish.": "Import już trwa. Poczekaj, aż się zakończy.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Importowanie :count pliku w tle.|Importowanie :count plików w tle.|Importowanie :count plików w tle."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Exibido em até 320 × 128 pixels, então cabem tanto um logo largo quanto um quadrado. PNG, JPG, GIF ou WebP, até 2 MB.",
|
||||
"Show the site name under the logo": "Mostrar o nome do site abaixo do logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Eles deixarão de estar disponíveis para todos com quem foram compartilhados.",
|
||||
"Uploading into :folder": "Enviando para :folder"
|
||||
"Uploading into :folder": "Enviando para :folder",
|
||||
"Select all :count matching files": "Selecionar todos os :count arquivos correspondentes",
|
||||
"Restricted and empty files are skipped.": "Arquivos com extensão restrita e arquivos vazios são ignorados.",
|
||||
"Import all": "Importar todos",
|
||||
"Importing in the background": "Importando em segundo plano",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported de :total arquivos importados. Você pode sair desta página — a importação continua.",
|
||||
"Import finished": "Importação concluída",
|
||||
":imported of :total files imported.": ":imported de :total arquivos importados.",
|
||||
"The import stopped": "A importação parou",
|
||||
"The import stopped making progress": "A importação parou de avançar",
|
||||
":imported of :total files were imported before it failed: :error": ":imported de :total arquivos foram importados antes da falha: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":imported de :total arquivos foram importados. Talvez o processo em segundo plano não esteja rodando.",
|
||||
"Choose Import all again to continue with the rest.": "Escolha “Importar todos” de novo para continuar com o restante.",
|
||||
"An import is already running. Wait for it to finish.": "Já existe uma importação em andamento. Espere ela terminar.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Importando :count arquivo em segundo plano.|Importando :count arquivos em segundo plano."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Показывается размером до 320 × 128 пикселей, поэтому поместится и широкий, и квадратный логотип. PNG, JPG, GIF или WebP, до 2 МБ.",
|
||||
"Show the site name under the logo": "Показывать название сайта под логотипом",
|
||||
"They will no longer be available to anyone they were shared with.": "Они больше не будут доступны никому, кому были открыты.",
|
||||
"Uploading into :folder": "Загрузка в: :folder"
|
||||
"Uploading into :folder": "Загрузка в: :folder",
|
||||
"Select all :count matching files": "Выбрать все подходящие файлы (:count)",
|
||||
"Restricted and empty files are skipped.": "Файлы с ограниченным расширением и пустые файлы пропускаются.",
|
||||
"Import all": "Импортировать все",
|
||||
"Importing in the background": "Импорт в фоновом режиме",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": "Импортировано файлов: :imported из :total. Можно уйти с этой страницы — импорт продолжится.",
|
||||
"Import finished": "Импорт завершён",
|
||||
":imported of :total files imported.": "Импортировано файлов: :imported из :total.",
|
||||
"The import stopped": "Импорт остановлен",
|
||||
"The import stopped making progress": "Импорт перестал продвигаться",
|
||||
":imported of :total files were imported before it failed: :error": "До ошибки импортировано файлов: :imported из :total. Ошибка: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Импортировано файлов: :imported из :total. Возможно, фоновый обработчик не запущен.",
|
||||
"Choose Import all again to continue with the rest.": "Чтобы продолжить с оставшимися, снова выберите «Импортировать все».",
|
||||
"An import is already running. Wait for it to finish.": "Импорт уже идёт. Дождитесь его завершения.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Импорт :count файла в фоновом режиме.|Импорт :count файлов в фоновом режиме.|Импорт :count файлов в фоновом режиме."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Huonyeshwa hadi pikseli 320 × 128, kwa hivyo nembo pana na ya mraba zote zinatosha. PNG, JPG, GIF au WebP, hadi MB 2.",
|
||||
"Show the site name under the logo": "Onyesha jina la tovuti chini ya nembo",
|
||||
"They will no longer be available to anyone they were shared with.": "Hayatapatikana tena kwa yeyote aliyeshirikishwa nayo.",
|
||||
"Uploading into :folder": "Inapakia kwenye :folder"
|
||||
"Uploading into :folder": "Inapakia kwenye :folder",
|
||||
"Select all :count matching files": "Chagua mafaili yote :count yanayolingana",
|
||||
"Restricted and empty files are skipped.": "Mafaili yenye kiendelezi kilichozuiwa na mafaili matupu yanarukwa.",
|
||||
"Import all": "Ingiza yote",
|
||||
"Importing in the background": "Inaingiza chinichini",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": "Mafaili :imported kati ya :total yameingizwa. Unaweza kuondoka kwenye ukurasa huu — uingizaji unaendelea.",
|
||||
"Import finished": "Uingizaji umekamilika",
|
||||
":imported of :total files imported.": "Mafaili :imported kati ya :total yameingizwa.",
|
||||
"The import stopped": "Uingizaji umesimama",
|
||||
"The import stopped making progress": "Uingizaji umeacha kusonga mbele",
|
||||
":imported of :total files were imported before it failed: :error": "Mafaili :imported kati ya :total yaliingizwa kabla ya hitilafu: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Mafaili :imported kati ya :total yameingizwa. Huenda mchakato wa nyuma hauendeshwi.",
|
||||
"Choose Import all again to continue with the rest.": "Chagua “Ingiza yote” tena ili kuendelea na yaliyosalia.",
|
||||
"An import is already running. Wait for it to finish.": "Uingizaji mwingine tayari unaendelea. Subiri umalizike.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Inaingiza faili :count chinichini.|Inaingiza mafaili :count chinichini."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "En fazla 320 × 128 piksel olarak gösterilir; böylece hem geniş hem kare logolar sığar. PNG, JPG, GIF veya WebP, en fazla 2 MB.",
|
||||
"Show the site name under the logo": "Site adını logonun altında göster",
|
||||
"They will no longer be available to anyone they were shared with.": "Paylaşıldıkları hiç kimse için artık erişilebilir olmayacaklar.",
|
||||
"Uploading into :folder": "Yükleme hedefi: :folder"
|
||||
"Uploading into :folder": "Yükleme hedefi: :folder",
|
||||
"Select all :count matching files": "Eşleşen :count dosyanın tümünü seç",
|
||||
"Restricted and empty files are skipped.": "Kısıtlı uzantılı ve boş dosyalar atlanır.",
|
||||
"Import all": "Tümünü içe aktar",
|
||||
"Importing in the background": "Arka planda içe aktarılıyor",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": ":total dosyadan :imported tanesi içe aktarıldı. Bu sayfadan ayrılabilirsiniz — içe aktarma devam eder.",
|
||||
"Import finished": "İçe aktarma tamamlandı",
|
||||
":imported of :total files imported.": ":total dosyadan :imported tanesi içe aktarıldı.",
|
||||
"The import stopped": "İçe aktarma durdu",
|
||||
"The import stopped making progress": "İçe aktarma ilerlemiyor",
|
||||
":imported of :total files were imported before it failed: :error": "Hata oluşmadan önce :total dosyadan :imported tanesi içe aktarıldı: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": ":total dosyadan :imported tanesi içe aktarıldı. Arka plan işleyicisi çalışmıyor olabilir.",
|
||||
"Choose Import all again to continue with the rest.": "Kalanlarla devam etmek için yeniden “Tümünü içe aktar”ı seçin.",
|
||||
"An import is already running. Wait for it to finish.": "Zaten bir içe aktarma sürüyor. Bitmesini bekleyin.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": ":count dosya arka planda içe aktarılıyor.|:count dosya arka planda içe aktarılıyor."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Hiển thị tối đa 320 × 128 pixel, nên cả logo ngang lẫn logo vuông đều vừa. PNG, JPG, GIF hoặc WebP, tối đa 2 MB.",
|
||||
"Show the site name under the logo": "Hiển thị tên trang dưới logo",
|
||||
"They will no longer be available to anyone they were shared with.": "Chúng sẽ không còn khả dụng với bất kỳ ai từng được chia sẻ.",
|
||||
"Uploading into :folder": "Đang tải lên vào :folder"
|
||||
"Uploading into :folder": "Đang tải lên vào :folder",
|
||||
"Select all :count matching files": "Chọn tất cả :count tệp khớp",
|
||||
"Restricted and empty files are skipped.": "Các tệp có phần mở rộng bị hạn chế và tệp rỗng sẽ được bỏ qua.",
|
||||
"Import all": "Nhập tất cả",
|
||||
"Importing in the background": "Đang nhập trong nền",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": "Đã nhập :imported/:total tệp. Bạn có thể rời trang này — việc nhập vẫn tiếp tục.",
|
||||
"Import finished": "Đã nhập xong",
|
||||
":imported of :total files imported.": "Đã nhập :imported/:total tệp.",
|
||||
"The import stopped": "Việc nhập đã dừng",
|
||||
"The import stopped making progress": "Việc nhập không còn tiến triển",
|
||||
":imported of :total files were imported before it failed: :error": "Đã nhập :imported/:total tệp trước khi gặp lỗi: :error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "Đã nhập :imported/:total tệp. Có thể tiến trình nền không chạy.",
|
||||
"Choose Import all again to continue with the rest.": "Hãy chọn lại “Nhập tất cả” để tiếp tục với phần còn lại.",
|
||||
"An import is already running. Wait for it to finish.": "Đang có một lượt nhập chạy. Hãy đợi nó xong.",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "Đang nhập :count tệp trong nền.|Đang nhập :count tệp trong nền."
|
||||
}
|
||||
|
||||
+15
-1
@@ -2283,5 +2283,19 @@
|
||||
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大按 320 × 128 像素显示,宽标志和方形标志都能放下。PNG、JPG、GIF 或 WebP,最大 2 MB。",
|
||||
"Show the site name under the logo": "在标志下方显示站点名称",
|
||||
"They will no longer be available to anyone they were shared with.": "这些文件将不再对任何已共享的人开放。",
|
||||
"Uploading into :folder": "上传到 :folder"
|
||||
"Uploading into :folder": "上传到 :folder",
|
||||
"Select all :count matching files": "选择全部 :count 个匹配的文件",
|
||||
"Restricted and empty files are skipped.": "受限扩展名的文件和空文件会被跳过。",
|
||||
"Import all": "全部导入",
|
||||
"Importing in the background": "正在后台导入",
|
||||
":imported of :total files imported. You can leave this page — the import keeps going.": "已导入 :imported / :total 个文件。你可以离开此页面——导入会继续进行。",
|
||||
"Import finished": "导入完成",
|
||||
":imported of :total files imported.": "已导入 :imported / :total 个文件。",
|
||||
"The import stopped": "导入已停止",
|
||||
"The import stopped making progress": "导入不再有进展",
|
||||
":imported of :total files were imported before it failed: :error": "失败前已导入 :imported / :total 个文件::error",
|
||||
":imported of :total files were imported. The queue worker may not be running.": "已导入 :imported / :total 个文件。后台进程可能没有在运行。",
|
||||
"Choose Import all again to continue with the rest.": "再次选择“全部导入”即可继续导入剩余文件。",
|
||||
"An import is already running. Wait for it to finish.": "已有一个导入正在进行。请等它完成。",
|
||||
"Importing :count file in the background.|Importing :count files in the background.": "正在后台导入 :count 个文件。|正在后台导入 :count 个文件。"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -126,6 +126,7 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::middleware(['staff', 'can:import_orphans'])->group(function () {
|
||||
Route::get('files/orphans', [OrphanFilesController::class, 'index'])->name('orphan-files.index');
|
||||
Route::post('files/orphans/import', [OrphanFilesController::class, 'import'])->name('orphan-files.import');
|
||||
Route::get('files/orphans/import-status', [OrphanFilesController::class, 'importStatus'])->name('orphan-files.import-status');
|
||||
Route::post('files/orphans/delete', [OrphanFilesController::class, 'destroy'])->name('orphan-files.delete');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Jobs\ImportOrphanFilesJob;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\OrphanImportProgress;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
@@ -31,6 +34,23 @@ function makeAdoptedFile(User $uploader, string $path, string $disk = 'files'):
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 30 importable .txt files under 2026/07/batch, plus one empty and one
|
||||
* restricted file there and one importable file outside it.
|
||||
*/
|
||||
function makeBatchOrphans(): void
|
||||
{
|
||||
app(Settings::class)->set(Setting::UploadTypeRestriction, 'all');
|
||||
app(Settings::class)->set(Setting::AllowedUploadExtensions, ['txt']);
|
||||
|
||||
foreach (range(1, 30) as $i) {
|
||||
makeOrphanFile(sprintf('2026/07/batch/%02d.txt', $i));
|
||||
}
|
||||
makeOrphanFile('2026/07/batch/empty.txt', '');
|
||||
makeOrphanFile('2026/07/batch/shell.php');
|
||||
makeOrphanFile('2026/07/elsewhere.txt');
|
||||
}
|
||||
|
||||
function orphanImport(array $items): TestResponse
|
||||
{
|
||||
return test()->postJson('/files/orphans/import', ['items' => $items]);
|
||||
@@ -215,6 +235,86 @@ test('importing multiple orphans at once stays on the list rather than picking o
|
||||
}
|
||||
});
|
||||
|
||||
test('import all queues a background run for every importable match instead of importing in the request', function () {
|
||||
Queue::fake();
|
||||
makeBatchOrphans();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->postJson('/files/orphans/import', ['all' => true, 'search' => 'batch'])
|
||||
->assertRedirect();
|
||||
|
||||
expect(File::query()->count())->toBe(0);
|
||||
Queue::assertPushed(ImportOrphanFilesJob::class, 1);
|
||||
|
||||
$this->getJson('/files/orphans/import-status')
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'running', 'total' => 30, 'imported' => 0]);
|
||||
});
|
||||
|
||||
test('the background run imports every match across as many chunks as it takes, then reports finished', function () {
|
||||
makeBatchOrphans();
|
||||
|
||||
app(OrphanImportProgress::class)->tryStart(30);
|
||||
|
||||
// A zero budget makes every chunk stop after one file, so the run has
|
||||
// to hand over to the next chunk 29 times to finish.
|
||||
ImportOrphanFilesJob::dispatch($this->admin->id, 'batch', budgetSeconds: 0);
|
||||
|
||||
expect(File::query()->count())->toBe(30)
|
||||
->and(File::query()->where('path', '2026/07/elsewhere.txt')->exists())->toBeFalse()
|
||||
->and(File::query()->where('uploaded_by', $this->admin->id)->count())->toBe(30);
|
||||
|
||||
$this->actingAs($this->admin)->getJson('/files/orphans/import-status')
|
||||
->assertJson(['status' => 'finished', 'total' => 30, 'imported' => 30]);
|
||||
});
|
||||
|
||||
test('while a run is in progress no other import is accepted, so nothing is adopted twice', function () {
|
||||
Queue::fake();
|
||||
makeOrphanFile('2026/07/one.txt');
|
||||
|
||||
app(OrphanImportProgress::class)->tryStart(1);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
orphanImport([['disk' => 'files', 'path' => '2026/07/one.txt']])->assertUnprocessable();
|
||||
$this->postJson('/files/orphans/import', ['all' => true])->assertUnprocessable();
|
||||
|
||||
expect(File::query()->count())->toBe(0);
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('a run that stops making progress is reported as stalled and no longer blocks a new one', function () {
|
||||
Queue::fake();
|
||||
makeOrphanFile('2026/07/one.txt');
|
||||
|
||||
app(OrphanImportProgress::class)->tryStart(5);
|
||||
$this->travel(6)->minutes();
|
||||
|
||||
$this->actingAs($this->admin)->getJson('/files/orphans/import-status')
|
||||
->assertJson(['status' => 'stalled', 'total' => 5, 'imported' => 0]);
|
||||
|
||||
$this->postJson('/files/orphans/import', ['all' => true])->assertRedirect();
|
||||
Queue::assertPushed(ImportOrphanFilesJob::class, 1);
|
||||
});
|
||||
|
||||
test('a run whose job fails is reported as failed with the reason', function () {
|
||||
app(OrphanImportProgress::class)->tryStart(5);
|
||||
|
||||
(new ImportOrphanFilesJob($this->admin->id, null))->failed(new RuntimeException('Disk unreachable'));
|
||||
|
||||
$this->actingAs($this->admin)->getJson('/files/orphans/import-status')
|
||||
->assertJson(['status' => 'failed', 'error' => 'Disk unreachable']);
|
||||
});
|
||||
|
||||
test('import all with nothing importable queues nothing', function () {
|
||||
Queue::fake();
|
||||
makeOrphanFile('2026/07/empty.txt', '');
|
||||
|
||||
$this->actingAs($this->admin)->postJson('/files/orphans/import', ['all' => true])->assertRedirect();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
$this->getJson('/files/orphans/import-status')->assertOk()->assertExactJson([]);
|
||||
});
|
||||
|
||||
test('a 0-byte orphan cannot be imported but can still be deleted', function () {
|
||||
makeOrphanFile('2026/07/empty.txt', '');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user