WSL/SLF GitLab Repository

WorkflowPanel.cc 35.1 KB
Newer Older
Michael Reisecker's avatar
Michael Reisecker committed
1
/*****************************************************************************/
2
3
4
5
/*  Copyright 2019 WSL Institute for Snow and Avalanche Research  SLF-DAVOS  */
/*****************************************************************************/
/* This file is part of INIshell.
   INIshell 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
8
9
10
11
12
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.

   INIshell 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
13
   GNU General Public License for more details.
14

15
16
   You should have received a copy of the GNU General Public License
   along with INIshell.  If not, see <http://www.gnu.org/licenses/>.
17
18
19
*/

#include "WorkflowPanel.h"
20
#include "src/gui/PathView.h"
21
#include "src/main/colors.h"
22
#include "src/main/common.h"
23
#include "src/main/constants.h"
24
#include "src/main/inishell.h"
25
#include "src/main/os.h"
26
#include "src/main/settings.h"
27
28
#include "src/panels/Atomic.h" //for getQtKey()

29

Mathias Bavay's avatar
Mathias Bavay committed
30
#include <QtGlobal>
31
#include <QComboBox>
32
#include <QCoreApplication>
33
#include <QDateTimeEdit>
34
#include <QDesktopServices>
35
#include <QDir>
36
#include <QGroupBox>
37
#include <QLineEdit>
38
#include <QListWidget>
39
#include <QProcess>
40
41
#include <QRegularExpression>
#include <QString>
42
#include <QToolButton>
43
44
#include <QVBoxLayout>

45
46
47
48
#ifdef DEBUG
	#include <iostream>
#endif

49
50
51
52
53
/**
 * @class WorkflowPanel
 * @brief The WorkflowPanel's default constructor.
 * @details This constructor creates the static part of the workflow portion of the GUI, i. e.
 * panels that are always present and not loaded through XML.
Michael Reisecker's avatar
Michael Reisecker committed
54
 * @param[in] parent The parent widget.
55
 */
56
WorkflowPanel::WorkflowPanel(QWidget *parent) : QWidget(parent)
57
{
58
	/* main toolbox */
Michael Reisecker's avatar
Michael Reisecker committed
59
	this->setMaximumWidth(Cst::width_workflow_max); //to stop the splitter from making it huge
60
	workflow_container_ = new QToolBox;
61
	connect(workflow_container_, &QToolBox::currentChanged, this, &WorkflowPanel::toolboxClicked);
62
63

	//add a list view for applications, one for simulations, and one for INI files:
64
65
	applications_ = new ApplicationsView(tr("Applications"));
	simulations_ = new ApplicationsView(tr("Simulations"));
66
	filesystem_ = new IniFolderView;
67
68
69
	auto *path_label = filesystem_->getInfoLabel();
	path_label->setText(tr("Open an application or simulation before opening INI files."));
	path_label->setWordWrap(true);
70
	path_label->setStyleSheet("QLabel {color: " + colors::getQColor("important").name() + "}");
71

72
73
	workflow_container_->addItem(applications_, tr("Applications"));
	//: Translation hint: This is for the workflow panel on the left side.
74
	workflow_container_->addItem(simulations_, tr("Simulations"));
75
	workflow_container_->addItem(filesystem_, tr("INI files"));
76

77
	/* main layout */
78
	auto *layout = new QVBoxLayout;
79
	layout->addWidget(workflow_container_);
80
	this->setLayout(layout);
81

82
	scanFoldersForApps(); //perform the search for XMLs that are an application or simulation
83
}
84

85
86
87
88
89
90
91
/**
 * @brief Build the dynamic part of the workflow panel.
 * @details This function reads the "workflow" part of an XML which is therefore user setable.
 * It is much like the dynamic GUI building of the main portion, but we keep it completely separate
 * from that code for stability / clarity reasons, and because the demands are a bit different.
 * @param[in] xml The XML document to parse for a "<workflow>" section.
 */
92
93
void WorkflowPanel::buildWorkflowPanel(const QDomDocument &xml)
{
94
95
	for (QDomNode workroot = xml.firstChildElement().firstChildElement("workflow");
	    !workroot.isNull(); workroot = workroot.nextSiblingElement("workflow")) {
96
97
		for (QDomElement work = workroot.toElement().firstChildElement("section"); !work.isNull();
		    work = work.nextSiblingElement("section")) {
98
			buildWorkflowSection(work); //one tab in the workflow tool bar
99
		}
100
	}
101
	int counter = 0; //yet another variation of how we must handle coloring...
102
	QList<QWidget *> widget_list = workflow_container_->findChildren<QWidget *>(QString(), Qt::FindDirectChildrenOnly);
103
	for (auto *wid : widget_list) {
104
105
		if (wid->metaObject()->className() == QString("QToolBoxButton")) {
			counter++;
106
			if (counter > 3) //color dynamic panels different to the static (always present) ones
107
				wid->setStyleSheet("* {color: " + colors::getQColor("special").name() + "}");
108
109
		}
	}
110
111
}

112
113
114
/**
 * @brief Iterate through a number of folders and look for applicable XML files.
 */
115
116
117
void WorkflowPanel::scanFoldersForApps()
{
	topStatus(tr("Scanning for applications and simulations..."));
Mathias Bavay's avatar
Mathias Bavay committed
118
119
	bool applications_found, simulations_found;
	readAppsFromDirs(applications_found, simulations_found); //populate the applications and simulations lists
120
	topStatus(tr("Done scanning, ") +
Mathias Bavay's avatar
Mathias Bavay committed
121
122
	    ((applications_found || simulations_found)? tr("items") : tr("nothing")) + tr(" found."));
	if (!applications_found)
123
124
		applications_->addInfoSeparator(tr(
		    "No applications found. Please check the help section \"Applicatons\" to obtain the necessary files."), 0);
125
	if (!simulations_found) //no files containing "<inishell_config simulation=..." found
126
127
128
129
		simulations_->addInfoSeparator(tr(
		    "No simulations found. Please check the help section \"Simulations\" to set up your simulations."), 0);
}

130
131
132
/**
 * @brief Clear the dynamic part of the workflow panel, i. e. the part that is read from XML.
 */
133
134
135
void WorkflowPanel::clearXmlPanels()
{
	for (int ii = 0; ii < workflow_container_->count(); ++ii) {
Michael Reisecker's avatar
Michael Reisecker committed
136
		if (workflow_container_->widget(ii)->property("from_xml").toBool())
137
138
139
140
			workflow_container_->widget(ii)->deleteLater();
	}
}

141
142
143
144
/**
 * @brief Parse an XML node and build a workflow tab from it.
 * @param[in] section XML node containing the section with all its options
 */
145
146
void WorkflowPanel::buildWorkflowSection(QDomElement &section)
{
147
148
149
150
151
	/*
	 * This function is the pendant to our element factory for the main part of the GUI.
	 * It populates the workflow panel with elements read from XML that can control simulations
	 * and generally perform actions on the operating system.
	 */
152
153
	auto *workflow_frame = new QFrame;
	auto *workflow_layout = new QVBoxLayout;
154
	const QString caption( section.attribute("caption") );
155
	for (QDomElement el = section.firstChildElement("element"); !el.isNull(); el = el.nextSiblingElement("element")) {
156
		QWidget *section_item = workflowElementFactory(el, caption); //construct the element to insert
157
		workflow_frame->setProperty("from_xml", true); //to be able to delete all dynamic ones
158
		if (section_item != nullptr) {
159
160
			//for once setting the parent is important (to check which terminal view is associated with a button):
			section_item->setParent(workflow_frame);
161
			workflow_layout->addWidget(section_item, 0, Qt::AlignTop);
162
			//if there is a system command associated to this element we remember to show a TerminalView for the tab:
163
164
			if (section_item->property("action").toString() == "terminal")
				workflow_frame->setProperty("action", "terminal");
165
		} else {
166
			topLog(tr(R"(Workflow element "%1" unknown)").arg(el.attribute("type")), "error");
167
		}
168
169
	}

170
171
	//only sections that contain a command line action receive their individual TerminalView,
	//simpler panels (e. g. only opening an URL) will show the main GUI:
172
	if (workflow_frame->property("action").toString() == "terminal") {
Michael Reisecker's avatar
Michael Reisecker committed
173
		auto *new_terminal = new TerminalView;
174
175
		workflow_frame->setProperty("stack_index", //connect the tab index to the terminal view index
		    getMainWindow()->getControlPanel()->getWorkflowStack()->count());
176
		getMainWindow()->getControlPanel()->getWorkflowStack()->addWidget(new_terminal);
177

178
		/* create working directory choice */
179
		auto *cwd_label = new QLabel(tr("Set working directory from:"));
Mathias Bavay's avatar
Mathias Bavay committed
180
181
		cwd_label->setWordWrap(true);
		workflow_layout->addWidget(cwd_label, 0, Qt::AlignBottom);
182
		auto *panel_working_directory = new QComboBox;
Mathias Bavay's avatar
Mathias Bavay committed
183
184
		panel_working_directory->setObjectName("_working_directory_");
		panel_working_directory->addItem( "{inifile}" );
185
		panel_working_directory->addItem( "{inifile}/../" );
Mathias Bavay's avatar
Mathias Bavay committed
186
		panel_working_directory->addItem( QDir::currentPath() );
187
		panel_working_directory->setSizeAdjustPolicy( QComboBox::AdjustToMinimumContentsLength );
188
189
190
191
192
193
		panel_working_directory->setEditable(true);

		auto help_button = new QToolButton;
		help_button->setAutoRaise(true);
		help_button->setIcon(getIcon("help-contents"));
		connect(help_button, &QToolButton::clicked, this,
194
			[=]{ getMainWindow()->loadHelp("Example Workflow", "help-workingdir"); });
195
196
197
198
199
200

		auto help_layout = new QHBoxLayout;
		help_layout->addWidget(panel_working_directory);
		help_layout->addWidget(help_button);
		workflow_layout->addLayout(help_layout);

201
202
		//remember the selection for next one that is loaded:
		panel_working_directory->setCurrentIndex(getSetting("user::working_dir", "value").toInt());
203
204
		connect(panel_working_directory, QOverload<const int>::of(&QComboBox::currentIndexChanged),
		    [=](int index){ setSetting("user::working_dir", "value", QString::number(index)); });
Mathias Bavay's avatar
Mathias Bavay committed
205
	}
206
207
	auto *spacer = new QSpacerItem(-1, -1, QSizePolicy::Expanding, QSizePolicy::Expanding);
	workflow_layout->addSpacerItem(spacer); //keep widgets to the top
208

209
	/*
210
	 * Create a separate status label because otherwise we would overwrite status messages about
211
212
213
	 * processes. This label is used to display errors / warnings about the workflow elements,
	 * for example if the INI file is not saved or a key is not found.
	 * This way the main status can still do its work and e. g. show that a process has terminated.
214
	 * TODO: Longer workflows may hide this label until scrolled down -> display or rotate in main status bar?
215
	*/
216
217
218
	QLabel *panel_status_label = new QLabel(workflow_frame);
	panel_status_label->setWordWrap(true);
	panel_status_label->setObjectName("_status_label_");
219
	panel_status_label->setStyleSheet("QLabel {color: " + colors::getQColor("error").name() + "}");
Mathias Bavay's avatar
Mathias Bavay committed
220
	workflow_layout->addWidget(panel_status_label, 0, Qt::AlignBottom);
221

222
	/* main layout */
223
224
225
226
	workflow_frame->setLayout(workflow_layout);
	workflow_container_->addItem(workflow_frame, caption);
}

227
228
229
230
231
/**
 * @brief Element factory routine for the workflow panel elements.
 * @param[in] item XML node containing the element with its options.
 * @return The constructed element.
 */
232
QWidget * WorkflowPanel::workflowElementFactory(QDomElement &item, const QString& appname)
233
234
{
	QWidget *element = nullptr;
235
236
237
	const QString identifier( item.attribute("type") );
	const QString id( item.attribute("id") ); //elements can be referred to by the user via their IDs
	const QString caption( item.attribute("caption") );
238
239

	if (identifier == "datetime") { //a date / time picker
240
		QDateTime default_date(QDateTime::fromString(item.attribute("default"), Qt::ISODate));
241
		if (!default_date.isValid()) {
242
			default_date = QDateTime::currentDateTime();
243
244
			default_date.setTime( QTime() ); //round to start of current day
		}
245
		auto *tmp = new QDateTimeEdit(default_date);
246
247
248
249
		tmp->setCalendarPopup(true);
		tmp->setToolTip(tr("Pick a date/time"));
		tmp->setDisplayFormat("yyyy-MM-ddThh:mm:ss");
		element = tmp;
Mathias Bavay's avatar
Mathias Bavay committed
250
251
	} else if (identifier == "checkbox") { //a checkbox to get a boolean
		element = new QCheckBox(caption);
252
	} else if (identifier == "button") { //a simple push button that can execute multiple commands
253
		element = new QPushButton(caption);
254
		element->setProperty("caption", caption); //remember the caption for later
255
		static const QString regex_openurl(R"((openurl|setpath)\((.*)\))");
256
		static const QRegularExpression rex(regex_openurl);
257
		QString commands;
258
259
		for (QDomElement cmd = item.firstChildElement("command"); !cmd.isNull();
		    cmd = cmd.nextSiblingElement("command")) {
260
			commands += cmd.text() + "\n";
Mathias Bavay's avatar
Mathias Bavay committed
261
			const QRegularExpressionMatch url_match = rex.match(cmd.text());
262
			if (!url_match.hasMatch())
Michael Reisecker's avatar
Michael Reisecker committed
263
264
				element->setProperty("action", "terminal");
		}
265
		commands.chop(1); //remove trailing "\n"
266
		const QStringList action_list( commands.split("\n") );
267
268
		if (!(action_list.size() == 1 && action_list.at(0).isEmpty())) {
			connect(static_cast<QPushButton *>(element), &QPushButton::clicked, this,
269
			    [=] { buttonClicked(static_cast<QPushButton *>(element), action_list, appname); });
270
271
			element->setToolTip(commands);
		} else {
272
			topLog(tr(R"(No command given for button "%1" (ID: "%2"))").arg(caption, id), "error");
273
274
			element->setToolTip(tr("No command"));
		}
275
	} else if (identifier == "label") { //info display element that can be styled with HTML
276
		element = new QLabel(caption);
277
		element->setStyleSheet("QLabel {color: " + colors::getQColor("normal").name() + "}");
278
279
		static_cast<QLabel *>(element)->setWordWrap(true); //multi-line
	} else if (identifier == "text") { //plain text input
280
		element = new QLineEdit;
281
		static_cast<QLineEdit *>(element)->setText(item.attribute("default"));
282
	} else if (identifier == "path") { //listing of a single folder on the file system
283
		element = new PathView;
284
285
		if (!item.attribute("path").isNull())
			static_cast<PathView *>(element)->setPath(item.attribute("path"));
286
	}
287
	if (element) //give the element a unique internal ID
Michael Reisecker's avatar
Michael Reisecker committed
288
		element->setObjectName("_workflow_" + Atomic::getQtKey(id));
289
290
291
	return element;
}

292
293
294
/**
 * @brief Scan a list of directories for suitable XML files to load.
 * @details This function runs through files of numerous directories and looks for the
295
 * "<inishell_config...>" header. It discerns between applications and simulations and populates
296
297
298
299
 * the corresponding list view in the workflow panel.
 * @param[out] applications_found True if at least one application was found.
 * @param[out] simulations_found True if at least one simulation was found.
 */
300
void WorkflowPanel::readAppsFromDirs(bool &applications_found, bool &simulations_found)
301
{
302
	static const QStringList filters = {"*.xml", "*.XML"};
303
304
305
	applications_->clear();
	simulations_->clear();

Mathias Bavay's avatar
Mathias Bavay committed
306
	const QStringList directory_list( getSearchDirs() ); //hardcoded and user set directories
307
308
309
310
	for (auto &directory : directory_list) {
		if (directory.isEmpty())
			continue;

311
		//display only (XML) files:
312
		const QStringList apps( QDir(directory).entryList(filters, QDir::Files) );
313
		//we check how much individual directories contribute for appropriate folder info display:
314
315
316
317
		const int count_before_applications = applications_->count();
		const int count_before_simulations = simulations_->count();

		for (auto &filename : apps) {
318
			QFile infile(directory + "/" + filename);
319
			if (!infile.open(QIODevice::ReadOnly | QIODevice::Text)) {
320
				topLog(tr(R"(Could not check application file: unable to read "%1" (%2))").arg(
321
				    QDir::toNativeSeparators(filename), infile.errorString()), "error");
322
323
324
				continue;
			}
			QTextStream tstream(&infile);
325
			int linecount = 0;
326
			while (!tstream.atEnd()) {
327
328
329
				linecount++;
				if (linecount > 50) //allow this many lines of comments, but then assume it's not our file
					break;
Mathias Bavay's avatar
Mathias Bavay committed
330
331
332
				const QString line( tstream.readLine() );
				static const QRegularExpression regex_inishell(R"(^\<inishell_config (application|simulation)=\"(.*?)\".*?(icon=\"(.*)\")*>.*)");
				const QRegularExpressionMatch match_inishell(regex_inishell.match(line)); //^ allow XML comment at the end
333
334
				static const int idx_type = 1;
				if (match_inishell.captured(0) == line && !line.isEmpty()) {
335
336
337
338
339
340
					/*
					 * There is no logical difference between a file containing
					 * an application and one containing a simulation. The attribute
					 * is used solemnly for clarity and to keep two separate lists
					 * for the two.
					 */
341
342
343
344
					if (match_inishell.captured(idx_type).toLower() == "application")
						applications_->addApplication(infile, match_inishell);
					else
						simulations_->addApplication(infile, match_inishell);
345
					break;
346
347
348
349
				}
			} //endwhile
		} //endfor filename

350
		//display the directory's path as a list separator if it contains valid XMLs:
351
352
353
354
355
356
		if (applications_->count() > count_before_applications)
			applications_->addInfoSeparator(directory, count_before_applications);
		if (simulations_->count() > count_before_simulations)
			simulations_->addInfoSeparator(directory, count_before_simulations);

	} //endfor directory_list
357

358
	applications_found = (applications_->count() > 0); //to display an info when empty
359
	simulations_found = (simulations_->count() > 0);
360
361
}

362
363
364
365
/**
 * @brief Parse a system command associated with a custom button.
 * @details This function performs substitutions to refer to other elements in the workflow
 * panel, and hardcoded substitutions that for example look for an INI key.
366
 * @param[in] action The system command to parse.
367
368
369
 * @param[in] status_label QLabel to display status info for this command.
 * @return The processed command that's ready to run on the system.
 */
370
QString WorkflowPanel::parseCommand(const QString &action, QPushButton *button, QLabel *status_label)
371
{
372
373
374
375
376
	/*
	 * IDs of the workflow panel elements are referred to with "%id".
	 * The values of these panels are retrieved, and within them the various
	 * available substitutions via the "${...}" syntax are performed.
	 */
377
	QString command(action);
378
	static const QString regex_substitution(R"(%\w+(?=\s|$))");
379
380
381
382
	static const QRegularExpression rex(regex_substitution);

	QRegularExpressionMatchIterator rit = rex.globalMatch(action);
	while (rit.hasNext()) {
Mathias Bavay's avatar
Mathias Bavay committed
383
		const QRegularExpressionMatch match( rit.next() );
384
		const QString id(match.captured(0).mid(1)); //ID without %
385
386
387
388
389
390
391
392
393
394

		const QString internal_id( "_workflow_" + Atomic::getQtKey(id) );
		QWidgetList input_widget_list( button->parent()->findChildren<QWidget *>(internal_id) );
		if (input_widget_list.isEmpty()) //current tab does not have it - look everywhere
			input_widget_list = this->findChildren<QWidget *>(internal_id);
		if (input_widget_list.size() > 1)
			workflowStatus(tr(R"(Multiple elements found for ID "%1")").arg(id), status_label);
		if (input_widget_list.isEmpty()) {
			workflowStatus(tr(R"(Element ID "%1" not found)").arg(id), status_label);
		} else {
395
			//get the element's value:
396
			QString substitution = getWidgetValue(input_widget_list.at(0));
397
398
			//substitutions are not only for commands but also available in widget values:
			commandSubstitutions(substitution, status_label);
399
400
401
			command.replace(match.captured(0), substitution);
		}
	} //end while rit.hasNext()
402

403
	//substitutions are also available in the command itself:
404
	commandSubstitutions(command, status_label);
405
406
407
	return command;
}

408
409
410
411
412
/**
 * @brief Perform a number of substitutions in a user-set system command.
 * @param[in] command Command to perform substitutions for.
 * @param[in] status_label The QLabel associated to the command's workflow section.
 */
413
414
void WorkflowPanel::commandSubstitutions(QString &command, QLabel *status_label)
{
415
416
417
418
419
	/*
	 * Currently available are "${inifile}" for the current INI file path,
	 * "${key:<ini_key>}" for INI values available in the GUI.
	 */

Mathias Bavay's avatar
Mathias Bavay committed
420
	/* substitute the current INI file's path */
421
	if (command.contains("${inifile}")) {
Mathias Bavay's avatar
Mathias Bavay committed
422
		const QString current_ini( getMainWindow()->getIni()->getFilename()) ;
423
424
		if (current_ini.isEmpty())
			workflowStatus(tr("Empty INI file - you need to save first"), status_label);
Mathias Bavay's avatar
Mathias Bavay committed
425
426
		else
			command.replace("${inifile}", current_ini);
427
	}
428
429

	/* substitute INI keys from the current GUI values */
430
431
432
433
434
	static const QString regex_key(R"(\${key:(.+)})");
	static const QRegularExpression rex_key(regex_key);
	const QRegularExpressionMatch match_key(rex_key.match(command));
	static const int idx_key = 1;
	if (match_key.hasMatch()) {
Mathias Bavay's avatar
Mathias Bavay committed
435
		QStringList section_and_key( match_key.captured(idx_key).split(Cst::sep) );
436
437
438
439
		if (section_and_key.size() != 2) {
			workflowStatus(tr("INI key must be SECTION") + Cst::sep + "KEY", status_label);
			return;
		}
Mathias Bavay's avatar
Mathias Bavay committed
440
		const QString value( getMainWindow()->getIni()->get(section_and_key.at(0), section_and_key.at(1)) );
441
442
		command.replace("${key:" + match_key.captured(idx_key) + "}", value, Qt::CaseInsensitive);
		if (value.isEmpty())
443
			workflowStatus(tr(R"(INI key "%1" not found)").arg(match_key.captured(idx_key)), status_label);
444
445
446
447
	}

}

448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
/**
 * @brief Parse button command and execute "open URL" if applicable.
 * @param[in] command The command to parse.
 * @return True if the command was indeed a request to open an URL.
 */
bool WorkflowPanel::actionOpenUrl(const QString &command) const
{
	static const QString regex_openurl(R"(openurl\((.*)\))");
	static const QRegularExpression rex(regex_openurl);
	const QRegularExpressionMatch match_url( rex.match(command) );
	if (match_url.captured(0) == command && !command.isEmpty()) {
		QDesktopServices::openUrl(QUrl(match_url.captured(1)));
		return true;
	}
	return false;
}

/**
 * @brief Parse button command and execute "switch PathView path" if applicable.
 * @param[in] command The command to parse.
 * @return True if the command was indeed a request to switch a PathView's path.
 */
470
bool WorkflowPanel::actionSwitchPath(const QString &command, QLabel *status_label, const QString &ref_path)
471
472
473
474
475
476
477
{
	static const QString regex_setpath(R"(setpath\(%(.*),\s*(.*)\))");
	static const QRegularExpression rex_setpath(regex_setpath);
	const QRegularExpressionMatch match_setpath(rex_setpath.match(command));
	static const int idx_element = 1;
	static const int idx_path = 2;
	if (match_setpath.captured(0) == command && !command.isEmpty()) {
478
		auto *path_view = this->findChild<PathView *>("_workflow_" +
479
480
481
482
483
484
485
		    Atomic::getQtKey(match_setpath.captured(idx_element)));
		/*
		 * On startup, the loaded INI is not available yet so we need a mechanism
		 * to change directory, for example to switch to the output folder
		 * of simulation software that is set via an INI key.
		 */
		if (path_view) {
486
487
			//if the provided path is relative, we make it relative to the reference file path
			//which is where the application was run
488
			if ( QFileInfo(match_setpath.captured(idx_path)).isRelative() ) {
489
				QDir iniDir( ref_path );
490
				const QString AbsolutePath( QDir::cleanPath(iniDir.absoluteFilePath( match_setpath.captured(idx_path) )) );
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
				path_view->setPath( AbsolutePath );
			} else {
				path_view->setPath(match_setpath.captured(idx_path));
			}
		} else {
			workflowStatus(tr(R"(Path element ID "%1" not found)").arg(
			    match_setpath.captured(idx_path)), status_label);
		}
		return true;
	}
	return false;
}

/**
 * @brief Parse button command and execute "click other button" if applicable.
 * @param[in] command The command to parse.
 * @return True if the command was indeed a request to click another button.
 */
bool WorkflowPanel::actionClickButton(const QString &command, QPushButton *button, QLabel *status_label)
{
	static const QString regex_clickbutton(R"(button\(%(.*?)\s*\))");
	static const QRegularExpression rex_clickbutton(regex_clickbutton);
	const QRegularExpressionMatch match_clickbutton(rex_clickbutton.match(command));
	static const int idx_button = 1;
	if (match_clickbutton.captured(0) == command && !command.isEmpty()) {
516
		auto *clicked_button = this->findChild<QPushButton *>("_workflow_" + Atomic::getQtKey(
517
518
519
520
521
522
523
524
525
526
		    match_clickbutton.captured(idx_button)));
		if (clicked_button) {
			if (clicked_button->objectName() == button->objectName()) {
				workflowStatus(tr("A button can not click itself"), status_label);
				return true; //TODO: circular clicks still possible --> infinite loop
			}
			//wait until the buttons' processes have finished before clicking the next one:
			clicked_button_running_ = true; //set to false when process finishes
			clicked_button->animateClick();
			while (clicked_button_running_)
527
				QApplication::processEvents(); //keep GUI responsive
528
529
530
531
532
533
534
535
536
537
538
539
540
541
		} else {
			workflowStatus(tr(R"(Button with ID "%1" not found)").arg(
			    match_clickbutton.captured(idx_button)), status_label);
		}
		return true;
	}
	return false;
}

/**
 * @brief Execute a system command on button click.
 * @param[in] command The command to execute.
 * @return True if the "stop all processes" flag has been set when waiting.
 */
542
bool WorkflowPanel::actionSystemCommand(const QString &command, QPushButton *button, const QString &ref_path, const QString &appname)
543
544
{
	const int idx = button->parent()->property("stack_index").toInt();
545
	auto *terminal = static_cast<TerminalView *>( //get the button's terminal view
546
547
548
549
550
	    getMainWindow()->getControlPanel()->getWorkflowStack()->widget(idx));

	button->setText(tr("Stop process"));
	button->setStyleSheet("QPushButton {background-color: " + colors::getQColor("important").name() + "}");

551
	os::setSystemPath(appname.toLower()); //set an enhanced PATH to have more chances to find the app
552
	auto *process = new QProcess(button); //set as parent so button can look up what to cancel
553
554
	//set the working directory for the child process: either cwd or something related to the inifile
	process->setWorkingDirectory(ref_path);
555
556
557
558
559
560
561
562

	connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
	    [=](int exit_code, QProcess::ExitStatus exit_status)
	    { processFinished(exit_code, exit_status, terminal, button); });
	connect(process, &QProcess::readyReadStandardOutput, this, [=]{ processStandardOutput(terminal); });
	connect(process, &QProcess::readyReadStandardError, this, [=]{ processStandardError(terminal); });
	connect(process, &QProcess::errorOccurred, this, [&](const QProcess::ProcessError& error){ processErrorOccured(error, terminal, button); });

563
	terminal->log("\033[3mPATH set to: "+QString::fromLocal8Bit(qgetenv("PATH"))+"\033[0m");
564
565
566
	process->start(command); //execute the system command via QProcess

	terminal->log(html::bold("$ " + command)); //show what is being run
567
	topStatus(tr("A process is running..."), "normal", true);
568
569
570
571
	getMainWindow()->refreshStatus(); //blocked at this point if not called manually
	getMainWindow()->repaint();
	while (process->state() == QProcess::Starting || process->state() == QProcess::Running)
		QCoreApplication::processEvents();
572
	return button->property("process_closing").toBool(); //user has requested all processes (of this button) to stop
573
574
}

575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
/**
 * @brief Build the path to use as a reference for running the application, for the outputs, etc from the user selection
 * @param[in] button Element to get the value from.
 * @param[in] ini_path Path to the ini file.
 * @return Either ini_path or cwd or something related to ini_path depending on the user's choice
 */
QString WorkflowPanel::setReferencePath(const QPushButton *button, const QString &ini_path) const
{
	if (button->parent()->property("action").toString() == "terminal") {
		const QComboBox *working_directory( button->parent()->findChild<QComboBox *>("_working_directory_") );
		if (working_directory && working_directory->currentText().contains("{inifile}")) {
			QString new_path( working_directory->currentText() );
			new_path.replace("{inifile}", ini_path);
			return new_path;
		} else {
			return QDir::currentPath();
		}
	} else {
		return ini_path;
	}
}

597
598
599
600
601
/**
 * @brief Get a workflow panel's value in text form.
 * @param[in] widget Element to get the value from.
 * @return Value that is currently set in the panel.
 */
602
603
604
605
606
QString WorkflowPanel::getWidgetValue(QWidget *widget) const
{
	if (auto *wid = qobject_cast<QDateTimeEdit *>(widget))
		return wid->dateTime().toString(Qt::DateFormat::ISODate);
	else if (auto *wid = qobject_cast<QLineEdit *>(widget))
607
		return wid->text();
608
609
	else if (auto *wid = qobject_cast<QCheckBox *>(widget))
		return (wid->checkState() == Qt::Checked? "TRUE" : "FALSE");
610
611
	return QString();
}
612

613
614
615
616
617
618
619
620
621
/**
 * @brief Delegated event listener for when a started system process finishes.
 * @details This function is called by a lambda when a process that was started by a button
 * click exits.
 * @param[in] exit_code Flag telling if the process has finished normally or with errors.
 * @param[in] exit_status Flag telling if the process has finished normally or with errors.
 * @param[in] terminal The TerminalView associated with the process.
 * @param[in] button Button that started the process.
 */
622
623
void WorkflowPanel::processFinished(int exit_code, QProcess::ExitStatus exit_status, TerminalView *terminal,
    QPushButton *button)
624
625
{
	if (exit_status != QProcess::NormalExit) {
Mathias Bavay's avatar
Mathias Bavay committed
626
		const QString msg( QString(tr(
627
		    "The process was terminated unexpectedly (exit code: %1, exit status: %2).")).arg(
Mathias Bavay's avatar
Mathias Bavay committed
628
		    exit_code).arg(exit_status) );
629
		terminal->log(html::color(html::bold(msg), "error"));
630
		topStatus(tr("Process was terminated"), "error", false);
631
	} else {
632
		topStatus(tr("Process has finished"), "normal", false);
633
	}
634
635
	//the button starting the command was set to "Stop process" when clicked - switch back:
	button->setText(button->property("caption").toString()); //original caption
636
	button->setStyleSheet("");
637
	terminal->log(html::color(html::bold("$ " + QDir::currentPath()), "normal"));
638
	clicked_button_running_ = false; //for buttons that click other buttons (and wait for them to finish)
Mathias Bavay's avatar
Mathias Bavay committed
639
	QApplication::alert( this ); //notify the user that the task is finished
640
641
}

642
643
644
645
646
/**
 * @brief Delegated event listener for when there is output ready from a started system process.
 * @details This function is called by a lambda when new stdout text is available.
 * @param[in] terminal The TerminalView associated with this process.
 */
647
648
void WorkflowPanel::processStandardOutput(TerminalView *terminal)
{
649
	//read line by line for proper buffering:
650
651
	 while( static_cast<QProcess *>((QObject::sender()))->canReadLine() ) {
		const QString str_std( static_cast<QProcess *>((QObject::sender()))->readLine() );
652
		terminal->log(str_std);
653
	}
654
655
}

656
657
658
659
660
/**
 * @brief Delegated event listener for when there is error output ready from a started system process.
 * @details This function is called by a lambda when new stderr text is available.
 * @param[in] terminal The TerminalView associated with this process.
 */
661
662
663
664
void WorkflowPanel::processStandardError(TerminalView *terminal)
{
	const QString str_err(static_cast<QProcess *>(QObject::sender())->readAllStandardError());
	if (!str_err.isEmpty())
665
		terminal->log(str_err, true); //log as error
666
}
667

668
669
670
671
672
673
674
675
676
/**
 * @brief Delegated event listener for when there is an error starting the process.
 * @details This function is called by a lambda when the child process returns an error.
 * @param[in] terminal The TerminalView associated with this process.
 */
void WorkflowPanel::processErrorOccured(const QProcess::ProcessError &error, TerminalView *terminal, QPushButton *button)
{
	QString message;
	switch(error) {
677
		case QProcess::FailedToStart:
678
679
680
		{
			const QString tmp( tr("Can not start process. Please make sure that the executable is in the PATH environment variable or in any of the following paths "));
			message = tmp + os::getExtraPath("{application name}");
681
			break;
682
		}
683
684
685
		case QProcess::Crashed:
			break;
		case QProcess::Timedout:
686
			message = tr("Timeout when running process...");
687
688
689
			break;
		case QProcess::WriteError:
		case QProcess::ReadError:
690
			message = tr("Can not read or write to process.");
691
			break;
692
		default:
693
			message = tr("Unknown error when running process.");
694
	}
695

696
697
698
699
700
	topStatus(tr("Process was terminated"), "error", false);
	if (!message.isEmpty()) {
		terminal->log(html::color(html::bold(message), "error"));
		topLog("[Workflow] " + message, "error"); //also log to main logger
	}
701

702
703
704
705
	//the button starting the command was set to "Stop process" when clicked - switch back:
	button->setText(button->property("caption").toString()); //original caption
	button->setStyleSheet("");
	terminal->log(html::color(html::bold("$ " + QDir::currentPath()), "normal"));
706
	clicked_button_running_ = false; //for buttons that click other buttons (and wait for them to finish)
707
708
}

709
710
711
/**
 * @brief Display info concerning a certain workflow panel.
 * @details Cf. notes in buildWorkflowSection() for the QLabel.
Michael Reisecker's avatar
Michael Reisecker committed
712
713
 * @param[in] message
 * @param[in] status_label
714
 */
715
716
717
void WorkflowPanel::workflowStatus(const QString &message, QLabel *status_label)
{
#ifdef DEBUG
718
	if (!status_label) {
719
720
721
		qDebug() << "A wokflow status label does not exist when it should in commandSubstitutions()";
		return;
	}
722
#endif //def DEBUG
723
	status_label->setText(message);
724
	topLog("[Workflow] " + message, "error"); //also log to main logger
725
}
726

727
728
729
730
731
732
733
/**
 * @brief Event listener for when a custom button (read from XML) is clicked in the workflow panel.
 * @details Buttons can execute and stop system processes, open URLs, and interact with other
 * workflow panels.
 * @param[in] button The clicked button.
 * @param[in] action_list A list of system commands that the button should execute.
 */
734
void WorkflowPanel::buttonClicked(QPushButton *button, const QStringList &action_list, const QString& appname)
735
{
736
	const QString current_ini( getMainWindow()->getIni()->getFilename());
737
	const QString ini_path = (current_ini.isEmpty())? QString() : QFileInfo( current_ini ).absolutePath();
738
	const QString ref_path( setReferencePath(button, ini_path) ); //this contains the path where the application runs
739
740
741
742
743
	if (!QDir(ref_path).exists()) { //HACK We don't have access to the log window here
		topStatus(QString("Reference path '")+ref_path+QString("' does not exists!"), "error", true);
		return;
	}
	
744

745
746
747
748
749
750
751
752
753
754
755
756
757
758
	/*
	 * A button can currently perform the following actions:
	 *   - Execute a system command
	 *   - Stop said system command
	 *   - Open an URL
	 *   - Set the path of a PathView element (e. g. to set a path from an INI key)
	 *   - Click another button (e. g. for a "Run all" button)
	 */

	/*
	 * If there is a stylesheet set for the button this means that it is currently running
	 * a process, and should therefore act as a stop button. Stop all processes and reset
	 * the style.
	 */
759
	auto *status_label = button->parent()->findChild<QLabel *>("_status_label_");
760
761
	topStatus(QString(), QString(), false); //clear left-over messages
	if (!button->styleSheet().isEmpty()) {
762
763
		//QProcess items are started with the starting button as parent, so we can find
		//all processes that were started by the button:
Mathias Bavay's avatar
Mathias Bavay committed
764
		QList<QProcess *> process_list( button->findChildren<QProcess *>() );
765
766
767
768
769
770
771
772
		for (auto &process : process_list)
			process->close();
		button->text() = button->property("caption").toString();
		button->setStyleSheet("");
		button->setProperty("process_closing", true); //to stop loop of multiple commands
		return;
	}

773
774
775
776
777
	/*
	 * Run through the list of commands that were given by the user in the XML. After each command
	 * we allow user input (e. g. Stop button clicks), but we do wait until the command has finished
	 * before starting a new one.
	 */
778
779
	status_label->clear();
	for (int ii = 0; ii < action_list.size(); ++ii) {
780
		//this property is only looked at in the loop below that waits for stop button clicks:
781
		button->setProperty("process_closing", false);
782
		const QString command( parseCommand(action_list[ii], button, status_label) ); //cmd ready to execute
783

784
		/* open an URL */
785
		if (actionOpenUrl(command))
786
787
			continue;

788
		/* set a PathView's path */
789
		if (actionSwitchPath(command, status_label, ref_path))
790
791
			continue;

792
		/* click another button */
793
		if (actionClickButton(command, button, status_label))
794
795
			continue;

796
		/* execute a system command */
797
		if (actionSystemCommand(command, button, ref_path, appname))
798
			break; //user has requested to stop all processes
799
800
801
	}
}

802
803
804
805
/**
 * @brief Display the content in the main area that belongs to the clicked workflow panel.
 * @param[in] index Index of the section that is being clicked in the workflow tab.
 */
806
807
808
809
void WorkflowPanel::toolboxClicked(int index)
{
	if (getMainWindow()->getControlPanel() == nullptr)
		return; //stacked widget not built yet
810
	QStackedWidget *stack( getMainWindow()->getControlPanel()->getWorkflowStack() );
Mathias Bavay's avatar
Mathias Bavay committed
811
	const QString action( workflow_container_->widget(index)->property("action").toString() );
812
813
	//if there is a terminal action associated with this section show the terminal; if not,
	//show the main GUI:
814
	if (action == "terminal") {
815
		const int idx = workflow_container_->widget(index)->property("stack_index").toInt();
816
		stack->setCurrentIndex(idx); //bring this section's main window to the top
817
	} else {
818
		stack->setCurrentIndex(0);
819
820
	}
}