diff --git a/discover/qml/ApplicationPage.qml b/discover/qml/ApplicationPage.qml index a688bd58..6f41dc76 100644 --- a/discover/qml/ApplicationPage.qml +++ b/discover/qml/ApplicationPage.qml @@ -1,445 +1,454 @@ /* * Copyright (C) 2012 Aleix Pol Gonzalez * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library/Lesser General Public License * version 2, or (at your option) any later version, as published by the * Free Software Foundation * * This program 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 * GNU General Public License for more details * * You should have received a copy of the GNU Library/Lesser General Public * License along with this program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import QtQuick 2.5 import QtQuick.Controls 2.3 import QtQuick.Window 2.1 import QtQuick.Layouts 1.1 import org.kde.discover 2.0 import org.kde.discover.app 1.0 import org.kde.kirigami 2.1 as Kirigami import "navigation.js" as Navigation DiscoverPage { id: appInfo property QtObject application: null readonly property int visibleReviews: 3 clip: true background: Rectangle { color: Kirigami.Theme.viewBackgroundColor } ReviewsPage { id: reviewsSheet model: ReviewsModel { id: reviewsModel resource: appInfo.application } } contextualActions: [originsMenuAction] ActionGroup { id: sourcesGroup exclusive: true } Kirigami.Action { id: originsMenuAction text: i18n("Sources") visible: children.length>1 readonly property var r0: Instantiator { model: ResourcesProxyModel { id: alternativeResourcesModel allBackends: true resourcesUrl: appInfo.application.url } delegate: Action { ActionGroup.group: sourcesGroup text: displayOrigin icon.name: sourceIcon checked: appInfo.application == model.application onTriggered: if(index>=0) { var res = model.application console.assert(res) window.stack.pop() Navigation.openApplication(res) } } onObjectAdded: originsMenuAction.children.push(object) } } actions { main: appbutton.action right: Kirigami.Action { visible: application.isInstalled && application.canExecute && !appbutton.isActive text: application.executeLabel icon.name: "media-playback-start" onTriggered: application.invokeApplication() } } InstallApplicationButton { id: appbutton Layout.rightMargin: Kirigami.Units.smallSpacing application: appInfo.application visible: false } leftPadding: Kirigami.Units.largeSpacing * (applicationWindow().wideScreen ? 2 : 1) rightPadding: Kirigami.Units.largeSpacing * (applicationWindow().wideScreen ? 2 : 1) // Icon, name, caption, screenshots, description and reviews ColumnLayout { spacing: 0 RowLayout { Kirigami.Icon { Layout.preferredHeight: 80 Layout.preferredWidth: 80 source: appInfo.application.icon Layout.rightMargin: Kirigami.Units.smallSpacing * 2 } ColumnLayout { spacing: 0 Kirigami.Heading { level: 1 text: appInfo.application.name lineHeight: 1.0 maximumLineCount: 1 elide: Text.ElideRight Layout.fillWidth: true Layout.alignment: Text.AlignBottom } RowLayout { spacing: Kirigami.Units.largeSpacing Rating { rating: appInfo.application.rating ? appInfo.application.rating.sortableRating : 0 starSize: summary.font.pointSize } Label { text: appInfo.application.rating ? i18np("%1 rating", "%1 ratings", appInfo.application.rating.ratingCount) : i18n("No ratings yet") opacity: 0.5 } } Kirigami.Heading { id: summary level: 4 text: appInfo.application.comment maximumLineCount: 2 lineHeight: lineCount > 1 ? 0.75 : 1.2 elide: Text.ElideRight Layout.fillWidth: true Layout.alignment: Qt.AlignTop } } Layout.bottomMargin: Kirigami.Units.largeSpacing } ApplicationScreenshots { Layout.fillWidth: true visible: count > 0 resource: appInfo.application ScrollBar.horizontal: screenshotsScrollbar } ScrollBar { id: screenshotsScrollbar Layout.fillWidth: true } Label { Layout.topMargin: Kirigami.Units.largeSpacing Layout.fillWidth: true wrapMode: Text.WordWrap text: appInfo.application.longDescription + onLinkActivated: Qt.openUrlExternally(link); + // Since Text (and Label) lack cursor-changing abilities of their own, + // as suggested by QTBUG-30804, use a MouseAra to do our dirty work. + // See comment https://bugreports.qt.io/browse/QTBUG-30804?#comment-206287 + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton // Not actually accepting clicks, just changing the cursor + } } Kirigami.Heading { Layout.topMargin: Kirigami.Units.largeSpacing text: i18n("What's New") level: 2 visible: changelogLabel.text.length > 0 } Rectangle { color: Kirigami.Theme.linkColor Layout.fillWidth: true height: 1 visible: changelogLabel.text.length > 0 } Label { id: changelogLabel Layout.topMargin: Kirigami.Units.largeSpacing Layout.fillWidth: true wrapMode: Text.WordWrap Component.onCompleted: appInfo.application.fetchChangelog() Connections { target: appInfo.application onChangelogFetched: { changelogLabel.text = changelog } } } LinkButton { id: addonsButton text: i18n("Addons") visible: addonsView.containsAddons onClicked: addonsView.sheetOpen = true } RowLayout { Layout.topMargin: Kirigami.Units.largeSpacing Layout.fillWidth: true Kirigami.Heading { Layout.fillWidth: true text: i18n("Reviews") Layout.alignment: Qt.AlignLeft | Qt.AlignBottom level: 2 visible: rep.count > 0 } LinkButton { visible: reviewsModel.count > visibleReviews text: i18np("Show %1 review...", "Show all %1 reviews...", reviewsModel.count) Layout.alignment: Qt.AlignRight | Qt.AlignBottom onClicked: { reviewsSheet.open() } } } Rectangle { color: Kirigami.Theme.linkColor Layout.fillWidth: true height: 1 visible: rep.count > 0 } Repeater { id: rep model: PaginateModel { sourceModel: reviewsSheet.model pageSize: visibleReviews } delegate: ReviewDelegate { Layout.topMargin: Kirigami.Units.largeSpacing separator: false compact: true Layout.bottomMargin: Kirigami.Units.largeSpacing } } LinkButton { function writeReviewText() { if (appInfo.application.isInstalled) { if (reviewsModel.count > 0) { return i18n("Write a review!") } else { return i18n("Be the first to write a review!") } // App not installed } else { if (reviewsModel.count > 0) { return i18n("Install this app to write a review!") } else { return i18n("Install this app and be the first to write a review!") } } } text: writeReviewText() Layout.alignment: Qt.AlignCenter onClicked: reviewsSheet.openReviewDialog() enabled: appInfo.application.isInstalled visible: reviewsModel.backend && reviewsModel.backend.isResourceSupported(appInfo.application) Layout.topMargin: Kirigami.Units.largeSpacing Layout.bottomMargin: Kirigami.Units.largeSpacing } Repeater { model: application.objects delegate: Loader { property QtObject resource: appInfo.application source: modelData } } Item { height: addonsButton.height width: 1 } // Details/metadata Rectangle { color: Kirigami.Theme.linkColor Layout.fillWidth: true height: 1 Layout.bottomMargin: Kirigami.Units.largeSpacing } GridLayout { rowSpacing: 0 columns: 2 // Category row Label { visible: categoryLabel.visible Layout.alignment: Qt.AlignRight text: i18n("Category:") } Label { id: categoryLabel visible: text.length > 0 Layout.fillWidth: true elide: Text.ElideRight text: appInfo.application.categoryDisplay } // Version row Label { visible: versionLabel.visible Layout.alignment: Qt.AlignRight text: i18n("Version:") } Label { readonly property string version: appInfo.application.isInstalled ? appInfo.application.installedVersion : appInfo.application.availableVersion readonly property string releaseDate: appInfo.application.releaseDate.toLocaleString() function versionString() { if (version.length == 0) { return "" } else { if (releaseDate.length > 0) { return i18n("%1, released on %2", version, releaseDate) } else { return version } } } id: versionLabel visible: text.length > 0 Layout.fillWidth: true elide: Text.ElideRight text: versionString() } // Author row Label { Layout.alignment: Qt.AlignRight text: i18n("Author:") visible: authorLabel.visible } Label { id: authorLabel Layout.fillWidth: true elide: Text.ElideRight visible: text.length>0 text: appInfo.application.author } // Size row Label { Layout.alignment: Qt.AlignRight text: i18n("Size:") } Label { Layout.fillWidth: true elide: Text.ElideRight text: appInfo.application.sizeDescription } // Source row Label { Layout.alignment: Qt.AlignRight text: i18n("Source:") } Label { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft text: appInfo.application.displayOrigin elide: Text.ElideRight } // License row Label { Layout.alignment: Qt.AlignRight text: i18n("License:") visible: appInfo.application.license.length>0 } UrlButton { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft // tooltip: i18n("See full license terms") text: appInfo.application.license url: "https://spdx.org/licenses/" + appInfo.application.license + ".html#licenseText" } // Homepage row Label { visible: homepageLink.visible Layout.alignment: Qt.AlignRight text: i18n("Homepage:") } UrlButton { id: homepageLink url: application.homepage Layout.fillWidth: true horizontalAlignment: Text.AlignLeft } // "User Guide" row Label { visible: docsLink.visible Layout.alignment: Qt.AlignRight text: i18n("User Guide:") } UrlButton { id: docsLink url: application.helpURL Layout.fillWidth: true horizontalAlignment: Text.AlignLeft } // Donate row Label { visible: donationLink.visible Layout.alignment: Qt.AlignRight text: i18n("Donate:") } UrlButton { id: donationLink url: application.donationURL Layout.fillWidth: true horizontalAlignment: Text.AlignLeft } // "Report a Problem" row Label { visible: bugLink.visible Layout.alignment: Qt.AlignRight text: i18n("Report a Problem:") } UrlButton { id: bugLink url: application.bugURL Layout.fillWidth: true horizontalAlignment: Text.AlignLeft } } } readonly property var addons: AddonsView { id: addonsView application: appInfo.application parent: overlay } } diff --git a/libdiscover/backends/KNSBackend/KNSResource.cpp b/libdiscover/backends/KNSBackend/KNSResource.cpp index c3d633b6..354cb7a1 100644 --- a/libdiscover/backends/KNSBackend/KNSResource.cpp +++ b/libdiscover/backends/KNSBackend/KNSResource.cpp @@ -1,273 +1,278 @@ /*************************************************************************** * Copyright © 2012 Aleix Pol Gonzalez * * * * 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) version 3 or any later version * * accepted by the membership of KDE e.V. (or its successor approved * * by the membership of KDE e.V.), which shall act as a proxy * * defined in Section 14 of version 3 of the license. * * * * This program 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 * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * ***************************************************************************/ #include "KNSResource.h" #include "KNSBackend.h" #include #include #include #include #include #include "ReviewsBackend/Rating.h" #include KNSResource::KNSResource(const KNSCore::EntryInternal& entry, QStringList categories, KNSBackend* parent) : AbstractResource(parent) , m_categories(std::move(categories)) , m_entry(entry) , m_lastStatus(entry.status()) { connect(this, &KNSResource::stateChanged, parent, &KNSBackend::updatesCountChanged); } KNSResource::~KNSResource() = default; AbstractResource::State KNSResource::state() { switch(m_entry.status()) { case KNS3::Entry::Invalid: return Broken; case KNS3::Entry::Downloadable: return None; case KNS3::Entry::Installed: return Installed; case KNS3::Entry::Updateable: return Upgradeable; case KNS3::Entry::Deleted: case KNS3::Entry::Installing: case KNS3::Entry::Updating: return None; } return None; } KNSBackend * KNSResource::knsBackend() const { return qobject_cast(parent()); } QVariant KNSResource::icon() const { const QString thumbnail = m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall1); return thumbnail.isEmpty() ? knsBackend()->iconName() : m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall1); } QString KNSResource::comment() { QString ret = m_entry.shortSummary(); if(ret.isEmpty()) { ret = m_entry.summary(); int newLine = ret.indexOf(QLatin1Char('\n')); if(newLine>0) { ret=ret.left(newLine); } - ret = ret.replace(QRegularExpression(QStringLiteral("\\[/?[a-z]*\\]")), QString()); + ret = ret.replace(QRegularExpression(QStringLiteral("\\[\\/?[a-z]*\\]")), QString()); ret = ret.remove(QRegularExpression(QStringLiteral("<[^>]*>"))); } return ret; } QString KNSResource::longDescription() { QString ret = m_entry.summary(); if (m_entry.shortSummary().isEmpty()) { const int newLine = ret.indexOf(QLatin1Char('\n')); if (newLine<0) ret.clear(); else ret = ret.mid(newLine+1).trimmed(); } ret = ret.replace(QLatin1Char('\r'), QString()); ret = ret.replace(QStringLiteral("[li]"), QStringLiteral("\n* ")); - ret = ret.replace(QRegularExpression(QStringLiteral("\\[/?[a-z]*\\]")), QString()); + // Get rid of all BBCode markup we don't handle above + ret = ret.replace(QRegularExpression(QStringLiteral("\\[\\/?[a-z]*\\]")), QString()); + // Find anything that looks like a link (but which also is not some html + // tag value or another already) and make it a link + static const QRegularExpression urlRegExp(QStringLiteral("(^|\\s)([-a-zA-Z0-9@:%_\\+.~#?&//=]{2,256}\\.[a-z]{2,4}\\b(\\/[-a-zA-Z0-9@:;%_\\+.~#?&//=]*)?)"), QRegularExpression::CaseInsensitiveOption); + ret = ret.replace(urlRegExp, QStringLiteral("\\2")); return ret; } QString KNSResource::name() const { return m_entry.name(); } QString KNSResource::packageName() const { return m_entry.uniqueId(); } QStringList KNSResource::categories() { return m_categories; } QUrl KNSResource::homepage() { return m_entry.homepage(); } void KNSResource::setEntry(const KNSCore::EntryInternal& entry) { const bool diff = entry.status() != m_lastStatus; m_entry = entry; if (diff) { m_lastStatus = entry.status(); Q_EMIT stateChanged(); } } KNSCore::EntryInternal KNSResource::entry() const { return m_entry; } QString KNSResource::license() { return m_entry.license(); } int KNSResource::size() { const auto downloadInfo = m_entry.downloadLinkInformationList(); return downloadInfo.isEmpty() ? 0 : downloadInfo.at(0).size; } QString KNSResource::installedVersion() const { return m_entry.version(); } QString KNSResource::availableVersion() const { return !m_entry.updateVersion().isEmpty() ? m_entry.updateVersion() : m_entry.version(); } QString KNSResource::origin() const { return m_entry.providerId(); } QString KNSResource::section() { return m_entry.category(); } static void appendIfValid(QList& list, const QUrl &value, const QUrl &fallback = {}) { if (!list.contains(value)) { if (value.isValid() && !value.isEmpty()) list << value; else if (!fallback.isEmpty()) appendIfValid(list, fallback); } } void KNSResource::fetchScreenshots() { QList preview; appendIfValid(preview, QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall1))); appendIfValid(preview, QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall2))); appendIfValid(preview, QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall3))); QList screenshots; appendIfValid(screenshots, QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewBig1)), QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall1))); appendIfValid(screenshots, QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewBig2)), QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall2))); appendIfValid(screenshots, QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewBig3)), QUrl(m_entry.previewUrl(KNSCore::EntryInternal::PreviewSmall3))); emit screenshotsFetched(preview, screenshots); } void KNSResource::fetchChangelog() { emit changelogFetched(m_entry.changelog()); } QStringList KNSResource::extends() const { return knsBackend()->extends(); } QStringList KNSResource::executables() const { if (knsBackend()->engine()->hasAdoptionCommand()) return {knsBackend()->engine()->adoptionCommand(m_entry)}; else return {}; } QUrl KNSResource::url() const { return QUrl(QStringLiteral("kns://")+knsBackend()->name() + QLatin1Char('/') + QUrl(m_entry.providerId()).host() + QLatin1Char('/') + m_entry.uniqueId()); } void KNSResource::invokeApplication() const { QStringList exes = executables(); if(!exes.isEmpty()) { const QString exe = exes.constFirst(); auto args = KShell::splitArgs(exe); QProcess::startDetached(args.takeFirst(), args); } else { qWarning() << "cannot execute" << packageName(); } } QString KNSResource::executeLabel() const { return i18n("Use"); } QDate KNSResource::releaseDate() const { return m_entry.updateReleaseDate().isNull() ? m_entry.releaseDate() : m_entry.updateReleaseDate(); } QVector KNSResource::linkIds() const { QVector ids; for(const auto &e : m_entry.downloadLinkInformationList()) { if (e.isDownloadtypeLink) ids << e.id; } return ids; } QUrl KNSResource::donationURL() { return QUrl(m_entry.donationLink()); } Rating * KNSResource::ratingInstance() { if (!m_rating) { const int noc = m_entry.numberOfComments(); const int rating = m_entry.rating(); Q_ASSERT(rating <= 100); return new Rating( packageName(), noc, { { QStringLiteral("star5"), rating } } ); } return m_rating; } QString KNSResource::author() const { return m_entry.author().name(); }