diff --git a/src/server/src/extensions/clipboard/clipboard-history-command.cpp b/src/server/src/extensions/clipboard/clipboard-history-command.cpp index 038a89e2f..9dc17901a 100644 --- a/src/server/src/extensions/clipboard/clipboard-history-command.cpp +++ b/src/server/src/extensions/clipboard/clipboard-history-command.cpp @@ -26,5 +26,30 @@ std::vector ClipboardHistoryCommand::preferences() const { "available if your environment supports it.")); preferences.emplace_back(defaultAction); + using Opt = Preference::DropdownData::Option; + + auto saveDirectoryMode = Preference::makeDropdown( + "saveDirectoryMode", + {Opt{"Downloads folder", "downloads"}, Opt{"Home folder", "home"}, Opt{"Custom folder", "custom"}}); + saveDirectoryMode.setDefaultValue("downloads"); + saveDirectoryMode.setTitle("Save Destination"); + saveDirectoryMode.setDescription("Where clipboard entries are saved when using the Save to file action."); + preferences.emplace_back(saveDirectoryMode); + + auto saveCustomDirectory = Preference::directory("saveCustomDirectory"); + saveCustomDirectory.setTitle("Custom Save Folder"); + saveCustomDirectory.setDescription("Used only when Save Destination is set to Custom folder."); + saveCustomDirectory.setRequired(false); + saveCustomDirectory.setDefaultValue(QJsonValue::Null); + preferences.emplace_back(saveCustomDirectory); + + auto saveFileNameMode = Preference::makeDropdown( + "saveFileNameMode", {Opt{"Use content preview", "content"}, Opt{"Use timestamp", "timestamp"}}); + saveFileNameMode.setDefaultValue("content"); + saveFileNameMode.setTitle("Save Filename"); + saveFileNameMode.setDescription( + "Choose whether generated save filenames come from the content or from timestamp."); + preferences.emplace_back(saveFileNameMode); + return preferences; } diff --git a/src/server/src/extensions/clipboard/history/clipboard-history-actions.hpp b/src/server/src/extensions/clipboard/history/clipboard-history-actions.hpp index 8fc930925..8fa7cf09a 100644 --- a/src/server/src/extensions/clipboard/history/clipboard-history-actions.hpp +++ b/src/server/src/extensions/clipboard/history/clipboard-history-actions.hpp @@ -1,11 +1,21 @@ #pragma once #include +#include +#include +#include +#include +#include +#include +#include +#include + #include "builtin_icon.hpp" #include "clipboard-actions.hpp" #include "qml/edit-keywords-view-host.hpp" #include "services/clipboard/clipboard-service.hpp" #include "services/toast/toast-service.hpp" #include "ui/alert/alert.hpp" +#include "utils/utils.hpp" class PasteClipboardSelection : public PasteToFocusedWindowAction { QString m_id; @@ -37,6 +47,212 @@ class CopyClipboardSelection : public AbstractAction { : AbstractAction(tr("Copy to clipboard"), BuiltinIcon::CopyClipboard), m_id(id) {} }; +class SaveClipboardSelectionToFileAction : public AbstractAction { + ClipboardHistoryEntry m_entry; + QString m_saveDirectoryMode; + QString m_saveCustomDirectory; + QString m_saveFileNameMode; + + QString kindToken() const { + switch (m_entry.kind) { + case ClipboardOfferKind::Image: + return "image"; + case ClipboardOfferKind::File: + return "file"; + case ClipboardOfferKind::Link: + return "link"; + case ClipboardOfferKind::Text: + return "text"; + default: + return "entry"; + } + } + + static QStringList uriLines(const QByteArray &data) { + QString const text = QString::fromUtf8(data); + auto lines = text.split("\r\n", Qt::SkipEmptyParts); + if (lines.empty()) lines = text.split('\n', Qt::SkipEmptyParts); + return lines; + } + + static std::optional singleLocalFileFromUriList(const QByteArray &data) { + auto lines = uriLines(data); + if (lines.size() != 1) return std::nullopt; + + QUrl const url(lines.front().trimmed()); + if (!url.isLocalFile()) return std::nullopt; + + QFileInfo const info(url.toLocalFile()); + if (!info.exists() || !info.isFile()) return std::nullopt; + return info; + } + + static QString sanitizeFileStem(const QString &input) { + QString stem = slugify(input.simplified()); + if (stem.isEmpty()) stem = "clipboard-entry"; + if (stem.size() > 64) stem = stem.first(64); + return stem; + } + + static std::optional fileStemFromUriList(const QByteArray &data) { + auto lines = uriLines(data); + if (lines.empty()) return std::nullopt; + + if (auto info = singleLocalFileFromUriList(data); info) { + QString stem = info->completeBaseName(); + if (stem.isEmpty()) stem = info->fileName(); + if (!stem.isEmpty()) return sanitizeFileStem(stem); + } + + return QString("clipboard-files-%1").arg(lines.size()); + } + + QString defaultFileSuffix(const QByteArray &data) const { + QString const mimeName = Utils::normalizeMimeName(m_entry.mimeType); + if (mimeName == "text/uri-list") { + if (auto info = singleLocalFileFromUriList(data); info) { + QString const ext = info->suffix(); + if (!ext.isEmpty()) return ext; + + QMimeType const fileMime = QMimeDatabase().mimeTypeForFile(*info); + if (fileMime.isValid()) { + QString const preferred = fileMime.preferredSuffix(); + if (!preferred.isEmpty()) return preferred; + } + } + return "txt"; + } + + QMimeType const mime = QMimeDatabase().mimeTypeForName(mimeName); + if (mime.isValid()) { + QString const preferred = mime.preferredSuffix(); + if (!preferred.isEmpty()) return preferred; + + auto const suffixes = mime.suffixes(); + if (!suffixes.isEmpty()) return suffixes.front(); + } + + if (Utils::isTextMimeType(mimeName)) return "txt"; + return "bin"; + } + + QString defaultFileStem(const QByteArray &data) const { + if (m_saveFileNameMode == "timestamp") { + auto dt = QDateTime::fromSecsSinceEpoch(m_entry.updatedAt); + return QString("clipboard-%1-%2").arg(kindToken()).arg(dt.toString("yyyyMMdd-HHmmss")); + } + + if (m_entry.kind == ClipboardOfferKind::Image) { + auto dt = QDateTime::fromSecsSinceEpoch(m_entry.updatedAt); + return QString("clipboard-image-%1").arg(dt.toString("yyyyMMdd-HHmmss")); + } + + if (m_entry.kind == ClipboardOfferKind::File) { + if (auto stem = fileStemFromUriList(data); stem) return *stem; + } + + QString prefix; + if (Utils::isTextMimeType(m_entry.mimeType) || m_entry.mimeType == "text/uri-list") { + prefix = QString::fromUtf8(data.left(240)).simplified(); + } + if (prefix.isEmpty()) prefix = m_entry.textPreview.simplified(); + if (prefix.isEmpty()) prefix = "clipboard-entry"; + + return sanitizeFileStem(prefix.left(64)); + } + + QString defaultFileName(const QByteArray &data) const { + auto stem = defaultFileStem(data); + auto suffix = defaultFileSuffix(data); + if (suffix.isEmpty()) return stem; + return QString("%1.%2").arg(stem).arg(suffix); + } + + QString saveDirectoryPath() const { + if (m_saveDirectoryMode == "custom") { + QString const custom = m_saveCustomDirectory.trimmed(); + if (!custom.isEmpty()) return custom; + } + + if (m_saveDirectoryMode == "home") { + auto folder = homeDir(); + if (!folder.empty()) return QString::fromStdString(folder.string()); + return QDir::homePath(); + } + + auto folder = downloadsFolder(); + std::error_code ec; + if (folder.empty() || !std::filesystem::is_directory(folder, ec)) { folder = homeDir(); } + if (folder.empty()) return QDir::homePath(); + return QString::fromStdString(folder.string()); + } + + QString uniqueTargetPath(const QString &defaultName) const { + QDir dir(saveDirectoryPath()); + if (!dir.exists()) { + if (!dir.mkpath(".")) return QString(); + } + + QString candidate = dir.filePath(defaultName); + if (!QFileInfo::exists(candidate)) return candidate; + + QFileInfo const info(defaultName); + QString const base = info.completeBaseName(); + QString const suffix = info.suffix(); + + for (int i = 2; i < 10'000; ++i) { + QString numbered = QString("%1-%2").arg(base).arg(i); + if (!suffix.isEmpty()) numbered += QString(".%1").arg(suffix); + + candidate = dir.filePath(numbered); + if (!QFileInfo::exists(candidate)) return candidate; + } + + return QString(); + } + + void execute(ApplicationContext *ctx) override { + auto clipman = ctx->services->clipman(); + auto toast = ctx->services->toastService(); + + auto data = clipman->getMainOfferData(m_entry.id); + if (!data) { + toast->failure("Failed to read clipboard entry"); + return; + } + + QByteArray const bytes = data.value(); + QString const path = uniqueTargetPath(defaultFileName(bytes)); + if (path.isEmpty()) { + toast->failure("Failed to resolve output file path"); + return; + } + + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + toast->failure("Failed to save file", file.errorString()); + return; + } + + if (file.write(bytes) != bytes.size()) { + toast->failure("Failed to write file", file.errorString()); + return; + } + + file.close(); + + toast->success(QString("Saved to %1").arg(path)); + } + +public: + SaveClipboardSelectionToFileAction(const ClipboardHistoryEntry &entry, QString saveDirectoryMode, + QString saveCustomDirectory, QString saveFileNameMode) + : AbstractAction("Save to file", BuiltinIcon::SaveDocument), m_entry(entry), + m_saveDirectoryMode(std::move(saveDirectoryMode)), + m_saveCustomDirectory(std::move(saveCustomDirectory)), + m_saveFileNameMode(std::move(saveFileNameMode)) {} +}; + class RemoveSelectionAction : public AbstractAction { Q_DECLARE_TR_FUNCTIONS(RemoveSelectionAction) QString m_id; diff --git a/src/server/src/qml/clipboard-history-model.cpp b/src/server/src/qml/clipboard-history-model.cpp index 79b0d8957..8be6c8894 100644 --- a/src/server/src/qml/clipboard-history-model.cpp +++ b/src/server/src/qml/clipboard-history-model.cpp @@ -95,6 +95,12 @@ std::unique_ptr ClipboardHistorySection::actionPanel(int i) co auto toolsSection = panel->createSection(); auto dangerSection = panel->createSection(); toolsSection->addAction(pin); + if (isCopyable) { + auto saveToFile = new SaveClipboardSelectionToFileAction(entry, m_saveDirectoryMode, + m_saveCustomDirectory, m_saveFileNameMode); + saveToFile->setShortcut(Keybind::SaveAction); + toolsSection->addAction(saveToFile); + } toolsSection->addAction(editKeywords); dangerSection->addAction(remove); dangerSection->addAction(removeAll); diff --git a/src/server/src/qml/clipboard-history-model.hpp b/src/server/src/qml/clipboard-history-model.hpp index 92fb59a16..11bb7067a 100644 --- a/src/server/src/qml/clipboard-history-model.hpp +++ b/src/server/src/qml/clipboard-history-model.hpp @@ -11,6 +11,11 @@ class ClipboardHistorySection : public SectionSource { void setEntries(const PaginatedResponse &page); void setDefaultAction(DefaultAction action) { m_defaultAction = action; } + void setSaveDirectoryMode(QString value) { m_saveDirectoryMode = std::move(value); } + void setSaveCustomDirectory(QString value) { m_saveCustomDirectory = std::move(value); } + void setSaveFileNameMode(QString value) { m_saveFileNameMode = std::move(value); } + void setFilter(std::string_view) override {} + void setFilter(const QString &text) { setFilter(text.toUtf8().toStdString()); } QString sectionName() const override { return {}; } int count() const override { return static_cast(m_entries.size()); } @@ -47,4 +52,7 @@ class ClipboardHistorySection : public SectionSource { std::vector m_entries; DefaultAction m_defaultAction = DefaultAction::Copy; std::function m_onEntrySelected; + QString m_saveDirectoryMode = "downloads"; + QString m_saveCustomDirectory; + QString m_saveFileNameMode = "content"; }; diff --git a/src/server/src/qml/clipboard-history-view-host.cpp b/src/server/src/qml/clipboard-history-view-host.cpp index b8241a80f..f184908ce 100644 --- a/src/server/src/qml/clipboard-history-view-host.cpp +++ b/src/server/src/qml/clipboard-history-view-host.cpp @@ -97,6 +97,13 @@ void ClipboardHistoryViewHost::initialize() { auto defaultActionStr = preferences.value("defaultAction").toString(); m_section.setDefaultAction(defaultActionStr == "paste" ? ClipboardHistorySection::DefaultAction::Paste : ClipboardHistorySection::DefaultAction::Copy); + auto saveDirectoryMode = preferences.value("saveDirectoryMode").toString(); + auto saveCustomDirectory = preferences.value("saveCustomDirectory").toString(); + auto saveFileNameMode = preferences.value("saveFileNameMode").toString(); + + m_section.setSaveDirectoryMode(saveDirectoryMode.isEmpty() ? "downloads" : saveDirectoryMode); + m_section.setSaveCustomDirectory(saveCustomDirectory); + m_section.setSaveFileNameMode(saveFileNameMode.isEmpty() ? "content" : saveFileNameMode); setSearchPlaceholderText(tr("Browse clipboard history..."));