You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

491 lines
18 KiB

4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
  1. <?php
  2. /*
  3. * AutoImports.php
  4. * Copyright (c) 2021 james@firefly-iii.org
  5. *
  6. * This file is part of the Firefly III Data Importer
  7. * (https://github.com/firefly-iii/data-importer).
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  21. */
  22. declare(strict_types=1);
  23. namespace App\Console;
  24. use App\Events\ImportedTransactions;
  25. use App\Exceptions\ImporterErrorException;
  26. use App\Services\Camt\Conversion\RoutineManager as CamtRoutineManager;
  27. use App\Services\CSV\Conversion\RoutineManager as CSVRoutineManager;
  28. use App\Services\Nordigen\Conversion\RoutineManager as NordigenRoutineManager;
  29. use App\Services\Shared\Configuration\Configuration;
  30. use App\Services\Shared\Conversion\ConversionStatus;
  31. use App\Services\Shared\Conversion\RoutineStatusManager;
  32. use App\Services\Shared\File\FileContentSherlock;
  33. use App\Services\Shared\Import\Routine\RoutineManager;
  34. use App\Services\Shared\Import\Status\SubmissionStatus;
  35. use App\Services\Shared\Import\Status\SubmissionStatusManager;
  36. use App\Services\Spectre\Conversion\RoutineManager as SpectreRoutineManager;
  37. use Illuminate\Contracts\Filesystem\FileNotFoundException;
  38. use JsonException;
  39. use League\Flysystem\FilesystemException;
  40. use Storage;
  41. /**
  42. * Trait AutoImports
  43. */
  44. trait AutoImports
  45. {
  46. protected array $conversionErrors = [];
  47. protected array $conversionMessages = [];
  48. protected array $conversionWarnings = [];
  49. protected string $identifier;
  50. protected array $importErrors = [];
  51. protected array $importMessages = [];
  52. protected array $importWarnings = [];
  53. /**
  54. * @param string $directory
  55. *
  56. * @return array
  57. */
  58. protected function getFiles(string $directory): array
  59. {
  60. $ignore = ['.', '..'];
  61. if ('' === $directory) {
  62. $this->error(sprintf('Directory "%s" is empty or invalid.', $directory));
  63. return [];
  64. }
  65. $array = scandir($directory);
  66. if (!is_array($array)) {
  67. $this->error(sprintf('Directory "%s" is empty or invalid.', $directory));
  68. return [];
  69. }
  70. $files = array_diff($array, $ignore);
  71. $return = [];
  72. foreach ($files as $file) {
  73. // import importable file with JSON companion
  74. // TODO may also need to be able to detect other file types. Or: detect any file with accompanying json file
  75. if ('csv' === $this->getExtension($file) && $this->hasJsonConfiguration($directory, $file)) {
  76. $return[] = $file;
  77. }
  78. // import JSON with no importable file.
  79. // TODO must detect json files without accompanying camt/csv/whatever file.
  80. if ('json' === $this->getExtension($file) && !$this->hasCsvFile($directory, $file)) {
  81. $return[] = $file;
  82. }
  83. }
  84. return $return;
  85. }
  86. /**
  87. * @param string $directory
  88. * @param array $files
  89. *
  90. * @throws ImporterErrorException
  91. */
  92. protected function importFiles(string $directory, array $files): void
  93. {
  94. /** @var string $file */
  95. foreach ($files as $file) {
  96. $this->importFile($directory, $file);
  97. }
  98. }
  99. /**
  100. * @param string $file
  101. *
  102. * @return string
  103. */
  104. private function getExtension(string $file): string
  105. {
  106. $parts = explode('.', $file);
  107. if (1 === count($parts)) {
  108. return '';
  109. }
  110. return strtolower($parts[count($parts) - 1]);
  111. }
  112. /**
  113. * TODO this function must be more universal.
  114. *
  115. * @param string $directory
  116. * @param string $file
  117. *
  118. * @return bool
  119. */
  120. private function hasCsvFile(string $directory, string $file): bool
  121. {
  122. $short = substr($file, 0, -5);
  123. $csvFile = sprintf('%s.csv', $short);
  124. $fullJson = sprintf('%s/%s', $directory, $csvFile);
  125. if (!file_exists($fullJson)) {
  126. return false;
  127. }
  128. return true;
  129. }
  130. /**
  131. * @param string $directory
  132. * @param string $file
  133. *
  134. * @return bool
  135. */
  136. private function hasJsonConfiguration(string $directory, string $file): bool
  137. {
  138. $short = substr($file, 0, -4);
  139. $jsonFile = sprintf('%s.json', $short);
  140. $fullJson = sprintf('%s/%s', $directory, $jsonFile);
  141. if (!file_exists($fullJson)) {
  142. $this->warn(sprintf('Can\'t find JSON file "%s" expected to go with CSV file "%s". CSV file will be ignored.', $fullJson, $file));
  143. return false;
  144. }
  145. return true;
  146. }
  147. /**
  148. * @param string $file
  149. * @param string $directory
  150. *
  151. * @throws ImporterErrorException
  152. */
  153. private function importFile(string $directory, string $file): void
  154. {
  155. app('log')->debug(sprintf('ImportFile: directory "%s"', $directory));
  156. app('log')->debug(sprintf('ImportFile: file "%s"', $file));
  157. $importableFile = sprintf('%s/%s', $directory, $file);
  158. $jsonFile = sprintf('%s/%s.json', $directory, substr($file, 0, -5));
  159. // TODO not yet sure why the distinction is necessary.
  160. // TODO this may also be necessary for camt files.
  161. if ('csv' === $this->getExtension($file)) {
  162. $jsonFile = sprintf('%s/%s.json', $directory, substr($file, 0, -4));
  163. }
  164. // same for XML files.
  165. if ('xml' === $this->getExtension($file)) {
  166. $jsonFile = sprintf('%s/%s.json', $directory, substr($file, 0, -4));
  167. }
  168. app('log')->debug(sprintf('ImportFile: importable "%s"', $importableFile));
  169. app('log')->debug(sprintf('ImportFile: JSON "%s"', $jsonFile));
  170. // do JSON check
  171. $jsonResult = $this->verifyJSON($jsonFile);
  172. if (false === $jsonResult) {
  173. $message = sprintf('The importer can\'t import %s: could not decode the JSON in config file %s.', $importableFile, $jsonFile);
  174. $this->error($message);
  175. return;
  176. }
  177. $configuration = Configuration::fromArray(json_decode(file_get_contents($jsonFile), true));
  178. // sanity check. If the importableFile is a .json file, and it parses as valid json, don't import it:
  179. if ('file' === $configuration->getFlow() && str_ends_with(strtolower($importableFile), '.json') && $this->verifyJSON($importableFile)) {
  180. app('log')->warning('Almost tried to import a JSON file as a file lol. Skip it.');
  181. return;
  182. }
  183. $configuration->updateDateRange();
  184. $this->line(sprintf('Going to convert from file %s using configuration %s and flow "%s".', $importableFile, $jsonFile, $configuration->getFlow()));
  185. // this is it!
  186. $this->startConversion($configuration, $importableFile);
  187. $this->reportConversion();
  188. $this->line(sprintf('Done converting from file %s using configuration %s.', $importableFile, $jsonFile));
  189. $this->startImport($configuration);
  190. $this->reportImport();
  191. $this->line('Done!');
  192. event(
  193. new ImportedTransactions(
  194. array_merge($this->conversionMessages, $this->importMessages),
  195. array_merge($this->conversionWarnings, $this->importWarnings),
  196. array_merge($this->conversionErrors, $this->importErrors)
  197. )
  198. );
  199. }
  200. /**
  201. * @param string $jsonFile
  202. * @param null|string $importableFile
  203. *
  204. * @throws ImporterErrorException
  205. */
  206. private function importUpload(string $jsonFile, ?string $importableFile): void
  207. {
  208. // do JSON check
  209. $jsonResult = $this->verifyJSON($jsonFile);
  210. if (false === $jsonResult) {
  211. $message = sprintf('The importer can\'t import %s: could not decode the JSON in config file %s.', $importableFile, $jsonFile);
  212. $this->error($message);
  213. return;
  214. }
  215. $configuration = Configuration::fromArray(json_decode(file_get_contents($jsonFile), true));
  216. $configuration->updateDateRange();
  217. $this->line(sprintf('Going to convert from file "%s" using configuration "%s" and flow "%s".', $importableFile, $jsonFile, $configuration->getFlow()));
  218. // this is it!
  219. $this->startConversion($configuration, $importableFile);
  220. $this->reportConversion();
  221. $this->line(sprintf('Done converting from file %s using configuration %s.', $importableFile, $jsonFile));
  222. $this->startImport($configuration);
  223. $this->reportImport();
  224. $this->line('Done!');
  225. event(
  226. new ImportedTransactions(
  227. array_merge($this->conversionMessages, $this->importMessages),
  228. array_merge($this->conversionWarnings, $this->importWarnings),
  229. array_merge($this->conversionErrors, $this->importErrors)
  230. )
  231. );
  232. }
  233. /**
  234. *
  235. */
  236. private function reportConversion(): void
  237. {
  238. $list = [
  239. 'info' => $this->conversionMessages,
  240. 'warn' => $this->conversionWarnings,
  241. 'error' => $this->conversionErrors,
  242. ];
  243. foreach ($list as $func => $set) {
  244. /**
  245. * @var int $index
  246. * @var array $messages
  247. */
  248. foreach ($set as $index => $messages) {
  249. if (count($messages) > 0) {
  250. foreach ($messages as $message) {
  251. $this->$func(sprintf('Conversion index %d: %s', $index, $message));
  252. }
  253. }
  254. }
  255. }
  256. }
  257. /**
  258. *
  259. */
  260. private function reportImport(): void
  261. {
  262. $list = [
  263. 'info' => $this->importMessages,
  264. 'warn' => $this->importWarnings,
  265. 'error' => $this->importErrors,
  266. ];
  267. foreach ($list as $func => $set) {
  268. /**
  269. * @var int $index
  270. * @var array $messages
  271. */
  272. foreach ($set as $index => $messages) {
  273. if (count($messages) > 0) {
  274. foreach ($messages as $message) {
  275. $this->$func(sprintf('Import index %d: %s', $index, $message));
  276. }
  277. }
  278. }
  279. }
  280. }
  281. /**
  282. * @param Configuration $configuration
  283. *
  284. * @param string|null $importableFile
  285. *
  286. * @throws ImporterErrorException
  287. */
  288. private function startConversion(Configuration $configuration, ?string $importableFile): void
  289. {
  290. $this->conversionMessages = [];
  291. $this->conversionWarnings = [];
  292. $this->conversionErrors = [];
  293. app('log')->debug(sprintf('Now in %s', __METHOD__));
  294. switch ($configuration->getFlow()) {
  295. default:
  296. $this->error(sprintf('There is no support for flow "%s"', $configuration->getFlow()));
  297. exit();
  298. case 'file':
  299. $contentType = $configuration->getContentType();
  300. if ('unknown' === $contentType) {
  301. app('log')->debug('Content type is "unknown" in startConversion(), detect it.');
  302. $detector = new FileContentSherlock();
  303. $contentType = $detector->detectContentType($importableFile);
  304. }
  305. switch ($contentType) {
  306. default:
  307. case 'unknown':
  308. case 'csv':
  309. $manager = new CSVRoutineManager(null);
  310. $this->identifier = $manager->getIdentifier();
  311. $manager->setContent(file_get_contents($importableFile));
  312. break;
  313. case 'camt':
  314. $manager = new CamtRoutineManager(null);
  315. $this->identifier = $manager->getIdentifier();
  316. $manager->setContent(file_get_contents($importableFile));
  317. break;
  318. }
  319. break;
  320. case 'nordigen':
  321. $manager = new NordigenRoutineManager(null);
  322. $this->identifier = $manager->getIdentifier();
  323. break;
  324. case 'spectre':
  325. $manager = new SpectreRoutineManager(null);
  326. $this->identifier = $manager->getIdentifier();
  327. break;
  328. }
  329. RoutineStatusManager::startOrFindConversion($this->identifier);
  330. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_RUNNING, $this->identifier);
  331. // then push stuff into the routine:
  332. $manager->setConfiguration($configuration);
  333. $transactions = [];
  334. try {
  335. $transactions = $manager->start();
  336. } catch (ImporterErrorException $e) {
  337. app('log')->error($e->getMessage());
  338. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_ERRORED, $this->identifier);
  339. $this->conversionMessages = $manager->getAllMessages();
  340. $this->conversionWarnings = $manager->getAllWarnings();
  341. $this->conversionErrors = $manager->getAllErrors();
  342. }
  343. if (0 === count($transactions)) {
  344. app('log')->error('Zero transactions!');
  345. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_ERRORED, $this->identifier);
  346. $this->conversionMessages = $manager->getAllMessages();
  347. $this->conversionWarnings = $manager->getAllWarnings();
  348. $this->conversionErrors = $manager->getAllErrors();
  349. }
  350. // save transactions in 'jobs' directory under the same key as the conversion thing.
  351. $disk = Storage::disk('jobs');
  352. try {
  353. $disk->put(sprintf('%s.json', $this->identifier), json_encode($transactions, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
  354. } catch (JsonException $e) {
  355. app('log')->error(sprintf('JSON exception: %s', $e->getMessage()));
  356. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_ERRORED, $this->identifier);
  357. $this->conversionMessages = $manager->getAllMessages();
  358. $this->conversionWarnings = $manager->getAllWarnings();
  359. $this->conversionErrors = $manager->getAllErrors();
  360. $transactions = [];
  361. }
  362. if (count($transactions) > 0) {
  363. // set done:
  364. RoutineStatusManager::setConversionStatus(ConversionStatus::CONVERSION_DONE, $this->identifier);
  365. $this->conversionMessages = $manager->getAllMessages();
  366. $this->conversionWarnings = $manager->getAllWarnings();
  367. $this->conversionErrors = $manager->getAllErrors();
  368. }
  369. }
  370. /**
  371. * @param Configuration $configuration
  372. * @throws FilesystemException
  373. */
  374. private function startImport(Configuration $configuration): void
  375. {
  376. app('log')->debug(sprintf('Now at %s', __METHOD__));
  377. $routine = new RoutineManager($this->identifier);
  378. SubmissionStatusManager::startOrFindSubmission($this->identifier);
  379. $disk = Storage::disk('jobs');
  380. $fileName = sprintf('%s.json', $this->identifier);
  381. // get files from disk:
  382. if (!$disk->has($fileName)) {
  383. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_ERRORED, $this->identifier);
  384. $message = sprintf('File "%s" not found, cannot continue.', $fileName);
  385. $this->error($message);
  386. SubmissionStatusManager::addError($this->identifier, 0, $message);
  387. $this->importMessages = $routine->getAllMessages();
  388. $this->importWarnings = $routine->getAllWarnings();
  389. $this->importErrors = $routine->getAllErrors();
  390. return;
  391. }
  392. try {
  393. $json = $disk->get($fileName);
  394. $transactions = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
  395. app('log')->debug(sprintf('Found %d transactions on the drive.', count($transactions)));
  396. } catch (FileNotFoundException|JsonException $e) {
  397. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_ERRORED, $this->identifier);
  398. $message = sprintf('File "%s" could not be decoded, cannot continue..', $fileName);
  399. $this->error($message);
  400. SubmissionStatusManager::addError($this->identifier, 0, $message);
  401. $this->importMessages = $routine->getAllMessages();
  402. $this->importWarnings = $routine->getAllWarnings();
  403. $this->importErrors = $routine->getAllErrors();
  404. return;
  405. }
  406. if (0 === count($transactions)) {
  407. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_DONE, $this->identifier);
  408. app('log')->error('No transactions in array, there is nothing to import.');
  409. $this->importMessages = $routine->getAllMessages();
  410. $this->importWarnings = $routine->getAllWarnings();
  411. $this->importErrors = $routine->getAllErrors();
  412. return;
  413. }
  414. $routine->setTransactions($transactions);
  415. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_RUNNING, $this->identifier);
  416. // then push stuff into the routine:
  417. $routine->setConfiguration($configuration);
  418. try {
  419. $routine->start();
  420. } catch (ImporterErrorException $e) {
  421. app('log')->error($e->getMessage());
  422. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_ERRORED, $this->identifier);
  423. SubmissionStatusManager::addError($this->identifier, 0, $e->getMessage());
  424. $this->importMessages = $routine->getAllMessages();
  425. $this->importWarnings = $routine->getAllWarnings();
  426. $this->importErrors = $routine->getAllErrors();
  427. return;
  428. }
  429. // set done:
  430. SubmissionStatusManager::setSubmissionStatus(SubmissionStatus::SUBMISSION_DONE, $this->identifier);
  431. $this->importMessages = $routine->getAllMessages();
  432. $this->importWarnings = $routine->getAllWarnings();
  433. $this->importErrors = $routine->getAllErrors();
  434. }
  435. }