Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/server/src/extensions/clipboard/clipboard-history-command.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,30 @@ std::vector<Preference> 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;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
#pragma once
#include <QCoreApplication>
#include <qdatetime.h>
#include <qdir.h>
#include <qfile.h>
#include <qfileinfo.h>
#include <qmimedatabase.h>
#include <qurl.h>
#include <filesystem>
#include <optional>

#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;
Expand Down Expand Up @@ -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<QFileInfo> 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<QString> 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;
Expand Down
6 changes: 6 additions & 0 deletions src/server/src/qml/clipboard-history-model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ std::unique_ptr<ActionPanelState> 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);
Expand Down
8 changes: 8 additions & 0 deletions src/server/src/qml/clipboard-history-model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ class ClipboardHistorySection : public SectionSource {

void setEntries(const PaginatedResponse<ClipboardHistoryEntry> &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<int>(m_entries.size()); }
Expand Down Expand Up @@ -47,4 +52,7 @@ class ClipboardHistorySection : public SectionSource {
std::vector<ClipboardHistoryEntry> m_entries;
DefaultAction m_defaultAction = DefaultAction::Copy;
std::function<void(const ClipboardHistoryEntry &)> m_onEntrySelected;
QString m_saveDirectoryMode = "downloads";
QString m_saveCustomDirectory;
QString m_saveFileNameMode = "content";
};
7 changes: 7 additions & 0 deletions src/server/src/qml/clipboard-history-view-host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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..."));

Expand Down