[education/rkward/devel/workspace_output] rkward: WIP: Start adjusting frontend API for output directory. This will not compile.

Thomas Friedrichsmeier null at kde.org
Sat Oct 17 11:41:51 BST 2020


Git commit 5c0f238b0a6caaab6b958a98cd89967e1ff16454 by Thomas Friedrichsmeier.
Committed on 17/10/2020 at 10:40.
Pushed by tfry into branch 'devel/workspace_output'.

WIP: Start adjusting frontend API for output directory. This will not compile.

While at it, move output directory functions to a dedicated source file.

M  +1    -0    rkward/misc/CMakeLists.txt
A  +383  -0    rkward/misc/rkoutputdirectory.cpp     [License: GPL (v2+)]
A  +85   -0    rkward/misc/rkoutputdirectory.h     [License: GPL (v2+)]
M  +1    -1    rkward/rbackend/rpackages/rkward/R/rk.output.R
A  +80   -0    rkward/rbackend/rpackages/rkward/man/RK.Output.Rd
M  +0    -331  rkward/windows/rkhtmlwindow.cpp
M  +6    -42   rkward/windows/rkhtmlwindow.h

https://invent.kde.org/education/rkward/commit/5c0f238b0a6caaab6b958a98cd89967e1ff16454

diff --git a/rkward/misc/CMakeLists.txt b/rkward/misc/CMakeLists.txt
index 0b1a8995..1628026e 100644
--- a/rkward/misc/CMakeLists.txt
+++ b/rkward/misc/CMakeLists.txt
@@ -27,6 +27,7 @@ SET(misc_STAT_SRCS
    rkaccordiontable.cpp
    rkxmlguipreviewarea.cpp
    rkdialogbuttonbox.cpp
+   rkoutputdirectory.cpp
    )
 
 ADD_LIBRARY(misc STATIC ${misc_STAT_SRCS})
diff --git a/rkward/misc/rkoutputdirectory.cpp b/rkward/misc/rkoutputdirectory.cpp
new file mode 100644
index 00000000..b1c6ef3d
--- /dev/null
+++ b/rkward/misc/rkoutputdirectory.cpp
@@ -0,0 +1,383 @@
+/***************************************************************************
+                          rkoutputdirectory  -  description
+                             -------------------
+    begin                : Mon Oct 12 2020
+    copyright            : (C) 2020 by Thomas Friedrichsmeier
+    email                : thomas.friedrichsmeier at kdemail.net
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+
+#include "rkoutputdirectory.h"
+
+#include <QDir>
+#include <QFileInfo>
+#include <QFileDialog>
+#include <KLocalizedString>
+#include <KMessageBox>
+
+#include "../debug.h"
+
+/** much like `ls -Rl`. List directory contents including timestamps and sizes, recursively. Used to detect whether an output directory has any changes. */
+void listDirectoryState(const QString& _dir, QString *list, const QString prefix) {
+	RK_TRACE(APP);
+
+	QDir dir(_dir);
+	QFileInfoList entries = dir.entryInfoList(QDir::NoDotAndDotDot, QDir::Name | QDir::DirsLast);
+	for (int i = 0; i < entries.size(); ++i) {
+		const QFileInfo fi = entries[i];
+		if (fi.isDir()) {
+			listDirectoryState(fi.absolutePath (), list, prefix + '/' + fi.fileName());
+		} else {
+			list->append(fi.fileName () + '\t');
+			list->append(fi.lastModified ().toString ("dd.hh.mm.ss.zzz") + '\t');
+			list->append(QString::number (fi.size()) + '\n');
+		}
+	}
+}
+
+QString hashDirectoryState(const QString& dir) {
+	RK_TRACE(APP);
+	QString list;
+	listDirectoryState(dir, &list, QString());
+	return list;
+//	return QCryptographicHash::hash (list.toUtf8 (), QCryptographicHash::Md5);
+}
+
+bool copyDirRecursively(const QString& _source_dir, const QString& _dest_dir) {
+	RK_TRACE(APP);
+
+	QDir dest_dir(_dest_dir);
+	QDir source_dir(_source_dir);
+	if (!QDir().mkpath(_dest_dir)) return false;
+	if (!source_dir.exists()) return false;
+
+	bool ok = true;
+	QFileInfoList entries = source_dir.entryInfoList(QDir::NoDotAndDotDot);
+	for (int i = 0; i < entries.size (); ++i) {
+		const QFileInfo fi = entries[i];
+		if (fi.isDir()) {
+			ok = ok && copyDirRecursively(fi.absoluteFilePath(), dest_dir.absoluteFilePath(fi.fileName ()));
+		} else {
+			// NOTE: this does not overwrite existing target files, but in our use case, we're always writing into empty targets
+			ok = ok && QFile::copy(fi.absoluteFilePath(), dest_dir.absoluteFilePath(fi.fileName ()));
+		}
+	}
+
+	return ok;
+}
+
+RKOutputDirectory::RKOutputDirectory() {
+	RK_TRACE(APP);
+}
+
+~RKOutputDirectory::RKOutputDirectory() {
+	RK_TRACE(APP);
+}
+
+RKOutputDirectory* RKOutputDirectory::getOpenDirectory(const QString id, GenericRCallResult* result) {
+	RK_TRACE (APP);
+	RK_ASSERT (result);
+
+	if (!outputs.contains(id)) {
+		result->error = i18n("The output identified by '%1' is not loaded in this session.", id);
+		return 0;
+	}
+	return &outputs[id];
+}
+
+RKOutputDirectory::GenericRCallResult RKOutputDirectory::save(const QString& dest, RKOutputDirectory::OverwriteBehavior overwrite) {
+	RK_TRACE (APP);
+	GenericRCallResult res = exportAs(dest, overwrite);
+	if (!res.failed()) {
+		save_timestamp = QDateTime::currentDateTime();
+		saved_hash = hashDirectoryState(work_dir);
+		save_dir = res.ret.toString(); // might by different from dest, notably, if dest was empty
+	}
+	return res;
+}
+
+RKOutputDirectory::GenericRCallResult RKOutputDirectory::exportAs (const QString& _dest, RKOutputDirectory::OverwriteBehavior overwrite) {
+	RK_TRACE (APP);
+
+	QString dest = _dest;
+	if (dest.isEmpty()) {
+		QFileDialog dialog(RKWardMainWindow::getMain(), i18n("Specify directory where to export output"), save_dir);
+		dialog.setFileMode(QFileDialog::Directory);
+		dialog.setOption(QFileDialog::ShowDirsOnly, true);
+		dialog.setAcceptMode(QFileDialog::AcceptSave);
+		dialog.setOption(QFileDialog::DontConfirmOverwrite, true);  // custom handling below
+
+		if (dialog.exec () != QDialog::Accepted) {
+			return GenericRCallResult::makeError(i18n("File selection cancelled"));
+		}
+
+		dest = QDir::cleanPath(dialog.selectedFiles().value(0));
+	}
+
+	// If destination already exists, rename it before copying, so we can restore the save in case of an error
+	QString tempname;
+
+	bool exists = QFileInfo(dest).exists();
+	if (exists) {
+#warning TODO Check terminology ("output directory") once finalized
+		if (!isRKWwardOutputDirectory(dest)) {
+			return GenericRCallResult::makeError(i18n("The directory %1 exists, but does not appear to be an RKWard output directory. Refusing to overwrite it.", dest));
+		}
+		if (isOutputDirectoryModified(dest)) {
+			return GenericRCallResult::makeError(i18n("The output directory %1 has been modified by an external process. Refusing to overwrite it.", dest));
+		}
+		if (overwrite == Ask) {
+			const QString warning = i18n("Are you sure you want to overwrite the existing directory '%1'? All current contents, <b>including subdirectories</b> will be lost.", dest);
+			KMessageBox::ButtonCode res = KMessageBox::warningContinueCancel(RKWardMainWindow::getMain(), warning, i18n("Overwrite Directory?"), KStandardGuiItem::overwrite(),
+												KStandardGuiItem::cance (), QString(), KMessageBox::Options(KMessageBox::Notify | KMessageBox::Dangerous));
+			if (KMessageBox::Continue != res) return GenericRCallResult::makeError(i18n("User cancelled"));
+		} else if (overwrite != Force) {
+			return GenericRCallResult::makeError(i18n("Not overwriting existing output"));
+		}
+
+		tempname = dest + '~';
+		while (QFileInfo(tempname).exists()) {
+			tempname.append('~');
+		}
+		if (!QDir().rename(dest, tempname)) {
+			return GenericRCallResult::makeError(i18n("Failed to create temporary backup file %1.", tempname));
+		}
+	}
+
+	bool error = copyDirRecursively(work_dir, dest);
+	if (error) {
+		if (!tempname.isEmpty()) {
+			QDir().rename(tempname, dest);
+		}
+		return GenericRCallResult::makeError(i18n("Error while copying %1 to %2", work_dir, dest));
+	}
+	if (!tempname.isEmpty()) {
+		QDir(tempname).removeRecursively();
+	}
+
+	return GenericRCallResult(QVariant(dest));  // return effective destination path. Needed by save()
+}
+
+
+
+
+
+
+QString RKOutputDirectory::importOutputDirectory (const QString& _dir, const QString& _index_file, bool ask_revert, RCommandChain* chain) {
+	RK_TRACE (APP);
+
+	QFileInfo fi (_dir);
+	if (!fi.isDir ()) {
+		return i18n ("The path %1 does not exist or is not a directory.", _dir);
+	}
+
+	QString dir = fi.canonicalFilePath ();
+	QMap<QString, OutputDirectory>::const_iterator i = outputs.constBegin ();
+	while (i != outputs.constEnd ()) {
+		if (i.value ().save_dir == dir) {
+			if (ask_revert && (KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), i18n ("The output directory %1 is already imported (use Window->Show Output to show it). Importing it again will revert any unsaved changes made to the output directory during the past %2 minutes. Are you sure you want to revert?", dir, i.value ().save_timestamp.secsTo (QDateTime::currentDateTime ()) / 60), i18n ("Reset output directory?"), KStandardGuiItem::reset ()) != KMessageBox::Continue)) {
+				return i18n ("Cancelled");
+			} else {
+				outputs.remove (i.key ());
+				break;
+			}
+		}
+	}
+
+	QString index_file = _index_file;
+	if (index_file.isEmpty ()) {
+		QStringList html_files = QDir (dir).entryList (QStringList () << "*.html" << "*.htm", QDir::Files | QDir::Readable);
+		if (html_files.isEmpty ()) {
+			return i18n ("The directory %1 does not appear to contain any (readable) HTML-files.", dir);
+		} else if (html_files.contains ("rk_out.html")) {
+			index_file = "rk_out.html";
+		} else if (html_files.contains ("index.html")) {
+			index_file = "index.html";
+		} else {
+			index_file = html_files.first ();
+		}
+	}
+
+	bool was_rkward_ouput = isRKWwardOutputDirectory (dir);
+	if ((!was_rkward_ouput) && ask_revert) {
+		if (KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), i18n ("The directory %1 does not appear to be an existing RKWard output directory. RKWard can try to import it, anyway (and this will usually work for output files created by RKWard < 0.7.0), but it may not be possible to append to the existing output, and for safety concerns RKWard will refuse to save the modified output back to this directory.\nAre you sure you want to continue?", dir)) != KMessageBox::Continue) {
+			return i18n ("Cancelled");
+		}
+	}
+
+	QString dest = createOutputDirectoryInternal ();
+
+	if (!copyDirRecursively (dir, dest)) {
+		QDir (dest).removeRecursively ();
+		return i18n ("The path %1 could not be imported (copy failure).", _dir);
+	}
+
+	OutputDirectory od;
+	od.index_file = index_file;
+	if (was_rkward_ouput) od.save_dir = dir;
+	od.saved_hash = hashDirectoryState (dest);
+	od.save_timestamp = QDateTime::currentDateTime ();
+	outputs.insert (dest, od);
+
+	backendActivateOutputDirectory (dest, chain);
+
+	return (QString ());
+}
+
+QString RKOutputDirectory::createOutputDirectory (RCommandChain* chain) {
+	RK_TRACE (APP);
+	
+id= 		const QString work_dir = QFileInfo (dir).canonicalFilePath ();
+
+
+	QString dest = createOutputDirectoryInternal ();
+
+	OutputDirectory od;
+	od.index_file = QStringLiteral ("index.html");
+	outputs.insert (dest, od);
+
+	backendActivateOutputDirectory (dest, chain);
+
+	// the state of the output directory cannot be hashed until the backend has created the index file (via backendActivateOutputDirectory())
+	RCommand *command = new RCommand (QString (), RCommand::App | RCommand::Sync | RCommand::EmptyCommand);
+	command->notifier ()->setProperty ("path", dest);
+	connect (command->notifier (), &RCommandNotifier::commandFinished, this, &RKOutputWindowManager::updateOutputSavedHash);
+	RKGlobals::rInterface ()->issueCommand (command, chain);
+
+	return dest;
+}
+
+QString RKOutputDirectory::createOutputDirectoryInternal () {
+	RK_TRACE (APP);
+
+	QString prefix = QStringLiteral ("unsaved_output");
+	QString destname = prefix;
+	QDir ddir (RKSettingsModuleGeneral::filesPath ());
+	int x = 0;
+	while (true) {
+		if (!ddir.exists (destname)) break;
+		destname = prefix + QString::number (x++);
+	}
+	ddir.mkpath (destname);
+
+	QFile marker (destname + QStringLiteral ("/rkward_output_marker.txt"));
+	marker.open (QIODevice::WriteOnly);
+	marker.write (i18n ("This file is used to indicate that this directory is an ouptut directory created by RKWard (http://rkward.kde.org). Do not place any other contents in this directory, as the entire directory will be purged if/when overwriting the output.\nRKWard will ask you before purging this directory (unless explicitly told otherwise), but if you remove this file, RKWard will not purge this directory.\n").toLocal8Bit ());
+	marker.close ();
+
+	return ddir.absoluteFilePath (destname);
+}
+
+QString RKOutputDirectory::dropOutputDirectory (const QString& dir, bool ask, RCommandChain* chain) {
+	RK_TRACE (APP);
+
+	const QString work_dir = QFileInfo (dir).canonicalFilePath ();
+	if (!outputs.contains (work_dir)) {
+		return i18n ("The directory %1 does not correspond to to any output directory loaded in this session.", dir);
+	}
+
+	OutputDirectory od = outputs[work_dir];
+	if (ask) {
+		if (od.saved_hash != hashDirectoryState (work_dir)) {
+			if (KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), i18n ("The output directory %1 has unsaved changes, which will be lost by dropping it. Are you sure you want to proceed?", work_dir)) != KMessageBox::Continue) return i18n ("Cancelled");
+		}
+	}
+
+	QString error = dropOutputDirectoryInternal (work_dir);
+	if (!error.isEmpty ()) return error;
+
+	if (current_default_path.startsWith (work_dir)) {
+		if (!outputs.isEmpty ()) {
+			backendActivateOutputDirectory (outputs.constBegin ().key (), chain);
+		} else {
+			createOutputDirectory (chain);
+		}
+	}
+
+	return QString ();
+}
+
+QString RKOutputDirectory::dropOutputDirectoryInternal (const QString& dir) {
+	RK_TRACE (APP);
+
+	if (!isRKWwardOutputDirectory (dir)) {
+		RK_ASSERT (false); // this should not happen unless user messes with the temporary file, but we better play it safe.
+		return (i18n ("The directory %1 does not appear to be an RKWard output directory. Refusing to remove it.", dir));
+	}
+	outputs.remove (dir);
+	QDir (dir).removeRecursively ();
+
+	QStringList paths = windows.keys ();
+	for (int i = 0; i < paths.size (); ++i) {
+		if (paths[i].startsWith (dir)) {
+//			RKWorkplace::closeWindows (windows.values (paths[i]));  // NOTE: Won't work with current compilers
+			QList<RKHTMLWindow*> wins = windows.values (paths[i]);
+			for (int j = 0; j < wins.size (); ++j) {
+				RKWorkplace::mainWorkplace ()->closeWindow (wins[j]);
+			}
+		}
+	}
+
+	return QString ();
+}
+
+void RKOutputDirectory::purgeAllOututputDirectories () {
+	RK_TRACE (APP);
+
+	QStringList output_dirs = outputs.keys ();
+	for (int i = output_dirs.size () - 1; i >= 0; --i) {
+		if (i > 0) dropOutputDirectoryInternal (output_dirs[i]);
+		else dropOutputDirectory (output_dirs[i], false);
+	}
+}
+
+QStringList RKOutputDirectory::modifiedOutputDirectories() const {
+	RK_TRACE (APP);
+
+	QStringList ret;
+	for (QMap<QString, OutputDirectory>::const_iterator it = outputs.constBegin (); it != outputs.constEnd (); ++it) {
+		if (it.value ().saved_hash != hashDirectoryState (it.key ())) ret.append (it.key ());
+	}
+	return ret;
+}
+
+QString RKOutputDirectory::outputCaption (const QString& dir) const {
+	RK_TRACE (APP);
+
+	// TODO: real implementation!
+	return (dir);
+}
+
+bool RKOutputDirectory::isRKWwardOutputDirectory (const QString& dir) {
+	RK_TRACE (APP);
+
+	return (QDir (dir).exists (QStringLiteral ("rkward_output_marker.txt")));
+}
+
+bool RKOutputDirectory::isOutputDirectoryModified(const QString dir) {
+	RK_TRACE (APP);
+}
+
+void RKOutputDirectory::backendActivateOutputDirectory (const QString& dir, RCommandChain* chain) {
+	RK_TRACE (APP);
+
+	RKGlobals::rInterface ()->issueCommand (QStringLiteral ("rk.set.output.html.file (\"") + RKCommonFunctions::escape (dir + '/' + outputs.value (dir).index_file) + QStringLiteral ("\")\n"), RCommand::App, QString (), 0, 0, chain);
+}
+
+void RKOutputDirectory::updateOutputSavedHash (RCommand* command) {
+	RK_TRACE (APP);
+
+	QString path = command->notifier ()->property ("path").toString ();
+	RK_ASSERT (outputs.contains (path));
+	OutputDirectory &output = outputs[path];
+	output.saved_hash = hashDirectoryState (path);
+	output.save_timestamp = QDateTime::currentDateTime ();
+}
diff --git a/rkward/misc/rkoutputdirectory.h b/rkward/misc/rkoutputdirectory.h
new file mode 100644
index 00000000..5f1a6b86
--- /dev/null
+++ b/rkward/misc/rkoutputdirectory.h
@@ -0,0 +1,85 @@
+/***************************************************************************
+                          rkoutputdirectory  -  description
+                             -------------------
+    begin                : Mon Oct 12 2020
+    copyright            : (C) 2020 by Thomas Friedrichsmeier
+    email                : thomas.friedrichsmeier at kdemail.net
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+
+#ifndef RKOUTPUTDIRECTORY_H
+#define RKOUTPUTDIRECTORY_H
+
+#include <QString>
+#include <QUrl>
+#include <QVariant>
+#include <QDateTime>
+
+class RKOutputDirectory {
+public:
+	RKOutputDirectory();
+	~RKOutputDirectory();
+#warning TODO: Move me to backend
+	/** Standard wrapper for the result of a "generic" API call, such that we need less special-casing in the backend. The conventions are simple:
+	 *  - If @param error is non-null, stop() will be called in the backend, with the given message.
+	 *  - If @param warning is non-null, the message will be shown (as a warning), but no error will be raised.
+	 *  - Unless an error was thrown, @param ret will be returned as a basic data type (possibly NULL). */
+	struct GenericRCallResult {
+		GenericRCallResult(const QVariant& ret=QVariant(), const QString& warning=QString(), const QString& error=QString()) :
+			error(error), warning(warning), ret(ret) {};
+		static GenericRCallResult makeError(const QString& error) {
+			return GenericRCallResult(QVariant(), QString(), error);
+		}
+		QString error;
+		QString warning;
+		QVariant ret;
+		bool failed() { !error.isEmpty(); }
+		bool toBool() { return ret.toBool(); }
+	};
+	enum OverwriteBehavior {
+		Force,
+		Ask,
+		Fail
+	};
+	void activate(RCommandChain* chain=0);
+	bool isEmpty();
+	GenericRCallResult revert(bool ask);
+	GenericRCallResult createOrImport(const QUrl from);
+	GenericRCallResult save(const QString& dest=QString(), OverwriteBehavior overwrite=Ask);
+	GenericRCallResult exportAs(const QString& dest=QString(), OverwriteBehavior overwrite=Ask);
+	GenericRCallResult clear(OverwriteBehavior discard=Ask);
+	GenericRCallResult purge(OverwriteBehavior discard=Ask);
+	QString getId() const { return id; };
+	void view(bool raise);
+	QString filename();
+	QString workDir();
+	static GenericRCallResult handleRCall(const QStringList& params);
+	static RKOutputDirectory* getOpenDirectory(const QString id, GenericCallResult* result);
+private:
+	QString saved_hash;
+	QDateTime save_timestamp;
+	QString work_dir;
+	QString save_dir;
+	QString id;
+	bool initialized;
+	/** map of outputs. */
+	static QMap<QString, RKOutputDirectory> outputs;
+
+	void backendActivateOutputDirectory (const QString& dir, RCommandChain* chain);
+	QString createOutputDirectoryInternal ();
+	static bool isRKWwardOutputDirectory (const QString &dir);
+	static bool isOutputDirectoryModified (const QString &dir);
+	QString dropOutputDirectoryInternal (const QString& dir);
+
+};
+
+#endif
+
diff --git a/rkward/rbackend/rpackages/rkward/R/rk.output.R b/rkward/rbackend/rpackages/rkward/R/rk.output.R
index 9825c8ba..c5e991bd 100644
--- a/rkward/rbackend/rpackages/rkward/R/rk.output.R
+++ b/rkward/rbackend/rpackages/rkward/R/rk.output.R
@@ -22,7 +22,7 @@
 #'                 back, to.
 #'
 #' @param create If \code{TRUE}, create a new output directory. The parameter \code{filename}, if specified, is the target save file/directory, in this case. Should this already exist,
-#'               an error will be raised. If \code{FALSE}, load or re-use an existing output directory. If the parameter \code{filename} is left \code{NULL}, \code{rk.output} will
+#'               an error will be raised. If \code{create=FALSE}, load or re-use an existing output directory. If the parameter \code{filename} is left \code{NULL}, \code{rk.output} will
 #'               return the currently active output.
 #'
 #' @param all If \code{TRUE}, return a list of all currently loaded output directories.
diff --git a/rkward/rbackend/rpackages/rkward/man/RK.Output.Rd b/rkward/rbackend/rpackages/rkward/man/RK.Output.Rd
new file mode 100644
index 00000000..257ba769
--- /dev/null
+++ b/rkward/rbackend/rpackages/rkward/man/RK.Output.Rd
@@ -0,0 +1,80 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/rk.output.R
+\docType{class}
+\name{RK.Output}
+\alias{RK.Output}
+\alias{rk.output}
+\title{Class and functions for organizing RKWard output.}
+\usage{
+rk.output(filename = NULL, create = FALSE, all = FALSE)
+}
+\arguments{
+\item{filename}{The location where an existing output directory is loaded from or saved/exported to. Note that this is usually not the same location where functions such as
+\link{rk.print} will write to (these operate on a temporary "working copy", but rather the target directory, where changes should eventually be saved
+back, to.}
+
+\item{create}{If \code{TRUE}, create a new output directory. The parameter \code{filename}, if specified, is the target save file/directory, in this case. Should this already exist,
+an error will be raised. If \code{FALSE}, load or re-use an existing output directory. If the parameter \code{filename} is left \code{NULL}, \code{rk.output} will
+return the currently active output.}
+
+\item{all}{If \code{TRUE}, return a list of all currently loaded output directories.}
+
+\item{overwrite}{If \code{TRUE}, RKWard will overwrite any existing output when saving, or discard any existing modifications when closing or reverting an output. If \code{FALSE}, trying
+to overwrite/discard existing files/modifications will result in an error. If \code{NULL} (the default), the frontend will ask what to do in this case.}
+
+\item{discard}{See \code{overwrite} for the meaning.}
+
+\item{ask}{Prompt the user for confirmation before potentially desctrucitve operations such as closing or reverting a modified output.}
+
+\item{raise}{Raise the output window, if it is already visble.}
+}
+\description{
+Since version 0.7.5, RKWard supports more than one piece of output. While dealing with only a single output page, there will be no need for the user to call any of
+             these functions, directly, as exactly one output
+             is always opened for writing in RKWard. However, these functions allow to manage several distinct output pages, programmatically.
+
+             The primary entry point is the function \code{rk.output}, which allows to retrieve the current output directly, or to load or create a new one. This will return
+             an instance of the \code{RK.Output} reference class, which has methods for subsequent manipulation. Two instances of this class may be pointing to the same
+             logical output file/directory (e.g. when loading the same output file, twice), in which case any operation will affect both instances the same.
+
+             Internally, outputs are managed by the RKWard frontend. The frontend will ask to save any unsaved modified output pages on exit.
+
+             At the time of this writing, output is stored in directories containing an HTML index file, and, usually, several image files, and possibly more.
+             However other types of output may be supported in the future, and therefore assumptions about the details of the output storage should be avoided.
+
+             \code{rk.output} can be used to create or load output files, as well as to obtain a reference an already loaded output file. After that, use the class methods
+             to operate on the reference obtained.
+}
+\section{Fields}{
+
+\describe{
+\item{\code{id}}{An internal identifier. NULL for a closed output. This should be treated as read-only, but you can use this to test whether two output handles are the same.}
+}}
+
+\section{Methods}{
+
+\describe{
+\item{\code{activate()}}{Set this output as the one that rk.print and other RKWard Output functions will write to.}
+
+\item{\code{clear(ask = TRUE)}}{Clear all content from this output. As with any function in this class, this affects the working copy, only, until you call save. Therefore, by default, the user will be prompted for confirmation
+if and only if there are unsaved changes pending.}
+
+\item{\code{close(discard = NULL)}}{Forget about this output file, also closing any open views. Note: Trying to call any further methods on this object will fail.}
+
+\item{\code{export(Class)}}{Returns the result of coercing the object to
+Class.  No effect on the object itself.}
+
+\item{\code{filename()}}{Return the target filename for this output, i.e. the location where it will be saved, to. This will be an empty string for newly created outputs that have not been saved, yet.
+Do not write anything to the target filename, directly! This is purely for information.}
+
+\item{\code{isEmpty()}}{Returns TRUE, if the output is currently empty.}
+
+\item{\code{isModified()}}{Returns TRUE, if this output has any changes that may need saving.}
+
+\item{\code{revert(ask = TRUE)}}{Revert this output to the last saved state. If no previous state is available (never saved, before), clears the output.}
+
+\item{\code{save(filename, overwrite = NULL)}}{Save this output, either to the last known save location (if no filename is specified) or to a new location ("save as").}
+
+\item{\code{view(raise = TRUE)}}{Open this output for viewing in the frontend.}
+}}
+
diff --git a/rkward/windows/rkhtmlwindow.cpp b/rkward/windows/rkhtmlwindow.cpp
index 9ac7e402..1b15d133 100644
--- a/rkward/windows/rkhtmlwindow.cpp
+++ b/rkward/windows/rkhtmlwindow.cpp
@@ -1355,335 +1355,4 @@ void RKOutputWindowManager::windowDestroyed (QObject *window) {
 	}
 }
 
-/** much like `ls -Rl`. List directory contents including timestamps and sizes, recursively. Used to detect whether an output directory has any changes. */
-void listDirectoryState (const QString& _dir, QString *list) {
-	RK_TRACE (APP);
-
-	QDir dir (_dir);
-	QFileInfoList entries = dir.entryInfoList (QDir::NoDotAndDotDot, QDir::Name | QDir::DirsLast);
-	for (int i = 0; i < entries.size (); ++i) {
-		const QFileInfo fi = entries[i];
-		if (fi.isDir ()) {
-			list->append (QStringLiteral ("dir: ") + fi.fileName () + '\n');
-			listDirectoryState (fi.absolutePath (), list);
-			list->append (QStringLiteral ("enddir\n"));
-		} else {
-			list->append (fi.fileName () + '\n');
-			list->append (fi.lastModified ().toString ("dd.hh.mm.ss.zzz") + '\n');
-			list->append (QString::number (fi.size ()) + '\n');
-		}
-	}
-}
-
-QString hashDirectoryState (const QString& dir) {
-	RK_TRACE (APP);
-	QString list;
-	listDirectoryState (dir, &list);
-	return QCryptographicHash::hash (list.toUtf8 (), QCryptographicHash::Md5);
-}
-
-bool copyDirRecursively (const QString& _source_dir, const QString& _dest_dir) {
-	RK_TRACE (APP);
-
-	QDir dest_dir (_dest_dir);
-	QDir source_dir (_source_dir);
-	if (!QDir ().mkpath (_dest_dir)) return false;
-	if (!source_dir.exists ()) return false;
-
-	bool ok = true;
-	QFileInfoList entries = source_dir.entryInfoList (QDir::NoDotAndDotDot);
-	for (int i = 0; i < entries.size (); ++i) {
-		const QFileInfo fi = entries[i];
-		if (fi.isDir ()) {
-			ok = ok && copyDirRecursively (fi.absoluteFilePath (), dest_dir.absoluteFilePath (fi.fileName ()));
-		} else {
-			// NOTE: this does not overwrite existing target files, but in our use case, we're always writing into empty targets
-			ok = ok && QFile::copy (fi.absoluteFilePath (), dest_dir.absoluteFilePath (fi.fileName ()));
-		}
-	}
-
-	return ok;
-}
-
-QString RKOutputWindowManager::exportOutputDirectory (const QString& _dir, RCommandChain* chain) {
-	RK_TRACE (APP);
-
-	const QString dir = QFileInfo (_dir).canonicalFilePath ();
-	return exportOutputDirectoryAs (_dir, outputs.value (dir).export_dir, true, chain);  // pass raw "dir" parameter b/c of error handling.
-}
-
-QString RKOutputWindowManager::exportOutputDirectoryAs (const QString& dir, const QString& _dest, bool ask_overwrite, RCommandChain* chain) {
-	RK_TRACE (APP);
-
-	const QString work_dir = QFileInfo (dir).canonicalFilePath ();
-	if (!outputs.contains (work_dir)) {
-		return i18n ("The directory %1 does not correspond to to any output directory loaded in this session.", dir);
-	}
-
-	QString dest = _dest;
-	if (dest.isEmpty ()) {
-		QFileDialog dialog (RKWardMainWindow::getMain (), i18n ("Specify directory where to export output"), outputs.value (work_dir).export_dir);
-		dialog.setFileMode (QFileDialog::Directory);
-		dialog.setOption (QFileDialog::ShowDirsOnly, true);
-		dialog.setAcceptMode (QFileDialog::AcceptSave);
-		dialog.setOption (QFileDialog::DontConfirmOverwrite, true);  // custom handling below
-
-		int result = dialog.exec ();
-		const QString cancelled = i18n ("File selection cancelled");
-		if (result != QDialog::Accepted) return cancelled;
-
-		dest = QDir::cleanPath (dialog.selectedFiles ().value (0));
-		if (ask_overwrite && QFileInfo (dest).exists ()) {
-			if (!isRKWwardOutputDirectory (dest)) {
-				return i18n ("The directory %1 exists, but does not appear to be an RKWard output directory. Refusing to overwrite it.", dest);
-			}
-
-			const QString warning = i18n ("Are you sure you want to overwrite the existing directory '%1'? All current contents, <b>including subdirectories</b> will be lost.", dest);
-			KMessageBox::ButtonCode res = KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), warning, i18n ("Overwrite Directory?"), KStandardGuiItem::overwrite (),
-			                                                                 KStandardGuiItem::cancel (), QString (), KMessageBox::Options (KMessageBox::Notify | KMessageBox::Dangerous));
-			if (KMessageBox::Continue != res) {
-				return cancelled;
-			}
-		}
-	}
-
-	// If destination already exists, rename it before copying, so we can restore the save in case of an error
-	QString tempname;
-	if (QFileInfo (dest).exists ()) {
-		if (!isRKWwardOutputDirectory (dest)) {
-			return i18n ("The directory %1 exists, but does not appear to be an RKWard output directory. Refusing to overwrite it.", dest);
-		}
-
-		tempname = dest + '~';
-		while (QFileInfo (tempname).exists ()) {
-			tempname.append ('~');
-		}
-		if (!QDir ().rename (dest, tempname)) {
-			return i18n ("Failed to create temporary backup file %1.", tempname);
-		}
-	}
-
-	bool error = copyDirRecursively (work_dir, dest);
-	if (error) {
-		if (!tempname.isEmpty ()) {
-			QDir ().rename (tempname, dest);
-		}
-		return i18n ("Error while copying %1 to %2", work_dir, dest);
-	}
-	if (!tempname.isEmpty ()) {
-		QDir (tempname).removeRecursively ();
-	}
-
-	OutputDirectory& od = outputs[work_dir];
-	od.save_timestamp = QDateTime::currentDateTime ();
-	od.export_dir = dest;
-	od.saved_hash = hashDirectoryState (work_dir);
-
-	return QString ();
-}
-
-QString RKOutputWindowManager::importOutputDirectory (const QString& _dir, const QString& _index_file, bool ask_revert, RCommandChain* chain) {
-	RK_TRACE (APP);
-
-	QFileInfo fi (_dir);
-	if (!fi.isDir ()) {
-		return i18n ("The path %1 does not exist or is not a directory.", _dir);
-	}
-
-	QString dir = fi.canonicalFilePath ();
-	QMap<QString, OutputDirectory>::const_iterator i = outputs.constBegin ();
-	while (i != outputs.constEnd ()) {
-		if (i.value ().save_dir == dir) {
-			if (ask_revert && (KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), i18n ("The output directory %1 is already imported (use Window->Show Output to show it). Importing it again will revert any unsaved changes made to the output directory during the past %2 minutes. Are you sure you want to revert?", dir, i.value ().save_timestamp.secsTo (QDateTime::currentDateTime ()) / 60), i18n ("Reset output directory?"), KStandardGuiItem::reset ()) != KMessageBox::Continue)) {
-				return i18n ("Cancelled");
-			} else {
-				outputs.remove (i.key ());
-				break;
-			}
-		}
-	}
-
-	QString index_file = _index_file;
-	if (index_file.isEmpty ()) {
-		QStringList html_files = QDir (dir).entryList (QStringList () << "*.html" << "*.htm", QDir::Files | QDir::Readable);
-		if (html_files.isEmpty ()) {
-			return i18n ("The directory %1 does not appear to contain any (readable) HTML-files.", dir);
-		} else if (html_files.contains ("rk_out.html")) {
-			index_file = "rk_out.html";
-		} else if (html_files.contains ("index.html")) {
-			index_file = "index.html";
-		} else {
-			index_file = html_files.first ();
-		}
-	}
-
-	bool was_rkward_ouput = isRKWwardOutputDirectory (dir);
-	if ((!was_rkward_ouput) && ask_revert) {
-		if (KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), i18n ("The directory %1 does not appear to be an existing RKWard output directory. RKWard can try to import it, anyway (and this will usually work for output files created by RKWard < 0.7.0), but it may not be possible to append to the existing output, and for safety concerns RKWard will refuse to save the modified output back to this directory.\nAre you sure you want to continue?", dir)) != KMessageBox::Continue) {
-			return i18n ("Cancelled");
-		}
-	}
-
-	QString dest = createOutputDirectoryInternal ();
-
-	if (!copyDirRecursively (dir, dest)) {
-		QDir (dest).removeRecursively ();
-		return i18n ("The path %1 could not be imported (copy failure).", _dir);
-	}
-
-	OutputDirectory od;
-	od.index_file = index_file;
-	if (was_rkward_ouput) od.save_dir = dir;
-	od.saved_hash = hashDirectoryState (dest);
-	od.save_timestamp = QDateTime::currentDateTime ();
-	outputs.insert (dest, od);
-
-	backendActivateOutputDirectory (dest, chain);
-
-	return (QString ());
-}
-
-QString RKOutputWindowManager::createOutputDirectory (RCommandChain* chain) {
-	RK_TRACE (APP);
-
-	QString dest = createOutputDirectoryInternal ();
-
-	OutputDirectory od;
-	od.index_file = QStringLiteral ("index.html");
-	outputs.insert (dest, od);
-
-	backendActivateOutputDirectory (dest, chain);
-
-	// the state of the output directory cannot be hashed until the backend has created the index file (via backendActivateOutputDirectory())
-	RCommand *command = new RCommand (QString (), RCommand::App | RCommand::Sync | RCommand::EmptyCommand);
-	command->notifier ()->setProperty ("path", dest);
-	connect (command->notifier (), &RCommandNotifier::commandFinished, this, &RKOutputWindowManager::updateOutputSavedHash);
-	RKGlobals::rInterface ()->issueCommand (command, chain);
-
-	return dest;
-}
-
-QString RKOutputWindowManager::createOutputDirectoryInternal () {
-	RK_TRACE (APP);
-
-	QString prefix = QStringLiteral ("unsaved_output");
-	QString destname = prefix;
-	QDir ddir (RKSettingsModuleGeneral::filesPath ());
-	int x = 0;
-	while (true) {
-		if (!ddir.exists (destname)) break;
-		destname = prefix + QString::number (x++);
-	}
-	ddir.mkpath (destname);
-
-	QFile marker (destname + QStringLiteral ("/rkward_output_marker.txt"));
-	marker.open (QIODevice::WriteOnly);
-	marker.write (i18n ("This file is used to indicate that this directory is an ouptut directory created by RKWard (http://rkward.kde.org). Do not place any other contents in this directory, as the entire directory will be purged if/when overwriting the output.\nRKWard will ask you before purging this directory (unless explicitly told otherwise), but if you remove this file, RKWard will not purge this directory.\n").toLocal8Bit ());
-	marker.close ();
-
-	return ddir.absoluteFilePath (destname);
-}
-
-QString RKOutputWindowManager::dropOutputDirectory (const QString& dir, bool ask, RCommandChain* chain) {
-	RK_TRACE (APP);
-
-	const QString work_dir = QFileInfo (dir).canonicalFilePath ();
-	if (!outputs.contains (work_dir)) {
-		return i18n ("The directory %1 does not correspond to to any output directory loaded in this session.", dir);
-	}
-
-	OutputDirectory od = outputs[work_dir];
-	if (ask) {
-		if (od.saved_hash != hashDirectoryState (work_dir)) {
-			if (KMessageBox::warningContinueCancel (RKWardMainWindow::getMain (), i18n ("The output directory %1 has unsaved changes, which will be lost by dropping it. Are you sure you want to proceed?", work_dir)) != KMessageBox::Continue) return i18n ("Cancelled");
-		}
-	}
-
-	QString error = dropOutputDirectoryInternal (work_dir);
-	if (!error.isEmpty ()) return error;
-
-	if (current_default_path.startsWith (work_dir)) {
-		if (!outputs.isEmpty ()) {
-			backendActivateOutputDirectory (outputs.constBegin ().key (), chain);
-		} else {
-			createOutputDirectory (chain);
-		}
-	}
-
-	return QString ();
-}
-
-QString RKOutputWindowManager::dropOutputDirectoryInternal (const QString& dir) {
-	RK_TRACE (APP);
-
-	if (!isRKWwardOutputDirectory (dir)) {
-		RK_ASSERT (false); // this should not happen unless user messes with the temporary file, but we better play it safe.
-		return (i18n ("The directory %1 does not appear to be an RKWard output directory. Refusing to remove it.", dir));
-	}
-	outputs.remove (dir);
-	QDir (dir).removeRecursively ();
-
-	QStringList paths = windows.keys ();
-	for (int i = 0; i < paths.size (); ++i) {
-		if (paths[i].startsWith (dir)) {
-//			RKWorkplace::closeWindows (windows.values (paths[i]));  // NOTE: Won't work with current compilers
-			QList<RKHTMLWindow*> wins = windows.values (paths[i]);
-			for (int j = 0; j < wins.size (); ++j) {
-				RKWorkplace::mainWorkplace ()->closeWindow (wins[j]);
-			}
-		}
-	}
-
-	return QString ();
-}
-
-void RKOutputWindowManager::purgeAllOututputDirectories () {
-	RK_TRACE (APP);
-
-	QStringList output_dirs = outputs.keys ();
-	for (int i = output_dirs.size () - 1; i >= 0; --i) {
-		if (i > 0) dropOutputDirectoryInternal (output_dirs[i]);
-		else dropOutputDirectory (output_dirs[i], false);
-	}
-}
-
-QStringList RKOutputWindowManager::modifiedOutputDirectories() const {
-	RK_TRACE (APP);
-
-	QStringList ret;
-	for (QMap<QString, OutputDirectory>::const_iterator it = outputs.constBegin (); it != outputs.constEnd (); ++it) {
-		if (it.value ().saved_hash != hashDirectoryState (it.key ())) ret.append (it.key ());
-	}
-	return ret;
-}
-
-QString RKOutputWindowManager::outputCaption (const QString& dir) const {
-	RK_TRACE (APP);
-
-	// TODO: real implementation!
-	return (dir);
-}
-
-bool RKOutputWindowManager::isRKWwardOutputDirectory (const QString& dir) {
-	RK_TRACE (APP);
-
-	return (QDir (dir).exists (QStringLiteral ("rkward_output_marker.txt")));
-}
-
-void RKOutputWindowManager::backendActivateOutputDirectory (const QString& dir, RCommandChain* chain) {
-	RK_TRACE (APP);
-
-	RKGlobals::rInterface ()->issueCommand (QStringLiteral ("rk.set.output.html.file (\"") + RKCommonFunctions::escape (dir + '/' + outputs.value (dir).index_file) + QStringLiteral ("\")\n"), RCommand::App, QString (), 0, 0, chain);
-}
-
-void RKOutputWindowManager::updateOutputSavedHash (RCommand* command) {
-	RK_TRACE (APP);
-
-	QString path = command->notifier ()->property ("path").toString ();
-	RK_ASSERT (outputs.contains (path));
-	OutputDirectory &output = outputs[path];
-	output.saved_hash = hashDirectoryState (path);
-	output.save_timestamp = QDateTime::currentDateTime ();
-}
-
 #include "rkhtmlwindow.moc"
diff --git a/rkward/windows/rkhtmlwindow.h b/rkward/windows/rkhtmlwindow.h
index 25f5a30c..74ce74bb 100644
--- a/rkward/windows/rkhtmlwindow.h
+++ b/rkward/windows/rkhtmlwindow.h
@@ -196,6 +196,7 @@ private:
 #include <QMultiHash>
 
 #include <kdirwatch.h>
+#include "../misc/rkoutputdirectory.h"
 
 /** Takes care of showing / refreshing output windows as needed. */
 class RKOutputWindowManager : public QObject {
@@ -210,39 +211,16 @@ public:
 	QList<RKHTMLWindow*> existingOutputWindows (const QString &path = QString ()) const;
 /** Create (and show) a new output window (for the current output path, unless path is specified), and @return the pointer */
 	RKHTMLWindow* newOutputWindow (const QString& path = QString ());
-	enum ImportMode {
-		Ask,           /// Ask whether to integrate imported output into workplace
-		Integrate,     /// Integrate imported output (forget original source, save with workplace)
-		KeepSeparate   /// Keep output separate (save back to original source)
-	};
-/** Import an existing output directory. @Returns error message, if any, and empty string in case of success */
-	QString importOutputDirectory (const QString& dir, const QString& index_file=QString (), bool ask_revert = true, RCommandChain* chain = 0);
-/** Export the given output directory to the location it was last exported to / imported from. If the output directory has not been exported / imported, yet, prompt the user for a destination.
-    @param dir output directory to export
-    @returns error message, if any, an empty string in case of success */
-	QString exportOutputDirectory (const QString& dir, RCommandChain* chain = 0);
-/** Export the given output directory. @see exportOutputDirectory ().
-    @param dir the output directory to export
-    @param dest destination directory. May be left empty, in which case the user will be prompted for a destination.
-    @returns error message, if any, an empty string in case of success */
-	QString exportOutputDirectoryAs (const QString& dir, const QString& dest = QString (), bool ask_overwrite = true, RCommandChain* chain = 0);
-/** Save the given output directory to the given location. Does not ask for overwrite confirmation.
-    @param dir output directory to save
-    @returns error message, if any, an empty string in case of success */
-	QString saveOutputDirectory (const QString& dir, const QString& dest=QString(), RCommandChain* chain = 0) {
-		return exportOutputDirectoryAs (dir, dest, false, chain);
-	}
-/** Create a new empty output directory.
-    @returns path of the new directory */
-	QString createOutputDirectory (RCommandChain* chain = 0);
-/** Drop the given output directory. If it was the active directory, activate another output file. Return error message if any, an empty string in case of success. */
-	QString dropOutputDirectory (const QString& dir, bool ask=true, RCommandChain* chain = 0);
+
+	RKOutputDirectory::GenericRCallResult R_rk_Output(const QString& filename=QString(), bool create=false, bool all=false);
+	RKOutputDirectory* getCurrentOutput(const QString& filename=QString(), bool create=false);
+	QList<RKOutputDirectory*> allOutputs();
 
 /** Return a list of all current output directories that have been modified. Used for asking for save during shutdown. */
 	QStringList modifiedOutputDirectories () const;
 /** Return the name / caption of the given output directory */
 	QString outputCaption (const QString &dir) const;
-/** Use with case! Purges all current output directories, saved or not. You should query modifiedOutputDirectories (), and make sure to prompt for saving, before calling this. For use during shutdown. */
+/** Use with care! Purges all current output directories, saved or not. You should query modifiedOutputDirectories (), and make sure to prompt for saving, before calling this. For use during shutdown. */
 	void purgeAllOututputDirectories ();
 private:
 	RKOutputWindowManager ();
@@ -252,20 +230,6 @@ private:
 	QString current_default_path;
 	KDirWatch *file_watcher;
 	QMultiHash<QString, RKHTMLWindow *> windows;
-
-	struct OutputDirectory {
-		QString index_file;
-		QString saved_hash;
-		QDateTime save_timestamp;
-		QString export_dir;
-		QString save_dir;
-	};
-	/** map of outputs. Key is the working directory of the output */
-	QMap<QString, OutputDirectory> outputs;
-	void backendActivateOutputDirectory (const QString& dir, RCommandChain* chain);
-	QString createOutputDirectoryInternal ();
-	bool isRKWwardOutputDirectory (const QString &dir);
-	QString dropOutputDirectoryInternal (const QString& dir);
 private slots:
 	void fileChanged (const QString &path);
 	void windowDestroyed (QObject *window);




More information about the rkward-tracker mailing list