2017-06-12 12:12:07 -05:00
|
|
|
<?php
|
|
|
|
/**
|
|
|
|
* CsvProcessor.php
|
|
|
|
* Copyright (c) 2017 thegrumpydictator@gmail.com
|
|
|
|
*
|
2017-10-21 01:40:00 -05:00
|
|
|
* This file is part of Firefly III.
|
|
|
|
*
|
|
|
|
* Firefly III is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU General Public License as published by
|
|
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
|
|
* (at your option) any later version.
|
|
|
|
*
|
|
|
|
* Firefly III is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU General Public License
|
2017-12-17 07:44:05 -06:00
|
|
|
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
|
2017-06-12 12:12:07 -05:00
|
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
namespace FireflyIII\Import\FileProcessor;
|
|
|
|
|
|
|
|
use FireflyIII\Exceptions\FireflyException;
|
2017-06-17 15:49:44 -05:00
|
|
|
use FireflyIII\Import\Object\ImportJournal;
|
2017-06-12 12:12:07 -05:00
|
|
|
use FireflyIII\Import\Specifics\SpecificInterface;
|
|
|
|
use FireflyIII\Models\ImportJob;
|
2018-01-05 10:29:42 -06:00
|
|
|
use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface;
|
2017-06-12 12:12:07 -05:00
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
use Iterator;
|
|
|
|
use League\Csv\Reader;
|
|
|
|
use Log;
|
|
|
|
|
|
|
|
/**
|
2017-06-17 15:49:44 -05:00
|
|
|
* Class CsvProcessor, as the name suggests, goes over CSV file line by line and creates
|
|
|
|
* "ImportJournal" objects, which are used in another step to create new journals and transactions
|
|
|
|
* and what-not.
|
2017-06-12 12:12:07 -05:00
|
|
|
*/
|
|
|
|
class CsvProcessor implements FileProcessorInterface
|
|
|
|
{
|
2017-11-15 05:25:49 -06:00
|
|
|
/** @var ImportJob */
|
2017-06-12 12:12:07 -05:00
|
|
|
private $job;
|
|
|
|
/** @var Collection */
|
|
|
|
private $objects;
|
2018-01-05 10:29:42 -06:00
|
|
|
/** @var ImportJobRepositoryInterface */
|
|
|
|
private $repository;
|
2017-06-12 12:12:07 -05:00
|
|
|
/** @var array */
|
|
|
|
private $validConverters = [];
|
|
|
|
/** @var array */
|
|
|
|
private $validSpecifics = [];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* FileProcessorInterface constructor.
|
|
|
|
*/
|
2017-06-14 13:13:19 -05:00
|
|
|
public function __construct()
|
2017-06-12 12:12:07 -05:00
|
|
|
{
|
|
|
|
$this->objects = new Collection;
|
|
|
|
$this->validSpecifics = array_keys(config('csv.import_specifics'));
|
|
|
|
$this->validConverters = array_keys(config('csv.import_roles'));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return Collection
|
2018-01-05 10:29:42 -06:00
|
|
|
* @throws FireflyException
|
2017-06-12 12:12:07 -05:00
|
|
|
*/
|
|
|
|
public function getObjects(): Collection
|
|
|
|
{
|
2018-01-05 10:29:42 -06:00
|
|
|
if (is_null($this->job)) {
|
|
|
|
throw new FireflyException('Cannot call getObjects() without a job.');
|
|
|
|
}
|
|
|
|
|
2017-06-14 13:13:19 -05:00
|
|
|
return $this->objects;
|
2017-06-12 12:12:07 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2017-08-12 09:12:30 -05:00
|
|
|
* Does the actual job.
|
2017-06-17 15:49:44 -05:00
|
|
|
*
|
2017-06-12 12:12:07 -05:00
|
|
|
* @return bool
|
2017-12-22 11:32:43 -06:00
|
|
|
*
|
2017-12-17 07:30:53 -06:00
|
|
|
* @throws \League\Csv\Exception
|
2017-12-29 02:05:35 -06:00
|
|
|
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
|
2018-01-05 10:29:42 -06:00
|
|
|
* @throws FireflyException
|
2017-06-12 12:12:07 -05:00
|
|
|
*/
|
|
|
|
public function run(): bool
|
|
|
|
{
|
2018-01-05 10:29:42 -06:00
|
|
|
if (is_null($this->job)) {
|
|
|
|
throw new FireflyException('Cannot call run() without a job.');
|
|
|
|
}
|
2017-06-17 15:49:44 -05:00
|
|
|
Log::debug('Now in CsvProcessor run(). Job is now running...');
|
2017-06-14 13:13:19 -05:00
|
|
|
|
2017-08-12 09:12:30 -05:00
|
|
|
$entries = new Collection($this->getImportArray());
|
2018-01-13 00:36:44 -06:00
|
|
|
$this->addStep();
|
2017-06-12 12:12:07 -05:00
|
|
|
Log::notice('Building importable objects from CSV file.');
|
2017-08-12 09:12:30 -05:00
|
|
|
Log::debug(sprintf('Number of entries: %d', $entries->count()));
|
|
|
|
$notImported = $entries->filter(
|
|
|
|
function (array $row, int $index) {
|
2017-12-13 10:09:55 -06:00
|
|
|
$row = array_values($row);
|
2017-08-12 09:12:30 -05:00
|
|
|
if ($this->rowAlreadyImported($row)) {
|
|
|
|
$message = sprintf('Row #%d has already been imported.', $index);
|
2018-01-10 11:18:49 -06:00
|
|
|
$this->repository->addError($this->job, $index, $message);
|
2017-08-12 09:12:30 -05:00
|
|
|
Log::info($message);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $row;
|
2017-06-17 15:49:44 -05:00
|
|
|
}
|
2017-08-12 09:12:30 -05:00
|
|
|
);
|
2018-01-13 00:36:44 -06:00
|
|
|
$this->addStep();
|
2017-08-12 09:12:30 -05:00
|
|
|
Log::debug(sprintf('Number of entries left: %d', $notImported->count()));
|
2017-06-23 22:49:33 -05:00
|
|
|
|
2017-08-12 09:12:30 -05:00
|
|
|
$notImported->each(
|
|
|
|
function (array $row, int $index) {
|
|
|
|
$journal = $this->importRow($index, $row);
|
|
|
|
$this->objects->push($journal);
|
|
|
|
}
|
|
|
|
);
|
2018-01-13 00:36:44 -06:00
|
|
|
$this->addStep();
|
2017-08-12 09:40:51 -05:00
|
|
|
|
2017-06-12 12:12:07 -05:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2018-01-05 10:29:42 -06:00
|
|
|
/**
|
2018-01-25 11:41:27 -06:00
|
|
|
* Shorthand method to set the extended status.
|
2018-01-05 10:29:42 -06:00
|
|
|
*
|
2018-01-25 11:41:27 -06:00
|
|
|
* @codeCoverageIgnore
|
2018-01-05 10:29:42 -06:00
|
|
|
* @param array $array
|
|
|
|
*/
|
|
|
|
public function setExtendedStatus(array $array)
|
|
|
|
{
|
|
|
|
$this->repository->setExtendedStatus($this->job, $array);
|
|
|
|
}
|
|
|
|
|
2017-06-14 13:13:19 -05:00
|
|
|
/**
|
2017-08-12 09:12:30 -05:00
|
|
|
* Set import job for this processor.
|
|
|
|
*
|
2017-06-14 13:13:19 -05:00
|
|
|
* @param ImportJob $job
|
|
|
|
*
|
|
|
|
* @return FileProcessorInterface
|
|
|
|
*/
|
|
|
|
public function setJob(ImportJob $job): FileProcessorInterface
|
|
|
|
{
|
2018-01-05 10:29:42 -06:00
|
|
|
$this->job = $job;
|
|
|
|
$this->repository = app(ImportJobRepositoryInterface::class);
|
|
|
|
$this->repository->setUser($job->user);
|
2017-06-14 13:13:19 -05:00
|
|
|
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
2018-01-13 00:36:44 -06:00
|
|
|
/**
|
2018-01-25 11:41:27 -06:00
|
|
|
* Shorthand method to add a step.
|
|
|
|
*
|
|
|
|
* @codeCoverageIgnore
|
2018-01-13 00:36:44 -06:00
|
|
|
*/
|
|
|
|
private function addStep()
|
|
|
|
{
|
|
|
|
$this->repository->addStepsDone($this->job, 1);
|
|
|
|
}
|
|
|
|
|
2017-06-12 12:12:07 -05:00
|
|
|
/**
|
2017-06-17 15:49:44 -05:00
|
|
|
* Add meta data to the individual value and verify that it can be handled in a later stage.
|
|
|
|
*
|
2017-06-12 12:12:07 -05:00
|
|
|
* @param int $index
|
|
|
|
* @param string $value
|
|
|
|
*
|
|
|
|
* @return array
|
2017-11-15 05:25:49 -06:00
|
|
|
*
|
2017-06-12 12:12:07 -05:00
|
|
|
* @throws FireflyException
|
|
|
|
*/
|
|
|
|
private function annotateValue(int $index, string $value)
|
|
|
|
{
|
2018-01-05 10:29:42 -06:00
|
|
|
$config = $this->getConfig();
|
2017-06-12 12:12:07 -05:00
|
|
|
$role = $config['column-roles'][$index] ?? '_ignore';
|
|
|
|
$mapped = $config['column-mapping-config'][$index][$value] ?? null;
|
|
|
|
|
|
|
|
// throw error when not a valid converter.
|
|
|
|
if (!in_array($role, $this->validConverters)) {
|
|
|
|
throw new FireflyException(sprintf('"%s" is not a valid role.', $role));
|
|
|
|
}
|
2017-06-17 15:49:44 -05:00
|
|
|
|
2017-06-12 12:12:07 -05:00
|
|
|
$entry = [
|
|
|
|
'role' => $role,
|
|
|
|
'value' => $value,
|
|
|
|
'mapped' => $mapped,
|
|
|
|
];
|
|
|
|
|
|
|
|
return $entry;
|
|
|
|
}
|
|
|
|
|
2018-01-05 10:29:42 -06:00
|
|
|
/**
|
2018-01-25 11:41:27 -06:00
|
|
|
* Shorthand method to return configuration.
|
2018-01-05 10:29:42 -06:00
|
|
|
*
|
2018-01-25 11:41:27 -06:00
|
|
|
* @codeCoverageIgnore
|
2018-01-05 10:29:42 -06:00
|
|
|
* @return array
|
|
|
|
*/
|
|
|
|
private function getConfig(): array
|
|
|
|
{
|
|
|
|
return $this->repository->getConfiguration($this->job);
|
|
|
|
}
|
|
|
|
|
2017-06-12 12:12:07 -05:00
|
|
|
/**
|
|
|
|
* @return Iterator
|
2017-12-22 11:32:43 -06:00
|
|
|
*
|
2017-12-17 07:30:53 -06:00
|
|
|
* @throws \League\Csv\Exception
|
|
|
|
* @throws \League\Csv\Exception
|
2017-06-12 12:12:07 -05:00
|
|
|
*/
|
|
|
|
private function getImportArray(): Iterator
|
|
|
|
{
|
2018-01-05 10:29:42 -06:00
|
|
|
$content = $this->repository->uploadFileContents($this->job);
|
|
|
|
$config = $this->getConfig();
|
|
|
|
$reader = Reader::createFromString($content);
|
|
|
|
$delimiter = $config['delimiter'] ?? ',';
|
|
|
|
$hasHeaders = isset($config['has-headers']) ? $config['has-headers'] : false;
|
2017-11-15 05:25:49 -06:00
|
|
|
if ('tab' === $delimiter) {
|
2018-01-05 10:29:42 -06:00
|
|
|
$delimiter = "\t"; // @codeCoverageIgnore
|
2017-09-08 13:24:11 -05:00
|
|
|
}
|
|
|
|
$reader->setDelimiter($delimiter);
|
2018-01-05 10:29:42 -06:00
|
|
|
if ($hasHeaders) {
|
|
|
|
$reader->setHeaderOffset(0); // @codeCoverageIgnore
|
2017-12-12 13:53:16 -06:00
|
|
|
}
|
|
|
|
$results = $reader->getRecords();
|
|
|
|
Log::debug('Created a CSV reader.');
|
2017-06-12 12:12:07 -05:00
|
|
|
|
|
|
|
return $results;
|
|
|
|
}
|
|
|
|
|
2017-08-11 23:40:17 -05:00
|
|
|
/**
|
|
|
|
* Will return string representation of JSON error code.
|
|
|
|
*
|
|
|
|
* @param int $jsonError
|
|
|
|
*
|
2018-01-05 10:29:42 -06:00
|
|
|
* @codeCoverageIgnore
|
2017-08-11 23:40:17 -05:00
|
|
|
* @return string
|
|
|
|
*/
|
|
|
|
private function getJsonError(int $jsonError): string
|
|
|
|
{
|
2017-09-29 08:32:47 -05:00
|
|
|
$messages = [
|
|
|
|
JSON_ERROR_NONE => 'No JSON error',
|
|
|
|
JSON_ERROR_DEPTH => 'The maximum stack depth has been exceeded.',
|
|
|
|
JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON.',
|
|
|
|
JSON_ERROR_CTRL_CHAR => 'Control character error, possibly incorrectly encoded.',
|
|
|
|
JSON_ERROR_SYNTAX => 'Syntax error.',
|
|
|
|
JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded.',
|
|
|
|
JSON_ERROR_RECURSION => 'One or more recursive references in the value to be encoded.',
|
|
|
|
JSON_ERROR_INF_OR_NAN => 'One or more NAN or INF values in the value to be encoded.',
|
|
|
|
JSON_ERROR_UNSUPPORTED_TYPE => 'A value of a type that cannot be encoded was given.',
|
|
|
|
JSON_ERROR_INVALID_PROPERTY_NAME => 'A property name that cannot be encoded was given.',
|
|
|
|
JSON_ERROR_UTF16 => 'Malformed UTF-16 characters, possibly incorrectly encoded.',
|
|
|
|
];
|
|
|
|
if (isset($messages[$jsonError])) {
|
|
|
|
return $messages[$jsonError];
|
2017-08-11 23:40:17 -05:00
|
|
|
}
|
2017-09-29 08:32:47 -05:00
|
|
|
|
|
|
|
return 'Unknown JSON error';
|
2017-08-11 23:40:17 -05:00
|
|
|
}
|
|
|
|
|
2017-08-12 09:12:30 -05:00
|
|
|
/**
|
|
|
|
* Hash an array and return the result.
|
|
|
|
*
|
|
|
|
* @param array $array
|
|
|
|
*
|
|
|
|
* @return string
|
2017-11-15 05:25:49 -06:00
|
|
|
*
|
2017-08-12 09:12:30 -05:00
|
|
|
* @throws FireflyException
|
|
|
|
*/
|
|
|
|
private function getRowHash(array $array): string
|
|
|
|
{
|
|
|
|
$json = json_encode($array);
|
|
|
|
$jsonError = json_last_error();
|
|
|
|
|
2017-11-15 05:25:49 -06:00
|
|
|
if (false === $json) {
|
2018-01-05 10:29:42 -06:00
|
|
|
throw new FireflyException(sprintf('Error while encoding JSON for CSV row: %s', $this->getJsonError($jsonError))); // @codeCoverageIgnore
|
2017-08-12 09:12:30 -05:00
|
|
|
}
|
|
|
|
$hash = hash('sha256', $json);
|
2017-08-12 09:40:51 -05:00
|
|
|
|
2017-08-12 09:12:30 -05:00
|
|
|
return $hash;
|
|
|
|
}
|
|
|
|
|
2017-06-12 12:12:07 -05:00
|
|
|
/**
|
2017-06-17 15:49:44 -05:00
|
|
|
* Take a row, build import journal by annotating each value and storing it in the import journal.
|
2017-06-20 14:04:25 -05:00
|
|
|
*
|
2017-06-17 15:49:44 -05:00
|
|
|
* @param int $index
|
|
|
|
* @param array $row
|
2017-06-12 12:12:07 -05:00
|
|
|
*
|
2017-06-17 15:49:44 -05:00
|
|
|
* @return ImportJournal
|
2017-11-15 05:25:49 -06:00
|
|
|
*
|
2017-08-11 23:40:17 -05:00
|
|
|
* @throws FireflyException
|
2017-06-12 12:12:07 -05:00
|
|
|
*/
|
2017-06-17 15:49:44 -05:00
|
|
|
private function importRow(int $index, array $row): ImportJournal
|
2017-06-12 12:12:07 -05:00
|
|
|
{
|
2017-12-13 10:09:55 -06:00
|
|
|
$row = array_values($row);
|
2017-06-17 15:49:44 -05:00
|
|
|
Log::debug(sprintf('Now at row %d', $index));
|
2018-01-05 10:29:42 -06:00
|
|
|
$row = $this->specifics($row);
|
|
|
|
$hash = $this->getRowHash($row);
|
|
|
|
$config = $this->getConfig();
|
2017-08-11 23:40:17 -05:00
|
|
|
|
2017-06-17 15:49:44 -05:00
|
|
|
$journal = new ImportJournal;
|
|
|
|
$journal->setUser($this->job->user);
|
2017-08-12 09:12:30 -05:00
|
|
|
$journal->setHash($hash);
|
2017-06-12 12:12:07 -05:00
|
|
|
|
2017-08-12 09:12:30 -05:00
|
|
|
/**
|
2017-11-15 05:25:49 -06:00
|
|
|
* @var int
|
2017-08-12 09:12:30 -05:00
|
|
|
* @var string $value
|
|
|
|
*/
|
2017-06-17 15:49:44 -05:00
|
|
|
foreach ($row as $rowIndex => $value) {
|
2017-12-16 13:21:05 -06:00
|
|
|
$value = trim(strval($value));
|
2017-06-20 14:04:25 -05:00
|
|
|
if (strlen($value) > 0) {
|
|
|
|
$annotated = $this->annotateValue($rowIndex, $value);
|
|
|
|
Log::debug('Annotated value', $annotated);
|
|
|
|
$journal->setValue($annotated);
|
|
|
|
}
|
2017-06-12 12:12:07 -05:00
|
|
|
}
|
2017-08-12 09:40:51 -05:00
|
|
|
// set some extra info:
|
2018-01-05 10:29:42 -06:00
|
|
|
$importAccount = intval($config['import-account'] ?? 0);
|
|
|
|
$journal->asset->setDefaultAccountId($importAccount);
|
2017-06-12 12:12:07 -05:00
|
|
|
|
2017-06-17 15:49:44 -05:00
|
|
|
return $journal;
|
2017-06-12 12:12:07 -05:00
|
|
|
}
|
|
|
|
|
2017-06-23 22:49:33 -05:00
|
|
|
/**
|
|
|
|
* Checks if the row has not been imported before.
|
|
|
|
*
|
|
|
|
* @param array $array
|
|
|
|
*
|
|
|
|
* @return bool
|
2017-12-22 11:32:43 -06:00
|
|
|
*
|
2017-12-17 07:30:53 -06:00
|
|
|
* @throws FireflyException
|
2017-06-23 22:49:33 -05:00
|
|
|
*/
|
|
|
|
private function rowAlreadyImported(array $array): bool
|
|
|
|
{
|
2017-08-12 09:40:51 -05:00
|
|
|
$hash = $this->getRowHash($array);
|
2018-01-05 10:29:42 -06:00
|
|
|
$count = $this->repository->countByHash($hash);
|
|
|
|
if ($count > 0) {
|
2017-06-23 22:49:33 -05:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2017-06-12 12:12:07 -05:00
|
|
|
/**
|
|
|
|
* And this is the point where the specifix go to work.
|
|
|
|
*
|
|
|
|
* @param array $row
|
|
|
|
*
|
|
|
|
* @return array
|
2017-11-15 05:25:49 -06:00
|
|
|
*
|
2017-06-12 12:12:07 -05:00
|
|
|
* @throws FireflyException
|
|
|
|
*/
|
|
|
|
private function specifics(array $row): array
|
|
|
|
{
|
2018-01-10 11:18:49 -06:00
|
|
|
$config = $this->getConfig();
|
2017-12-19 12:25:50 -06:00
|
|
|
$names = array_keys($config['specifics'] ?? []);
|
2017-08-12 03:27:45 -05:00
|
|
|
foreach ($names as $name) {
|
2017-06-12 12:12:07 -05:00
|
|
|
if (!in_array($name, $this->validSpecifics)) {
|
|
|
|
throw new FireflyException(sprintf('"%s" is not a valid class name', $name));
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @var SpecificInterface $specific */
|
|
|
|
$specific = app('FireflyIII\Import\Specifics\\' . $name);
|
|
|
|
|
|
|
|
// it returns the row, possibly modified:
|
|
|
|
$row = $specific->run($row);
|
|
|
|
}
|
|
|
|
|
|
|
|
return $row;
|
|
|
|
}
|
2017-07-07 01:09:42 -05:00
|
|
|
}
|