Reader.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. <?php
  2. /**
  3. * This file is part of the PHPExiftool package.
  4. *
  5. * (c) Alchemy <support@alchemy.fr>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace PHPExiftool;
  11. use Doctrine\Common\Collections\ArrayCollection;
  12. use PHPExiftool\Exception\EmptyCollectionException;
  13. use PHPExiftool\Exception\LogicException;
  14. use PHPExiftool\Exception\RuntimeException;
  15. use Psr\Log\LoggerInterface;
  16. /**
  17. *
  18. * Exiftool Reader, inspired by Symfony2 Finder.
  19. *
  20. * It scans files and directories, and provide an iterator on the FileEntities
  21. * generated based on the results.
  22. *
  23. * Example usage:
  24. *
  25. * $Reader = new Reader();
  26. *
  27. * $Reader->in('/path/to/directory')
  28. * ->exclude('tests')
  29. * ->extensions(array('jpg', 'xml));
  30. *
  31. * //Throws an exception if no file found
  32. * $first = $Reader->first();
  33. *
  34. * //Returns null if no file found
  35. * $first = $Reader->getOneOrNull();
  36. *
  37. * foreach($Reader as $entity)
  38. * {
  39. * //Do your logic with FileEntity
  40. * }
  41. *
  42. *
  43. * @todo implement match conditions (-if EXPR) (name or metadata tag)
  44. * @todo implement match filter
  45. * @todo implement sort
  46. * @todo implement -l
  47. *
  48. * @author Romain Neutron <imprec@gmail.com>
  49. */
  50. class Reader implements \IteratorAggregate
  51. {
  52. protected $files = array();
  53. protected $dirs = array();
  54. protected $excludeDirs = array();
  55. protected $extensions = array();
  56. protected $extensionsToggle = null;
  57. protected $followSymLinks = false;
  58. protected $recursive = true;
  59. protected $ignoreDotFile = false;
  60. protected $sort = array();
  61. protected $parser;
  62. protected $exiftool;
  63. protected $timeout = 60;
  64. /**
  65. *
  66. * @var ArrayCollection
  67. */
  68. protected $collection;
  69. protected $readers = array();
  70. /**
  71. * Constructor
  72. */
  73. public function __construct(Exiftool $exiftool, RDFParser $parser)
  74. {
  75. $this->exiftool = $exiftool;
  76. $this->parser = $parser;
  77. }
  78. public function __destruct()
  79. {
  80. $this->parser = null;
  81. $this->collection = null;
  82. }
  83. public function setTimeout($timeout)
  84. {
  85. $this->timeout = $timeout;
  86. return $this;
  87. }
  88. public function reset()
  89. {
  90. $this->files
  91. = $this->dirs
  92. = $this->excludeDirs
  93. = $this->extensions
  94. = $this->sort
  95. = $this->readers = array();
  96. $this->recursive = true;
  97. $this->ignoreDotFile = $this->followSymLinks = false;
  98. $this->extensionsToggle = null;
  99. return $this;
  100. }
  101. /**
  102. * Implements \IteratorAggregate Interface
  103. *
  104. * @return \Iterator
  105. */
  106. public function getIterator()
  107. {
  108. return $this->all()->getIterator();
  109. }
  110. /**
  111. * Add files to scan
  112. *
  113. * Example usage:
  114. *
  115. * // Will scan 3 files : dc00.jpg in CWD and absolute
  116. * // paths /tmp/image.jpg and /tmp/raw.CR2
  117. * $Reader ->files('dc00.jpg')
  118. * ->files(array('/tmp/image.jpg', '/tmp/raw.CR2'))
  119. *
  120. * @param string|array $files The files
  121. * @return Reader
  122. */
  123. public function files($files)
  124. {
  125. $this->resetResults();
  126. $this->files = array_merge($this->files, (array) $files);
  127. return $this;
  128. }
  129. /**
  130. * Add dirs to scan
  131. *
  132. * Example usage:
  133. *
  134. * // Will scan 3 dirs : documents in CWD and absolute
  135. * // paths /usr and /var
  136. * $Reader ->in('documents')
  137. * ->in(array('/tmp', '/var'))
  138. *
  139. * @param string|array $dirs The directories
  140. * @return Reader
  141. */
  142. public function in($dirs)
  143. {
  144. $this->resetResults();
  145. $this->dirs = array_merge($this->dirs, (array) $dirs);
  146. return $this;
  147. }
  148. /**
  149. * Append a reader to this one.
  150. * Finale result will be the sum of the current reader and all appended ones.
  151. *
  152. * @param Reader $reader The reader to append
  153. * @return Reader
  154. */
  155. public function append(Reader $reader)
  156. {
  157. $this->resetResults();
  158. $this->readers[] = $reader;
  159. return $this;
  160. }
  161. /**
  162. * Sort results with one or many criteria
  163. *
  164. * Example usage:
  165. *
  166. * // Will sort by directory then filename
  167. * $Reader ->in('documents')
  168. * ->sort(array('directory', 'filename'))
  169. *
  170. * // Will sort by filename
  171. * $Reader ->in('documents')
  172. * ->sort('filename')
  173. *
  174. * @param string|array $by
  175. * @return Reader
  176. */
  177. public function sort($by)
  178. {
  179. static $availableSorts = array(
  180. 'directory', 'filename', 'createdate', 'modifydate', 'filesize'
  181. );
  182. foreach ((array) $by as $sort) {
  183. if ( ! in_array($sort, $availableSorts)) {
  184. continue;
  185. }
  186. $this->sort[] = $sort;
  187. }
  188. return $this;
  189. }
  190. /**
  191. * Exclude directories from scan
  192. *
  193. * Warning: only first depth directories can be excluded
  194. * Imagine a directory structure like below, With a scan in "root", only
  195. * sub1 or sub2 can be excluded, not subsub.
  196. *
  197. * root
  198. * ├── sub1
  199. * └── sub2
  200. *    └── subsub
  201. *
  202. * Example usage:
  203. *
  204. * // Will scan documents recursively, discarding documents/test
  205. * $Reader ->in('documents')
  206. * ->exclude(array('test'))
  207. *
  208. * @param string|array $dirs The directories
  209. * @return Reader
  210. */
  211. public function exclude($dirs)
  212. {
  213. $this->resetResults();
  214. $this->excludeDirs = array_merge($this->excludeDirs, (array) $dirs);
  215. return $this;
  216. }
  217. /**
  218. * Restrict / Discard files based on extensions
  219. * Extensions are case insensitive
  220. *
  221. * @param string|array $extensions The list of extension
  222. * @param Boolean $restrict Toggle restrict/discard method
  223. * @return Reader
  224. * @throws LogicException
  225. */
  226. public function extensions($extensions, $restrict = true)
  227. {
  228. $this->resetResults();
  229. if ( ! is_null($this->extensionsToggle)) {
  230. if ((boolean) $restrict !== $this->extensionsToggle) {
  231. throw new LogicException('You cannot restrict extensions AND exclude extension at the same time');
  232. }
  233. }
  234. $this->extensionsToggle = (boolean) $restrict;
  235. $this->extensions = array_merge($this->extensions, (array) $extensions);
  236. return $this;
  237. }
  238. /**
  239. * Toggle to enable follow Symbolic Links
  240. *
  241. * @return Reader
  242. */
  243. public function followSymLinks()
  244. {
  245. $this->resetResults();
  246. $this->followSymLinks = true;
  247. return $this;
  248. }
  249. /**
  250. * Ignore files starting with a dot (.)
  251. *
  252. * Folders starting with a dot are always exluded due to exiftool behaviour.
  253. * You should include them manually
  254. *
  255. * @return Reader
  256. */
  257. public function ignoreDotFiles()
  258. {
  259. $this->resetResults();
  260. $this->ignoreDotFile = true;
  261. return $this;
  262. }
  263. /**
  264. * Disable recursivity in directories scan.
  265. * If you only specify files, this toggle has no effect
  266. *
  267. * @return Reader
  268. */
  269. public function notRecursive()
  270. {
  271. $this->resetResults();
  272. $this->recursive = false;
  273. return $this;
  274. }
  275. /**
  276. * Return the first result. If no result available, null is returned
  277. *
  278. * @return FileEntity
  279. */
  280. public function getOneOrNull()
  281. {
  282. return count($this->all()) === 0 ? null : $this->all()->first();
  283. }
  284. /**
  285. * Return the first result. If no result available, throws an exception
  286. *
  287. * @return FileEntity
  288. * @throws EmptyCollectionException
  289. */
  290. public function first()
  291. {
  292. if (count($this->all()) === 0) {
  293. throw new EmptyCollectionException('Collection is empty');
  294. }
  295. return $this->all()->first();
  296. }
  297. /**
  298. * Perform the scan and returns all the results
  299. *
  300. * @return ArrayCollection
  301. */
  302. public function all()
  303. {
  304. if (! $this->collection) {
  305. $this->collection = $this->buildQueryAndExecute();
  306. }
  307. if ($this->readers) {
  308. $elements = $this->collection->toArray();
  309. $this->collection = null;
  310. foreach ($this->readers as $reader) {
  311. $elements = array_merge($elements, $reader->all()->toArray());
  312. }
  313. $this->collection = new ArrayCollection($elements);
  314. }
  315. return $this->collection;
  316. }
  317. public static function create(LoggerInterface $logger)
  318. {
  319. return new static(new Exiftool($logger), new RDFParser());
  320. }
  321. /**
  322. * Reset any computed result
  323. *
  324. * @return Reader
  325. */
  326. protected function resetResults()
  327. {
  328. $this->collection = null;
  329. return $this;
  330. }
  331. /**
  332. * Build the command returns an ArrayCollection of FileEntity
  333. *
  334. * @return ArrayCollection
  335. */
  336. protected function buildQueryAndExecute()
  337. {
  338. $result = '';
  339. try {
  340. $result = trim($this->exiftool->executeCommand($this->buildQuery(), $this->timeout));
  341. } catch (RuntimeException $e) {
  342. /**
  343. * In case no file found, an exit code 1 is returned
  344. */
  345. if (! $this->ignoreDotFile) {
  346. throw $e;
  347. }
  348. }
  349. if ($result === '') {
  350. return new ArrayCollection();
  351. }
  352. $this->parser->open($result);
  353. return $this->parser->ParseEntities();
  354. }
  355. /**
  356. * Compute raw exclude rules to simple ones, based on exclude dirs and search dirs
  357. *
  358. * @param string $rawExcludeDirs
  359. * @param string $rawDirs
  360. * @return array
  361. * @throws RuntimeException
  362. */
  363. protected function computeExcludeDirs($rawExcludeDirs, $rawSearchDirs)
  364. {
  365. $excludeDirs = array();
  366. foreach ($rawExcludeDirs as $excludeDir) {
  367. $found = false;
  368. /**
  369. * is this a relative path ?
  370. */
  371. foreach ($rawSearchDirs as $dir) {
  372. $currentPrefix = realpath($dir) . DIRECTORY_SEPARATOR;
  373. $supposedExcluded = str_replace($currentPrefix, '', realpath($currentPrefix . $excludeDir));
  374. if (! $supposedExcluded) {
  375. continue;
  376. }
  377. if (strpos($supposedExcluded, DIRECTORY_SEPARATOR) === false) {
  378. $excludeDirs[] = $supposedExcluded;
  379. $found = true;
  380. break;
  381. }
  382. }
  383. if ($found) {
  384. continue;
  385. }
  386. /**
  387. * is this an absolute path ?
  388. */
  389. $supposedExcluded = realpath($excludeDir);
  390. if ($supposedExcluded) {
  391. foreach ($rawSearchDirs as $dir) {
  392. $searchDir = realpath($dir) . DIRECTORY_SEPARATOR;
  393. $supposedRelative = str_replace($searchDir, '', $supposedExcluded);
  394. if (strpos($supposedRelative, DIRECTORY_SEPARATOR) !== false) {
  395. continue;
  396. }
  397. if (strpos($supposedExcluded, $searchDir) !== 0) {
  398. continue;
  399. }
  400. if ( ! trim($supposedRelative)) {
  401. continue;
  402. }
  403. $excludeDirs[] = $supposedRelative;
  404. $found = true;
  405. break;
  406. }
  407. }
  408. if (! $found) {
  409. throw new RuntimeException(sprintf("Invalid exclude dir %s ; Exclude dir is limited to the name of a directory at first depth", $excludeDir));
  410. }
  411. }
  412. return $excludeDirs;
  413. }
  414. /**
  415. * Build query from criterias
  416. *
  417. * @return string
  418. *
  419. * @throws LogicException
  420. */
  421. protected function buildQuery()
  422. {
  423. if (! $this->dirs && ! $this->files) {
  424. throw new LogicException('You have not set any files or directory');
  425. }
  426. $command = '-n -q -b -X -charset UTF8';
  427. if ($this->recursive) {
  428. $command .= ' -r';
  429. }
  430. if (!empty($this->extensions)) {
  431. if (! $this->extensionsToggle) {
  432. $extensionPrefix = ' --ext';
  433. } else {
  434. $extensionPrefix = ' -ext';
  435. }
  436. foreach ($this->extensions as $extension) {
  437. $command .= $extensionPrefix . ' ' . escapeshellarg($extension);
  438. }
  439. }
  440. if (! $this->followSymLinks) {
  441. $command .= ' -i SYMLINKS';
  442. }
  443. if ($this->ignoreDotFile) {
  444. $command .= " -if '\$filename !~ /^\./'";
  445. }
  446. foreach ($this->sort as $sort) {
  447. $command .= ' -fileOrder ' . $sort;
  448. }
  449. foreach ($this->computeExcludeDirs($this->excludeDirs, $this->dirs) as $excludedDir) {
  450. $command .= ' -i ' . escapeshellarg($excludedDir);
  451. }
  452. foreach ($this->dirs as $dir) {
  453. $command .= ' ' . escapeshellarg(realpath($dir));
  454. }
  455. foreach ($this->files as $file) {
  456. $command .= ' ' . escapeshellarg(realpath($file));
  457. }
  458. return $command;
  459. }
  460. }