1 /***************************************************************************
2  *   Copyright (C) 2008-2021 by Andrzej Rybczak                            *
3  *   andrzej@rybczak.net                                                   *
4  *                                                                         *
5  *   This program is free software; you can redistribute it and/or modify  *
6  *   it under the terms of the GNU General Public License as published by  *
7  *   the Free Software Foundation; either version 2 of the License, or     *
8  *   (at your option) any later version.                                   *
9  *                                                                         *
10  *   This program is distributed in the hope that it will be useful,       *
11  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
12  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
13  *   GNU General Public License for more details.                          *
14  *                                                                         *
15  *   You should have received a copy of the GNU General Public License     *
16  *   along with this program; if not, write to the                         *
17  *   Free Software Foundation, Inc.,                                       *
18  *   51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.              *
19  ***************************************************************************/
20 
21 #include <array>
22 #include <boost/range/detail/any_iterator.hpp>
23 #include <iomanip>
24 
25 #include "curses/menu_impl.h"
26 #include "display.h"
27 #include "global.h"
28 #include "helpers.h"
29 #include "screens/playlist.h"
30 #include "screens/search_engine.h"
31 #include "settings.h"
32 #include "status.h"
33 #include "statusbar.h"
34 #include "format_impl.h"
35 #include "helpers/song_iterator_maker.h"
36 #include "utility/comparators.h"
37 #include "title.h"
38 #include "screens/screen_switcher.h"
39 
40 using Global::MainHeight;
41 using Global::MainStartY;
42 
43 namespace ph = std::placeholders;
44 
45 SearchEngine *mySearcher;
46 
47 namespace {
48 
49 /*const std::array<const std::string, 11> constraintsNames = {{
50 	"Any",
51 	"Artist",
52 	"Album Artist",
53 	"Title",
54 	"Album",
55 	"Filename",
56 	"Composer",
57 	"Performer",
58 	"Genre",
59 	"Date",
60 	"Comment"
61 }};
62 
63 const std::array<const char *, 3> searchModes = {{
64 	"Match if tag contains searched phrase (no regexes)",
65 	"Match if tag contains searched phrase (regexes supported)",
66 	"Match only if both values are the same"
67 }};
68 
69 namespace pos {
70 	const size_t searchIn = constraintsNames.size()-1+1+1; // separated
71 	const size_t searchMode = searchIn+1;
72 	const size_t search = searchMode+1+1; // separated
73 	const size_t reset = search+1;
74 }*/
75 
76 std::string SEItemToString(const SEItem &ei);
77 bool SEItemEntryMatcher(const Regex::Regex &rx,
78                         const NC::Menu<SEItem>::Item &item,
79                         bool filter);
80 
81 }
82 
83 template <>
84 struct SongPropertiesExtractor<SEItem>
85 {
86 	template <typename ItemT>
operator ()SongPropertiesExtractor87 	auto &operator()(ItemT &item) const
88 	{
89 		auto s = !item.isSeparator() && item.value().isSong()
90 			? &item.value().song()
91 			: nullptr;
92 		return m_cache.assign(&item.properties(), s);
93 	}
94 
95 private:
96 	mutable SongProperties m_cache;
97 };
98 
currentS()99 SongIterator SearchEngineWindow::currentS()
100 {
101 	return makeSongIterator(current());
102 }
103 
currentS() const104 ConstSongIterator SearchEngineWindow::currentS() const
105 {
106 	return makeConstSongIterator(current());
107 }
108 
beginS()109 SongIterator SearchEngineWindow::beginS()
110 {
111 	return makeSongIterator(begin());
112 }
113 
beginS() const114 ConstSongIterator SearchEngineWindow::beginS() const
115 {
116 	return makeConstSongIterator(begin());
117 }
118 
endS()119 SongIterator SearchEngineWindow::endS()
120 {
121 	return makeSongIterator(end());
122 }
123 
endS() const124 ConstSongIterator SearchEngineWindow::endS() const
125 {
126 	return makeConstSongIterator(end());
127 }
128 
getSelectedSongs()129 std::vector<MPD::Song> SearchEngineWindow::getSelectedSongs()
130 {
131 	std::vector<MPD::Song> result;
132 	for (auto &item : *this)
133 	{
134 		if (item.isSelected())
135 		{
136 			assert(item.value().isSong());
137 			result.push_back(item.value().song());
138 		}
139 	}
140 	// If no item is selected, add the current one if it's a song.
141 	if (result.empty() && !empty() && current()->value().isSong())
142 		result.push_back(current()->value().song());
143 	return result;
144 }
145 
146 /**********************************************************************/
147 
148 const char *SearchEngine::ConstraintsNames[] =
149 {
150 	"Any",
151 	"Artist",
152 	"Album Artist",
153 	"Title",
154 	"Album",
155 	"Filename",
156 	"Composer",
157 	"Performer",
158 	"Genre",
159 	"Date",
160 	"Comment"
161 };
162 
163 const char *SearchEngine::SearchModes[] =
164 {
165 	"Match if tag contains searched phrase (no regexes)",
166 	"Match if tag contains searched phrase (regexes supported)",
167 	"Match only if both values are the same",
168 	0
169 };
170 
171 size_t SearchEngine::StaticOptions = 20;
172 size_t SearchEngine::ResetButton = 16;
173 size_t SearchEngine::SearchButton = 15;
174 
SearchEngine()175 SearchEngine::SearchEngine()
176 : Screen(NC::Menu<SEItem>(0, MainStartY, COLS, MainHeight, "", Config.main_color, NC::Border()))
177 {
178 	setHighlightFixes(w);
179 	w.cyclicScrolling(Config.use_cyclic_scrolling);
180 	w.centeredCursor(Config.centered_cursor);
181 	w.setItemDisplayer(std::bind(Display::SEItems, ph::_1, std::cref(w)));
182 	w.setSelectedPrefix(Config.selected_item_prefix);
183 	w.setSelectedSuffix(Config.selected_item_suffix);
184 	SearchMode = &SearchModes[Config.search_engine_default_search_mode];
185 }
186 
resize()187 void SearchEngine::resize()
188 {
189 	size_t x_offset, width;
190 	getWindowResizeParams(x_offset, width);
191 	w.resize(width, MainHeight);
192 	w.moveTo(x_offset, MainStartY);
193 	switch (Config.search_engine_display_mode)
194 	{
195 		case DisplayMode::Columns:
196 			if (Config.titles_visibility)
197 				w.setTitle(Display::Columns(w.getWidth()));
198 			break;
199 		case DisplayMode::Classic:
200 			w.setTitle("");
201 			break;
202 	}
203 	hasToBeResized = 0;
204 }
205 
switchTo()206 void SearchEngine::switchTo()
207 {
208 	SwitchTo::execute(this);
209 	if (w.empty())
210 		Prepare();
211 	drawHeader();
212 }
213 
title()214 std::wstring SearchEngine::title()
215 {
216 	return L"Search engine";
217 }
218 
mouseButtonPressed(MEVENT me)219 void SearchEngine::mouseButtonPressed(MEVENT me)
220 {
221 	if (w.empty() || !w.hasCoords(me.x, me.y) || size_t(me.y) >= w.size())
222 		return;
223 	if (me.bstate & (BUTTON1_PRESSED | BUTTON3_PRESSED))
224 	{
225 		if (!w.Goto(me.y))
226 			return;
227 		w.refresh();
228 		if ((me.bstate & BUTTON3_PRESSED)
229 		    && w.choice() < StaticOptions)
230 			runAction();
231 		else if (w.choice() >= StaticOptions)
232 		{
233 			bool play = me.bstate & BUTTON3_PRESSED;
234 			addItemToPlaylist(play);
235 		}
236 	}
237 	else
238 		Screen<WindowType>::mouseButtonPressed(me);
239 }
240 
241 /***********************************************************************/
242 
allowsSearching()243 bool SearchEngine::allowsSearching()
244 {
245 	ScopedUnfilteredMenu<SEItem> sunfilter(ReapplyFilter::Yes, w);
246 	return w.rbegin()->value().isSong();
247 }
248 
searchConstraint()249 const std::string &SearchEngine::searchConstraint()
250 {
251 	return m_search_predicate.constraint();
252 }
253 
setSearchConstraint(const std::string & constraint)254 void SearchEngine::setSearchConstraint(const std::string &constraint)
255 {
256 	m_search_predicate = Regex::ItemFilter<SEItem>(
257 		constraint,
258 		Config.regex_type,
259 		std::bind(SEItemEntryMatcher, ph::_1, ph::_2, false));
260 }
261 
clearSearchConstraint()262 void SearchEngine::clearSearchConstraint()
263 {
264 	m_search_predicate.clear();
265 }
266 
search(SearchDirection direction,bool wrap,bool skip_current)267 bool SearchEngine::search(SearchDirection direction, bool wrap, bool skip_current)
268 {
269 	return ::search(w, m_search_predicate, direction, wrap, skip_current);
270 }
271 
272 /***********************************************************************/
273 
allowsFiltering()274 bool SearchEngine::allowsFiltering()
275 {
276 	return allowsSearching();
277 }
278 
currentFilter()279 std::string SearchEngine::currentFilter()
280 {
281 	std::string result;
282 	if (auto pred = w.filterPredicate<Regex::ItemFilter<SEItem>>())
283 		result = pred->constraint();
284 	return result;
285 }
286 
applyFilter(const std::string & constraint)287 void SearchEngine::applyFilter(const std::string &constraint)
288 {
289 	if (!constraint.empty())
290 	{
291 		w.applyFilter(Regex::ItemFilter<SEItem>(
292 			              constraint,
293 			              Config.regex_type,
294 			              std::bind(SEItemEntryMatcher, ph::_1, ph::_2, true)));
295 	}
296 	else
297 		w.clearFilter();
298 }
299 
300 /***********************************************************************/
301 
actionRunnable()302 bool SearchEngine::actionRunnable()
303 {
304 	return !w.empty() && !w.current()->value().isSong();
305 }
306 
runAction()307 void SearchEngine::runAction()
308 {
309 	size_t option = w.choice();
310 	if (option > ConstraintsNumber && option < SearchButton)
311 		w.current()->value().buffer().clear();
312 
313 	if (option < ConstraintsNumber)
314 	{
315 		Statusbar::ScopedLock slock;
316 		std::string constraint = ConstraintsNames[option];
317 		Statusbar::put() << NC::Format::Bold << constraint << NC::Format::NoBold << ": ";
318 		itsConstraints[option] = Global::wFooter->prompt(itsConstraints[option]);
319 		w.current()->value().buffer().clear();
320 		constraint.resize(13, ' ');
321 		w.current()->value().buffer() << NC::Format::Bold << constraint << NC::Format::NoBold << ": ";
322 		ShowTag(w.current()->value().buffer(), itsConstraints[option]);
323 	}
324 	else if (option == ConstraintsNumber+1)
325 	{
326 		Config.search_in_db = !Config.search_in_db;
327 		w.current()->value().buffer() << NC::Format::Bold << "Search in:" << NC::Format::NoBold << ' ' << (Config.search_in_db ? "Database" : "Current playlist");
328 	}
329 	else if (option == ConstraintsNumber+2)
330 	{
331 		if (!*++SearchMode)
332 			SearchMode = &SearchModes[0];
333 		w.current()->value().buffer() << NC::Format::Bold << "Search mode:" << NC::Format::NoBold << ' ' << *SearchMode;
334 	}
335 	else if (option == SearchButton)
336 	{
337 		w.clearFilter();
338 		Statusbar::print("Searching...");
339 		if (w.size() > StaticOptions)
340 			Prepare();
341 		Search();
342 		if (w.rbegin()->value().isSong())
343 		{
344 			if (Config.search_engine_display_mode == DisplayMode::Columns)
345 				w.setTitle(Config.titles_visibility ? Display::Columns(w.getWidth()) : "");
346 			size_t found = w.size()-SearchEngine::StaticOptions;
347 			found += 3; // don't count options inserted below
348 			w.insertSeparator(ResetButton+1);
349 			w.insertItem(ResetButton+2, SEItem(), NC::List::Properties::Inactive);
350 			w.at(ResetButton+2).value().mkBuffer()
351 				<< NC::Format::Bold
352 				<< Config.color1
353 				<< "Search results: "
354 				<< NC::FormattedColor::End<>(Config.color1)
355 				<< Config.color2
356 				<< "Found " << found << (found > 1 ? " songs" : " song")
357 				<< NC::FormattedColor::End<>(Config.color2)
358 				<< NC::Format::NoBold;
359 			w.insertSeparator(ResetButton+3);
360 				Statusbar::print("Searching finished");
361 			if (Config.block_search_constraints_change)
362 				for (size_t i = 0; i < StaticOptions-4; ++i)
363 					w.at(i).setInactive(true);
364 			w.scroll(NC::Scroll::Down);
365 			w.scroll(NC::Scroll::Down);
366 		}
367 		else
368 			Statusbar::print("No results found");
369 	}
370 	else if (option == ResetButton)
371 	{
372 		reset();
373 	}
374 	else
375 		addSongToPlaylist(w.current()->value().song(), true);
376 }
377 
378 /***********************************************************************/
379 
itemAvailable()380 bool SearchEngine::itemAvailable()
381 {
382 	return !w.empty() && w.current()->value().isSong();
383 }
384 
addItemToPlaylist(bool play)385 bool SearchEngine::addItemToPlaylist(bool play)
386 {
387 	return addSongToPlaylist(w.current()->value().song(), play);
388 }
389 
getSelectedSongs()390 std::vector<MPD::Song> SearchEngine::getSelectedSongs()
391 {
392 	return w.getSelectedSongs();
393 }
394 
395 /***********************************************************************/
396 
Prepare()397 void SearchEngine::Prepare()
398 {
399 	w.setTitle("");
400 	w.clear();
401 	w.resizeList(StaticOptions-3);
402 
403 	for (auto &item : w)
404 		item.setSelectable(false);
405 
406 	w.at(ConstraintsNumber).setSeparator(true);
407 	w.at(SearchButton-1).setSeparator(true);
408 
409 	for (size_t i = 0; i < ConstraintsNumber; ++i)
410 	{
411 		std::string constraint = ConstraintsNames[i];
412 		constraint.resize(13, ' ');
413 		w[i].value().mkBuffer() << NC::Format::Bold << constraint << NC::Format::NoBold << ": ";
414 		ShowTag(w[i].value().buffer(), itsConstraints[i]);
415 	}
416 
417 	w.at(ConstraintsNumber+1).value().mkBuffer() << NC::Format::Bold << "Search in:" << NC::Format::NoBold << ' ' << (Config.search_in_db ? "Database" : "Current playlist");
418 	w.at(ConstraintsNumber+2).value().mkBuffer() << NC::Format::Bold << "Search mode:" << NC::Format::NoBold << ' ' << *SearchMode;
419 
420 	w.at(SearchButton).value().mkBuffer() << "Search";
421 	w.at(ResetButton).value().mkBuffer() << "Reset";
422 }
423 
reset()424 void SearchEngine::reset()
425 {
426 	for (size_t i = 0; i < ConstraintsNumber; ++i)
427 		itsConstraints[i].clear();
428 	w.clearFilter();
429 	w.reset();
430 	Prepare();
431 	Statusbar::print("Search state reset");
432 }
433 
Search()434 void SearchEngine::Search()
435 {
436 	bool constraints_empty = 1;
437 	for (size_t i = 0; i < ConstraintsNumber; ++i)
438 	{
439 		if (!itsConstraints[i].empty())
440 		{
441 			constraints_empty = 0;
442 			break;
443 		}
444 	}
445 	if (constraints_empty)
446 		return;
447 
448 	if (Config.search_in_db && (SearchMode == &SearchModes[0] || SearchMode == &SearchModes[2])) // use built-in mpd searching
449 	{
450 		Mpd.StartSearch(SearchMode == &SearchModes[2]);
451 		if (!itsConstraints[0].empty())
452 			Mpd.AddSearchAny(itsConstraints[0]);
453 		if (!itsConstraints[1].empty())
454 			Mpd.AddSearch(MPD_TAG_ARTIST, itsConstraints[1]);
455 		if (!itsConstraints[2].empty())
456 			Mpd.AddSearch(MPD_TAG_ALBUM_ARTIST, itsConstraints[2]);
457 		if (!itsConstraints[3].empty())
458 			Mpd.AddSearch(MPD_TAG_TITLE, itsConstraints[3]);
459 		if (!itsConstraints[4].empty())
460 			Mpd.AddSearch(MPD_TAG_ALBUM, itsConstraints[4]);
461 		if (!itsConstraints[5].empty())
462 			Mpd.AddSearchURI(itsConstraints[5]);
463 		if (!itsConstraints[6].empty())
464 			Mpd.AddSearch(MPD_TAG_COMPOSER, itsConstraints[6]);
465 		if (!itsConstraints[7].empty())
466 			Mpd.AddSearch(MPD_TAG_PERFORMER, itsConstraints[7]);
467 		if (!itsConstraints[8].empty())
468 			Mpd.AddSearch(MPD_TAG_GENRE, itsConstraints[8]);
469 		if (!itsConstraints[9].empty())
470 			Mpd.AddSearch(MPD_TAG_DATE, itsConstraints[9]);
471 		if (!itsConstraints[10].empty())
472 			Mpd.AddSearch(MPD_TAG_COMMENT, itsConstraints[10]);
473 		for (MPD::SongIterator s = Mpd.CommitSearchSongs(), end; s != end; ++s)
474 			w.addItem(std::move(*s));
475 		return;
476 	}
477 
478 	Regex::Regex rx[ConstraintsNumber];
479 	if (SearchMode != &SearchModes[2]) // match to pattern
480 	{
481 		for (size_t i = 0; i < ConstraintsNumber; ++i)
482 		{
483 			if (!itsConstraints[i].empty())
484 			{
485 				try
486 				{
487 					rx[i] = Regex::make(itsConstraints[i], Config.regex_type);
488 				}
489 				catch (boost::bad_expression &) { }
490 			}
491 		}
492 	}
493 
494 	typedef boost::range_detail::any_iterator<
495 		const MPD::Song,
496 		boost::single_pass_traversal_tag,
497 		const MPD::Song &,
498 		std::ptrdiff_t
499 	> input_song_iterator;
500 	input_song_iterator s, end;
501 	if (Config.search_in_db)
502 	{
503 		s = input_song_iterator(Mpd.GetDirectoryRecursive("/"));
504 		end = input_song_iterator(MPD::SongIterator());
505 	}
506 	else
507 	{
508 		s = input_song_iterator(myPlaylist->main().beginV());
509 		end = input_song_iterator(myPlaylist->main().endV());
510 	}
511 
512 	LocaleStringComparison cmp(std::locale(), Config.ignore_leading_the);
513 	for (; s != end; ++s)
514 	{
515 		bool any_found = true, found = true;
516 
517 		if (SearchMode != &SearchModes[2]) // match to pattern
518 		{
519 			if (!rx[0].empty())
520 				any_found =
521 					   Regex::search(s->getArtist(), rx[0], Config.ignore_diacritics)
522 					|| Regex::search(s->getAlbumArtist(), rx[0], Config.ignore_diacritics)
523 					|| Regex::search(s->getTitle(), rx[0], Config.ignore_diacritics)
524 					|| Regex::search(s->getAlbum(), rx[0], Config.ignore_diacritics)
525 					|| Regex::search(s->getName(), rx[0], Config.ignore_diacritics)
526 					|| Regex::search(s->getComposer(), rx[0], Config.ignore_diacritics)
527 					|| Regex::search(s->getPerformer(), rx[0], Config.ignore_diacritics)
528 					|| Regex::search(s->getGenre(), rx[0], Config.ignore_diacritics)
529 					|| Regex::search(s->getDate(), rx[0], Config.ignore_diacritics)
530 					|| Regex::search(s->getComment(), rx[0], Config.ignore_diacritics);
531 			if (found && !rx[1].empty())
532 				found = Regex::search(s->getArtist(), rx[1], Config.ignore_diacritics);
533 			if (found && !rx[2].empty())
534 				found = Regex::search(s->getAlbumArtist(), rx[2], Config.ignore_diacritics);
535 			if (found && !rx[3].empty())
536 				found = Regex::search(s->getTitle(), rx[3], Config.ignore_diacritics);
537 			if (found && !rx[4].empty())
538 				found = Regex::search(s->getAlbum(), rx[4], Config.ignore_diacritics);
539 			if (found && !rx[5].empty())
540 				found = Regex::search(s->getName(), rx[5], Config.ignore_diacritics);
541 			if (found && !rx[6].empty())
542 				found = Regex::search(s->getComposer(), rx[6], Config.ignore_diacritics);
543 			if (found && !rx[7].empty())
544 				found = Regex::search(s->getPerformer(), rx[7], Config.ignore_diacritics);
545 			if (found && !rx[8].empty())
546 				found = Regex::search(s->getGenre(), rx[8], Config.ignore_diacritics);
547 			if (found && !rx[9].empty())
548 				found = Regex::search(s->getDate(), rx[9], Config.ignore_diacritics);
549 			if (found && !rx[10].empty())
550 				found = Regex::search(s->getComment(), rx[10], Config.ignore_diacritics);
551 		}
552 		else // match only if values are equal
553 		{
554 			if (!itsConstraints[0].empty())
555 				any_found =
556 				   !cmp(s->getArtist(), itsConstraints[0])
557 				|| !cmp(s->getAlbumArtist(), itsConstraints[0])
558 				|| !cmp(s->getTitle(), itsConstraints[0])
559 				|| !cmp(s->getAlbum(), itsConstraints[0])
560 				|| !cmp(s->getName(), itsConstraints[0])
561 				|| !cmp(s->getComposer(), itsConstraints[0])
562 				|| !cmp(s->getPerformer(), itsConstraints[0])
563 				|| !cmp(s->getGenre(), itsConstraints[0])
564 				|| !cmp(s->getDate(), itsConstraints[0])
565 				|| !cmp(s->getComment(), itsConstraints[0]);
566 
567 			if (found && !itsConstraints[1].empty())
568 				found = !cmp(s->getArtist(), itsConstraints[1]);
569 			if (found && !itsConstraints[2].empty())
570 				found = !cmp(s->getAlbumArtist(), itsConstraints[2]);
571 			if (found && !itsConstraints[3].empty())
572 				found = !cmp(s->getTitle(), itsConstraints[3]);
573 			if (found && !itsConstraints[4].empty())
574 				found = !cmp(s->getAlbum(), itsConstraints[4]);
575 			if (found && !itsConstraints[5].empty())
576 				found = !cmp(s->getName(), itsConstraints[5]);
577 			if (found && !itsConstraints[6].empty())
578 				found = !cmp(s->getComposer(), itsConstraints[6]);
579 			if (found && !itsConstraints[7].empty())
580 				found = !cmp(s->getPerformer(), itsConstraints[7]);
581 			if (found && !itsConstraints[8].empty())
582 				found = !cmp(s->getGenre(), itsConstraints[8]);
583 			if (found && !itsConstraints[9].empty())
584 				found = !cmp(s->getDate(), itsConstraints[9]);
585 			if (found && !itsConstraints[10].empty())
586 				found = !cmp(s->getComment(), itsConstraints[10]);
587 		}
588 
589 		if (any_found && found)
590 			w.addItem(*s);
591 	}
592 }
593 
594 namespace {
595 
SEItemToString(const SEItem & ei)596 std::string SEItemToString(const SEItem &ei)
597 {
598 	std::string result;
599 	if (ei.isSong())
600 	{
601 		switch (Config.search_engine_display_mode)
602 		{
603 			case DisplayMode::Classic:
604 				result = Format::stringify<char>(Config.song_list_format, &ei.song());
605 				break;
606 			case DisplayMode::Columns:
607 				result = Format::stringify<char>(Config.song_columns_mode_format, &ei.song());
608 				break;
609 		}
610 	}
611 	else
612 		result = ei.buffer().str();
613 	return result;
614 }
615 
SEItemEntryMatcher(const Regex::Regex & rx,const NC::Menu<SEItem>::Item & item,bool filter)616 bool SEItemEntryMatcher(const Regex::Regex &rx, const NC::Menu<SEItem>::Item &item, bool filter)
617 {
618 	if (item.isSeparator() || !item.value().isSong())
619 		return filter;
620 	return Regex::search(SEItemToString(item.value()), rx, Config.ignore_diacritics);
621 }
622 
623 }
624