commit 61623f5a357cd734432cb4872b3e471a9c2fd4b6 Author: Joop Schilder Date: Tue Mar 23 21:58:40 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cac762f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8873fb3 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# PDF Finder 🗞 🖇 + +This is a simple command line utility that allows you to look for PDF documents in any directory (recursively). + +I have a lot of PDF documents spread around my home directory and subfolders and I'm too unorganized to do something +about it. Instead of taking an hour to organize the files, I took 7 hours to write this program. It uses `pdfinfo` to +collect metadata. The same can probably be achieved with simple shell scripts (globbing combined with `grep`, `sed` +and `awk` +gets you very very far). I chose PHP because I wanted to do something more with this (JSON API for my home network). +That part is left as an exercise for the reader. + +There's two executables in `bin`: `pdf-finder.php` and `pdf-show-info.php`. + +## Runtime requirements + +To run it, you need [Composer](https://getcomposer.org/) and [PHP >= 7.4](https://www.php.net/), as well +as [poppler-utils](https://pypi.org/project/poppler-utils/). Installation of poppler-utils on Ubuntu is very simple: + +```sh +# apt update && apt install poppler-utils +``` + +## Finding documents: `bin/pdf-finder.php` + +The first executable, `pdf-finder.php`, is used to actually find PDFs based on search terms. The first argument should +always be the directory. Filters are optional. + +### Examples + +To find every PDF document with 'python' in its path, filename or any metadata field in the ~/Documents folder: + +```sh +$ bin/pdf-finder.php ~/Documents python +``` + +... with 'python' in the title (metadata property): + +```sh +$ bin/pdf-finder.php ~/Documents title=python +``` + +... with 'ritchie' in the author field and where the title property is set: + +```sh +$ bin/pdf-finder.php ~/Documents author=ritchie title= +``` + +... with 'programming' and 'python' in the filename: + +```sh +$ bin/pdf-finder.php ~/Documents filename=programming filename=python +``` + +### Available filters + +Filters are based on the information supplied by the `pdfinfo` +command [(man page here)](https://www.xpdfreader.com/pdfinfo-man.html). Dates, when given, are printed in ISO-8601 +format. Common fields are listed below. `filepath` (or `path`) is the path excluding the filename. `filename` (or `file` +or `name`) is the name of the file excluding the path. + +| Common filters | +| :--- | +| `filepath`, `path` | +| `filename`, `file`, `name` | +| `title` | +| `subject` | +| `keywords` | +| `author` | +| `creator` | +| `producer` | + +### A note on filters + +About 50% of the PDF files on my computer contain usable metadata. It's almost never complete, although this depends on +the source you got your files from. + +Using `path=python` yields the same results as `filepath=python`. The `path` is an alias to `filepath`. The same goes +for `file` and `name`: both are aliases to `filename`. + +Filters are cumulative: adding more filters further restricts the output. + +## Listing document info: `bin/pdf-show-info.php` + +The second utility is basically a fancy wrapper for `pdfinfo`. It takes one argument, the path to a PDF document, and +spits out a table with information about the document. + +```sh +$ bin/pdf-show-info.php ~/path/to/document.pdf +``` + +## Final note + +Do as you please, as that is the beauty of open source. diff --git a/bin/pdf-finder.php b/bin/pdf-finder.php new file mode 100755 index 0000000..34efa0b --- /dev/null +++ b/bin/pdf-finder.php @@ -0,0 +1,26 @@ +#!/usr/bin/env php +getDirectory(); +$filters = $arguments->getFilters(); + +printf('Scanning "%s"...%s', $directory, PHP_EOL); +$locator = new RecursiveDocumentLocator(); +$documents = $locator->findDocuments($directory); + +foreach ($filters as $filter) { + printf('Applying filter { %s }...%s', $filter, PHP_EOL); + $documents = $documents->filter(fn(Document $document) => $filter->allows($document)); +} + +DocumentListingOutput::forDocuments($documents)->render(); diff --git a/bin/pdf-show-info.php b/bin/pdf-show-info.php new file mode 100755 index 0000000..695cf09 --- /dev/null +++ b/bin/pdf-show-info.php @@ -0,0 +1,19 @@ +#!/usr/bin/env php +getFile(); + +$documentFactory = DocumentFactory::create(); +$document = $documentFactory->createDocument($file); + +$output = DocumentOutput::forDocument($document); +$output->render(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..cca6561 --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "joopschilder/pdf-finder", + "type": "project", + "license": "MIT", + "keywords": ["pdf", "documents", "search", "metadata", "info", "portable document format"], + "description": "Utility to locate PDF files based on their metadata", + "autoload": { + "psr-0": { + "": [ + "src" + ] + } + }, + "require": { + "symfony/console": "^5.2", + "cocur/slugify": "^4.0", + "illuminate/collections": "^8.33" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..72154e3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1085 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a3914edba2fcc6c8e2100d06d5beaefd", + "packages": [ + { + "name": "cocur/slugify", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/cocur/slugify.git", + "reference": "3f1ffc300f164f23abe8b64ffb3f92d35cec8307" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cocur/slugify/zipball/3f1ffc300f164f23abe8b64ffb3f92d35cec8307", + "reference": "3f1ffc300f164f23abe8b64ffb3f92d35cec8307", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=7.0" + }, + "conflict": { + "symfony/config": "<3.4 || >=4,<4.3", + "symfony/dependency-injection": "<3.4 || >=4,<4.3", + "symfony/http-kernel": "<3.4 || >=4,<4.3", + "twig/twig": "<2.12.1" + }, + "require-dev": { + "laravel/framework": "~5.1", + "latte/latte": "~2.2", + "league/container": "^2.2.0", + "mikey179/vfsstream": "~1.6.8", + "mockery/mockery": "^1.3", + "nette/di": "~2.4", + "phpunit/phpunit": "^5.7.27", + "pimple/pimple": "~1.1", + "plumphp/plum": "~0.1", + "symfony/config": "^3.4 || ^4.3 || ^5.0", + "symfony/dependency-injection": "^3.4 || ^4.3 || ^5.0", + "symfony/http-kernel": "^3.4 || ^4.3 || ^5.0", + "twig/twig": "^2.12.1 || ~3.0", + "zendframework/zend-modulemanager": "~2.2", + "zendframework/zend-servicemanager": "~2.2", + "zendframework/zend-view": "~2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cocur\\Slugify\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florian Eckerstorfer", + "email": "florian@eckerstorfer.co", + "homepage": "https://florian.ec" + }, + { + "name": "Ivo Bathke", + "email": "ivo.bathke@gmail.com" + } + ], + "description": "Converts a string into a slug.", + "keywords": [ + "slug", + "slugify" + ], + "support": { + "issues": "https://github.com/cocur/slugify/issues", + "source": "https://github.com/cocur/slugify/tree/master" + }, + "time": "2019-12-14T13:04:14+00:00" + }, + { + "name": "illuminate/collections", + "version": "v8.34.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "e18d6e4cf03dd597bc3ecd86fefc2023d0c7a5e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/e18d6e4cf03dd597bc3ecd86fefc2023d0c7a5e8", + "reference": "e18d6e4cf03dd597bc3ecd86fefc2023d0c7a5e8", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^8.0", + "illuminate/macroable": "^8.0", + "php": "^7.3|^8.0" + }, + "suggest": { + "symfony/var-dumper": "Required to use the dump method (^5.1.4)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + }, + "files": [ + "helpers.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2021-03-19T00:05:33+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v8.34.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "121cea1d8b8772bc7fee99c71ecf0f57c1d77b3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/121cea1d8b8772bc7fee99c71ecf0f57c1d77b3b", + "reference": "121cea1d8b8772bc7fee99c71ecf0f57c1d77b3b", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0", + "psr/container": "^1.0", + "psr/simple-cache": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2021-03-12T14:45:30+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v8.34.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "300aa13c086f25116b5f3cde3ca54ff5c822fb05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/300aa13c086f25116b5f3cde3ca54ff5c822fb05", + "reference": "300aa13c086f25116b5f3cde3ca54ff5c822fb05", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2020-10-27T15:20:30+00:00" + }, + { + "name": "psr/container", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", + "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.1" + }, + "time": "2021-03-05T17:36:06+00:00" + }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, + "time": "2017-10-23T01:57:42+00:00" + }, + { + "name": "symfony/console", + "version": "v5.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "938ebbadae1b0a9c9d1ec313f87f9708609f1b79" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/938ebbadae1b0a9c9d1ec313f87f9708609f1b79", + "reference": "938ebbadae1b0a9c9d1ec313f87f9708609f1b79", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2", + "symfony/string": "^5.1" + }, + "conflict": { + "symfony/dependency-injection": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/event-dispatcher": "<4.4", + "symfony/lock": "<4.4", + "symfony/process": "<4.4" + }, + "provide": { + "psr/log-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/lock": "^4.4|^5.0", + "symfony/process": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v5.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-06T13:42:15+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "c6c942b1ac76c82448322025e084cadc56048b4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e", + "reference": "c6c942b1ac76c82448322025e084cadc56048b4e", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5601e09b69f26c1828b13b6bb87cb07cddba3170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5601e09b69f26c1828b13b6bb87cb07cddba3170", + "reference": "5601e09b69f26c1828b13b6bb87cb07cddba3170", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "43a0283138253ed1d48d352ab6d0bdb3f809f248" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/43a0283138253ed1d48d352ab6d0bdb3f809f248", + "reference": "43a0283138253ed1d48d352ab6d0bdb3f809f248", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "5232de97ee3b75b0360528dae24e73db49566ab1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/5232de97ee3b75b0360528dae24e73db49566ab1", + "reference": "5232de97ee3b75b0360528dae24e73db49566ab1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a678b42e92f86eca04b7fa4c0f6f19d097fb69e2", + "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91", + "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/master" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-09-07T11:33:47+00:00" + }, + { + "name": "symfony/string", + "version": "v5.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "4e78d7d47061fa183639927ec40d607973699609" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/4e78d7d47061fa183639927ec40d607973699609", + "reference": "4e78d7d47061fa183639927ec40d607973699609", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "~1.15" + }, + "require-dev": { + "symfony/error-handler": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/translation-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "files": [ + "Resources/functions.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v5.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-16T10:20:28+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.0.0" +} diff --git a/src/DocumentFactory.php b/src/DocumentFactory.php new file mode 100644 index 0000000..3c14172 --- /dev/null +++ b/src/DocumentFactory.php @@ -0,0 +1,25 @@ +pdfinfo = $pdfinfo ?? new Pdfinfo(); + } + + public static function create(): self + { + return new self(); + } + + public function createDocument(SplFileInfo $file): Document + { + $metadata = $this->pdfinfo->getMetadata($file); + return new Document($file, $metadata); + } +} diff --git a/src/Filter/DocumentFilter.php b/src/Filter/DocumentFilter.php new file mode 100644 index 0000000..61864b4 --- /dev/null +++ b/src/Filter/DocumentFilter.php @@ -0,0 +1,12 @@ +term = $term; + } + + public function allows(Document $document): bool + { + if ($this->term === '') { + return true; + } + + foreach ($document->getProperties() as $key => $value) { + if (stripos($value, $this->term) !== false) { + return true; + } + } + return false; + } + + public function __toString(): string + { + return sprintf('[*] contains \'%s\'', $this->term); + } +} diff --git a/src/Filter/SpecificFilter.php b/src/Filter/SpecificFilter.php new file mode 100644 index 0000000..f2e2c9b --- /dev/null +++ b/src/Filter/SpecificFilter.php @@ -0,0 +1,42 @@ +property = strtolower($property); + $this->term = strtolower($term); + } + + public function allows(Document $document): bool + { + if ($this->property === '') { + return true; + } + + try { + $property = $document->getProperty($this->property); + if ($this->term === '' && !empty($property)) { + // Filter is "prop=", which only checks if it exists. + return true; + } + return stripos($property, $this->term) !== false; + } catch (RuntimeException $e) { + // No such property exists, we don't pass + return false; + } + } + + public function __toString(): string + { + return sprintf('property \'%s\' contains \'%s\'', $this->property, $this->term); + } +} diff --git a/src/IO/Exception/DirectoryNotFoundException.php b/src/IO/Exception/DirectoryNotFoundException.php new file mode 100644 index 0000000..2b96180 --- /dev/null +++ b/src/IO/Exception/DirectoryNotFoundException.php @@ -0,0 +1,11 @@ +getMessage()); + exit(1); + }); + + self::$registered = true; + } +} diff --git a/src/IO/Input/ArgvAccess.php b/src/IO/Input/ArgvAccess.php new file mode 100644 index 0000000..439297e --- /dev/null +++ b/src/IO/Input/ArgvAccess.php @@ -0,0 +1,18 @@ +directory = $directory; + $this->filters = $filters; + + $factory = new FilterFactory(); + $this->filters = array_map([$factory, 'createFromString'], $this->filters); + } + + public static function createFromGlobals(): self + { + $arguments = self::getArguments(); + + $dir = array_shift($arguments) ?? getcwd(); + $dir = rtrim($dir, DIRECTORY_SEPARATOR); + + return new self($dir, $arguments); + } + + public function getDirectory(): string + { + if (!file_exists($this->directory)) { + throw new DirectoryNotFoundException($this->directory); + } + if (!is_dir($this->directory)) { + throw new NotADirectoryException($this->directory); + } + + return $this->directory; + } + + /** + * @return DocumentFilter[] + */ + public function getFilters(): array + { + return $this->filters; + } +} diff --git a/src/IO/Input/ShowInfoArguments.php b/src/IO/Input/ShowInfoArguments.php new file mode 100644 index 0000000..5659094 --- /dev/null +++ b/src/IO/Input/ShowInfoArguments.php @@ -0,0 +1,41 @@ +file = $file; + } + + public static function createFromGlobals(): self + { + $arguments = self::getArguments(); + return new self(array_shift($arguments)); + } + + public function getFile(): SplFileInfo + { + if (is_null($this->file)) { + throw new MissingFileArgumentException(); + } + if (!file_exists($this->file)) { + throw new FileNotFoundException($this->file); + } + if (!is_readable($this->file)) { + throw new FileNotReadableException($this->file); + } + + return new SplFileInfo($this->file); + } +} diff --git a/src/IO/Output/DocumentListingOutput.php b/src/IO/Output/DocumentListingOutput.php new file mode 100644 index 0000000..b773a71 --- /dev/null +++ b/src/IO/Output/DocumentListingOutput.php @@ -0,0 +1,67 @@ +documents = $documents; + } + + public static function forDocuments(iterable $documents): self + { + return new self($documents); + } + + public function render(?OutputInterface $output = null): void + { + if (count($this->documents) === 0) { + print('Your search yielded no results.' . PHP_EOL); + return; + } + + $template = new TableTemplate([ + 'Filename' => [ + 'min_width' => 40, + 'max_width' => 80, + ], + 'Title' => [ + 'min_width' => 40, + 'max_width' => 80, + 'null_value' => '-', + + ], + 'Author' => [ + 'min_width' => 16, + 'max_width' => 32, + 'null_value' => '-', + ], + 'Path' => [ + 'min_width' => 16, + 'max_width' => 32, + 'formatter' => static function (string $path) { + $search = sprintf('/home/%s', get_current_user()); + return str_replace($search, '~', $path); + }, + ], + ]); + + foreach ($this->documents as $document) { + $template->addRow([ + $document->file->getBasename(), + $document->metadata->title, + $document->metadata->author, + $document->file->getPath(), + ]); + } + + $template->generate($output)->render(); + } +} diff --git a/src/IO/Output/DocumentOutput.php b/src/IO/Output/DocumentOutput.php new file mode 100644 index 0000000..1747d6c --- /dev/null +++ b/src/IO/Output/DocumentOutput.php @@ -0,0 +1,42 @@ +document = $document; + } + + public static function forDocument(Document $document): self + { + return new self($document); + } + + public function render(?OutputInterface $output = null): void + { + $template = new TableTemplate([ + 'Property' => [ + 'min_width' => 20, + 'max_width' => 20, + ], + 'Value' => [ + 'min_width' => 80, + 'max_width' => 80, + 'null_value' => '-', + ], + ]); + + foreach ($this->document->getProperties() as $property => $value) { + $template->addRow([$property, $value]); + } + + $template->generate($output)->render(); + } +} diff --git a/src/IO/Output/Output.php b/src/IO/Output/Output.php new file mode 100644 index 0000000..73e6e7e --- /dev/null +++ b/src/IO/Output/Output.php @@ -0,0 +1,8 @@ +headers = array_keys($properties); + $this->properties = array_values($properties); + } + + public function addRow(array $row): void + { + $row = array_values($row); + + foreach ($row as $columnIndex => &$value) { + if (isset($this->properties[$columnIndex]['null_value'])) { + $value ??= $this->properties[$columnIndex]['null_value']; + } + if (isset($this->properties[$columnIndex]['formatter'])) { + $value = call_user_func($this->properties[$columnIndex]['formatter'], $value); + } + if (isset($this->properties[$columnIndex]['max_width'])) { + $value = $this->trim($value, $this->properties[$columnIndex]['max_width']); + } + } + unset($value); + + $this->rows[] = $row; + } + + public function generate(?OutputInterface $output = null): Table + { + $table = new Table($output ?? new ConsoleOutput()); + $table->setStyle('box-double'); + $table->setHeaders($this->headers); + + foreach ($this->properties as $columnIndex => $columnProperties) { + if (isset($columnProperties['min_width'])) { + $table->setColumnWidth($columnIndex, $columnProperties['min_width']); + } + if (isset($columnProperties['max_width'])) { + $table->setColumnMaxWidth($columnIndex, $columnProperties['max_width']); + } + } + + $table->setRows($this->rows); + return $table; + } + + /** + * Trims a string if it's longer than $length and adds '...' to the end if trimmed. + * @param string $string + * @param int $length + * @return string + */ + private function trim(string $string, int $length): string + { + if (strlen($string) <= $length) { + return $string; + } + + return '' . substr($string, 0, $length - 3) . '...'; + } +} diff --git a/src/IO/Shell/Pdfinfo.php b/src/IO/Shell/Pdfinfo.php new file mode 100644 index 0000000..ee0f3a0 --- /dev/null +++ b/src/IO/Shell/Pdfinfo.php @@ -0,0 +1,25 @@ +shellExec('pdfinfo', '-isodates', $filepath); + + $data = []; + foreach ($lines as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $data[trim($parts[0])] = trim($parts[1]); + } + } + + return (new Metadata)->fillWith($data); + } +} diff --git a/src/IO/Shell/ShellCommandExecutor.php b/src/IO/Shell/ShellCommandExecutor.php new file mode 100644 index 0000000..e9a053e --- /dev/null +++ b/src/IO/Shell/ShellCommandExecutor.php @@ -0,0 +1,19 @@ +/dev/null', + escapeshellcmd($command), + implode(' ', $args) + )); + + return explode(PHP_EOL, $output); + } +} diff --git a/src/PDF/Document.php b/src/PDF/Document.php new file mode 100644 index 0000000..41a2edf --- /dev/null +++ b/src/PDF/Document.php @@ -0,0 +1,43 @@ +file = $file; + $this->metadata = $metadata ?? new Metadata(); + } + + public function getProperty(string $prop): ?string + { + if (in_array($prop, ['path', 'filepath'])) { + return $this->file->getPath(); + } + + if (in_array($prop, ['file', 'name', 'filename'])) { + return $this->file->getBasename(); + } + + if (property_exists($this->metadata, $prop)) { + return $this->metadata->{$prop}; + } + + throw new RuntimeException('No such property'); + } + + public function getProperties(): array + { + return [ + 'filepath' => $this->file->getPath(), + 'filename' => $this->file->getBasename(), + ] + $this->metadata->toArray(); + } +} diff --git a/src/PDF/Metadata.php b/src/PDF/Metadata.php new file mode 100644 index 0000000..b946f61 --- /dev/null +++ b/src/PDF/Metadata.php @@ -0,0 +1,53 @@ + '_']); + + $array = array_filter($array, static fn(string $v) => trim($v) !== ''); + foreach ($array as $key => $value) { + $key = $slugify->slugify($key); + if (property_exists(__CLASS__, $key)) { + $this->{$key} = trim($value); + } + } + + return $this; + } + + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/src/RecursiveDocumentLocator.php b/src/RecursiveDocumentLocator.php new file mode 100644 index 0000000..0a942bd --- /dev/null +++ b/src/RecursiveDocumentLocator.php @@ -0,0 +1,30 @@ +documentFactory = $documentFactory ?? new DocumentFactory(); + } + + /** + * @return Collection|Document[] + */ + public function findDocuments(string $directory): Collection + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory), + RecursiveIteratorIterator::SELF_FIRST + ); + + return collect($iterator) + ->filter(static fn(SplFileInfo $fileInfo) => $fileInfo->isFile()) + ->filter(static fn(SplFileInfo $fileInfo) => preg_match('/.pdf$/i', $fileInfo->getBasename())) + ->map(fn(SplFileInfo $fileInfo) => $this->documentFactory->createDocument($fileInfo)); + } +}