diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index c8dfe2391..f31eba030 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -10,14 +10,14 @@ if (USE_SYSTEM_KF6) find_package(KF6SyntaxHighlighting REQUIRED) endif() -list(APPEND LIBS +list(APPEND LIBS Qt6::Sql Qt6::Network Qt6::Svg Qt6::DBus Qt6::Concurrent Qt6::Quick Qt6::Qml Qt6::GuiPrivate # for deeper integration with wayland protocols, we need wl_surface Qt6::QuickDialogs2 Qt6::QuickControls2 ${CMARK_LIBRARY} ${CMARK_EXT_LIBRARY} minizip - OpenSSL::Crypto - wayland-client + OpenSSL::Crypto + wayland-client qt6keychain LibXml2::LibXml2 KF6::SyntaxHighlighting @@ -102,6 +102,8 @@ set(SRCS include/favicon/cached-favicon-request.hpp src/favicon/cached-favicon-request.cpp + include/url-metadata/url-metadata-service.hpp + src/url-metadata/url-metadata-service.cpp src/command-actions.cpp @@ -111,7 +113,7 @@ set(SRCS src/root-search/shortcuts/shortcut-root-provider.cpp src/root-search/apps/app-root-provider.hpp src/root-search/apps/app-root-provider.cpp - + src/extension/manager/extension-manager.hpp src/extension/manager/extension-manager.cpp @@ -134,7 +136,7 @@ set(SRCS src/actions/app/app-actions.hpp src/actions/app/app-actions.cpp - + src/service-registry.cpp src/services/background-effect/ext-background-effect-v1-manager.cpp @@ -182,7 +184,6 @@ set(SRCS src/lib/crypto.cpp - include/extension/extension-command.hpp src/extension/extension-action-panel-builder.hpp @@ -194,8 +195,6 @@ set(SRCS include/extension/extension-command.hpp src/extension/extension-command.cpp - - src/ui/omni-painter/omni-painter.hpp src/ui/omni-painter/omni-painter.cpp @@ -216,15 +215,12 @@ set(SRCS include/command-database.hpp src/command-database.cpp - - src/ui/image/url.hpp - + src/ui/image/url.hpp include/create-quicklink-command.hpp src/builtin_icon.cpp - src/extend/accessory-model.cpp src/extend/action-shortcut-parser.cpp src/extend/action-model.cpp @@ -243,14 +239,11 @@ set(SRCS src/image-fetcher.hpp - src/command.hpp - src/vicinae.hpp src/vicinae.cpp - ${EXTRA_PATH}/extension-boilerplate/boilerplate.qrc ./icons/icons.qrc @@ -314,7 +307,6 @@ set(SRCS src/cli/server.cpp - src/utils/utils.cpp src/services/file-chooser/abstract-file-chooser.hpp @@ -327,15 +319,13 @@ set(SRCS src/services/root-item-manager/root-item-manager.hpp src/services/root-item-manager/root-item-manager.cpp src/services/root-item-manager/visit-tracker.cpp - + src/services/app-service/app-service.hpp src/services/app-service/app-service.cpp src/services/emoji-service/emoji-service.hpp src/services/emoji-service/emoji-service.cpp - - src/services/calculator-service/calculator-service.hpp src/services/calculator-service/calculator-service.cpp @@ -405,7 +395,6 @@ set(SRCS src/overlay-controller/overlay-controller.hpp - src/lib/zip/unzip.cpp src/lib/data-uri/data-uri.cpp src/lib/pid-file/pid-file.cpp @@ -692,7 +681,6 @@ if (UNIX AND NOT APPLE) wayland_generate_protocol("wlr-data-control-unstable-v1") endif() - qt_add_executable(${TARGET} ${SRCS}) @@ -733,6 +721,7 @@ set(VICINAE_QML_FILES src/qml/qml/SearchableDropdown.qml src/qml/qml/ClipboardHistoryView.qml src/qml/qml/ClipboardFilterAccessory.qml + src/qml/qml/LinkPreview.qml src/qml/qml/DetailPanel.qml src/qml/qml/MetadataBar.qml src/qml/qml/AlertDialog.qml diff --git a/src/server/database/clipboard/migrations.qrc b/src/server/database/clipboard/migrations.qrc index 9b49ec4b8..82aa3d36f 100644 --- a/src/server/database/clipboard/migrations.qrc +++ b/src/server/database/clipboard/migrations.qrc @@ -1,5 +1,6 @@ migrations/001_init.sql + migrations/002_add_url_metadata.sql diff --git a/src/server/database/clipboard/migrations/002_add_url_metadata.sql b/src/server/database/clipboard/migrations/002_add_url_metadata.sql new file mode 100644 index 000000000..7134373d3 --- /dev/null +++ b/src/server/database/clipboard/migrations/002_add_url_metadata.sql @@ -0,0 +1,3 @@ +ALTER TABLE selection ADD COLUMN og_title TEXT; +ALTER TABLE selection ADD COLUMN og_description TEXT; +ALTER TABLE selection ADD COLUMN og_image TEXT; diff --git a/src/server/include/url-metadata/url-metadata-service.hpp b/src/server/include/url-metadata/url-metadata-service.hpp new file mode 100644 index 000000000..7197d5727 --- /dev/null +++ b/src/server/include/url-metadata/url-metadata-service.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class QNetworkReply; + +class UrlMetadataService : public QObject { + Q_OBJECT + +public: + struct UrlMetadata { + std::optional ogTitle; + std::optional ogDescription; + std::optional ogImage; + }; + + explicit UrlMetadataService(QObject *parent = nullptr); + ~UrlMetadataService() override; + + void fetchMetadata(const QString &selectionId, const QUrl &url); + +signals: + void metadataReady(const QString &selectionId, const QString &ogTitle, const QString &ogDescription, + const QString &ogImage); + +private: + static constexpr size_t MAX_CACHE_SIZE = 200; + static constexpr qint64 MAX_BUFFER_SIZE = 64 * 1024; + static constexpr int TIMEOUT_MS = 10000; + + static UrlMetadata parseMetaTags(const QByteArray &html); + void abortAll(); + + struct InFlightRequest { + QNetworkReply *reply; + QByteArray buffer; + std::vector selectionIds; + }; + + std::unordered_map m_inFlight; + std::unordered_map m_cache; +}; diff --git a/src/server/src/extensions/clipboard/history/clipboard-history-controller.cpp b/src/server/src/extensions/clipboard/history/clipboard-history-controller.cpp index 7000d4d53..cc095f48a 100644 --- a/src/server/src/extensions/clipboard/history/clipboard-history-controller.cpp +++ b/src/server/src/extensions/clipboard/history/clipboard-history-controller.cpp @@ -14,6 +14,8 @@ ClipboardHistoryController::ClipboardHistoryController(ClipboardService *clipboa &ClipboardHistoryController::handleClipboardChanged); connect(clipboard, &ClipboardService::selectionUpdated, this, &ClipboardHistoryController::handleClipboardChanged); + connect(clipboard, &ClipboardService::selectionMetadataUpdated, this, + &ClipboardHistoryController::handleClipboardChanged); } void ClipboardHistoryController::setFilter(const QString &query) { diff --git a/src/server/src/qml/clipboard-history-model.cpp b/src/server/src/qml/clipboard-history-model.cpp index a95dc2fe4..f5c3bda6b 100644 --- a/src/server/src/qml/clipboard-history-model.cpp +++ b/src/server/src/qml/clipboard-history-model.cpp @@ -41,10 +41,16 @@ QVariant ClipboardHistoryModel::data(const QModelIndex &index, int role) const { QString ClipboardHistoryModel::itemId(int, int i) const { return m_entries[i].id; } -QString ClipboardHistoryModel::itemTitle(int, int i) const { return m_entries[i].textPreview; } +QString ClipboardHistoryModel::itemTitle(int, int i) const { + const auto &entry = m_entries[i]; + if (entry.ogTitle && !entry.ogTitle->isEmpty()) return *entry.ogTitle; + return entry.textPreview; +} QString ClipboardHistoryModel::itemSubtitle(int, int i) const { - auto dt = QDateTime::fromSecsSinceEpoch(m_entries[i].updatedAt); + const auto &entry = m_entries[i]; + if (entry.ogDescription && !entry.ogDescription->isEmpty()) return *entry.ogDescription; + auto dt = QDateTime::fromSecsSinceEpoch(entry.updatedAt); return getRelativeTimeString(dt); } diff --git a/src/server/src/qml/clipboard-history-view-host.cpp b/src/server/src/qml/clipboard-history-view-host.cpp index 9a89f8e9e..ed15c5e7d 100644 --- a/src/server/src/qml/clipboard-history-view-host.cpp +++ b/src/server/src/qml/clipboard-history-view-host.cpp @@ -165,10 +165,16 @@ void ClipboardHistoryViewHost::handleDataRetrieved(int totalCount) { void ClipboardHistoryViewHost::loadDetail(const ClipboardHistoryEntry &entry) { m_detailTextContent.clear(); m_detailImageSource.clear(); + m_detailOgTitle.clear(); + m_detailOgDescription.clear(); + m_detailOgImageUrl.clear(); m_hasDetailError = false; m_detailErrorTitle.clear(); m_detailErrorDescription.clear(); + m_detailOgTitle = entry.ogTitle.value_or(QString()); + m_detailOgDescription = entry.ogDescription.value_or(QString()); + m_detailOgImageUrl = entry.ogImage.value_or(QString()); m_detailMimeType = entry.mimeType; m_detailSize = formatSize(entry.size); m_detailCopiedAt = QDateTime::fromSecsSinceEpoch(entry.updatedAt).toString(); @@ -268,6 +274,9 @@ void ClipboardHistoryViewHost::clearDetail() { m_hasDetailError = false; m_detailTextContent.clear(); m_detailImageSource.clear(); + m_detailOgTitle.clear(); + m_detailOgDescription.clear(); + m_detailOgImageUrl.clear(); m_detailMimeType.clear(); m_detailSize.clear(); m_detailCopiedAt.clear(); diff --git a/src/server/src/qml/clipboard-history-view-host.hpp b/src/server/src/qml/clipboard-history-view-host.hpp index 6aff15436..85220d545 100644 --- a/src/server/src/qml/clipboard-history-view-host.hpp +++ b/src/server/src/qml/clipboard-history-view-host.hpp @@ -27,6 +27,9 @@ class ClipboardHistoryViewHost : public ViewHostBase { Q_PROPERTY(QString detailEncryptionIcon READ detailEncryptionIcon NOTIFY detailChanged) Q_PROPERTY(QString detailErrorTitle READ detailErrorTitle NOTIFY detailChanged) Q_PROPERTY(QString detailErrorDescription READ detailErrorDescription NOTIFY detailChanged) + Q_PROPERTY(QString detailOgTitle READ detailOgTitle NOTIFY detailChanged) + Q_PROPERTY(QString detailOgDescription READ detailOgDescription NOTIFY detailChanged) + Q_PROPERTY(QString detailOgImageUrl READ detailOgImageUrl NOTIFY detailChanged) public: explicit ClipboardHistoryViewHost(); @@ -60,6 +63,9 @@ class ClipboardHistoryViewHost : public ViewHostBase { QString detailEncryptionIcon() const { return m_detailEncryptionIcon; } QString detailErrorTitle() const { return m_detailErrorTitle; } QString detailErrorDescription() const { return m_detailErrorDescription; } + QString detailOgTitle() const { return m_detailOgTitle; } + QString detailOgDescription() const { return m_detailOgDescription; } + QString detailOgImageUrl() const { return m_detailOgImageUrl; } signals: void itemCountTextChanged(); @@ -96,6 +102,9 @@ class ClipboardHistoryViewHost : public ViewHostBase { QString m_detailEncryptionIcon; QString m_detailErrorTitle; QString m_detailErrorDescription; + QString m_detailOgTitle; + QString m_detailOgDescription; + QString m_detailOgImageUrl; std::unique_ptr m_tmpFile; }; diff --git a/src/server/src/qml/qml/ClipboardHistoryView.qml b/src/server/src/qml/qml/ClipboardHistoryView.qml index 0370e2540..3cf4690ff 100644 --- a/src/server/src/qml/qml/ClipboardHistoryView.qml +++ b/src/server/src/qml/qml/ClipboardHistoryView.qml @@ -1,20 +1,24 @@ import QtQuick -import QtQuick.Layouts import QtQuick.Controls +import QtQuick.Layouts Item { id: root + required property var host function moveUp() { listView.moveUp(); } + function moveDown() { listView.moveDown(); } + function moveSectionUp() { listView.moveSectionUp(); } + function moveSectionDown() { listView.moveSectionDown(); } @@ -50,7 +54,7 @@ Item { visible: root.host.clipboardStatusIcon !== "" width: 25 height: 25 - opacity: statusMouseArea.containsMouse ? 1.0 : 0.6 + opacity: statusMouseArea.containsMouse ? 1 : 0.6 Image { anchors.fill: parent @@ -62,13 +66,16 @@ Item { MouseArea { id: statusMouseArea + anchors.fill: parent hoverEnabled: true cursorShape: root.host.canToggleMonitoring ? Qt.PointingHandCursor : Qt.ArrowCursor enabled: root.host.canToggleMonitoring onClicked: root.host.toggleMonitoring() } + } + } Rectangle { @@ -79,9 +86,9 @@ Item { GenericListView { id: listView + Layout.fillWidth: true Layout.fillHeight: true - listModel: root.host.listModel model: root.host.listModel autoWireModel: true @@ -91,7 +98,6 @@ Item { delegate: Loader { id: delegateLoader - width: ListView.view.width required property int index required property bool isSection @@ -103,20 +109,25 @@ Item { required property var itemAccessory required property bool isPinned + width: ListView.view.width sourceComponent: isSection ? sectionComponent : itemComponent Component { id: sectionComponent + Item { width: 1 height: 0 } + } Component { id: itemComponent + SelectableDelegate { id: itemDelegate + width: delegateLoader.width height: 50 selected: listView.currentIndex === delegateLoader.index @@ -153,6 +164,7 @@ Item { RowLayout { spacing: 5 + Image { visible: delegateLoader.isPinned source: "image://vicinae/builtin:pin?fg=" + Theme.red @@ -161,6 +173,7 @@ Item { Layout.preferredWidth: 14 Layout.preferredHeight: 14 } + Text { text: delegateLoader.subtitle color: Theme.textMuted @@ -169,54 +182,60 @@ Item { maximumLineCount: 1 Layout.fillWidth: true } + } + } + } + } + } + } + } + } Component { id: detailPanel DetailPanel { - metadata: [ - { - label: "Mime", - value: root.host.detailMimeType, - icon: root.host.detailEncryptionIcon - }, - { - label: "Size", - value: root.host.detailSize - }, - { - label: "Copied at", - value: root.host.detailCopiedAt - }, - { - label: "MD5", - value: root.host.detailMd5 - } - ] + metadata: [{ + "label": "Mime", + "value": root.host.detailMimeType, + "icon": root.host.detailEncryptionIcon + }, { + "label": "Size", + "value": root.host.detailSize + }, { + "label": "Copied at", + "value": root.host.detailCopiedAt + }, { + "label": "MD5", + "value": root.host.detailMd5 + }] Loader { anchors.fill: parent active: root.host.hasDetailError visible: active + sourceComponent: EmptyView { title: root.host.detailErrorTitle description: root.host.detailErrorDescription icon: "image://vicinae/builtin:key?fg=" + Theme.red } + } Loader { anchors.fill: parent active: !root.host.hasDetailError && root.host.detailImageSource !== "" visible: active + sourceComponent: Item { ViciImage { anchors.fill: parent @@ -226,28 +245,39 @@ Item { sourceSize: Qt.size(width, height) cache: false } + } + } Loader { anchors.fill: parent active: !root.host.hasDetailError && root.host.detailImageSource === "" && root.host.detailTextContent !== "" visible: active - sourceComponent: TextViewer { - text: root.host.detailTextContent - monospace: true + + sourceComponent: LinkPreview { + url: root.host.detailTextContent + title: root.host.detailOgTitle + description: root.host.detailOgDescription + imageUrl: root.host.detailOgImageUrl } + } Loader { anchors.fill: parent active: !root.host.hasDetailError && root.host.detailImageSource === "" && root.host.detailTextContent === "" visible: active + sourceComponent: EmptyView { title: root.host.detailMimeType description: "Preview not available for this content type" } + } + } + } + } diff --git a/src/server/src/qml/qml/LinkPreview.qml b/src/server/src/qml/qml/LinkPreview.qml new file mode 100644 index 000000000..2f9a0e88b --- /dev/null +++ b/src/server/src/qml/qml/LinkPreview.qml @@ -0,0 +1,99 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts + +ScrollView { + id: root + + required property string url + property string title + property string description + property string imageUrl + + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: root.availableWidth + spacing: 0 + + Text { + text: root.url + color: Theme.textMuted + font.pointSize: Theme.smallerFontSize + font.family: "monospace" + elide: Text.ElideMiddle + Layout.fillWidth: true + Layout.margins: 12 + Layout.bottomMargin: 4 + } + + Text { + visible: root.title !== "" + text: root.title + color: Theme.accent + font.pointSize: Theme.regularFontSize + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.bottomMargin: 4 + } + + Text { + visible: root.description !== "" + text: root.description + color: Theme.foreground + font.pointSize: Theme.smallerFontSize + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 4 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.bottomMargin: 8 + } + + Item { + visible: root.imageUrl !== "" + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.preferredHeight: ogImage.implicitWidth > 0 ? width * (ogImage.implicitHeight / ogImage.implicitWidth) : 0 + + Rectangle { + id: imageMask + + anchors.fill: parent + radius: 8 + visible: false + layer.enabled: true + } + + Image { + id: ogImage + + anchors.fill: parent + source: root.imageUrl + fillMode: Image.PreserveAspectFit + sourceSize.width: 500 + cache: true + asynchronous: true + visible: false + } + + MultiEffect { + anchors.fill: ogImage + source: ogImage + maskEnabled: true + maskSource: imageMask + } + + } + + } + +} diff --git a/src/server/src/services/clipboard/clipboard-db.cpp b/src/server/src/services/clipboard/clipboard-db.cpp index add562e7f..a06fadb59 100644 --- a/src/server/src/services/clipboard/clipboard-db.cpp +++ b/src/server/src/services/clipboard/clipboard-db.cpp @@ -65,9 +65,13 @@ PaginatedResponse ClipboardDatabase::query(int limit, int s.kind, o.url_host, o.encryption_type, - s.total_count + s.total_count, + s.og_title, + s.og_description, + s.og_image FROM ( SELECT id, pinned_at, updated_at, kind, preferred_mime_type, + og_title, og_description, og_image, COUNT(*) OVER() as total_count FROM selection ORDER BY pinned_at DESC, updated_at DESC @@ -92,7 +96,10 @@ PaginatedResponse ClipboardDatabase::query(int limit, int selection.kind, o.url_host, o.encryption_type, - COUNT(*) OVER() as count + COUNT(*) OVER() as count, + selection.og_title, + selection.og_description, + selection.og_image FROM selection JOIN data_offer o ON o.selection_id = selection.id @@ -140,6 +147,9 @@ PaginatedResponse ClipboardDatabase::query(int limit, int .encryption = static_cast(query.value(9).toUInt())}; if (auto val = query.value(8); !val.isNull()) { dto.urlHost = val.toString(); } + if (auto val = query.value(11); !val.isNull()) { dto.ogTitle = val.toString(); } + if (auto val = query.value(12); !val.isNull()) { dto.ogDescription = val.toString(); } + if (auto val = query.value(13); !val.isNull()) { dto.ogImage = val.toString(); } response.totalCount = query.value(10).toInt(); response.data.push_back(dto); @@ -324,6 +334,46 @@ bool ClipboardDatabase::tryBubbleUpSelection(const QString &idLike) { return query.numRowsAffected() > 0; } +bool ClipboardDatabase::updateUrlMetadata(const QString &selectionId, const QString &ogTitle, + const QString &ogDescription, const QString &ogImage) { + QSqlQuery query(m_db); + + query.prepare( + "UPDATE selection SET og_title = :title, og_description = :desc, og_image = :image WHERE id = :id"); + query.bindValue(":title", ogTitle); + query.bindValue(":desc", ogDescription); + query.bindValue(":image", ogImage); + query.bindValue(":id", selectionId); + + if (!query.exec()) { + qWarning() << "Failed to update URL metadata for selection" << selectionId << query.lastError(); + return false; + } + + return true; +} + +QString ClipboardDatabase::findSelectionIdByHash(const QString &hash) { + QSqlQuery query(m_db); + + query.prepare("SELECT id FROM selection WHERE hash_md5 = :hash LIMIT 1"); + query.bindValue(":hash", hash); + + if (!query.exec() || !query.next()) return {}; + + return query.value(0).toString(); +} + +bool ClipboardDatabase::selectionHasMetadata(const QString &id) { + QSqlQuery query(m_db); + + query.prepare( + "SELECT 1 FROM selection WHERE id = :id AND (og_title IS NOT NULL OR og_image IS NOT NULL) LIMIT 1"); + query.bindValue(":id", id); + + return query.exec() && query.next(); +} + bool ClipboardDatabase::indexSelectionContent(const QString &selectionId, const QString &content) { QSqlQuery query(m_db); diff --git a/src/server/src/services/clipboard/clipboard-db.hpp b/src/server/src/services/clipboard/clipboard-db.hpp index 91baa5114..1ae9e2fe6 100644 --- a/src/server/src/services/clipboard/clipboard-db.hpp +++ b/src/server/src/services/clipboard/clipboard-db.hpp @@ -61,6 +61,9 @@ struct ClipboardHistoryEntry { ClipboardOfferKind kind; std::optional urlHost; ClipboardEncryptionType encryption; + std::optional ogTitle; + std::optional ogDescription; + std::optional ogImage; }; struct ClipboardListSettings { @@ -103,8 +106,12 @@ class ClipboardDatabase { * The id can either be the selection id or the selection hash. */ bool tryBubbleUpSelection(const QString &idLike); + QString findSelectionIdByHash(const QString &hash); + bool selectionHasMetadata(const QString &id); bool insertSelection(const InsertSelectionPayload &payload); bool insertOffer(const InsertClipboardOfferPayload &payload); + bool updateUrlMetadata(const QString &selectionId, const QString &ogTitle, const QString &ogDescription, + const QString &ogImage); bool indexSelectionContent(const QString &selectionId, const QString &content); /** * Remove the selection from the database and return the list of offers diff --git a/src/server/src/services/clipboard/clipboard-service.cpp b/src/server/src/services/clipboard/clipboard-service.cpp index d0c6b35e4..b4e2e4e8c 100644 --- a/src/server/src/services/clipboard/clipboard-service.cpp +++ b/src/server/src/services/clipboard/clipboard-service.cpp @@ -381,13 +381,18 @@ void ClipboardService::saveSelection(ClipboardSelection selection) { return; } + QString savedSelectionId; + cdb.transaction([&](ClipboardDatabase *db) { if (db->tryBubbleUpSelection(selectionHash)) { qInfo() << "A similar clipboard selection is already indexed: moving it on top of the history"; + auto id = db->findSelectionIdByHash(selectionHash); + if (!id.isEmpty() && !db->selectionHasMetadata(id)) { savedSelectionId = id; } return true; } QString const selectionId = Crypto::UUID::v4(); + savedSelectionId = selectionId; if (!db->insertSelection({.id = selectionId, .offerCount = static_cast(selection.offers.size()), @@ -469,6 +474,11 @@ void ClipboardService::saveSelection(ClipboardSelection selection) { }); emit itemInserted(insertedEntry); + + if (preferredKind == ClipboardOfferKind::Link && !savedSelectionId.isEmpty()) { + auto url = QUrl::fromEncoded(preferredOfferIt->data, QUrl::StrictMode); + m_urlMetadata->fetchMetadata(savedSelectionId, url); + } } std::optional ClipboardService::retrieveSelectionById(const QString &id) { @@ -616,6 +626,16 @@ ClipboardService::ClipboardService(const std::filesystem::path &path) { fs::create_directories(m_dataDir); ClipboardDatabase().runMigrations(); + m_urlMetadata = std::make_unique(this); + connect(m_urlMetadata.get(), &UrlMetadataService::metadataReady, this, + [this](const QString &selectionId, const QString &ogTitle, const QString &ogDescription, + const QString &ogImage) { + ClipboardDatabase db; + if (db.updateUrlMetadata(selectionId, ogTitle, ogDescription, ogImage)) { + emit selectionMetadataUpdated(selectionId); + } + }); + connect(m_clipboardServer.get(), &AbstractClipboardServer::selectionAdded, this, &ClipboardService::saveSelection); } diff --git a/src/server/src/services/clipboard/clipboard-service.hpp b/src/server/src/services/clipboard/clipboard-service.hpp index de490c0b4..93989920c 100644 --- a/src/server/src/services/clipboard/clipboard-service.hpp +++ b/src/server/src/services/clipboard/clipboard-service.hpp @@ -4,6 +4,7 @@ #include "services/clipboard/clipboard-db.hpp" #include "services/clipboard/clipboard-encrypter.hpp" #include "services/clipboard/clipboard-server.hpp" +#include "url-metadata/url-metadata-service.hpp" #include #include #include @@ -65,6 +66,7 @@ class ClipboardService : public QObject, public NonCopyable { * of the list. */ void selectionUpdated() const; + void selectionMetadataUpdated(const QString &id) const; void monitoringChanged(bool value) const; public: @@ -118,6 +120,7 @@ class ClipboardService : public QObject, public NonCopyable { private: std::unique_ptr m_encrypter; + std::unique_ptr m_urlMetadata; QMimeDatabase _mimeDb; std::filesystem::path m_dataDir; diff --git a/src/server/src/url-metadata/url-metadata-service.cpp b/src/server/src/url-metadata/url-metadata-service.cpp new file mode 100644 index 000000000..d96c13fd6 --- /dev/null +++ b/src/server/src/url-metadata/url-metadata-service.cpp @@ -0,0 +1,140 @@ +#include "url-metadata/url-metadata-service.hpp" +#include +#include +#include +#include +#include +#include "lib/http-client.hpp" + +UrlMetadataService::UrlMetadataService(QObject *parent) : QObject(parent) {} + +UrlMetadataService::~UrlMetadataService() { abortAll(); } + +void UrlMetadataService::abortAll() { + for (auto &[url, req] : m_inFlight) { + req.reply->abort(); + req.reply->deleteLater(); + } + m_inFlight.clear(); +} + +UrlMetadataService::UrlMetadata UrlMetadataService::parseMetaTags(const QByteArray &html) { + UrlMetadata result; + + auto *doc = htmlReadMemory(html.constData(), html.size(), nullptr, "UTF-8", + HTML_PARSE_NOERROR | HTML_PARSE_NOWARNING | HTML_PARSE_RECOVER); + if (!doc) return result; + + std::function walk = [&](xmlNode *node) { + for (auto *cur = node; cur; cur = cur->next) { + if (cur->type == XML_ELEMENT_NODE) { + if (xmlStrcasecmp(cur->name, BAD_CAST "meta") == 0) { + xmlChar *prop = xmlGetProp(cur, BAD_CAST "property"); + xmlChar *name = xmlGetProp(cur, BAD_CAST "name"); + xmlChar *content = xmlGetProp(cur, BAD_CAST "content"); + + if (content) { + auto *c = reinterpret_cast(content); + auto *p = reinterpret_cast(prop); + auto *n = reinterpret_cast(name); + + if (p && strcasecmp(p, "og:title") == 0 && !result.ogTitle) { + result.ogTitle = QString::fromUtf8(c); + } else if (p && strcasecmp(p, "og:description") == 0 && !result.ogDescription) { + result.ogDescription = QString::fromUtf8(c); + } else if (p && strcasecmp(p, "og:image") == 0 && !result.ogImage) { + result.ogImage = QString::fromUtf8(c); + } else if (n && strcasecmp(n, "description") == 0 && !result.ogDescription) { + result.ogDescription = QString::fromUtf8(c); + } + } + + xmlFree(prop); + xmlFree(name); + xmlFree(content); + } else if (xmlStrcasecmp(cur->name, BAD_CAST "title") == 0 && !result.ogTitle) { + auto *text = xmlNodeGetContent(cur); + if (text) { + result.ogTitle = QString::fromUtf8(reinterpret_cast(text)); + xmlFree(text); + } + } + + if (xmlStrcasecmp(cur->name, BAD_CAST "body") == 0) return; + } + + walk(cur->children); + } + }; + + walk(xmlDocGetRootElement(doc)); + xmlFreeDoc(doc); + + return result; +} + +void UrlMetadataService::fetchMetadata(const QString &selectionId, const QUrl &url) { + QString const urlStr = url.toString(); + + if (!url.scheme().startsWith("http")) return; + + if (auto it = m_cache.find(urlStr); it != m_cache.end()) { + const auto &cached = it->second; + if (cached.ogTitle || cached.ogDescription || cached.ogImage) { + emit metadataReady(selectionId, cached.ogTitle.value_or(QString()), + cached.ogDescription.value_or(QString()), cached.ogImage.value_or(QString())); + } + return; + } + + if (auto it = m_inFlight.find(urlStr); it != m_inFlight.end()) { + it->second.selectionIds.push_back(selectionId); + return; + } + + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + static_cast(QNetworkRequest::NoLessSafeRedirectPolicy)); + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Mozilla/5.0 (compatible; Vicinae/1.0)")); + req.setRawHeader("Accept", "text/html"); + + auto *reply = http::networkManager()->get(req); + m_inFlight.insert({urlStr, {reply, QByteArray(), {selectionId}}}); + m_inFlight[urlStr].buffer.reserve(MAX_BUFFER_SIZE); + + auto *timer = new QTimer(reply); + timer->setSingleShot(true); + + connect(timer, &QTimer::timeout, reply, [reply]() { reply->abort(); }); + + connect(reply, &QNetworkReply::readyRead, reply, [this, urlStr]() { + auto it = m_inFlight.find(urlStr); + if (it == m_inFlight.end()) return; + it->second.buffer.append(it->second.reply->readAll()); + if (it->second.buffer.size() >= MAX_BUFFER_SIZE) { it->second.reply->abort(); } + }); + + connect(reply, &QNetworkReply::finished, this, [this, reply, urlStr]() { + auto it = m_inFlight.find(urlStr); + if (it == m_inFlight.end()) return; + + if (reply->error() == QNetworkReply::NoError) { it->second.buffer.append(reply->readAll()); } + auto metadata = parseMetaTags(it->second.buffer); + auto selectionIds = std::move(it->second.selectionIds); + + m_inFlight.erase(it); + reply->deleteLater(); + + if (m_cache.size() >= MAX_CACHE_SIZE) { m_cache.erase(m_cache.begin()); } + m_cache.insert({urlStr, metadata}); + + if (metadata.ogTitle || metadata.ogDescription || metadata.ogImage) { + for (const auto &id : selectionIds) { + emit metadataReady(id, metadata.ogTitle.value_or(QString()), + metadata.ogDescription.value_or(QString()), metadata.ogImage.value_or(QString())); + } + } + }); + + timer->start(TIMEOUT_MS); +}