[PATCH v2] Parse html links in the Notes section.

K. "pestophagous" Heller pestophagous at gmail.com
Sun Oct 25 14:46:39 PDT 2015


In the spirit of "Do the simplest thing that could
possibly work": capture Ctrl+leftclick mouse events
in the Notes area. If the string under the clicked
position is a valid url, then launch it.

Many common URI schemes will work. Typing a url that
starts with https:// will work. So will mailto: and
file://

See #733

Signed-off-by: K. Heller <pestophagous at gmail.com>
---

This is a re-submission of an earlier patch.
I am addressing the items from the checklist that was originally posted here:
http://lists.subsurface-divelog.org/pipermail/subsurface/2015-October/022628.html

Compared to the first submitted version, these are now complete:
[x] remove the "if (true)" comment about making things preference-based.
[x] fix indentation in ctor, and also whitespace damage in comment block.
[x] replace "Ctrl" in the tooltip text with something that would say "Cmd" on mac.
[x] find some better prose to explain the cursor comings-and-goings

There was one other item:
[_] ... look into security implications??

I found that QDesktopServices::openUrl relies on xdg-open. I found a (minor?) CVE for xdg-open.

http://seclists.org/fulldisclosure/2014/Nov/36
http://www.openwall.com/lists/oss-security/2015/01/01/3
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-9622

I suppose that as is 'customary' by now: let he/she who clicks a link beware. In the case of this feature, it should be obvious to the user what the clicked URL string consists of. (famous last words?)

 qt-ui/maintab.cpp       |   3 +
 qt-ui/simplewidgets.cpp | 155 ++++++++++++++++++++++++++++++++++++++++++++++++
 qt-ui/simplewidgets.h   |  20 +++++++
 3 files changed, 178 insertions(+)

diff --git a/qt-ui/maintab.cpp b/qt-ui/maintab.cpp
index 0afb7b4..597e7ae 100644
--- a/qt-ui/maintab.cpp
+++ b/qt-ui/maintab.cpp
@@ -202,6 +202,9 @@ MainTab::MainTab(QWidget *parent) : QTabWidget(parent),
 	connect(ui.diveNotesMessage, &KMessageWidget::showAnimationFinished,
 					ui.location, &DiveLocationLineEdit::fixPopupPosition);

+	// enable URL clickability in notes:
+	new TextHyperlinkEventFilter(ui.notes);//destroyed when ui.notes is destroyed
+
 	acceptingEdit = false;

 	ui.diveTripLocation->hide();
diff --git a/qt-ui/simplewidgets.cpp b/qt-ui/simplewidgets.cpp
index 62a9cc6..e213c64 100644
--- a/qt-ui/simplewidgets.cpp
+++ b/qt-ui/simplewidgets.cpp
@@ -7,6 +7,8 @@
 #include <QCalendarWidget>
 #include <QKeyEvent>
 #include <QAction>
+#include <QDesktopServices>
+#include <QToolTip>

 #include "file.h"
 #include "mainwindow.h"
@@ -734,3 +736,156 @@ void MultiFilter::closeFilter()
 	MultiFilterSortModel::instance()->clearFilter();
 	hide();
 }
+
+TextHyperlinkEventFilter::TextHyperlinkEventFilter(QTextEdit *txtEdit) : QObject(txtEdit),
+	textEdit(txtEdit),
+	scrollView(textEdit->viewport())
+{
+	// If you install the filter on textEdit, you fail to capture any clicks.
+	// The clicks go to the viewport. http://stackoverflow.com/a/31582977/10278
+	textEdit->viewport()->installEventFilter(this);
+}
+
+bool TextHyperlinkEventFilter::eventFilter(QObject *target, QEvent *evt)
+{
+	if (target != scrollView)
+		return false;
+
+	if (evt->type() != QEvent::MouseButtonPress &&
+	    evt->type() != QEvent::ToolTip)
+		return false;
+
+	// --------------------
+
+	// Note: Qt knows that on Mac OSX, ctrl (and Control) are the command key.
+	const bool isCtrlClick = evt->type() == QEvent::MouseButtonPress &&
+				 static_cast<QMouseEvent *>(evt)->modifiers() & Qt::ControlModifier &&
+				 static_cast<QMouseEvent *>(evt)->button() == Qt::LeftButton;
+
+	const bool isTooltip = evt->type() == QEvent::ToolTip;
+
+	QString urlUnderCursor;
+
+	if (isCtrlClick || isTooltip) {
+		QTextCursor cursor = isCtrlClick ?
+					     textEdit->cursorForPosition(static_cast<QMouseEvent *>(evt)->pos()) :
+					     textEdit->cursorForPosition(static_cast<QHelpEvent *>(evt)->pos());
+
+		urlUnderCursor = tryToFormulateUrl(&cursor);
+	}
+
+	if (isCtrlClick) {
+		handleUrlClick(urlUnderCursor);
+	}
+
+	if (isTooltip) {
+		handleUrlTooltip(urlUnderCursor, static_cast<QHelpEvent *>(evt)->globalPos());
+	}
+
+	// 'return true' would mean that all event handling stops for this event.
+	// 'return false' lets Qt continue propagating the event to the target.
+	// Since our URL behavior is meant as 'additive' and not necessarily mutually
+	// exclusive with any default behaviors, it seems ok to return false to
+	// avoid unintentially hijacking any 'normal' event handling.
+	return false;
+}
+
+void TextHyperlinkEventFilter::handleUrlClick(const QString &urlStr)
+{
+	if (!urlStr.isEmpty()) {
+		QUrl url(urlStr, QUrl::StrictMode);
+		QDesktopServices::openUrl(url);
+	}
+}
+
+void TextHyperlinkEventFilter::handleUrlTooltip(const QString &urlStr, const QPoint &pos)
+{
+	if (urlStr.isEmpty()) {
+		QToolTip::hideText();
+	} else {
+		// per Qt docs, QKeySequence::toString does localization "tr()" on strings like Ctrl.
+		// Note: Qt knows that on Mac OSX, ctrl (and Control) are the command key.
+		const QString ctrlKeyName = QKeySequence(Qt::CTRL).toString();
+		// ctrlKeyName comes with a trailing '+', as in: 'Ctrl+'
+		QToolTip::showText(pos, tr("%1click to visit %2").arg(ctrlKeyName).arg(urlStr));
+	}
+}
+
+bool TextHyperlinkEventFilter::stringMeetsOurUrlRequirements(const QString &maybeUrlStr)
+{
+	QUrl url(maybeUrlStr, QUrl::StrictMode);
+	return url.isValid() && (!url.scheme().isEmpty());
+}
+
+QString TextHyperlinkEventFilter::tryToFormulateUrl(QTextCursor *cursor)
+{
+	// tryToFormulateUrl exists because WordUnderCursor will not
+	// treat "http://m.abc.def" as a word.
+
+	// tryToFormulateUrl invokes fromCursorTilWhitespace two times (once
+	// with a forward moving cursor and once in the backwards direction) in
+	// order to expand the selection to try to capture a complete string
+	// like "http://m.abc.def"
+
+	// loosely inspired by advice here: http://stackoverflow.com/q/19262064/10278
+
+	cursor->select(QTextCursor::WordUnderCursor);
+	QString maybeUrlStr = cursor->selectedText();
+
+	const bool soFarSoGood = !maybeUrlStr.simplified().replace(" ", "").isEmpty();
+
+	if (soFarSoGood && !stringMeetsOurUrlRequirements(maybeUrlStr)) {
+		// If we don't yet have a full url, try to expand til we get one.  Note:
+		// after requesting WordUnderCursor, empirically (all platforms, in
+		// Qt5), the 'anchor' is just past the end of the word.
+
+		QTextCursor cursor2(*cursor);
+		QString left = fromCursorTilWhitespace(cursor, true /*searchBackwards*/);
+		QString right = fromCursorTilWhitespace(&cursor2, false);
+		maybeUrlStr = left + right;
+	}
+
+	return stringMeetsOurUrlRequirements(maybeUrlStr) ? maybeUrlStr : QString::null;
+}
+
+QString TextHyperlinkEventFilter::fromCursorTilWhitespace(QTextCursor *cursor, const bool searchBackwards)
+{
+	// fromCursorTilWhitespace calls cursor->movePosition repeatedly, while
+	// preserving the original 'anchor' (qt terminology) of the cursor.
+	// We widen the selection with 'movePosition' until hitting any whitespace.
+
+	QString result;
+	QString grownText;
+	QString noSpaces;
+	bool movedOk = false;
+
+	do {
+		result = grownText; // this is a no-op on the first visit.
+
+		if (searchBackwards) {
+			movedOk = cursor->movePosition(QTextCursor::PreviousWord, QTextCursor::KeepAnchor);
+		} else {
+			movedOk = cursor->movePosition(QTextCursor::NextWord, QTextCursor::KeepAnchor);
+		}
+
+		grownText = cursor->selectedText();
+		noSpaces = grownText.simplified().replace(" ", "");
+	} while (grownText == noSpaces && movedOk);
+
+	// while growing the selection forwards, we have an extra step to do:
+	if (!searchBackwards) {
+		/*
+		  The cursor keeps jumping to the start of the next word.
+		  (for example) in the string "mn.abcd.edu is the spot" you land at
+		  m,a,e,i (the 'i' in 'is). if we stop at e, then we only capture
+		  "mn.abcd." for the url (wrong). So we have to go to 'i', to
+		  capture "mn.abcd.edu " (with trailing space), and then clean it up.
+		*/
+		QStringList list = grownText.split(QRegExp("\\s"), QString::SkipEmptyParts);
+		if (!list.isEmpty()) {
+			result = list[0];
+		}
+	}
+
+	return result;
+}
diff --git a/qt-ui/simplewidgets.h b/qt-ui/simplewidgets.h
index 595c4cd..8a7a5df 100644
--- a/qt-ui/simplewidgets.h
+++ b/qt-ui/simplewidgets.h
@@ -231,6 +231,26 @@ private:
 	Ui::FilterWidget ui;
 };

+class TextHyperlinkEventFilter : public QObject {
+	Q_OBJECT
+public:
+	explicit TextHyperlinkEventFilter(QTextEdit *txtEdit);
+
+	virtual bool eventFilter(QObject *target, QEvent *evt);
+
+private:
+	void handleUrlClick(const QString &urlStr);
+	void handleUrlTooltip(const QString &urlStr, const QPoint &pos);
+	bool stringMeetsOurUrlRequirements(const QString &maybeUrlStr);
+	QString fromCursorTilWhitespace(QTextCursor *cursor, const bool searchBackwards);
+	QString tryToFormulateUrl(QTextCursor *cursor);
+
+	QTextEdit const *const textEdit;
+	QWidget const *const scrollView;
+
+	Q_DISABLE_COPY(TextHyperlinkEventFilter)
+};
+
 bool isGnome3Session();
 QImage grayImage(const QImage &coloredImg);

--
2.5.0


More information about the subsurface mailing list