diff --git a/README.md b/README.md index f171eca..239d8a2 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,32 @@ -
- +## Commands -## About Laravel +Updates information about courses and chapters -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +```php +php artisan symfonycast:update +``` -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +Dispatch chapters marked as sync offline to queue for download -Laravel is accessible, powerful, and provides tools required for large, robust applications. +```php +php artisan symfonycast:sync +``` +Dispatch chapters without file size to queue for update -## Learning Laravel +```php +php artisan symfonycast:video_size +``` -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. +## Queues -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 2000 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +Downloading chapters marked as sync offline: -## Laravel Sponsors +```php +php artisan queue:work --queue=downloadVideoFile +``` +Get information about file size of chapters: -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell). - -### Premium Partners - -- **[Vehikl](https://vehikl.com/)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Cubet Techno Labs](https://cubettech.com)** -- **[Cyber-Duck](https://cyber-duck.co.uk)** -- **[Many](https://www.many.co.uk)** -- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)** -- **[DevSquad](https://devsquad.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel/)** -- **[OP.GG](https://op.gg)** -- **[WebReinvent](https://webreinvent.com/?utm_source=laravel&utm_medium=github&utm_campaign=patreon-sponsors)** -- **[Lendio](https://lendio.com)** - -## Contributing - -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). - -## Code of Conduct - -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). - -## Security Vulnerabilities - -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. - -## License - -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +```php +php artisan queue:work --queue=getVideoFileSize +``` diff --git a/app/Console/Commands/Update.php b/app/Console/Commands/Update.php new file mode 100644 index 0000000..8644c7a --- /dev/null +++ b/app/Console/Commands/Update.php @@ -0,0 +1,34 @@ +isSubscriptionActive()) { + $this->error('To fully update courses and chapters you need active subscription'); + return self::FAILURE; + } + $this->info('Start updating courses'); + $courses = $symfonyCastDlService->updateCourses(); + $this->output->success(count($courses) . ' courses updated'); + $this->output->info('Start updating each course chapters'); + $this->withProgressBar($courses, function (Course $course) use ($symfonyCastDlService) { + $symfonyCastDlService->updateChapters($course); + }); + $this->output->success('Course chapters updated.'); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Chapter/Sync.php b/app/Http/Controllers/Chapter/Sync.php new file mode 100644 index 0000000..08b7b4c --- /dev/null +++ b/app/Http/Controllers/Chapter/Sync.php @@ -0,0 +1,19 @@ +update(['sync_offline' => 1]); + DownloadVideoFile::dispatch($chapter->id); + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Index.php b/app/Http/Controllers/Index.php index bdd319b..5561027 100644 --- a/app/Http/Controllers/Index.php +++ b/app/Http/Controllers/Index.php @@ -19,13 +19,8 @@ class Index extends Controller }, ]); $request->whenHas('order', fn($order) => $courses->orderby($order, $request->dir)); - $request->whenMissing('order', fn() => $courses->orderby('name')); + $request->whenMissing('order', fn() => $courses->orderby('published_at', 'desc')); $courses = $courses->get(); return view('index', compact(['courses'])); } - - public function download(SymfonyCastDlService $symfonyCastDlService) - { - $symfonyCastDlService->getInfo(); - } } diff --git a/app/Http/SymfonyCastDl/HtmlParser.php b/app/Http/SymfonyCastDl/HtmlParser.php index b3c5f7c..860461d 100644 --- a/app/Http/SymfonyCastDl/HtmlParser.php +++ b/app/Http/SymfonyCastDl/HtmlParser.php @@ -67,8 +67,12 @@ class HtmlParser public function getVideoSource(Response $respose): ?string { - $document = new Document($respose->getBody()->getContents()); - return $document->first('video source')?->getAttribute('src'); + return (new Document($respose->getBody()->getContents()))->first('video source')?->getAttribute('src'); + } + + public function isSubscriptionActive(Response $respose): bool + { + return (new Document($respose->getBody()->getContents()))->has('.subscription-status .i-active'); } } diff --git a/app/Http/SymfonyCastDl/SymfonyCastDlService.php b/app/Http/SymfonyCastDl/SymfonyCastDlService.php index c8c0a5a..9ed3bd2 100644 --- a/app/Http/SymfonyCastDl/SymfonyCastDlService.php +++ b/app/Http/SymfonyCastDl/SymfonyCastDlService.php @@ -5,8 +5,10 @@ namespace App\Http\SymfonyCastDl; use App\Jobs\GetVideoFileSize; use App\Models\Chapter; use App\Models\Course; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\TransferStats; use GuzzleHttp\Client; +use Illuminate\Support\Collection; use JetBrains\PhpStorm\NoReturn; class SymfonyCastDlService @@ -33,26 +35,34 @@ class SymfonyCastDlService ]); } - public function getInfo(): void + /** + * @return Collection + * @throws GuzzleException + */ + public function updateCourses(): Collection { $coursePage = $this->client->get('courses/filtering'); $courses = $this->htmlParser->getCourses($coursePage); - $courses->each->save(); - /** @var Course $course */ - foreach ($courses as $course) { - $singleCoursePage = $this->client->get('/screencast/' . $course->link); - $chapters = $this->htmlParser->getCourseDetails($singleCoursePage, $course->id); - $chapters->each->save(); - $chapters->each(fn($chapter) => GetVideoFileSize::dispatch($chapter->id)); - } + return $courses->each->save(); } - public function updateCourse(Course $course): void + /** + * @param Course $course + * @return Collection + * @throws GuzzleException + */ + public function updateChapters(Course $course): Collection { $singleCoursePage = $this->client->get('/screencast/' . $course->link); $chapters = $this->htmlParser->getCourseDetails($singleCoursePage, $course->id); $chapters->each->save(); $chapters->each(fn($chapter) => GetVideoFileSize::dispatch($chapter->id)); + return $chapters; + } + + public function isSubscriptionActive(): bool + { + return $this->htmlParser->isSubscriptionActive($this->client->get('/profile/show')); } public function getChapterInfo(Chapter $chapter): ?string @@ -89,5 +99,7 @@ class SymfonyCastDlService ['sink' => $chapter->video_path], ); } + $chapter->video_size = filesize($chapter->video_path); + $chapter->save(); } } diff --git a/app/Jobs/DownloadVideoFile.php b/app/Jobs/DownloadVideoFile.php index 17371f0..9f0b27c 100644 --- a/app/Jobs/DownloadVideoFile.php +++ b/app/Jobs/DownloadVideoFile.php @@ -11,12 +11,20 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class DownloadVideoFile implements ShouldQueue +class DownloadVideoFile implements ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const NAME = 'downloadVideoFile'; + public function __construct(private int $chapterId) { + $this->onQueue(self::NAME); + } + + public function uniqueId(): string + { + return $this->chapterId; } public function handle(SymfonyCastDlService $symfonyCastDlService) diff --git a/app/Jobs/GetVideoFileSize.php b/app/Jobs/GetVideoFileSize.php index ec37c97..eaab970 100644 --- a/app/Jobs/GetVideoFileSize.php +++ b/app/Jobs/GetVideoFileSize.php @@ -11,12 +11,20 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class GetVideoFileSize implements ShouldQueue +class GetVideoFileSize implements ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const NAME = 'getVideoFileSize'; + public function __construct(private int $chapterId) { + $this->onQueue(self::NAME); + } + + public function uniqueId(): string + { + return $this->chapterId; } public function handle(SymfonyCastDlService $symfonyCastDlService) diff --git a/composer.json b/composer.json index 1664cc8..97ff872 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "laravel/sail": "^1.0.1", "mockery/mockery": "^1.4.4", "nunomaduro/collision": "^6.1", + "phpspec/phpspec": "^7.4", "phpunit/phpunit": "^9.5.10", "spatie/laravel-ignition": "^1.0" }, diff --git a/composer.lock b/composer.lock index 3ce701d..0fb07a1 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": "394ff81206d4ce7c5805b59c06e517fd", + "content-hash": "682e19ac9391daeae8301a6cee88c5df", "packages": [ { "name": "brick/math", @@ -6719,6 +6719,417 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.3", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.2", + "psalm/phar": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "account@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" + }, + "time": "2021-10-19T17:43:47+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.7.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "dfc078e8af9c99210337325ff5aa152872c98714" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/dfc078e8af9c99210337325ff5aa152872c98714", + "reference": "dfc078e8af9c99210337325ff5aa152872c98714", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.13" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.7.1" + }, + "time": "2023-03-27T19:02:04+00:00" + }, + { + "name": "phpspec/php-diff", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/phpspec/php-diff.git", + "reference": "fc1156187f9f6c8395886fe85ed88a0a245d72e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/php-diff/zipball/fc1156187f9f6c8395886fe85ed88a0a245d72e9", + "reference": "fc1156187f9f6c8395886fe85ed88a0a245d72e9", + "shasum": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "Diff": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Chris Boulton", + "homepage": "http://github.com/chrisboulton" + } + ], + "description": "A comprehensive library for generating differences between two hashable objects (strings or arrays).", + "support": { + "source": "https://github.com/phpspec/php-diff/tree/v1.1.3" + }, + "time": "2020-09-18T13:47:07+00:00" + }, + { + "name": "phpspec/phpspec", + "version": "7.4.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/phpspec.git", + "reference": "28faa87d1151a15848166226f33de61cb7107d0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/phpspec/zipball/28faa87d1151a15848166226f33de61cb7107d0d", + "reference": "28faa87d1151a15848166226f33de61cb7107d0d", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.5 || ^2", + "ext-tokenizer": "*", + "php": "^7.3 || 8.0.* || 8.1.* || 8.2.*", + "phpspec/php-diff": "^1.0.0", + "phpspec/prophecy": "^1.9", + "sebastian/exporter": "^3.0 || ^4.0 || ^5.0", + "symfony/console": "^3.4 || ^4.4 || ^5.0 || ^6.0", + "symfony/event-dispatcher": "^3.4 || ^4.4 || ^5.0 || ^6.0", + "symfony/finder": "^3.4 || ^4.4 || ^5.0 || ^6.0", + "symfony/process": "^3.4 || ^4.4 || ^5.0 || ^6.0", + "symfony/yaml": "^3.4 || ^4.4 || ^5.0 || ^6.0" + }, + "conflict": { + "sebastian/comparator": "<1.2.4" + }, + "require-dev": { + "behat/behat": "^3.3", + "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0", + "symfony/filesystem": "^3.4 || ^4.0 || ^5.0 || ^6.0", + "vimeo/psalm": "^4.3 || ^5.2" + }, + "suggest": { + "phpspec/nyan-formatters": "Adds Nyan formatters" + }, + "bin": [ + "bin/phpspec" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.4.x-dev" + } + }, + "autoload": { + "psr-0": { + "PhpSpec": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "homepage": "http://marcelloduarte.net/" + }, + { + "name": "Ciaran McNulty", + "homepage": "https://ciaranmcnulty.com/" + } + ], + "description": "Specification-oriented BDD framework for PHP 7.1+", + "homepage": "http://phpspec.net/", + "keywords": [ + "BDD", + "SpecBDD", + "TDD", + "spec", + "specification", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/phpspec/phpspec/issues", + "source": "https://github.com/phpspec/phpspec/tree/7.4.0" + }, + "time": "2023-04-21T13:17:48+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "15873c65b207b07765dbc3c95d20fdf4a320cbe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/15873c65b207b07765dbc3c95d20fdf4a320cbe2", + "reference": "15873c65b207b07765dbc3c95d20fdf4a320cbe2", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.2 || ^2.0", + "php": "^7.2 || 8.0.* || 8.1.* || 8.2.*", + "phpdocumentor/reflection-docblock": "^5.2", + "sebastian/comparator": "^3.0 || ^4.0", + "sebastian/recursion-context": "^3.0 || ^4.0" + }, + "require-dev": { + "phpspec/phpspec": "^6.0 || ^7.0", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\": "src/Prophecy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/v1.17.0" + }, + "time": "2023-02-02T15:41:36+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.20.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "90490bd8fd8530a272043c4950c180b6d0cf5f81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/90490bd8fd8530a272043c4950c180b6d0cf5f81", + "reference": "90490bd8fd8530a272043c4950c180b6d0cf5f81", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.20.2" + }, + "time": "2023-04-22T12:59:35+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.26", diff --git a/resources/views/chapter/index.blade.php b/resources/views/chapter/index.blade.php index 538e7a0..12f95af 100644 --- a/resources/views/chapter/index.blade.php +++ b/resources/views/chapter/index.blade.php @@ -66,17 +66,17 @@ diff --git a/resources/views/course/index.blade.php b/resources/views/course/index.blade.php index ea5f500..834f57e 100644 --- a/resources/views/course/index.blade.php +++ b/resources/views/course/index.blade.php @@ -14,6 +14,7 @@+
course status: {{ $course->status }} published: group(function () { Route::get('/{course}/sync', Course\Sync::class)->name('course.sync'); Route::get('/{course}/update', Course\Update::class)->name('course.update'); Route::get('/{course}/chapter/{chapter}', ChapterController::class)->name('course.chapter'); + Route::get('/{course}/chapter/{chapter}/sync', Chapter\Sync::class)->name('course.chapter.sync'); });