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);
+}