firefly-iii/app/Import/FileProcessor/CsvProcessor.php

378 lines
11 KiB
PHP
Raw Normal View History

<?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/>.
*/
declare(strict_types=1);
namespace FireflyIII\Import\FileProcessor;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Import\Object\ImportJournal;
use FireflyIII\Import\Specifics\SpecificInterface;
use FireflyIII\Models\ImportJob;
2018-01-05 10:29:42 -06:00
use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface;
use Illuminate\Support\Collection;
use League\Csv\Reader;
2018-03-19 03:09:02 -05:00
use League\Csv\Statement;
use Log;
/**
* 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.
*/
class CsvProcessor implements FileProcessorInterface
{
2017-11-15 05:25:49 -06:00
/** @var ImportJob */
private $job;
/** @var Collection */
private $objects;
2018-01-05 10:29:42 -06:00
/** @var ImportJobRepositoryInterface */
private $repository;
/** @var array */
private $validConverters = [];
/** @var array */
private $validSpecifics = [];
/**
* FileProcessorInterface constructor.
*/
2017-06-14 13:13:19 -05:00
public function __construct()
{
$this->objects = new Collection;
$this->validSpecifics = array_keys(config('csv.import_specifics'));
$this->validConverters = array_keys(config('csv.import_roles'));
}
/**
* @return Collection
2018-03-24 04:35:42 -05:00
*
2018-01-05 10:29:42 -06:00
* @throws FireflyException
*/
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-08-12 09:12:30 -05:00
* Does the actual job.
*
* @return bool
2017-12-22 11:32:43 -06:00
*
2018-01-05 10:29:42 -06:00
* @throws FireflyException
2018-03-11 10:24:07 -05:00
* @throws \League\Csv\Exception
*/
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.');
}
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());
$this->addStep();
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-08-12 09:12:30 -05:00
);
$this->addStep();
2017-08-12 09:12:30 -05:00
Log::debug(sprintf('Number of entries left: %d', $notImported->count()));
2017-08-12 09:12:30 -05:00
$notImported->each(
function (array $row, int $index) {
$journal = $this->importRow($index, $row);
$this->objects->push($journal);
}
);
$this->addStep();
2017-08-12 09:40:51 -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-02-09 12:11:55 -06:00
*
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-25 11:41:27 -06:00
* Shorthand method to add a step.
*
* @codeCoverageIgnore
*/
private function addStep()
{
$this->repository->addStepsDone($this->job, 1);
}
/**
* Add meta data to the individual value and verify that it can be handled in a later stage.
*
* @param int $index
* @param string $value
*
* @return array
2017-11-15 05:25:49 -06:00
*
* @throws FireflyException
*/
private function annotateValue(int $index, string $value)
{
2018-01-05 10:29:42 -06:00
$config = $this->getConfig();
$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));
}
$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-03-24 04:35:42 -05:00
*
2018-01-05 10:29:42 -06:00
* @return array
*/
private function getConfig(): array
{
return $this->repository->getConfiguration($this->job);
}
/**
2018-03-19 03:09:02 -05:00
* @return array
2017-12-22 11:32:43 -06:00
*
2017-12-17 07:30:53 -06:00
* @throws \League\Csv\Exception
* @throws \League\Csv\Exception
*/
2018-03-19 03:09:02 -05:00
private function getImportArray(): array
{
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;
2018-03-19 03:09:02 -05:00
$offset = 0;
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) {
2018-03-19 03:09:02 -05:00
$offset = 1;
}
$stmt = (new Statement)->offset($offset);
$records = $stmt->process($reader);
$return = [];
foreach ($records as $record) {
$return[] = $record;
2017-12-12 13:53:16 -06:00
}
Log::debug('Created a CSV reader.');
2018-03-19 03:09:02 -05:00
return $return;
}
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
2018-03-24 04:35:42 -05:00
*
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;
}
/**
* 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
*
* @param int $index
* @param array $row
*
* @return ImportJournal
2017-11-15 05:25:49 -06:00
*
2017-08-11 23:40:17 -05:00
* @throws FireflyException
*/
private function importRow(int $index, array $row): ImportJournal
{
2017-12-13 10:09:55 -06:00
$row = array_values($row);
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
$journal = new ImportJournal;
$journal->setUser($this->job->user);
2017-08-12 09:12:30 -05:00
$journal->setHash($hash);
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
*/
foreach ($row as $rowIndex => $value) {
$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-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);
return $journal;
}
/**
* 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
*/
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) {
return true;
}
return false;
}
/**
* And this is the point where the specifix go to work.
*
* @param array $row
*
* @return array
2017-11-15 05:25:49 -06: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) {
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
}