. */ declare(strict_types=1); namespace FireflyIII\Support\Search; use Carbon\Carbon; use FireflyIII\Helpers\Collector\TransactionCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Models\Transaction; use FireflyIII\User; use Illuminate\Support\Collection; use Log; /** * Class Search. */ class Search implements SearchInterface { /** @var int */ private $limit = 100; /** @var Collection */ private $modifiers; /** @var string */ private $originalQuery = ''; /** @var User */ private $user; /** @var array */ private $validModifiers; /** @var array */ private $words = []; /** * Search constructor. */ public function __construct() { $this->modifiers = new Collection; $this->validModifiers = (array)config('firefly.search_modifiers'); if ('testing' === env('APP_ENV')) { Log::warning(sprintf('%s should not be instantiated in the TEST environment!', \get_class($this))); } } /** * @return string */ public function getWordsAsString(): string { $string = implode(' ', $this->words); if ('' === $string) { return \is_string($this->originalQuery) ? $this->originalQuery : ''; } return $string; } /** * @return bool */ public function hasModifiers(): bool { return $this->modifiers->count() > 0; } /** * @param string $query */ public function parseQuery(string $query): void { $filteredQuery = $query; $this->originalQuery = $query; $pattern = '/[a-z_]*:[0-9a-z-.]*/i'; $matches = []; preg_match_all($pattern, $query, $matches); foreach ($matches[0] as $match) { $this->extractModifier($match); $filteredQuery = str_replace($match, '', $filteredQuery); } $filteredQuery = trim(str_replace(['"', "'"], '', $filteredQuery)); if (\strlen($filteredQuery) > 0) { $this->words = array_map('trim', explode(' ', $filteredQuery)); } } /** * @return Collection */ public function searchTransactions(): Collection { Log::debug('Start of searchTransactions()'); $pageSize = 100; $processed = 0; $page = 1; $result = new Collection(); $startTime = microtime(true); do { /** @var TransactionCollectorInterface $collector */ $collector = app(TransactionCollectorInterface::class); $collector->setAllAssetAccounts()->setLimit($pageSize)->setPage($page)->withOpposingAccount(); if ($this->hasModifiers()) { $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); } // some modifiers can be applied to the collector directly. $collector = $this->applyModifiers($collector); $collector->removeFilter(InternalTransferFilter::class); $set = $collector->getPaginatedTransactions()->getCollection(); Log::debug(sprintf('Found %d journals to check. ', $set->count())); // Filter transactions that match the given triggers. $filtered = $set->filter( function (Transaction $transaction) { if ($this->matchModifiers($transaction)) { return $transaction; } // return false: return false; } ); Log::debug(sprintf('Found %d journals that match.', $filtered->count())); // merge: /** @var Collection $result */ $result = $result->merge($filtered); Log::debug(sprintf('Total count is now %d', $result->count())); // Update counters ++$page; $processed += \count($set); Log::debug(sprintf('Page is now %d, processed is %d', $page, $processed)); // Check for conditions to finish the loop $reachedEndOfList = $set->count() < 1; $foundEnough = $result->count() >= $this->limit; Log::debug(sprintf('reachedEndOfList: %s', var_export($reachedEndOfList, true))); Log::debug(sprintf('foundEnough: %s', var_export($foundEnough, true))); // break at some point so the script does not crash: $currentTime = microtime(true) - $startTime; Log::debug(sprintf('Have been running for %f seconds.', $currentTime)); } while (!$reachedEndOfList && !$foundEnough && $currentTime <= 30); $result = $result->slice(0, $this->limit); return $result; } /** * @param int $limit */ public function setLimit(int $limit): void { $this->limit = $limit; } /** * @param User $user */ public function setUser(User $user): void { $this->user = $user; } /** * @param TransactionCollectorInterface $collector * * @return TransactionCollectorInterface * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ private function applyModifiers(TransactionCollectorInterface $collector): TransactionCollectorInterface { foreach ($this->modifiers as $modifier) { switch ($modifier['type']) { case 'amount_is': case 'amount': $amount = app('steam')->positive((string)$modifier['value']); Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $amount)); $collector->amountIs($amount); break; case 'amount_max': case 'amount_less': $amount = app('steam')->positive((string)$modifier['value']); Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $amount)); $collector->amountLess($amount); break; case 'amount_min': case 'amount_more': $amount = app('steam')->positive((string)$modifier['value']); Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $amount)); $collector->amountMore($amount); break; case 'type': $collector->setTypes([ucfirst($modifier['value'])]); Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $modifier['value'])); break; case 'date': case 'on': Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $modifier['value'])); $start = new Carbon($modifier['value']); $collector->setRange($start, $start); break; case 'date_before': case 'before': Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $modifier['value'])); $before = new Carbon($modifier['value']); $collector->setBefore($before); break; case 'date_after': case 'after': Log::debug(sprintf('Set "%s" using collector with value "%s"', $modifier['type'], $modifier['value'])); $after = new Carbon($modifier['value']); $collector->setBefore($after); break; } } return $collector; } /** * @param string $string */ private function extractModifier(string $string): void { $parts = explode(':', $string); if (2 === \count($parts) && '' !== trim((string)$parts[1]) && \strlen(trim((string)$parts[0])) > 0) { $type = trim((string)$parts[0]); $value = trim((string)$parts[1]); if (\in_array($type, $this->validModifiers, true)) { // filter for valid type $this->modifiers->push(['type' => $type, 'value' => $value]); } } } /** * @param Transaction $transaction * * @return bool * */ private function matchModifiers(Transaction $transaction): bool { Log::debug(sprintf('Now at transaction #%d', $transaction->id)); // first "modifier" is always the text of the search: // check descr of journal: if (\count($this->words) > 0 && !$this->strposArray(strtolower((string)$transaction->description), $this->words) && !$this->strposArray(strtolower((string)$transaction->transaction_description), $this->words) ) { Log::debug('Description does not match', $this->words); return false; } // then a for-each and a switch for every possible other thingie. foreach ($this->modifiers as $modifier) { $res = Modifier::apply($modifier, $transaction); if (false === $res) { return $res; } } return true; } /** * @param string $haystack * @param array $needle * * @return bool */ private function strposArray(string $haystack, array $needle): bool { if ('' === $haystack) { return false; } foreach ($needle as $what) { if (false !== stripos($haystack, $what)) { return true; } } return false; } }