diff --git a/.github/workflows/codesniffer.yml b/.github/workflows/codesniffer.yml index 60ccf92..6694960 100644 --- a/.github/workflows/codesniffer.yml +++ b/.github/workflows/codesniffer.yml @@ -2,16 +2,13 @@ name: "Codesniffer" on: pull_request: - + workflow_dispatch: push: branches: ["*"] - schedule: - cron: "0 8 * * 1" jobs: codesniffer: name: "Codesniffer" - uses: contributte/.github/.github/workflows/codesniffer.yml@v1 - with: - php: "8.2" + uses: contributte/.github/.github/workflows/codesniffer.yml@master diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6fb8b98..4eaa9fb 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,16 +2,15 @@ name: "Coverage" on: pull_request: - + workflow_dispatch: push: branches: ["*"] - schedule: - cron: "0 8 * * 1" jobs: coverage: name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester-coverage.yml@v1 + uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master with: - php: "8.2" + php: "8.4" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 131be8e..6c037df 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -2,16 +2,13 @@ name: "Phpstan" on: pull_request: - + workflow_dispatch: push: branches: ["*"] - schedule: - cron: "0 8 * * 1" jobs: phpstan: name: "Phpstan" - uses: contributte/.github/.github/workflows/phpstan.yml@v1 - with: - php: "8.2" + uses: contributte/.github/.github/workflows/phpstan.yml@master diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9ef1dd..88b6848 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,29 +2,34 @@ name: "Nette Tester" on: pull_request: - + workflow_dispatch: push: - branches: [ "*" ] - + branches: ["*"] schedule: - cron: "0 8 * * 1" jobs: - test82: + test84: name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.2" + php: "8.4" + + test83: + name: "Nette Tester" + uses: contributte/.github/.github/workflows/nette-tester.yml@master + with: + php: "8.3" - test81: + test82: name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.1" + php: "8.2" testlower: name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.1" + php: "8.2" composer: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --prefer-lowest" diff --git a/Makefile b/Makefile index 29bf57c..d53bea0 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,26 @@ -.PHONY: install +.PHONY: install qa cs csf phpstan tests coverage + install: composer update -.PHONY: qa qa: phpstan cs -.PHONY: cs cs: ifdef GITHUB_ACTION - vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp -q --report=checkstyle src tests | cs2pr + vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt -q --report=checkstyle src tests | cs2pr else - vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests + vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt src tests endif -.PHONY: csf csf: - vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests + vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt src tests -.PHONY: phpstan phpstan: - vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=256M + vendor/bin/phpstan analyse -c phpstan.neon -.PHONY: tests tests: vendor/bin/tester -s -p php --colors 1 -C tests/Cases -.PHONY: coverage coverage: ifdef GITHUB_ACTION vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage coverage.xml --coverage-src src tests/Cases diff --git a/composer.json b/composer.json index f601327..869ed1c 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,15 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "nette/utils": "^4.0.0" }, "require-dev": { - "nette/di": "^3.2.4", + "contributte/phpstan": "^0.2", "contributte/qa": "^0.4", "contributte/tester": "^0.4", - "contributte/phpstan": "^0.2", - "mockery/mockery": "^1.5.0" + "mockery/mockery": "^1.6.0", + "nette/di": "^3.2.4" }, "suggest": { "nette/di": "to use DateTimeExtension[CompilerExtension]" @@ -54,7 +54,7 @@ }, "extra": { "branch-alias": { - "dev-master": "0.7.x-dev" + "dev-master": "0.8.x-dev" } } } diff --git a/phpstan.neon b/phpstan.neon index 562ab05..cd64186 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,7 +3,7 @@ includes: parameters: level: 9 - phpVersion: 80100 + phpVersion: 80200 scanDirectories: - src @@ -14,5 +14,3 @@ parameters: paths: - src - .docs - - ignoreErrors: diff --git a/ruleset.xml b/ruleset.xml index bed6e0f..d35c60b 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -1,7 +1,7 @@ - + diff --git a/src/Deeper.php b/src/Deeper.php index f5a788a..5ce5aa1 100644 --- a/src/Deeper.php +++ b/src/Deeper.php @@ -9,11 +9,10 @@ class Deeper /** * @template T - * @param array-key $key * @param array $arr * @param non-empty-string $sep */ - public static function has($key, array $arr, string $sep = '.'): bool + public static function has(string|int $key, array $arr, string $sep = '.'): bool { try { static::get($key, $arr, $sep); @@ -26,13 +25,12 @@ public static function has($key, array $arr, string $sep = '.'): bool /** * @template T - * @param array-key $key * @param array $arr * @param non-empty-string $sep * @param ?T $default * @return ?T */ - public static function get($key, array $arr, string $sep = '.', $default = null) + public static function get(string|int $key, array $arr, string $sep = '.', mixed $default = null): mixed { if (func_num_args() < 4) { return Arrays::get($arr, static::flat($key, $sep)); diff --git a/src/Exception/Runtime/FileNotFoundException.php b/src/Exception/Runtime/FileNotFoundException.php new file mode 100644 index 0000000..bb96c06 --- /dev/null +++ b/src/Exception/Runtime/FileNotFoundException.php @@ -0,0 +1,15 @@ +path = FileSystem::pathalize($path); + $this->title = $title; + } + + public function getName(): string + { + return basename($this->path); + } + + /** + * @throws FileNotFoundException + */ + public function getSize(): int + { + $this->assertExists(); + + $size = filesize($this->path); + + return $size !== false ? $size : 0; + } + + public function getTitle(): string|null + { + return $this->title; + } + + public function getPath(): string + { + return $this->path; + } + + public function exists(): bool + { + return file_exists($this->path) && is_file($this->path); + } + + /** + * @throws FileNotFoundException + */ + public function move(string $destination, bool $overwrite = true): static + { + $this->assertExists(); + + $destination = FileSystem::pathalize($destination); + NetteFileSystem::rename($this->path, $destination, $overwrite); + $this->path = $destination; + + return $this; + } + + /** + * @throws FileNotFoundException + */ + public function toImage(): Image + { + $this->assertExists(); + + return Image::fromFile($this->path); + } + + /** + * @throws FileNotFoundException + */ + public function toResponse(string|null $name = null, string|null $contentType = null, bool $forceDownload = true): FileResponse + { + $this->assertExists(); + + return new FileResponse( + $this->path, + $name ?? $this->title ?? $this->getName(), + $contentType, + $forceDownload + ); + } + + /** + * @throws FileNotFoundException + */ + private function assertExists(): void + { + if (!$this->exists()) { + throw new FileNotFoundException($this->path); + } + } + +} diff --git a/src/Http/FileResponse.php b/src/Http/FileResponse.php new file mode 100644 index 0000000..02ba55f --- /dev/null +++ b/src/Http/FileResponse.php @@ -0,0 +1,76 @@ +file = $file; + $this->name = $name ?? basename($file); + $this->contentType = $contentType; + $this->forceDownload = $forceDownload; + } + + public function getFile(): string + { + return $this->file; + } + + public function getName(): string + { + return $this->name; + } + + public function getContentType(): string + { + if ($this->contentType !== null) { + return $this->contentType; + } + + $mimeType = mime_content_type($this->file); + + return $mimeType !== false ? $mimeType : 'application/octet-stream'; + } + + public function isForceDownload(): bool + { + return $this->forceDownload; + } + + public function send(): void + { + $name = $this->name; + $contentType = $this->getContentType(); + + header('Content-Type: ' . $contentType); + header('Content-Disposition: ' . ($this->forceDownload ? 'attachment' : 'inline') . '; filename="' . $name . '"; filename*=utf-8\'\'' . rawurlencode($name)); + header('Content-Length: ' . filesize($this->file)); + + readfile($this->file); + } + +} diff --git a/src/LazyCollection.php b/src/LazyCollection.php index c72e78e..190b1cf 100644 --- a/src/LazyCollection.php +++ b/src/LazyCollection.php @@ -18,7 +18,7 @@ class LazyCollection implements IteratorAggregate /** @var mixed[] */ private ?array $data = null; - private function __construct(callable $callback) + protected function __construct(callable $callback) { $this->callback = $callback; } diff --git a/tests/Cases/File.phpt b/tests/Cases/File.phpt new file mode 100644 index 0000000..3a3457a --- /dev/null +++ b/tests/Cases/File.phpt @@ -0,0 +1,162 @@ +getName()); + + $file2 = new File('/another/path/document.pdf'); + Assert::same('document.pdf', $file2->getName()); +}); + +// File::getTitle +Toolkit::test(function (): void { + $file = new File('/path/to/test.txt', 'My Document'); + Assert::same('My Document', $file->getTitle()); + + $fileWithoutTitle = new File('/path/to/test.txt'); + Assert::null($fileWithoutTitle->getTitle()); +}); + +// File::getPath +Toolkit::test(function (): void { + $path = '/path/to/test.txt'; + $file = new File($path); + Assert::same(str_replace('/', DIRECTORY_SEPARATOR, $path), $file->getPath()); +}); + +// File::exists +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/existing.txt'; + FileSystem::write($filePath, 'test content'); + + $file = new File($filePath); + Assert::true($file->exists()); + + $nonExisting = new File($tempDir . '/non-existing.txt'); + Assert::false($nonExisting->exists()); +}); + +// File::getSize +Toolkit::test(function () use ($tempDir): void { + $content = 'Hello, World!'; + $filePath = $tempDir . '/size-test.txt'; + FileSystem::write($filePath, $content); + + $file = new File($filePath); + Assert::same(strlen($content), $file->getSize()); +}); + +// File::getSize throws on non-existing file +Toolkit::test(function () use ($tempDir): void { + $file = new File($tempDir . '/non-existing.txt'); + + Assert::exception(function () use ($file): void { + $file->getSize(); + }, FileNotFoundException::class); +}); + +// File::move +Toolkit::test(function () use ($tempDir): void { + $originalPath = $tempDir . '/original.txt'; + $newPath = $tempDir . '/moved.txt'; + FileSystem::write($originalPath, 'test content'); + + $file = new File($originalPath); + $result = $file->move($newPath); + + Assert::same($file, $result); + Assert::same(str_replace('/', DIRECTORY_SEPARATOR, $newPath), $file->getPath()); + Assert::true($file->exists()); + Assert::false(file_exists($originalPath)); +}); + +// File::move throws on non-existing file +Toolkit::test(function () use ($tempDir): void { + $file = new File($tempDir . '/non-existing.txt'); + + Assert::exception(function () use ($file, $tempDir): void { + $file->move($tempDir . '/destination.txt'); + }, FileNotFoundException::class); +}); + +// File::toImage +Toolkit::test(function () use ($tempDir): void { + $imagePath = $tempDir . '/test.png'; + $image = Image::fromBlank(100, 100, Image::rgb(255, 0, 0)); + $image->save($imagePath); + + $file = new File($imagePath); + $loadedImage = $file->toImage(); + + Assert::type(Image::class, $loadedImage); + Assert::same(100, $loadedImage->getWidth()); + Assert::same(100, $loadedImage->getHeight()); +}); + +// File::toImage throws on non-existing file +Toolkit::test(function () use ($tempDir): void { + $file = new File($tempDir . '/non-existing.png'); + + Assert::exception(function () use ($file): void { + $file->toImage(); + }, FileNotFoundException::class); +}); + +// File::toResponse +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/download.txt'; + FileSystem::write($filePath, 'downloadable content'); + + $file = new File($filePath, 'My Download'); + $response = $file->toResponse(); + + Assert::type(FileResponse::class, $response); +}); + +// File::toResponse with custom name +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/download2.txt'; + FileSystem::write($filePath, 'downloadable content'); + + $file = new File($filePath); + $response = $file->toResponse('custom-name.txt'); + + Assert::type(FileResponse::class, $response); +}); + +// File::toResponse throws on non-existing file +Toolkit::test(function () use ($tempDir): void { + $file = new File($tempDir . '/non-existing.txt'); + + Assert::exception(function () use ($file): void { + $file->toResponse(); + }, FileNotFoundException::class); +}); + +// File path normalization +Toolkit::test(function (): void { + $file = new File('/path/to\\mixed/separators.txt'); + $path = $file->getPath(); + + if (DIRECTORY_SEPARATOR === '\\') { + Assert::same('\\path\\to\\mixed\\separators.txt', $path); + } else { + Assert::same('/path/to/mixed/separators.txt', $path); + } +}); + +FileSystem::delete($tempDir); diff --git a/tests/Cases/Http/FileResponse.phpt b/tests/Cases/Http/FileResponse.phpt new file mode 100644 index 0000000..56b834f --- /dev/null +++ b/tests/Cases/Http/FileResponse.phpt @@ -0,0 +1,71 @@ +getFile()); + Assert::same('test.txt', $response->getName()); +}); + +// FileResponse::__construct with custom name +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/test2.txt'; + FileSystem::write($filePath, 'content'); + + $response = new FileResponse($filePath, 'custom.txt'); + Assert::same('custom.txt', $response->getName()); +}); + +// FileResponse::__construct throws on non-existing file +Toolkit::test(function () use ($tempDir): void { + Assert::exception(function () use ($tempDir): void { + new FileResponse($tempDir . '/non-existing.txt'); + }, FileNotFoundException::class); +}); + +// FileResponse::getContentType +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/test3.txt'; + FileSystem::write($filePath, 'text content'); + + $response = new FileResponse($filePath); + $contentType = $response->getContentType(); + Assert::contains('text', $contentType); +}); + +// FileResponse::getContentType with custom type +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/test4.txt'; + FileSystem::write($filePath, 'content'); + + $response = new FileResponse($filePath, null, 'application/custom'); + Assert::same('application/custom', $response->getContentType()); +}); + +// FileResponse::isForceDownload +Toolkit::test(function () use ($tempDir): void { + $filePath = $tempDir . '/test5.txt'; + FileSystem::write($filePath, 'content'); + + $responseForce = new FileResponse($filePath, null, null, true); + Assert::true($responseForce->isForceDownload()); + + $responseInline = new FileResponse($filePath, null, null, false); + Assert::false($responseInline->isForceDownload()); +}); + +FileSystem::delete($tempDir);