1 /*
2  * Copyright (C) 2018 Emeric Poupon
3  *
4  * This file is part of LMS.
5  *
6  * LMS is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * LMS is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with LMS.  If not, see <http://www.gnu.org/licenses/>.
18  */
19 
20 #include "LmsApplication.hpp"
21 
22 #include <Wt/WAnchor.h>
23 #include <Wt/WEnvironment.h>
24 #include <Wt/WLineEdit.h>
25 #include <Wt/WPopupMenu.h>
26 #include <Wt/WPushButton.h>
27 #include <Wt/WServer.h>
28 #include <Wt/WStackedWidget.h>
29 #include <Wt/WText.h>
30 
31 #include "auth/IEnvService.hpp"
32 #include "auth/IPasswordService.hpp"
33 #include "cover/ICoverArtGrabber.hpp"
34 #include "database/Artist.hpp"
35 #include "database/Cluster.hpp"
36 #include "database/Db.hpp"
37 #include "database/Release.hpp"
38 #include "database/Session.hpp"
39 #include "database/User.hpp"
40 #include "scrobbling/IScrobbling.hpp"
41 #include "utils/Logger.hpp"
42 #include "utils/Service.hpp"
43 #include "utils/String.hpp"
44 
45 #include "admin/InitWizardView.hpp"
46 #include "admin/DatabaseSettingsView.hpp"
47 #include "admin/UserView.hpp"
48 #include "admin/UsersView.hpp"
49 #include "explore/Explore.hpp"
50 #include "explore/Filters.hpp"
51 #include "resource/AudioFileResource.hpp"
52 #include "resource/AudioTranscodeResource.hpp"
53 #include "resource/DownloadResource.hpp"
54 #include "resource/CoverResource.hpp"
55 #include "Auth.hpp"
56 #include "LmsApplicationException.hpp"
57 #include "LmsApplicationManager.hpp"
58 #include "LmsTheme.hpp"
59 #include "MediaPlayer.hpp"
60 #include "PlayQueue.hpp"
61 #include "SettingsView.hpp"
62 
63 namespace UserInterface {
64 
65 static constexpr const char* defaultPath {"/releases"};
66 
67 std::unique_ptr<Wt::WApplication>
create(const Wt::WEnvironment & env,Database::Db & db,LmsApplicationManager & appManager)68 LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager)
69 {
70 	if (auto *authEnvService {Service<::Auth::IEnvService>::get()})
71 	{
72 		const auto checkResult {authEnvService->processEnv(db.getTLSSession(), env)};
73 		if (checkResult.state != ::Auth::IEnvService::CheckResult::State::Granted)
74 		{
75 			LMS_LOG(UI, ERROR) << "Cannot authenticate user from environment!";
76 			// return a blank page
77 			return std::make_unique<Wt::WApplication>(env);
78 		}
79 
80 		return std::make_unique<LmsApplication>(env, db, appManager, checkResult.userId);
81 	}
82 
83 	return std::make_unique<LmsApplication>(env, db, appManager);
84 }
85 
86 LmsApplication*
instance()87 LmsApplication::instance()
88 {
89 	return reinterpret_cast<LmsApplication*>(Wt::WApplication::instance());
90 }
91 
92 Database::Session&
getDbSession()93 LmsApplication::getDbSession()
94 {
95 	return _db.getTLSSession();
96 }
97 
98 Wt::Dbo::ptr<Database::User>
getUser()99 LmsApplication::getUser()
100 {
101 	if (!_authenticatedUser)
102 		return {};
103 
104 	return Database::User::getById(getDbSession(), _authenticatedUser->userId);
105 }
106 
107 Database::IdType
getUserId()108 LmsApplication::getUserId()
109 {
110 	return _authenticatedUser->userId;
111 }
112 
113 bool
isUserAuthStrong() const114 LmsApplication::isUserAuthStrong() const
115 {
116 	return _authenticatedUser->strongAuth;
117 }
118 
119 bool
isUserAdmin()120 LmsApplication::isUserAdmin()
121 {
122 	auto transaction {getDbSession().createSharedTransaction()};
123 
124 	return getUser()->isAdmin();
125 }
126 
127 bool
isUserDemo()128 LmsApplication::isUserDemo()
129 {
130 	auto transaction {getDbSession().createSharedTransaction()};
131 
132 	return getUser()->isDemo();
133 }
134 
135 std::string
getUserLoginName()136 LmsApplication::getUserLoginName()
137 {
138 	auto transaction {getDbSession().createSharedTransaction()};
139 
140 	return getUser()->getLoginName();
141 }
142 
LmsApplication(const Wt::WEnvironment & env,Database::Db & db,LmsApplicationManager & appManager,std::optional<Database::IdType> userId)143 LmsApplication::LmsApplication(const Wt::WEnvironment& env,
144 		Database::Db& db,
145 		LmsApplicationManager& appManager,
146 		std::optional<Database::IdType> userId)
147 : Wt::WApplication {env}
148 ,  _db {db}
149 ,  _appManager {appManager}
150 , _authenticatedUser {userId ? std::make_optional<UserAuthInfo>(UserAuthInfo {*userId, false}) : std::nullopt}
151 {
152 	try
153 	{
154 		init();
155 	}
156 	catch (LmsApplicationException& e)
157 	{
158 		LMS_LOG(UI, WARNING) << "Caught a LmsApplication exception: " << e.what();
159 		handleException(e);
160 	}
161 	catch (std::exception& e)
162 	{
163 		LMS_LOG(UI, ERROR) << "Caught exception: " << e.what();
164 		throw LmsException {"Internal error"}; // Do not put details here at it may appear on the user rendered html
165 	}
166 }
167 
168 LmsApplication::~LmsApplication() = default;
169 
170 void
init()171 LmsApplication::init()
172 {
173 	useStyleSheet("resources/font-awesome/css/font-awesome.min.css");
174 
175 	// Add a resource bundle
176 	messageResourceBundle().use(appRoot() + "admin-database");
177 	messageResourceBundle().use(appRoot() + "admin-initwizard");
178 	messageResourceBundle().use(appRoot() + "admin-scannercontroller");
179 	messageResourceBundle().use(appRoot() + "admin-user");
180 	messageResourceBundle().use(appRoot() + "admin-users");
181 	messageResourceBundle().use(appRoot() + "artist");
182 	messageResourceBundle().use(appRoot() + "artists");
183 	messageResourceBundle().use(appRoot() + "error");
184 	messageResourceBundle().use(appRoot() + "explore");
185 	messageResourceBundle().use(appRoot() + "login");
186 	messageResourceBundle().use(appRoot() + "mediaplayer");
187 	messageResourceBundle().use(appRoot() + "messages");
188 	messageResourceBundle().use(appRoot() + "playqueue");
189 	messageResourceBundle().use(appRoot() + "release");
190 	messageResourceBundle().use(appRoot() + "releases");
191 	messageResourceBundle().use(appRoot() + "search");
192 	messageResourceBundle().use(appRoot() + "settings");
193 	messageResourceBundle().use(appRoot() + "templates");
194 	messageResourceBundle().use(appRoot() + "tracks");
195 
196 	// Require js here to avoid async problems
197 	requireJQuery("js/jquery-1.10.2.min.js");
198 	require("js/bootstrap-notify.js");
199 	require("js/bootstrap.min.js");
200 	require("js/mediaplayer.js");
201 
202 	setTitle("LMS");
203 
204 	// Handle Media Scanner events and other session events
205 	enableUpdates(true);
206 
207 	if (_authenticatedUser)
208 	{
209 		onUserLoggedIn();
210 	}
211 	else if (Service<::Auth::IPasswordService>::exists())
212 		processPasswordAuth();
213 }
214 
215 void
processPasswordAuth()216 LmsApplication::processPasswordAuth()
217 {
218 	{
219 		std::optional<Database::IdType> userId {processAuthToken(environment())};
220 		if (userId)
221 		{
222 			LMS_LOG(UI, DEBUG) << "User authenticated using Auth token!";
223 			_authenticatedUser = {*userId, false};
224 			onUserLoggedIn();
225 			return;
226 		}
227 	}
228 
229 	setTheme();
230 
231 	// If here is no account in the database, launch the first connection wizard
232 	bool firstConnection {};
233 	{
234 		auto transaction {getDbSession().createSharedTransaction()};
235 		firstConnection = Database::User::getCount(getDbSession()) == 0;
236 	}
237 
238 	LMS_LOG(UI, DEBUG) << "Creating root widget. First connection = " << firstConnection;
239 
240 	if (firstConnection && Service<::Auth::IPasswordService>::get()->canSetPasswords())
241 	{
242 		root()->addWidget(std::make_unique<InitWizardView>());
243 	}
244 	else
245 	{
246 		Auth* auth {root()->addNew<Auth>()};
247 		auth->userLoggedIn.connect(this, [this](Database::IdType userId)
248 		{
249 			_authenticatedUser = {userId, true};
250 			onUserLoggedIn();
251 		});
252 	}
253 }
254 
255 void
setTheme()256 LmsApplication::setTheme()
257 {
258 	Database::User::UITheme theme {Database::User::defaultUITheme};
259 	{
260 		auto transaction {getDbSession().createSharedTransaction()};
261 		if (const auto user {getUser()})
262 			theme = user->getUITheme();
263 	}
264 
265 	WApplication::setTheme(std::make_unique<LmsTheme>(theme));
266 }
267 
268 void
finalize()269 LmsApplication::finalize()
270 {
271 	if (_authenticatedUser)
272 		_appManager.unregisterApplication(*this);
273 
274 	preQuit().emit();
275 }
276 
277 Wt::WLink
createArtistLink(Database::Artist::pointer artist)278 LmsApplication::createArtistLink(Database::Artist::pointer artist)
279 {
280 	return Wt::WLink {Wt::LinkType::InternalPath, "/artist/" + std::to_string(artist.id())};
281 }
282 
283 std::unique_ptr<Wt::WAnchor>
createArtistAnchor(Database::Artist::pointer artist,bool addText)284 LmsApplication::createArtistAnchor(Database::Artist::pointer artist, bool addText)
285 {
286 	auto res = std::make_unique<Wt::WAnchor>(createArtistLink(artist));
287 
288 	if (addText)
289 	{
290 		res->setTextFormat(Wt::TextFormat::Plain);
291 		res->setText(Wt::WString::fromUTF8(artist->getName()));
292 		res->setToolTip(Wt::WString::fromUTF8(artist->getName()), Wt::TextFormat::Plain);
293 	}
294 
295 	return res;
296 }
297 
298 Wt::WLink
createReleaseLink(Database::Release::pointer release)299 LmsApplication::createReleaseLink(Database::Release::pointer release)
300 {
301 	return Wt::WLink {Wt::LinkType::InternalPath, "/release/" + std::to_string(release.id())};
302 }
303 
304 std::unique_ptr<Wt::WAnchor>
createReleaseAnchor(Database::Release::pointer release,bool addText)305 LmsApplication::createReleaseAnchor(Database::Release::pointer release, bool addText)
306 {
307 	auto res = std::make_unique<Wt::WAnchor>(createReleaseLink(release));
308 
309 	if (addText)
310 	{
311 		res->setWordWrap(false);
312 		res->setTextFormat(Wt::TextFormat::Plain);
313 		res->setText(Wt::WString::fromUTF8(release->getName()));
314 		res->setToolTip(Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain);
315 	}
316 
317 	return res;
318 }
319 
320 std::unique_ptr<Wt::WText>
createCluster(Database::Cluster::pointer cluster,bool canDelete)321 LmsApplication::createCluster(Database::Cluster::pointer cluster, bool canDelete)
322 {
323 	auto getStyleClass = [](const Database::Cluster::pointer cluster)
324 	{
325 		switch (cluster->getType().id() % 6)
326 		{
327 			case 0: return "label-info";
328 			case 1: return "label-warning";
329 			case 2: return "label-primary";
330 			case 3: return "label-default";
331 			case 4: return "label-success";
332 			case 5: return "label-danger";
333 		}
334 		return "label-default";
335 	};
336 
337 	const std::string styleClass {getStyleClass(cluster)};
338 	auto res {std::make_unique<Wt::WText>(std::string {} + (canDelete ? "<i class=\"fa fa-times-circle\"></i> " : "") + Wt::WString::fromUTF8(cluster->getName()), Wt::TextFormat::UnsafeXHTML)};
339 
340 	res->setStyleClass("Lms-cluster label " + styleClass);
341 	res->setToolTip(cluster->getType()->getName(), Wt::TextFormat::Plain);
342 	res->setInline(true);
343 
344 	return res;
345 }
346 
347 Wt::WPopupMenu*
createPopupMenu()348 LmsApplication::createPopupMenu()
349 {
350 	_popupMenu = std::make_unique<Wt::WPopupMenu>();
351 	return _popupMenu.get();
352 }
353 
354 void
handleException(LmsApplicationException & e)355 LmsApplication::handleException(LmsApplicationException& e)
356 {
357 	root()->clear();
358 	Wt::WTemplate* t {root()->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Error.template"))};
359 	t->addFunction("tr", &Wt::WTemplate::Functions::tr);
360 
361 	t->bindString("error", e.what(), Wt::TextFormat::Plain);
362 	Wt::WPushButton* btn {t->bindNew<Wt::WPushButton>("btn-go-home", Wt::WString::tr("Lms.Error.go-home"))};
363 	btn->clicked().connect([this]()
364 	{
365 		redirect(defaultPath);
366 	});
367 }
368 
369 void
goHomeAndQuit()370 LmsApplication::goHomeAndQuit()
371 {
372 	WApplication::quit("");
373 	redirect(".");
374 }
375 
376 enum IdxRoot
377 {
378 	IdxExplore	= 0,
379 	IdxPlayQueue,
380 	IdxSettings,
381 	IdxAdminDatabase,
382 	IdxAdminUsers,
383 	IdxAdminUser,
384 };
385 
386 static
387 void
handlePathChange(Wt::WStackedWidget & stack,bool isAdmin)388 handlePathChange(Wt::WStackedWidget& stack, bool isAdmin)
389 {
390 	static const struct
391 	{
392 		std::string path;
393 		int index;
394 		bool admin;
395 	} views[] =
396 	{
397 		{ "/artists",		IdxExplore,		false },
398 		{ "/artist",		IdxExplore,		false },
399 		{ "/releases",		IdxExplore,		false },
400 		{ "/release",		IdxExplore,		false },
401 		{ "/search",		IdxExplore,		false },
402 		{ "/tracks",		IdxExplore,		false },
403 		{ "/playqueue",		IdxPlayQueue,		false },
404 		{ "/settings",		IdxSettings,		false },
405 		{ "/admin/database",	IdxAdminDatabase,	true },
406 		{ "/admin/users",	IdxAdminUsers,		true },
407 		{ "/admin/user",	IdxAdminUser,		true },
408 	};
409 
410 	LMS_LOG(UI, DEBUG) << "Internal path changed to '" << wApp->internalPath() << "'";
411 
412 	LmsApp->doJavaScript(R"($('.navbar-nav li.active').removeClass('active'); $('.navbar-nav a[href="' + location.pathname + '"]').closest('li').addClass('active');)");
413 
414 	for (const auto& view : views)
415 	{
416 		if (wApp->internalPathMatches(view.path))
417 		{
418 			if (view.admin && !isAdmin)
419 				break;
420 
421 			stack.setCurrentIndex(view.index);
422 			return;
423 		}
424 	}
425 
426 	wApp->setInternalPath(defaultPath, true);
427 }
428 
429 void
logoutUser()430 LmsApplication::logoutUser()
431 {
432 	{
433 		auto transaction {getDbSession().createUniqueTransaction()};
434 		getUser().modify()->clearAuthTokens();
435 	}
436 
437 	LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << " 'logged out";
438 	goHomeAndQuit();
439 }
440 
441 void
onUserLoggedIn()442 LmsApplication::onUserLoggedIn()
443 {
444 	setTheme();
445 	root()->clear();
446 
447 	LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent();
448 
449 	_appManager.registerApplication(*this);
450 	_appManager.applicationRegistered.connect(this, [this] (LmsApplication& otherApplication)
451 	{
452 		// Only one active session by user
453 		if (otherApplication.getUserId() == getUserId())
454 		{
455 			if (!LmsApp->isUserDemo())
456 			{
457 				quit(Wt::WString::tr("Lms.quit-other-session"));
458 			}
459 		}
460 	});
461 
462 	createHome();
463 }
464 
465 void
createHome()466 LmsApplication::createHome()
467 {
468 	_coverResource = std::make_shared<CoverResource>();
469 
470 	declareJavaScriptFunction("onLoadCover", "function(id) { id.className += \" Lms-cover-loaded\"}");
471 	doJavaScript("$('body').tooltip({ selector: '[data-toggle=\"tooltip\"]'})");
472 
473 	Wt::WTemplate* main {root()->addWidget(std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.template")))};
474 
475 	main->addFunction("tr", &Wt::WTemplate::Functions::tr);
476 
477 	// MediaPlayer
478 	_mediaPlayer = main->bindNew<MediaPlayer>("player");
479 
480 	main->bindNew<Wt::WAnchor>("title",  Wt::WLink {Wt::LinkType::InternalPath, defaultPath}, "LMS");
481 	main->bindNew<Wt::WAnchor>("artists", Wt::WLink {Wt::LinkType::InternalPath, "/artists"}, Wt::WString::tr("Lms.Explore.artists"));
482 	main->bindNew<Wt::WAnchor>("releases", Wt::WLink {Wt::LinkType::InternalPath, "/releases"}, Wt::WString::tr("Lms.Explore.releases"));
483 	main->bindNew<Wt::WAnchor>("tracks", Wt::WLink {Wt::LinkType::InternalPath, "/tracks"}, Wt::WString::tr("Lms.Explore.tracks"));
484 
485 	Filters* filters {main->bindNew<Filters>("filters")};
486 	main->bindNew<Wt::WAnchor>("playqueue", Wt::WLink {Wt::LinkType::InternalPath, "/playqueue"}, Wt::WString::tr("Lms.PlayQueue.playqueue"));
487 	main->bindString("username", getUserLoginName(), Wt::TextFormat::Plain);
488 	main->bindNew<Wt::WAnchor>("settings", Wt::WLink {Wt::LinkType::InternalPath, "/settings"}, Wt::WString::tr("Lms.Settings.menu-settings"));
489 
490 	{
491 		Wt::WAnchor* logout {main->bindNew<Wt::WAnchor>("logout")};
492 		logout->setText(Wt::WString::tr("Lms.logout"));
493 		logout->clicked().connect(this, &LmsApplication::logoutUser);
494 	}
495 
496 	Wt::WLineEdit* searchEdit {main->bindNew<Wt::WLineEdit>("search")};
497 	searchEdit->setPlaceholderText(Wt::WString::tr("Lms.Explore.Search.search-placeholder"));
498 
499 	if (isUserAdmin())
500 	{
501 		main->setCondition("if-is-admin", true);
502 		main->bindNew<Wt::WAnchor>("database", Wt::WLink {Wt::LinkType::InternalPath, "/admin/database"}, Wt::WString::tr("Lms.Admin.Database.menu-database"));
503 		main->bindNew<Wt::WAnchor>("users", Wt::WLink {Wt::LinkType::InternalPath, "/admin/users"}, Wt::WString::tr("Lms.Admin.Users.menu-users"));
504 	}
505 
506 	// Contents
507 	// Order is important in mainStack, see IdxRoot!
508 	Wt::WStackedWidget* mainStack {main->bindNew<Wt::WStackedWidget>("contents")};
509 	mainStack->setAttributeValue("style", "overflow-x:visible;overflow-y:visible;");
510 
511 	Explore* explore {mainStack->addNew<Explore>(filters)};
512 	_playQueue = mainStack->addNew<PlayQueue>();
513 	mainStack->addNew<SettingsView>();
514 
515 	searchEdit->enterPressed().connect([=]
516 	{
517 		setInternalPath("/search", true);
518 	});
519 
520 	searchEdit->textInput().connect([=]
521 	{
522 		setInternalPath("/search", true);
523 		explore->search(searchEdit->text());
524 	});
525 
526 	// Admin stuff
527 	if (isUserAdmin())
528 	{
529 		mainStack->addNew<DatabaseSettingsView>();
530 		mainStack->addNew<UsersView>();
531 		mainStack->addNew<UserView>();
532 	}
533 
534 	explore->tracksAction.connect([this] (PlayQueueAction action, const std::vector<Database::IdType>& trackIds)
535 	{
536 		_playQueue->processTracks(action, trackIds);
537 	});
538 
539 	// Events from MediaPlayer
540 	_mediaPlayer->playNext.connect([this]
541 	{
542 		_playQueue->playNext();
543 	});
544 	_mediaPlayer->playPrevious.connect([this]
545 	{
546 		_playQueue->playPrevious();
547 	});
548 
549 	_mediaPlayer->scrobbleListenNow.connect([this](Database::IdType trackId)
550 	{
551 		LMS_LOG(UI, DEBUG) << "Received ScrobbleListenNow from player for trackId = " << trackId;
552 		const Scrobbling::Listen listen {getUserId(), trackId};
553 		Service<Scrobbling::IScrobbling>::get()->listenStarted(listen);
554 	});
555 	_mediaPlayer->scrobbleListenFinished.connect([this](Database::IdType trackId, unsigned durationMs)
556 	{
557 		LMS_LOG(UI, DEBUG) << "Received ScrobbleListenFinished from player for trackId = " << trackId << ", duration = " << (durationMs / 1000) << "s";
558 		const std::chrono::milliseconds duration {durationMs};
559 		const Scrobbling::Listen listen {getUserId(), trackId};
560 		Service<Scrobbling::IScrobbling>::get()->listenFinished(listen, std::chrono::duration_cast<std::chrono::seconds>(duration));
561 	});
562 
563 	_mediaPlayer->playbackEnded.connect([this]
564 	{
565 		_playQueue->playNext();
566 	});
567 
568 	_playQueue->trackSelected.connect([this] (Database::IdType trackId, bool play, float replayGain)
569 	{
570 		_mediaPlayer->loadTrack(trackId, play, replayGain);
571 	});
572 
573 	_playQueue->trackUnselected.connect([this]
574 	{
575 		_mediaPlayer->stop();
576 	});
577 
578 	if (isUserAdmin())
579 	{
580 		_scannerEvents.scanComplete.connect([=] (const Scanner::ScanStats& stats)
581 		{
582 			notifyMsg(MsgType::Info, Wt::WString::tr("Lms.Admin.Database.scan-complete")
583 				.arg(static_cast<unsigned>(stats.nbFiles()))
584 				.arg(static_cast<unsigned>(stats.additions))
585 				.arg(static_cast<unsigned>(stats.updates))
586 				.arg(static_cast<unsigned>(stats.deletions))
587 				.arg(static_cast<unsigned>(stats.duplicates.size()))
588 				.arg(static_cast<unsigned>(stats.errors.size())));
589 		});
590 	}
591 
592 	internalPathChanged().connect([=]
593 	{
594 		handlePathChange(*mainStack, isUserAdmin());
595 	});
596 
597 	handlePathChange(*mainStack, isUserAdmin());
598 }
599 
600 void
notify(const Wt::WEvent & event)601 LmsApplication::notify(const Wt::WEvent& event)
602 {
603 	try
604 	{
605 		WApplication::notify(event);
606 	}
607 	catch (LmsApplicationException& e)
608 	{
609 		LMS_LOG(UI, WARNING) << "Caught a LmsApplication exception: " << e.what();
610 		handleException(e);
611 	}
612 	catch (std::exception& e)
613 	{
614 		LMS_LOG(UI, ERROR) << "Caught exception: " << e.what();
615 		throw LmsException {"Internal error"}; // Do not put details here at it may appear on the user rendered html
616 	}
617 }
618 
msgTypeToString(LmsApplication::MsgType type)619 static std::string msgTypeToString(LmsApplication::MsgType type)
620 {
621 	switch(type)
622 	{
623 		case LmsApplication::MsgType::Success:	return "success";
624 		case LmsApplication::MsgType::Info:	return "info";
625 		case LmsApplication::MsgType::Warning:	return "warning";
626 		case LmsApplication::MsgType::Danger:	return "danger";
627 	}
628 	return "";
629 }
630 
631 void
post(std::function<void ()> func)632 LmsApplication::post(std::function<void()> func)
633 {
634 	Wt::WServer::instance()->post(LmsApp->sessionId(), std::move(func));
635 }
636 
637 
638 void
notifyMsg(MsgType type,const Wt::WString & message,std::chrono::milliseconds duration)639 LmsApplication::notifyMsg(MsgType type, const Wt::WString& message, std::chrono::milliseconds duration)
640 {
641 	LMS_LOG(UI, INFO) << "Notifying message '" << message.toUTF8() << "' of type '" << msgTypeToString(type) << "'";
642 
643 	std::ostringstream oss;
644 
645 	oss << "$.notify({"
646 			"message: '" << StringUtils::jsEscape(message.toUTF8()) << "'"
647 		"},{"
648 			"type: '" << msgTypeToString(type) << "',"
649 			"placement: {from: 'bottom', align: 'right'},"
650 			"timer: 250,"
651 			"offset: {x: 20, y: 80},"
652 			"delay: " << duration.count() << ""
653 		"});";
654 
655 	LmsApp->doJavaScript(oss.str());
656 }
657 
658 
659 } // namespace UserInterface
660 
661 
662