diff --git a/.env.example b/.env.example index b1ff91de7..c9934be83 100644 --- a/.env.example +++ b/.env.example @@ -285,6 +285,19 @@ NIGHTWATCH_TOKEN= # TELEGRAM_ENABLED=true # Media Services +# Read-only WebDAV share offered as a media source in the editor (Nextcloud, +# ownCloud, ...). WEBDAV_URL points at the collection to browse, for example +# https://cloud.example.com/remote.php/dav/files/ +WEBDAV_URL= +WEBDAV_USERNAME= +WEBDAV_PASSWORD= +# Optional folder inside the share to confine browsing to. +WEBDAV_ROOT= +# Name of the tab in the media picker. +WEBDAV_LABEL="Files" +# Largest single file that may be imported, in megabytes. +WEBDAV_MAX_IMPORT_MB=512 + UNSPLASH_ACCESS_KEY= UNSPLASH_SECRET_KEY= GIPHY_API_KEY= diff --git a/app/Enums/Media/Source.php b/app/Enums/Media/Source.php index 98d44d646..424375d05 100644 --- a/app/Enums/Media/Source.php +++ b/app/Enums/Media/Source.php @@ -8,7 +8,7 @@ * Origin of a media attachment on a post. `null`/absent means "uploaded by * the user" (legacy/unknown). The enum is forward-compatible so the regen * UI can decide on a per-source basis what actions are available (only `Ai` - * exposes a "regenerate" button today, but `Unsplash`/`Giphy` may surface + * exposes a "regenerate" button today, but `Unsplash`/`Giphy`/`Webdav` may surface * "fetch alternate" or attribution UIs later). */ enum Source: string @@ -16,4 +16,5 @@ enum Source: string case Ai = 'ai'; case Unsplash = 'unsplash'; case Giphy = 'giphy'; + case Webdav = 'webdav'; } diff --git a/app/Http/Controllers/App/WebdavController.php b/app/Http/Controllers/App/WebdavController.php new file mode 100644 index 000000000..c8f688ce7 --- /dev/null +++ b/app/Http/Controllers/App/WebdavController.php @@ -0,0 +1,90 @@ +user()->currentWorkspace; + + $this->authorize('createPost', $workspace); + + abort_unless($webdav->enabled(), SymfonyResponse::HTTP_NOT_FOUND); + + try { + return response()->json($webdav->list((string) $request->string('path'))); + } catch (RuntimeException $e) { + abort(SymfonyResponse::HTTP_BAD_GATEWAY, $e->getMessage()); + } + } + + public function store(ImportWebdavRequest $request, WebdavService $webdav): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + $this->authorize('createPost', $workspace); + + abort_unless($webdav->enabled(), SymfonyResponse::HTTP_NOT_FOUND); + + $imported = []; + $failed = []; + + foreach ($request->validated('paths') as $path) { + $temporary = null; + + try { + $temporary = $webdav->download($path); + + $imported[] = $workspace->addMediaFromPath( + filePath: $temporary, + originalFilename: basename($webdav->normalize($path)), + collection: 'assets', + ); + } catch (InvalidArgumentException|RuntimeException $e) { + // A share holds everything, not just postable media, and one + // unreadable file should not discard the rest of a selection. + $failed[] = [ + 'path' => $path, + 'name' => basename($webdav->normalize($path)), + 'message' => $e->getMessage(), + 'status' => $e instanceof InvalidArgumentException + ? SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY + : SymfonyResponse::HTTP_BAD_GATEWAY, + ]; + } finally { + if ($temporary !== null && file_exists($temporary)) { + @unlink($temporary); + } + } + } + + // Nothing came through: report it as the error it is, using the reason + // of the first file so the message stays specific. + if ($imported === [] && $failed !== []) { + abort($failed[0]['status'], $failed[0]['message']); + } + + return response()->json([ + 'media' => MediaResource::collection($imported), + 'failed' => array_map( + static fn (array $failure): array => [ + 'name' => $failure['name'], + 'message' => $failure['message'], + ], + $failed, + ), + ]); + } +} diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index c3b19ae05..3f922e893 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -13,6 +13,7 @@ use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource; use App\Http\Resources\App\PlanResource; use App\Models\Plan; +use App\Services\WebdavService; use Illuminate\Http\Request; use Inertia\Middleware; @@ -63,6 +64,8 @@ public function share(Request $request): array 'locale' => app()->getLocale(), 'languages' => Locale::options(), 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), + 'webdavEnabled' => app(WebdavService::class)->enabled(), + 'webdavLabel' => (string) config('services.webdav.label'), 'selfHosted' => $isSelfHosted, 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), diff --git a/app/Http/Requests/App/Asset/ImportWebdavRequest.php b/app/Http/Requests/App/Asset/ImportWebdavRequest.php new file mode 100644 index 000000000..7b95d823b --- /dev/null +++ b/app/Http/Requests/App/Asset/ImportWebdavRequest.php @@ -0,0 +1,21 @@ + + */ + public function rules(): array + { + return [ + 'paths' => ['required', 'array', 'min:1', 'max:20'], + 'paths.*' => ['required', 'string', 'max:1024'], + ]; + } +} diff --git a/app/Services/WebdavService.php b/app/Services/WebdavService.php new file mode 100644 index 000000000..749f91c8e --- /dev/null +++ b/app/Services/WebdavService.php @@ -0,0 +1,262 @@ +>} + */ + public function list(string $path = ''): array + { + $path = $this->normalize($path); + + $response = $this->request() + ->withHeaders(['Depth' => '1']) + ->send('PROPFIND', $this->absoluteUrl($path), [ + 'body' => $this->propfindBody(), + 'headers' => ['Content-Type' => 'application/xml'], + ]); + + if ($response->failed()) { + Log::warning('WebDAV listing failed', [ + 'path' => $path, + 'status' => $response->status(), + ]); + + throw new RuntimeException('The WebDAV share could not be listed.'); + } + + return [ + 'path' => $path, + 'parent' => $path === '' ? null : $this->parentOf($path), + 'entries' => $this->parseListing($response->body(), $path), + ]; + } + + /** + * Downloads one file into a temporary path and returns it. The caller owns + * the file and is responsible for removing it. + */ + public function download(string $path): string + { + $path = $this->normalize($path); + + if ($path === '') { + throw new RuntimeException('No file was given.'); + } + + $url = $this->absoluteUrl($path); + + // Ask how big it is first: a share holds film rushes as readily as + // snapshots, and one of those should be refused before it is pulled + // across the network rather than after. + $head = $this->request()->head($url); + + if ($head->successful() && (int) $head->header('Content-Length') > $this->maxImportBytes()) { + throw new InvalidArgumentException('The file is too large to import.'); + } + + $temporary = tempnam(sys_get_temp_dir(), 'webdav'); + + if ($temporary === false) { + throw new RuntimeException('The downloaded file could not be stored.'); + } + + // Streamed onto disk instead of held in memory - the share decides how + // large a file is, so the process must not have to hold one whole. + $response = $this->request()->timeout(120)->sink($temporary)->get($url); + + if ($response->failed()) { + @unlink($temporary); + + Log::warning('WebDAV download failed', [ + 'path' => $path, + 'status' => $response->status(), + ]); + + throw new RuntimeException('The file could not be downloaded from the WebDAV share.'); + } + + clearstatcache(true, $temporary); + + // A share that answers no HEAD, or understates the length, lands here. + if (filesize($temporary) > $this->maxImportBytes()) { + @unlink($temporary); + + throw new InvalidArgumentException('The file is too large to import.'); + } + + return $temporary; + } + + /** + * Keeps a path inside the configured root. Traversal, absolute paths and + * backslashes are dropped rather than rejected, so a mangled path lands on + * the root instead of somewhere else on the server. + */ + public function normalize(string $path): string + { + $path = str_replace('\\', '/', $path); + + $segments = array_filter( + explode('/', $path), + static fn (string $segment): bool => $segment !== '' && $segment !== '.' && $segment !== '..' + ); + + return implode('/', $segments); + } + + private function parentOf(string $path): string + { + $segments = explode('/', $path); + array_pop($segments); + + return implode('/', $segments); + } + + private function request(): PendingRequest + { + return Http::withBasicAuth( + (string) config('services.webdav.username'), + (string) config('services.webdav.password'), + )->timeout(30); + } + + private function absoluteUrl(string $path): string + { + $base = rtrim((string) config('services.webdav.url'), '/'); + $root = $this->encodeSegments(trim((string) config('services.webdav.root', ''), '/')); + $path = $this->encodeSegments($path); + + return implode('/', array_filter([$base, $root, $path], static fn (string $part): bool => $part !== '')); + } + + /** + * Encodes each segment on its own so that slashes stay separators while + * spaces and non-ASCII names survive the round-trip. + */ + private function encodeSegments(string $path): string + { + $segments = array_filter(explode('/', $path), static fn (string $segment): bool => $segment !== ''); + + return implode('/', array_map('rawurlencode', $segments)); + } + + private function propfindBody(): string + { + return '' + .'' + .'' + .'' + .''; + } + + /** + * @return array> + */ + private function parseListing(string $xml, string $currentPath): array + { + $previous = libxml_use_internal_errors(true); + $document = simplexml_load_string($xml); + libxml_use_internal_errors($previous); + + if ($document === false) { + throw new RuntimeException('The WebDAV share returned an unreadable listing.'); + } + + // Decoded, because the hrefs coming back are: a folder called + // "Bilder 2026" arrives with a space where the URL we built has %20, + // and a mismatch here makes the collection list itself as its own + // child. + $self = rawurldecode(rtrim($this->hrefPathOf($currentPath), '/')); + $entries = []; + + // Properties live in the DAV namespace, so every step goes through + // children('DAV:') - plain property access does not cross namespaces + // and silently yields nothing. + foreach ($document->children('DAV:')->response as $response) { + $href = rawurldecode((string) $response->children('DAV:')->href); + + // The first entry of a PROPFIND is the collection itself. + if (rtrim($href, '/') === $self) { + continue; + } + + $name = basename(rtrim($href, '/')); + + if ($name === '') { + continue; + } + + $properties = $response->children('DAV:')->propstat->children('DAV:')->prop ?? null; + + if ($properties === null) { + continue; + } + + $isDirectory = isset($properties->resourcetype->children('DAV:')->collection); + + $entries[] = [ + 'name' => (string) ($properties->displayname ?: $name), + 'path' => trim(($currentPath === '' ? '' : $currentPath.'/').$name, '/'), + 'is_directory' => $isDirectory, + 'size' => $isDirectory ? null : (int) $properties->getcontentlength, + 'mime_type' => $isDirectory ? null : ((string) $properties->getcontenttype ?: null), + 'modified_at' => (string) $properties->getlastmodified ?: null, + ]; + } + + usort($entries, static function (array $a, array $b): int { + if ($a['is_directory'] !== $b['is_directory']) { + return $a['is_directory'] ? -1 : 1; + } + + return strnatcasecmp((string) $a['name'], (string) $b['name']); + }); + + return $entries; + } + + private function hrefPathOf(string $path): string + { + return (string) parse_url($this->absoluteUrl($path), PHP_URL_PATH); + } +} diff --git a/config/services.php b/config/services.php index 4b5d5690f..7b9910a8a 100644 --- a/config/services.php +++ b/config/services.php @@ -127,6 +127,19 @@ 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), ], + // Read-only WebDAV share (Nextcloud, ownCloud, ...) offered as a media + // source in the editor, so files a team already keeps there do not have to + // be downloaded and uploaded again. + 'webdav' => [ + 'url' => env('WEBDAV_URL'), + 'username' => env('WEBDAV_USERNAME'), + 'password' => env('WEBDAV_PASSWORD'), + // Optional folder inside the share to confine browsing to. + 'root' => env('WEBDAV_ROOT', ''), + 'label' => env('WEBDAV_LABEL', 'Files'), + 'max_import_bytes' => ((int) env('WEBDAV_MAX_IMPORT_MB', 512)) * 1024 * 1024, + ], + 'unsplash' => [ 'access_key' => env('UNSPLASH_ACCESS_KEY'), 'secret_key' => env('UNSPLASH_SECRET_KEY'), diff --git a/lang/ar/assets.php b/lang/ar/assets.php index fd70513bf..b7f215618 100644 --- a/lang/ar/assets.php +++ b/lang/ar/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'جرّب كلمة بحث مختلفة.', 'powered_by' => 'مقدَّم من GIPHY', ], + + 'webdav' => [ + 'up' => 'مجلد للأعلى', + 'import' => 'استيراد :count عنصر محدد', + 'loading' => 'جارٍ تحميل المجلد...', + 'empty' => 'هذا المجلد فارغ.', + 'unreachable' => 'تعذّر الوصول إلى المشاركة.', + 'import_failed' => 'تعذّر استيراد هذه الملفات.', + 'import_partial' => '{1} تعذّر استيراد :names.|[2,*] تعذّر استيراد :count ملفات: :names', + ], ]; diff --git a/lang/de/assets.php b/lang/de/assets.php index 35f708be0..9148e379a 100644 --- a/lang/de/assets.php +++ b/lang/de/assets.php @@ -54,4 +54,13 @@ 'no_results_description' => 'Versuche einen anderen Suchbegriff.', 'powered_by' => 'Bereitgestellt von GIPHY', ], + 'webdav' => [ + 'up' => 'Eine Ebene höher', + 'import' => ':count ausgewählte übernehmen', + 'loading' => 'Ordner wird geladen …', + 'empty' => 'Dieser Ordner ist leer.', + 'unreachable' => 'Die Freigabe ist nicht erreichbar.', + 'import_failed' => 'Diese Dateien konnten nicht übernommen werden.', + 'import_partial' => '{1} :names konnte nicht übernommen werden.|[2,*] :count Dateien konnten nicht übernommen werden: :names', + ], ]; diff --git a/lang/el/assets.php b/lang/el/assets.php index e4c278df7..b65729c12 100644 --- a/lang/el/assets.php +++ b/lang/el/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Δοκιμάστε διαφορετικό όρο αναζήτησης.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Ένα φάκελο επάνω', + 'import' => 'Εισαγωγή :count επιλεγμένων', + 'loading' => 'Φόρτωση φακέλου...', + 'empty' => 'Αυτός ο φάκελος είναι κενός.', + 'unreachable' => 'Δεν ήταν δυνατή η σύνδεση με τον κοινόχρηστο φάκελο.', + 'import_failed' => 'Δεν ήταν δυνατή η εισαγωγή αυτών των αρχείων.', + 'import_partial' => '{1} Δεν ήταν δυνατή η εισαγωγή του :names.|[2,*] Δεν ήταν δυνατή η εισαγωγή :count αρχείων: :names', + ], ]; diff --git a/lang/en/assets.php b/lang/en/assets.php index 07efca1a7..c207c1324 100644 --- a/lang/en/assets.php +++ b/lang/en/assets.php @@ -52,4 +52,13 @@ 'no_results_description' => 'Try a different search term.', 'powered_by' => 'Powered by GIPHY', ], + 'webdav' => [ + 'up' => 'Up one folder', + 'import' => 'Import :count selected', + 'loading' => 'Loading the folder...', + 'empty' => 'This folder is empty.', + 'unreachable' => 'The share could not be reached.', + 'import_failed' => 'Those files could not be imported.', + 'import_partial' => '{1} :names could not be imported.|[2,*] :count files could not be imported: :names', + ], ]; diff --git a/lang/es/assets.php b/lang/es/assets.php index 4d6264070..666065b20 100644 --- a/lang/es/assets.php +++ b/lang/es/assets.php @@ -54,4 +54,14 @@ 'no_results_description' => 'Prueba con otro término de búsqueda.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Subir un nivel', + 'import' => 'Importar :count seleccionados', + 'loading' => 'Cargando la carpeta...', + 'empty' => 'Esta carpeta está vacía.', + 'unreachable' => 'No se pudo acceder al recurso compartido.', + 'import_failed' => 'No se pudieron importar esos archivos.', + 'import_partial' => '{1} No se pudo importar :names.|[2,*] No se pudieron importar :count archivos: :names', + ], ]; diff --git a/lang/fr/assets.php b/lang/fr/assets.php index 4843b9848..d6ef319fc 100644 --- a/lang/fr/assets.php +++ b/lang/fr/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Essayez un autre terme de recherche.', 'powered_by' => 'Propulsé par GIPHY', ], + + 'webdav' => [ + 'up' => 'Dossier parent', + 'import' => 'Importer :count élément(s)', + 'loading' => 'Chargement du dossier...', + 'empty' => 'Ce dossier est vide.', + 'unreachable' => 'Le partage est inaccessible.', + 'import_failed' => 'Ces fichiers n\'ont pas pu être importés.', + 'import_partial' => '{1} :names n\'a pas pu être importé.|[2,*] :count fichiers n\'ont pas pu être importés : :names', + ], ]; diff --git a/lang/it/assets.php b/lang/it/assets.php index 76b3b5182..461a1dda1 100644 --- a/lang/it/assets.php +++ b/lang/it/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Prova un altro termine di ricerca.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Cartella superiore', + 'import' => 'Importa :count selezionati', + 'loading' => 'Caricamento della cartella...', + 'empty' => 'Questa cartella è vuota.', + 'unreachable' => 'Impossibile raggiungere la condivisione.', + 'import_failed' => 'Impossibile importare questi file.', + 'import_partial' => '{1} Impossibile importare :names.|[2,*] Impossibile importare :count file: :names', + ], ]; diff --git a/lang/ja/assets.php b/lang/ja/assets.php index a46570849..7b257f31d 100644 --- a/lang/ja/assets.php +++ b/lang/ja/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => '別のキーワードで検索してみてください。', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => '一つ上のフォルダー', + 'import' => '選択した :count 件を取り込む', + 'loading' => 'フォルダーを読み込んでいます...', + 'empty' => 'このフォルダーは空です。', + 'unreachable' => '共有に接続できませんでした。', + 'import_failed' => 'これらのファイルを取り込めませんでした。', + 'import_partial' => '{1} :names を取り込めませんでした。|[2,*] :count 件のファイルを取り込めませんでした: :names', + ], ]; diff --git a/lang/ko/assets.php b/lang/ko/assets.php index 02bd9b2f0..93824f7b3 100644 --- a/lang/ko/assets.php +++ b/lang/ko/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => '다른 검색어로 시도해 보세요.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => '상위 폴더', + 'import' => '선택한 :count개 가져오기', + 'loading' => '폴더를 불러오는 중...', + 'empty' => '이 폴더는 비어 있습니다.', + 'unreachable' => '공유에 연결할 수 없습니다.', + 'import_failed' => '해당 파일을 가져오지 못했습니다.', + 'import_partial' => '{1} :names을(를) 가져오지 못했습니다.|[2,*] :count개 파일을 가져오지 못했습니다: :names', + ], ]; diff --git a/lang/nl/assets.php b/lang/nl/assets.php index 6ce192f3f..8d22f5e92 100644 --- a/lang/nl/assets.php +++ b/lang/nl/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Probeer een andere zoekterm.', 'powered_by' => 'Mogelijk gemaakt door GIPHY', ], + + 'webdav' => [ + 'up' => 'Map omhoog', + 'import' => ':count geselecteerde importeren', + 'loading' => 'Map wordt geladen...', + 'empty' => 'Deze map is leeg.', + 'unreachable' => 'De share is niet bereikbaar.', + 'import_failed' => 'Deze bestanden konden niet worden geïmporteerd.', + 'import_partial' => '{1} :names kon niet worden geïmporteerd.|[2,*] :count bestanden konden niet worden geïmporteerd: :names', + ], ]; diff --git a/lang/pl/assets.php b/lang/pl/assets.php index 06dfd95fe..1bcd6c85e 100644 --- a/lang/pl/assets.php +++ b/lang/pl/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Spróbuj innego wyszukiwanego hasła.', 'powered_by' => 'Napędzane przez GIPHY', ], + + 'webdav' => [ + 'up' => 'Folder wyżej', + 'import' => 'Importuj zaznaczone (:count)', + 'loading' => 'Wczytywanie folderu...', + 'empty' => 'Ten folder jest pusty.', + 'unreachable' => 'Nie można połączyć się z udziałem.', + 'import_failed' => 'Nie udało się zaimportować tych plików.', + 'import_partial' => '{1} Nie udało się zaimportować :names.|[2,*] Nie udało się zaimportować :count plików: :names', + ], ]; diff --git a/lang/pt-BR/assets.php b/lang/pt-BR/assets.php index f298b956b..7b46fdb34 100644 --- a/lang/pt-BR/assets.php +++ b/lang/pt-BR/assets.php @@ -54,4 +54,14 @@ 'no_results_description' => 'Tente outro termo de busca.', 'powered_by' => 'Powered by GIPHY', ], + + 'webdav' => [ + 'up' => 'Pasta acima', + 'import' => 'Importar :count selecionados', + 'loading' => 'Carregando a pasta...', + 'empty' => 'Esta pasta está vazia.', + 'unreachable' => 'Não foi possível acessar o compartilhamento.', + 'import_failed' => 'Não foi possível importar esses arquivos.', + 'import_partial' => '{1} Não foi possível importar :names.|[2,*] Não foi possível importar :count arquivos: :names', + ], ]; diff --git a/lang/ru/assets.php b/lang/ru/assets.php index 77f3f19f9..8827ce1cf 100644 --- a/lang/ru/assets.php +++ b/lang/ru/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Попробуйте другой запрос.', 'powered_by' => 'При поддержке GIPHY', ], + + 'webdav' => [ + 'up' => 'На папку выше', + 'import' => 'Импортировать выбранное (:count)', + 'loading' => 'Загрузка папки...', + 'empty' => 'Эта папка пуста.', + 'unreachable' => 'Не удалось подключиться к общему ресурсу.', + 'import_failed' => 'Не удалось импортировать эти файлы.', + 'import_partial' => '{1} Не удалось импортировать :names.|[2,*] Не удалось импортировать файлов: :count — :names', + ], ]; diff --git a/lang/tr/assets.php b/lang/tr/assets.php index 500313edd..cf89a4f88 100644 --- a/lang/tr/assets.php +++ b/lang/tr/assets.php @@ -54,4 +54,14 @@ 'no_results_description' => 'Farklı bir arama terimi deneyin.', 'powered_by' => 'GIPHY tarafından desteklenmektedir', ], + + 'webdav' => [ + 'up' => 'Bir üst klasör', + 'import' => 'Seçilen :count öğeyi içe aktar', + 'loading' => 'Klasör yükleniyor...', + 'empty' => 'Bu klasör boş.', + 'unreachable' => 'Paylaşıma ulaşılamadı.', + 'import_failed' => 'Bu dosyalar içe aktarılamadı.', + 'import_partial' => '{1} :names içe aktarılamadı.|[2,*] :count dosya içe aktarılamadı: :names', + ], ]; diff --git a/lang/uk/assets.php b/lang/uk/assets.php index 0e4132f0d..1edb6fc89 100644 --- a/lang/uk/assets.php +++ b/lang/uk/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => 'Спробуйте інший пошуковий запит.', 'powered_by' => 'Працює на GIPHY', ], + + 'webdav' => [ + 'up' => 'На теку вище', + 'import' => 'Імпортувати вибране (:count)', + 'loading' => 'Завантаження теки...', + 'empty' => 'Ця тека порожня.', + 'unreachable' => 'Не вдалося підключитися до спільного ресурсу.', + 'import_failed' => 'Не вдалося імпортувати ці файли.', + 'import_partial' => '{1} Не вдалося імпортувати :names.|[2,*] Не вдалося імпортувати файлів: :count — :names', + ], ]; diff --git a/lang/zh/assets.php b/lang/zh/assets.php index 565b763fa..68aca9fb6 100644 --- a/lang/zh/assets.php +++ b/lang/zh/assets.php @@ -52,4 +52,14 @@ 'no_results_description' => '换一个搜索词试试。', 'powered_by' => '由 GIPHY 提供支持', ], + + 'webdav' => [ + 'up' => '上一级文件夹', + 'import' => '导入所选 :count 项', + 'loading' => '正在加载文件夹…', + 'empty' => '此文件夹为空。', + 'unreachable' => '无法访问该共享。', + 'import_failed' => '这些文件无法导入。', + 'import_partial' => '{1} :names 无法导入。|[2,*] :count 个文件无法导入::names', + ], ]; diff --git a/resources/js/components/assets/GalleryBrowser.vue b/resources/js/components/assets/GalleryBrowser.vue index 310e6b182..e71d0cdd6 100644 --- a/resources/js/components/assets/GalleryBrowser.vue +++ b/resources/js/components/assets/GalleryBrowser.vue @@ -1,7 +1,7 @@