From 5073fd937c1ae789387a905af1cc89c3cf6691cd Mon Sep 17 00:00:00 2001 From: James Cole Date: Sat, 18 Feb 2017 20:10:03 +0100 Subject: [PATCH] Expand search with a bunch of keywords for #510 --- app/Http/Controllers/SearchController.php | 68 ++++++--- app/Support/Search/Modifier.php | 108 ++++++++++++++ app/Support/Search/Search.php | 173 ++++++++++++++++++---- app/Support/Search/SearchInterface.php | 47 +++--- config/firefly.php | 2 + resources/views/search/index.twig | 161 ++++++++++++-------- 6 files changed, 426 insertions(+), 133 deletions(-) create mode 100644 app/Support/Search/Modifier.php diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 4b47777c62..17e868a2fb 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -15,6 +15,8 @@ namespace FireflyIII\Http\Controllers; use FireflyIII\Support\Search\SearchInterface; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use View; /** * Class SearchController @@ -30,44 +32,66 @@ class SearchController extends Controller { parent::__construct(); + $this->middleware( + function ($request, $next) { + View::share('mainTitleIcon', 'fa-search'); + View::share('title', trans('firefly.search')); + + return $next($request); + } + ); + } /** - * Results always come in the form of an array [results, count, fullCount] - * * @param Request $request * @param SearchInterface $searcher * - * @return $this + * @return View */ public function index(Request $request, SearchInterface $searcher) { - $minSearchLen = 1; - $subTitle = null; - $query = null; - $result = []; - $title = trans('firefly.search'); - $limit = 20; - $mainTitleIcon = 'fa-search'; + // yes, hard coded values: + $minSearchLen = 1; + $limit = 20; - // set limit for search: - $searcher->setLimit($limit); + // ui stuff: + $subTitle = ''; + + // query stuff + $query = null; + $result = []; + $rawQuery = $request->get('q'); + $hasModifiers = true; + $modifiers = []; if (!is_null($request->get('q')) && strlen($request->get('q')) >= $minSearchLen) { - $query = trim(strtolower($request->get('q'))); - $words = explode(' ', $query); - $subTitle = trans('firefly.search_results_for', ['query' => $query]); + // parse query, find modifiers: + // set limit for search + $searcher->setLimit($limit); + $searcher->parseQuery($request->get('q')); - $transactions = $searcher->searchTransactions($words); - $accounts = $searcher->searchAccounts($words); - $categories = $searcher->searchCategories($words); - $budgets = $searcher->searchBudgets($words); - $tags = $searcher->searchTags($words); - $result = ['transactions' => $transactions, 'accounts' => $accounts, 'categories' => $categories, 'budgets' => $budgets, 'tags' => $tags]; + $transactions = $searcher->searchTransactions(); + $accounts = new Collection; + $categories = new Collection; + $tags = new Collection; + $budgets = new Collection; + + // no special search thing? + if (!$searcher->hasModifiers()) { + $hasModifiers = false; + $accounts = $searcher->searchAccounts(); + $categories = $searcher->searchCategories(); + $budgets = $searcher->searchBudgets(); + $tags = $searcher->searchTags(); + } + $query = $searcher->getWordsAsString(); + $subTitle = trans('firefly.search_results_for', ['query' => $query]); + $result = ['transactions' => $transactions, 'accounts' => $accounts, 'categories' => $categories, 'budgets' => $budgets, 'tags' => $tags]; } - return view('search.index', compact('title', 'subTitle', 'limit', 'mainTitleIcon', 'query', 'result')); + return view('search.index', compact('rawQuery', 'hasModifiers', 'modifiers', 'subTitle', 'limit', 'query', 'result')); } } diff --git a/app/Support/Search/Modifier.php b/app/Support/Search/Modifier.php new file mode 100644 index 0000000000..821e34c0fe --- /dev/null +++ b/app/Support/Search/Modifier.php @@ -0,0 +1,108 @@ +transaction_amount); + + $compare = bccomp($amount, $transactionAmount); + Log::debug(sprintf('%s vs %s is %d', $amount, $transactionAmount, $compare)); + + return $compare === $expected; + } + + /** + * @param Transaction $transaction + * @param string $amount + * + * @return bool + */ + public static function amountIs(Transaction $transaction, string $amount): bool + { + return self::amountCompare($transaction, $amount, 0); + } + + /** + * @param Transaction $transaction + * @param string $amount + * + * @return bool + */ + public static function amountLess(Transaction $transaction, string $amount): bool + { + return self::amountCompare($transaction, $amount, 1); + } + + /** + * @param Transaction $transaction + * @param string $amount + * + * @return bool + */ + public static function amountMore(Transaction $transaction, string $amount): bool + { + return self::amountCompare($transaction, $amount, -1); + } + + /** + * @param Transaction $transaction + * @param string $destination + * + * @return bool + */ + public static function destination(Transaction $transaction, string $destination): bool + { + return self::stringCompare($transaction->opposing_account_name, $destination); + } + + /** + * @param Transaction $transaction + * @param string $source + * + * @return bool + */ + public static function source(Transaction $transaction, string $source): bool + { + return self::stringCompare($transaction->account_name, $source); + } + + /** + * @param string $haystack + * @param string $needle + * + * @return bool + */ + public static function stringCompare(string $haystack, string $needle): bool + { + $res = !(strpos(strtolower($haystack), strtolower($needle)) === false); + Log::debug(sprintf('"%s" is in "%s"? %s', $needle, $haystack, var_export($res, true))); + + return $res; + + } +} \ No newline at end of file diff --git a/app/Support/Search/Search.php b/app/Support/Search/Search.php index b37af85daa..2063252ae3 100644 --- a/app/Support/Search/Search.php +++ b/app/Support/Search/Search.php @@ -14,6 +14,7 @@ declare(strict_types = 1); namespace FireflyIII\Support\Search; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Helpers\Collector\JournalCollectorInterface; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; @@ -34,19 +35,64 @@ class Search implements SearchInterface { /** @var int */ private $limit = 100; + /** @var Collection */ + private $modifiers; /** @var User */ private $user; + /** @var array */ + private $validModifiers = []; + /** @var array */ + private $words = []; + + /** + * Search constructor. + */ + public function __construct() + { + $this->modifiers = new Collection; + $this->validModifiers = config('firefly.search_modifiers'); + } + + /** + * @return string + */ + public function getWordsAsString(): string + { + return join(' ', $this->words); + } + + /** + * @return bool + */ + public function hasModifiers(): bool + { + return $this->modifiers->count() > 0; + } + + /** + * @param string $query + */ + public function parseQuery(string $query) + { + $filteredQuery = $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)); + $this->words = array_map('trim', explode(' ', $filteredQuery)); + } /** - * The search will assume that the user does not have so many accounts - * that this search should be paginated. - * - * @param array $words - * * @return Collection */ - public function searchAccounts(array $words): Collection + public function searchAccounts(): Collection { + $words = $this->words; $accounts = $this->user->accounts() ->accountTypeIn([AccountType::DEFAULT, AccountType::ASSET, AccountType::EXPENSE, AccountType::REVENUE, AccountType::BENEFICIARY]) ->get(['accounts.*']); @@ -67,14 +113,13 @@ class Search implements SearchInterface } /** - * @param array $words - * * @return Collection */ - public function searchBudgets(array $words): Collection + public function searchBudgets(): Collection { /** @var Collection $set */ - $set = auth()->user()->budgets()->get(); + $set = auth()->user()->budgets()->get(); + $words = $this->words; /** @var Collection $result */ $result = $set->filter( function (Budget $budget) use ($words) { @@ -92,14 +137,11 @@ class Search implements SearchInterface } /** - * Search assumes the user does not have that many categories. So no paginated search. - * - * @param array $words - * * @return Collection */ - public function searchCategories(array $words): Collection + public function searchCategories(): Collection { + $words = $this->words; $categories = $this->user->categories()->get(); /** @var Collection $result */ $result = $categories->filter( @@ -117,15 +159,12 @@ class Search implements SearchInterface } /** - * - * @param array $words - * * @return Collection */ - public function searchTags(array $words): Collection + public function searchTags(): Collection { - $tags = $this->user->tags()->get(); - + $words = $this->words; + $tags = $this->user->tags()->get(); /** @var Collection $result */ $result = $tags->filter( function (Tag $tag) use ($words) { @@ -142,11 +181,9 @@ class Search implements SearchInterface } /** - * @param array $words - * * @return Collection */ - public function searchTransactions(array $words): Collection + public function searchTransactions(): Collection { $pageSize = 100; $processed = 0; @@ -156,20 +193,17 @@ class Search implements SearchInterface /** @var JournalCollectorInterface $collector */ $collector = app(JournalCollectorInterface::class); $collector->setUser($this->user); - $collector->setAllAssetAccounts()->setLimit($pageSize)->setPage($page); - $set = $collector->getPaginatedJournals(); + $collector->setAllAssetAccounts()->setLimit($pageSize)->setPage($page)->withOpposingAccount(); + $set = $collector->getPaginatedJournals(); + $words = $this->words; + Log::debug(sprintf('Found %d journals to check. ', $set->count())); // Filter transactions that match the given triggers. $filtered = $set->filter( function (Transaction $transaction) use ($words) { - // check descr of journal: - if ($this->strpos_arr(strtolower(strval($transaction->description)), $words)) { - return $transaction; - } - // check descr of transaction - if ($this->strpos_arr(strtolower(strval($transaction->transaction_description)), $words)) { + if ($this->matchModifiers($transaction)) { return $transaction; } @@ -201,6 +235,8 @@ class Search implements SearchInterface } while (!$reachedEndOfList && !$foundEnough); $result = $result->slice(0, $this->limit); + var_dump($result->toArray()); + exit; return $result; } @@ -221,6 +257,79 @@ class Search implements SearchInterface $this->user = $user; } + /** + * @param string $string + */ + private function extractModifier(string $string) + { + $parts = explode(':', $string); + if (count($parts) === 2 && strlen(trim(strval($parts[0]))) > 0 && strlen(trim(strval($parts[1])))) { + $type = trim(strval($parts[0])); + $value = trim(strval($parts[1])); + if (in_array($type, $this->validModifiers)) { + // filter for valid type + $this->modifiers->push(['type' => $type, 'value' => $value,]); + } + } + } + + /** + * @param Transaction $transaction + * + * @return bool + * @throws FireflyException + */ + 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 (!$this->strpos_arr(strtolower(strval($transaction->description)), $this->words) + && !$this->strpos_arr(strtolower(strval($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) { + switch ($modifier['type']) { + default: + throw new FireflyException(sprintf('Search modifier "%s" is not (yet) supported. Sorry!', $modifier['type'])); + break; + case 'amount_is': + $res = Modifier::amountIs($transaction, $modifier['value']); + Log::debug(sprintf('Amount is %s? %s', $modifier['value'], var_export($res, true))); + break; + case 'amount_less': + $res = Modifier::amountLess($transaction, $modifier['value']); + Log::debug(sprintf('Amount less than %s? %s', $modifier['value'], var_export($res, true))); + break; + case 'amount_more': + $res = Modifier::amountMore($transaction, $modifier['value']); + Log::debug(sprintf('Amount more than %s? %s', $modifier['value'], var_export($res, true))); + break; + case 'source': + $res = Modifier::source($transaction, $modifier['value']); + Log::debug(sprintf('Source is %s? %s', $modifier['value'], var_export($res, true))); + break; + case 'destination': + $res = Modifier::destination($transaction, $modifier['value']); + Log::debug(sprintf('Destination is %s? %s', $modifier['value'], var_export($res, true))); + break; + + } + if ($res === false) { + return $res; + } + } + + return true; + + } + /** * @param string $haystack * @param array $needle diff --git a/app/Support/Search/SearchInterface.php b/app/Support/Search/SearchInterface.php index 918f3caee3..e3962fcfea 100644 --- a/app/Support/Search/SearchInterface.php +++ b/app/Support/Search/SearchInterface.php @@ -24,40 +24,49 @@ use Illuminate\Support\Collection; interface SearchInterface { /** - * @param array $words - * - * @return Collection + * @return string */ - public function searchAccounts(array $words): Collection; + public function getWordsAsString(): string; /** - * @param array $words - * - * @return Collection + * @return bool */ - public function searchBudgets(array $words): Collection; + public function hasModifiers(): bool; /** - * @param array $words - * - * @return Collection + * @param string $query */ - public function searchCategories(array $words): Collection; + public function parseQuery(string $query); /** - * - * @param array $words - * * @return Collection */ - public function searchTags(array $words): Collection; + public function searchAccounts(): Collection; /** - * @param array $words - * * @return Collection */ - public function searchTransactions(array $words): Collection; + public function searchBudgets(): Collection; + + /** + * @return Collection + */ + public function searchCategories(): Collection; + + /** + * @return Collection + */ + public function searchTags(): Collection; + + /** + * @return Collection + */ + public function searchTransactions(): Collection; + + /** + * @param int $limit + */ + public function setLimit(int $limit); /** * @param User $user diff --git a/config/firefly.php b/config/firefly.php index 5ffebe0c43..6844fa7a10 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -209,4 +209,6 @@ return [ ], 'default_currency' => 'EUR', 'default_language' => 'en_US', + 'search_modifiers' => ['amount_is', 'amount_less', 'amount_more', 'source', 'destination', 'category', 'budget', 'tag', 'bill', 'type', 'date_on', + 'date_before', 'date_after', 'has_attachments', 'notes',], ]; diff --git a/resources/views/search/index.twig b/resources/views/search/index.twig index e166a612c7..fc82a50940 100644 --- a/resources/views/search/index.twig +++ b/resources/views/search/index.twig @@ -16,15 +16,38 @@
+
+
+

{{ 'advanced_search'|_ }}

+
+
+

+ There are several modifiers that you can use in your search to narrow down the results. + If you use any of these, the search will only return transactions. + Please click the -icon for more information. +

+ {# search form #} +
+
+ +
+ +
+
+
+
+ +
+
+
+
+

{{ trans('firefly.results_limited', {count: limit}) }}

- -
- - - {% if result.transactions|length > 0 %} -
+ {% if hasModifiers %} +
+

{{ 'transactions'|_ }}

@@ -37,68 +60,86 @@
- {% endif %} - {% if result.categories|length > 0 %} -
-
-
-

{{ 'categories'|_ }}

-
-
-

- {{ trans('firefly.search_found_categories', {count: result.categories|length}) }} -

- {% include 'search.partials.categories' %} +
+ {% else %} +
+ {% if result.transactions|length > 0 %} +
+
+
+

{{ 'transactions'|_ }}

+
+
+

+ {{ trans('firefly.search_found_transactions', {count: result.transactions|length}) }} +

+ {% include 'search.partials.transactions' with {'transactions' : result.transactions} %} +
-
- {% endif %} - {% if result.tags|length > 0 %} -
-
-
-

{{ 'tags'|_ }}

-
-
-

- {{ trans('firefly.search_found_tags', {count: result.tags|length}) }} -

- {% include 'search.partials.tags' %} + {% endif %} + {% if result.categories|length > 0 %} +
+
+
+

{{ 'categories'|_ }}

+
+
+

+ {{ trans('firefly.search_found_categories', {count: result.categories|length}) }} +

+ {% include 'search.partials.categories' %} +
-
- {% endif %} - {% if result.accounts|length > 0 %} -
-
-
-

{{ 'accounts'|_ }}

-
-
-

- {{ trans('firefly.search_found_accounts', {count: result.accounts|length}) }} -

- {% include 'search.partials.accounts' %} + {% endif %} + {% if result.tags|length > 0 %} +
+
+
+

{{ 'tags'|_ }}

+
+
+

+ {{ trans('firefly.search_found_tags', {count: result.tags|length}) }} +

+ {% include 'search.partials.tags' %} +
-
- {% endif %} - {% if result.budgets|length > 0 %} -
-
-
-

{{ 'budgets'|_ }}

-
-
-

- {{ trans('firefly.search_found_budgets', {count: result.budgets|length}) }} -

- {% include 'search.partials.budgets' %} + {% endif %} + {% if result.accounts|length > 0 %} +
+
+
+

{{ 'accounts'|_ }}

+
+
+

+ {{ trans('firefly.search_found_accounts', {count: result.accounts|length}) }} +

+ {% include 'search.partials.accounts' %} +
-
- {% endif %} -
+ {% endif %} + {% if result.budgets|length > 0 %} +
+
+
+

{{ 'budgets'|_ }}

+
+
+

+ {{ trans('firefly.search_found_budgets', {count: result.budgets|length}) }} +

+ {% include 'search.partials.budgets' %} +
+
+
+ {% endif %} +
+ {% endif %} {% endif %}