diff --git a/app/Http/Controllers/Admin/Maintenance/FullTree.php b/app/Http/Controllers/Admin/Maintenance/FullTree.php new file mode 100644 index 00000000000..2d3b444662f --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/FullTree.php @@ -0,0 +1,42 @@ +skip. + * + * @return void + */ + public function do(FullTreeUpdateRequest $request): void + { + $keyName = 'id'; + $albumInstance = new Album(); + batch()->update($albumInstance, $request->albums(), $keyName); + } + + /** + * Check whether there are files to be removed. + * If not, we will not display the module to reduce complexity. + * + * @return Collection + */ + public function check(MaintenanceRequest $request): Collection + { + $albums = Album::query()->join('base_albums', 'base_albums.id', '=', 'albums.id')->select(['albums.id', 'title', 'parent_id', '_lft', '_rgt'])->orderBy('_lft', 'asc')->toBase()->get(); + + return AlbumTree::collect($albums); + } +} diff --git a/app/Http/Requests/Maintenance/FullTreeUpdateRequest.php b/app/Http/Requests/Maintenance/FullTreeUpdateRequest.php new file mode 100644 index 00000000000..968510ee706 --- /dev/null +++ b/app/Http/Requests/Maintenance/FullTreeUpdateRequest.php @@ -0,0 +1,52 @@ + + */ + private array $albums; + + /** + * {@inheritDoc} + */ + public function authorize(): bool + { + return Gate::check(SettingsPolicy::CAN_EDIT, Configs::class); + } + + public function rules(): array + { + return [ + 'albums' => 'required|array|min:1', + 'albums.*' => 'required|array', + 'albums.*.id' => ['required', new AlbumIDRule(false)], + 'albums.*._lft' => 'required|integer|min:1', + 'albums.*._rgt' => 'required|integer|min:1', + 'albums.*.parent_id' => [new AlbumIDRule(true)], + ]; + } + + protected function processValidatedValues( + array $values, + array $files, + ): void { + $this->albums = $values['albums']; + } + + /** + * @return array + */ + public function albums(): array + { + return $this->albums; + } +} diff --git a/app/Http/Resources/Diagnostics/AlbumTree.php b/app/Http/Resources/Diagnostics/AlbumTree.php new file mode 100644 index 00000000000..ce8062d7d70 --- /dev/null +++ b/app/Http/Resources/Diagnostics/AlbumTree.php @@ -0,0 +1,31 @@ +id, + $album->title, + $album->parent_id, + $album->_lft, + $album->_rgt + ); + } +} diff --git a/composer.json b/composer.json index f1a147e5d39..7d35b243896 100644 --- a/composer.json +++ b/composer.json @@ -63,6 +63,7 @@ "lychee-org/nestedset": "^9.0", "lychee-org/php-exif": "^1.0.4", "maennchen/zipstream-php": "^3.1", + "mavinoo/laravel-batch": "^2.4", "opcodesio/log-viewer": "dev-lycheeOrg", "php-ffmpeg/php-ffmpeg": "^1.0", "php-http/guzzle7-adapter": "^1.0", diff --git a/composer.lock b/composer.lock index 2dd859beef8..57939fc60fd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "24b4c68e469e17a8ca89078b6976dbf3", + "content-hash": "3ce0219be0ac8430114dab3f0dacbd00", "packages": [ { "name": "amphp/amp", @@ -4987,6 +4987,62 @@ ], "time": "2024-10-10T12:33:01+00:00" }, + { + "name": "mavinoo/laravel-batch", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/mavinoo/laravelBatch.git", + "reference": "8f85ba2923b63bfad8b9181ef210094eba47b5ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mavinoo/laravelBatch/zipball/8f85ba2923b63bfad8b9181ef210094eba47b5ec", + "reference": "8f85ba2923b63bfad8b9181ef210094eba47b5ec", + "shasum": "" + }, + "require": { + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.3@dev" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Mavinoo\\Batch\\BatchServiceProvider" + ], + "aliases": { + "Batch": "Mavinoo\\Batch\\BatchFacade" + } + } + }, + "autoload": { + "files": [ + "src/Common/Helpers.php" + ], + "psr-4": { + "Mavinoo\\Batch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mohammad Ghanbari", + "email": "mavin.developer@gmail.com" + } + ], + "description": "Insert and update batch (bulk) in laravel", + "support": { + "issues": "https://github.com/mavinoo/laravelBatch/issues", + "source": "https://github.com/mavinoo/laravelBatch/tree/v2.4.1" + }, + "time": "2024-09-17T11:09:20+00:00" + }, { "name": "monolog/monolog", "version": "3.8.0", diff --git a/config/app.php b/config/app.php index 62ff18af14f..8900e974ab2 100644 --- a/config/app.php +++ b/config/app.php @@ -229,6 +229,7 @@ function renv(string $cst, ?string $default = null): string \SocialiteProviders\Manager\ServiceProvider::class, // Barryvdh\Debugbar\ServiceProvider::class, + Mavinoo\Batch\BatchServiceProvider::class, /* * Application Service Providers... diff --git a/phpstan.neon b/phpstan.neon index 3ea78e8580e..61da8eac1f3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -166,3 +166,6 @@ parameters: - message: '#Dynamic call to static method Illuminate\\Session\\Store::(has|get|now|forget)\(\).#' + + - + message: '#Dynamic call to static method Kalnoy\\Nestedset\\QueryBuilder<.*>::(join|select|orderBy)\(\)#' \ No newline at end of file diff --git a/resources/js/components/maintenance/FixTreeLine.vue b/resources/js/components/maintenance/FixTreeLine.vue new file mode 100644 index 00000000000..38518f825a6 --- /dev/null +++ b/resources/js/components/maintenance/FixTreeLine.vue @@ -0,0 +1,177 @@ + + diff --git a/resources/js/components/maintenance/MaintenanceFixTree.vue b/resources/js/components/maintenance/MaintenanceFixTree.vue index 94dd152c907..52a3032cef7 100644 --- a/resources/js/components/maintenance/MaintenanceFixTree.vue +++ b/resources/js/components/maintenance/MaintenanceFixTree.vue @@ -15,27 +15,25 @@ - + diff --git a/resources/js/components/maintenance/mini/Left.vue b/resources/js/components/maintenance/mini/Left.vue new file mode 100644 index 00000000000..cd6923a7219 --- /dev/null +++ b/resources/js/components/maintenance/mini/Left.vue @@ -0,0 +1,3 @@ + diff --git a/resources/js/components/maintenance/mini/LeftWarn.vue b/resources/js/components/maintenance/mini/LeftWarn.vue new file mode 100644 index 00000000000..fd47d15522f --- /dev/null +++ b/resources/js/components/maintenance/mini/LeftWarn.vue @@ -0,0 +1,3 @@ + diff --git a/resources/js/components/maintenance/mini/Right.vue b/resources/js/components/maintenance/mini/Right.vue new file mode 100644 index 00000000000..73a1afdbfcb --- /dev/null +++ b/resources/js/components/maintenance/mini/Right.vue @@ -0,0 +1,3 @@ + diff --git a/resources/js/components/maintenance/mini/RightWarn.vue b/resources/js/components/maintenance/mini/RightWarn.vue new file mode 100644 index 00000000000..7acacbe2cbf --- /dev/null +++ b/resources/js/components/maintenance/mini/RightWarn.vue @@ -0,0 +1,3 @@ + diff --git a/resources/js/composables/album/treeOperations.ts b/resources/js/composables/album/treeOperations.ts new file mode 100644 index 00000000000..3b8cc7fb684 --- /dev/null +++ b/resources/js/composables/album/treeOperations.ts @@ -0,0 +1,244 @@ +import { set } from "@vueuse/core"; +import { ToastServiceMethods } from "primevue/toastservice"; +import { computed, ref, Ref } from "vue"; + +export type Augmented = { + prefix: string; + trimmedId: string; + trimmedParentId: string; + isDuplicate_rgt: boolean; + isDuplicate_lft: boolean; + isExpectedParentId: boolean; +}; + +export type AlbumPile = { + parentId: string; + rgt: number; +}; + +export type AugmentedAlbum = App.Http.Resources.Diagnostics.AlbumTree & Augmented; + +export function useTreeOperations( + originalAlbums: Ref, + albums: Ref, + toast: ToastServiceMethods, +) { + const isValidated = ref(false); + const errors = ref([]); + + function isError(album: AugmentedAlbum): boolean { + return ( + album._lft === null || + album._rgt === null || + album._lft === 0 || + album._rgt === 0 || + album.isDuplicate_lft || + album.isDuplicate_rgt || + !album.isExpectedParentId + ); + } + + function setErrors(): void { + if (albums.value === undefined) { + errors.value = []; + return; + } + + errors.value = albums.value.filter(isError).map((a) => { + if (a._lft === null || a._lft === 0) { + return `Album ${a.id.slice(0, 6)} has an invalid left value.`; + } + if (a._rgt === null || a._rgt === 0) { + return `Album ${a.id.slice(0, 6)} has an invalid right value.`; + } + if (a._lft >= a._rgt) { + return `Album ${a.id.slice(0, 6)} has an invalid left/right values. Left should be strictly smaller than right: ${a._lft} < ${a._rgt}.`; + } + if (a.isDuplicate_lft) { + return `Album ${a.id.slice(0, 6)} has a duplicate left value ${a._lft}.`; + } + if (a.isDuplicate_rgt) { + return `Album ${a.id.slice(0, 6)} has a duplicate right value ${a._rgt}.`; + } + if (!a.isExpectedParentId) { + return `Album ${a.id.slice(0, 6)} has an unexpected parent id ${a.parent_id ?? "root"}.`; + } + return `Album ${a.id.slice(0, 6)} has an unknown error.`; + }); + } + + function validate() { + setErrors(); + + return errors.value.length === 0; + } + + function prepareAlbums() { + if (originalAlbums.value === undefined) { + return; + } + + albums.value = []; + + let pile = [] as AlbumPile[]; + for (let index = 0; index < originalAlbums.value.length; index++) { + const album = originalAlbums.value[index]; + + const trimmedId = album.id.slice(0, 6); + const trimmedParentId = (album.parent_id ?? "root").slice(0, 6); + const isDuplicate_lft = hasDuplicateLft(album); + const isDuplicate_rgt = hasDuplicateRgt(album); + + let isExpectedParentId = true; + // If current lft is greater than the last rgt, + // we are no longer a child of the last album. We pop out the pile until we are smaller than _rgt of the pile. + while (pile.length > 0 && (album._lft > pile[pile.length - 1].rgt || album._rgt > pile[pile.length - 1].rgt)) { + pile.pop(); + } + + if (pile.length > 0) { + // We are inside an album + const last = pile[pile.length - 1]; + isExpectedParentId = last.parentId === album.parent_id; + } else { + // We are at the root + isExpectedParentId = album.parent_id === null; + } + + albums.value.push({ + ...album, + prefix: " │ ".repeat(pile.length), + trimmedId, + trimmedParentId, + isDuplicate_lft, + isDuplicate_rgt, + isExpectedParentId, + }); + + if (album._rgt > album._lft + 1) { + // We are a parent + pile.push({ parentId: album.id, rgt: album._rgt }); + } + } + + isValidated.value = validate(); + } + + function hasDuplicateLft(album: App.Http.Resources.Diagnostics.AlbumTree): boolean { + if (originalAlbums.value === undefined) { + return false; + } + + return originalAlbums.value.filter((a) => a._lft === album._lft || a._rgt === album._lft).length > 1; + } + + function hasDuplicateRgt(album: App.Http.Resources.Diagnostics.AlbumTree): boolean { + if (originalAlbums.value === undefined) { + return false; + } + + return originalAlbums.value.filter((a) => a._lft === album._rgt || a._rgt === album._rgt).length > 1; + } + + function check() { + originalAlbums.value = albums.value?.sort((a, b) => a._lft - b._lft); + prepareAlbums(); + errors.value.forEach((e) => toast.add({ severity: "error", summary: "Error", detail: e, life: 3000 })); + } + + // We increment all the nodes' (>= lft) left and right by 1. + function incrementLft(id: string) { + if (albums.value === undefined) { + return; + } + + const lft = albums.value.find((a) => a.id === id)?._lft as number; + + albums.value = albums.value.map((a) => { + if (a._lft < lft) { + return a; + } + a._lft += 1; + a._rgt += 1; + return a; + }); + } + + // We increment all the nodes above rgt by 1 and increment rgt by 1. + function incrementRgt(id: string) { + if (albums.value === undefined) { + return; + } + + const rgt = albums.value.find((a) => a.id === id)?._rgt as number; + + albums.value = albums.value.map((a) => { + if (a._rgt < rgt) { + return a; + } + if (a._rgt === rgt) { + a._rgt += 1; + } else { + a._lft += 1; + a._rgt += 1; + } + return a; + }); + } + + // We decrement all the nodes above lft by 1. + function decrementLft(id: string) { + if (albums.value === undefined) { + return; + } + + const lft = albums.value.find((a) => a.id === id)?._lft as number; + + albums.value = albums.value.map((a) => { + if (a._lft < lft) { + return a; + } + a._lft -= 1; + a._rgt -= 1; + return a; + }); + } + + // We decrement all the nodes above rgt by 1 and decrement rgt by 1 IF lft > rgt - 1. + function decrementRgt(id: string) { + if (albums.value === undefined) { + return; + } + + const rgt = albums.value.find((a) => a.id === id)?._rgt as number; + + albums.value = albums.value.map((a) => { + if (a._rgt < rgt) { + return a; + } + // safety check + if (a._lft === rgt - 1) { + return a; + } + if (a._rgt === rgt) { + a._rgt -= 1; + } else { + a._lft += 1; + a._rgt += 1; + } + return a; + }); + } + + return { + isValidated, + setErrors, + validate, + prepareAlbums, + check, + incrementLft, + incrementRgt, + decrementLft, + decrementRgt, + }; +} diff --git a/resources/js/lychee.d.ts b/resources/js/lychee.d.ts index 5417f0ec659..183290e9da1 100644 --- a/resources/js/lychee.d.ts +++ b/resources/js/lychee.d.ts @@ -116,6 +116,13 @@ declare namespace App.Http.Resources.Collections { }; } declare namespace App.Http.Resources.Diagnostics { + export type AlbumTree = { + id: string; + title: string; + parent_id: string | null; + _lft: number; + _rgt: number; + }; export type CleaningState = { path: string; base: string; diff --git a/resources/js/router/routes.ts b/resources/js/router/routes.ts index b67a32d06f1..45382059464 100644 --- a/resources/js/router/routes.ts +++ b/resources/js/router/routes.ts @@ -15,6 +15,7 @@ const Maintenance = () => import("@/views/Maintenance.vue"); const Diagnostics = () => import("@/views/Diagnostics.vue"); const Statistics = () => import("@/views/Statistics.vue"); const Jobs = () => import("@/views/Jobs.vue"); +const FixTree = () => import("@/views/FixTree.vue"); const routes_ = [ { @@ -98,6 +99,11 @@ const routes_ = [ path: "/maintenance", component: Maintenance, }, + { + name: "tree", + path: "/fixTree", + component: FixTree, + }, { name: "profile", path: "/profile", diff --git a/resources/js/services/maintenance-service.ts b/resources/js/services/maintenance-service.ts index 8b122909b08..b4b3ddd66f6 100644 --- a/resources/js/services/maintenance-service.ts +++ b/resources/js/services/maintenance-service.ts @@ -1,6 +1,12 @@ import axios, { type AxiosResponse } from "axios"; import Constants from "./constants"; +export type UpdateTreeData = { + id: string; + _lft: number; + _rgt: number; +}; + const MaintenanceService = { updateGet(): Promise> { return axios.get(`${Constants.getApiUrl()}Maintenance::update`, { data: {} }); @@ -51,6 +57,14 @@ const MaintenanceService = { register(key: string): Promise> { return axios.post(`${Constants.getApiUrl()}Maintenance::register`, { key: key }); }, + + fullTreeGet(): Promise> { + return axios.get(`${Constants.getApiUrl()}Maintenance::fullTree`, { data: {} }); + }, + + updateFullTree(albums: UpdateTreeData[]): Promise { + return axios.post(`${Constants.getApiUrl()}Maintenance::fullTree`, { albums: albums }); + }, }; export default MaintenanceService; diff --git a/resources/js/style/preset.ts b/resources/js/style/preset.ts index b8cc55cb838..473f3470947 100644 --- a/resources/js/style/preset.ts +++ b/resources/js/style/preset.ts @@ -786,6 +786,16 @@ const LycheePrimeVueConfig = { }, }, }, + inplace: { + colorScheme: { + light: { + displayHoverBackground: "transparent", + }, + dark: { + displayHoverBackground: "transparent", + }, + }, + }, inputtext: { background: "transparent", padding: { diff --git a/resources/js/views/FixTree.vue b/resources/js/views/FixTree.vue new file mode 100644 index 00000000000..6406f2303a7 --- /dev/null +++ b/resources/js/views/FixTree.vue @@ -0,0 +1,168 @@ + + + diff --git a/routes/api_v2.php b/routes/api_v2.php index 0dfd606b1cf..b80336d734e 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -208,6 +208,8 @@ Route::post('/Maintenance::missingFileSize', [Admin\Maintenance\MissingFileSizes::class, 'do']); Route::post('/Maintenance::optimize', [Admin\Maintenance\Optimize::class, 'do']); Route::post('/Maintenance::register', Admin\Maintenance\RegisterController::class); +Route::get('/Maintenance::fullTree', [Admin\Maintenance\FullTree::class, 'check']); +Route::post('/Maintenance::fullTree', [Admin\Maintenance\FullTree::class, 'do']); /** * STATISTICS. diff --git a/routes/web_v2.php b/routes/web_v2.php index 9548f70e505..387ee645428 100644 --- a/routes/web_v2.php +++ b/routes/web_v2.php @@ -45,6 +45,7 @@ Route::get('/users', [VueController::class, 'view'])->middleware(['migration:complete']); Route::get('/settings', [VueController::class, 'view'])->middleware(['migration:complete']); Route::get('/permissions', [VueController::class, 'view'])->middleware(['migration:complete']); +Route::get('/fixTree', [VueController::class, 'view'])->middleware(['migration:complete']); Route::match(['get', 'post'], '/migrate', [Admin\UpdateController::class, 'migrate']) ->name('migrate')