feat: implement file and text drop handling for chat and add shrunk sidebar override for window view
This commit is contained in:
@@ -22,10 +22,12 @@ qml_module(ZShell-services
|
||||
networkusage.hpp networkusage.cpp
|
||||
lyriccandidate.hpp lyriccandidate.cpp
|
||||
lyrics.hpp lyrics.cpp
|
||||
dropimporter.hpp dropimporter.cpp
|
||||
LIBRARIES
|
||||
Qt6::Core
|
||||
Qt6::Qml
|
||||
Qt6::Concurrent
|
||||
Qt6::Network
|
||||
PkgConfig::Pipewire
|
||||
PkgConfig::Aubio
|
||||
PkgConfig::Cava
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
#include "dropimporter.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMetaObject>
|
||||
#include <QMimeDatabase>
|
||||
#include <QNetworkReply>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace {
|
||||
constexpr qint64 kMaxBytes = 100 * 1024 * 1024;
|
||||
|
||||
bool isHttp(const QUrl& u) {
|
||||
return u.scheme() == QLatin1String("http") ||
|
||||
u.scheme() == QLatin1String("https");
|
||||
}
|
||||
|
||||
QString firstImgSrc(const QString& html) {
|
||||
static const QRegularExpression re(
|
||||
R"(<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'])",
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
const auto m = re.match(html);
|
||||
if (!m.hasMatch()) return {};
|
||||
return m.captured(1).replace(QLatin1String("&"), QLatin1String("&"));
|
||||
}
|
||||
|
||||
bool looksLikeUrl(const QString& text) {
|
||||
const QString t = text.trimmed();
|
||||
if (t.isEmpty() || t.contains(QRegularExpression(QStringLiteral("\\s"))))
|
||||
return false;
|
||||
const QUrl u(t);
|
||||
return u.isValid() && (isHttp(u) || u.isLocalFile());
|
||||
}
|
||||
|
||||
QString sanitize(QString name) {
|
||||
name = QFileInfo(name).fileName();
|
||||
name.replace(
|
||||
QRegularExpression(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1f]")),
|
||||
QStringLiteral("_"));
|
||||
return name.left(120);
|
||||
}
|
||||
|
||||
QString fileNameFor(QNetworkReply* reply, const QUrl& url) {
|
||||
static const QRegularExpression cd(
|
||||
QStringLiteral("filename\\*?=(?:UTF-8'')?\"?([^\";]+)\"?"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
const auto m =
|
||||
cd.match(QString::fromUtf8(reply->rawHeader("Content-Disposition")));
|
||||
QString name = m.hasMatch()
|
||||
? QUrl::fromPercentEncoding(m.captured(1).toUtf8())
|
||||
: url.fileName();
|
||||
name = sanitize(name);
|
||||
return name.isEmpty() ? QStringLiteral("download") : name;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace ZShell::Services {
|
||||
|
||||
DropImporter::DropImporter(QObject* parent) : QObject(parent) {}
|
||||
|
||||
DropImporter::Decision DropImporter::decide(
|
||||
const QList<QUrl>& urls,
|
||||
const QString& text,
|
||||
const QString& html,
|
||||
bool hasRawImage) const {
|
||||
for (const QUrl& u : urls)
|
||||
if (u.isLocalFile()) return Attach;
|
||||
|
||||
const QString src = firstImgSrc(html);
|
||||
if (hasRawImage ||
|
||||
(!src.isEmpty() && (text.trimmed().isEmpty() || looksLikeUrl(text))))
|
||||
return Attach;
|
||||
|
||||
QMimeDatabase db;
|
||||
for (const QUrl& u : urls) {
|
||||
if (!isHttp(u) || QFileInfo(u.path()).suffix().isEmpty()) continue;
|
||||
const QMimeType mt = db.mimeTypeForUrl(u);
|
||||
if (!mt.isDefault() && !mt.inherits(QStringLiteral("text/html")))
|
||||
return Attach;
|
||||
}
|
||||
|
||||
if (!text.isEmpty() || !urls.isEmpty()) return InsertText;
|
||||
return Ignore;
|
||||
}
|
||||
|
||||
int DropImporter::importAttachments(
|
||||
const QList<QUrl>& urls, const QString& html, const QByteArray& rawImage) {
|
||||
const int id = ++m_nextId;
|
||||
m_requests[id].pending = 1;
|
||||
|
||||
if (!rawImage.isEmpty()) {
|
||||
saveBytes(id, rawImage);
|
||||
} else if (const QString src = firstImgSrc(html); !src.isEmpty()) {
|
||||
if (src.startsWith(QLatin1String("data:"), Qt::CaseInsensitive)) {
|
||||
const int comma = src.indexOf(QLatin1Char(','));
|
||||
if (comma < 0) {
|
||||
failWith(id, tr("Malformed data URI"));
|
||||
} else {
|
||||
const bool b64 = src.left(comma).contains(
|
||||
QLatin1String(";base64"), Qt::CaseInsensitive);
|
||||
const QString payload = src.mid(comma + 1);
|
||||
saveBytes(
|
||||
id,
|
||||
b64 ? QByteArray::fromBase64(payload.toLatin1())
|
||||
: QByteArray::fromPercentEncoding(payload.toUtf8()));
|
||||
}
|
||||
} else {
|
||||
const QUrl u(src);
|
||||
if (u.isLocalFile())
|
||||
addLocal(id, u);
|
||||
else if (isHttp(u))
|
||||
download(id, u);
|
||||
else
|
||||
failWith(id, tr("Unsupported image source"));
|
||||
}
|
||||
} else {
|
||||
for (const QUrl& u : urls) {
|
||||
if (u.isLocalFile())
|
||||
addLocal(id, u);
|
||||
else if (isHttp(u))
|
||||
download(id, u);
|
||||
else
|
||||
failWith(id, tr("Unsupported URL: %1").arg(u.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
QMetaObject::invokeMethod(
|
||||
this, [this, id] { finishUnit(id); }, Qt::QueuedConnection);
|
||||
return id;
|
||||
}
|
||||
|
||||
void DropImporter::addLocal(int id, const QUrl& url) {
|
||||
const QFileInfo fi(url.toLocalFile());
|
||||
if (!fi.exists())
|
||||
failWith(id, tr("File not found: %1").arg(fi.filePath()));
|
||||
else if (fi.isDir())
|
||||
failWith(id, tr("Folders can't be attached: %1").arg(fi.fileName()));
|
||||
else if (fi.size() > kMaxBytes)
|
||||
failWith(id, tr("File too large: %1").arg(fi.fileName()));
|
||||
else
|
||||
succeed(id, fi.absoluteFilePath());
|
||||
}
|
||||
|
||||
void DropImporter::saveBytes(int id, const QByteArray& bytes) {
|
||||
if (bytes.isEmpty() || bytes.size() > kMaxBytes) {
|
||||
failWith(id, tr("Empty or oversized data"));
|
||||
return;
|
||||
}
|
||||
const QString dir = newSubdir();
|
||||
if (dir.isEmpty()) {
|
||||
failWith(id, tr("No temp directory"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString suffix =
|
||||
QMimeDatabase().mimeTypeForData(bytes).preferredSuffix();
|
||||
const QString path =
|
||||
dir + QStringLiteral("/dropped") +
|
||||
(suffix.isEmpty() ? QString() : QLatin1Char('.') + suffix);
|
||||
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size()) {
|
||||
failWith(id, tr("Could not write temp file"));
|
||||
return;
|
||||
}
|
||||
f.close();
|
||||
succeed(id, path);
|
||||
}
|
||||
|
||||
void DropImporter::download(int id, const QUrl& url) {
|
||||
const QString dir = newSubdir();
|
||||
if (dir.isEmpty()) {
|
||||
failWith(id, tr("No temp directory"));
|
||||
return;
|
||||
}
|
||||
|
||||
++m_requests[id].pending;
|
||||
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(
|
||||
QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
QNetworkReply* reply = m_nam.get(req);
|
||||
auto* file = new QFile(dir + QStringLiteral("/download.part"), reply);
|
||||
|
||||
connect(reply, &QNetworkReply::readyRead, this, [reply, file] {
|
||||
if (!file->isOpen() && !file->open(QIODevice::WriteOnly)) {
|
||||
reply->abort();
|
||||
return;
|
||||
}
|
||||
file->write(reply->readAll());
|
||||
if (file->size() > kMaxBytes) reply->abort();
|
||||
});
|
||||
|
||||
connect(
|
||||
reply,
|
||||
&QNetworkReply::finished,
|
||||
this,
|
||||
[this, reply, file, id, dir, url] {
|
||||
reply->deleteLater();
|
||||
const bool tooBig = file->size() > kMaxBytes;
|
||||
const bool wrote = file->isOpen();
|
||||
file->close();
|
||||
|
||||
if (tooBig) {
|
||||
failWith(id, tr("Download too large: %1").arg(url.toString()));
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
failWith(
|
||||
id,
|
||||
tr("Download failed (%1): %2")
|
||||
.arg(url.toString(), reply->errorString()));
|
||||
} else if (!wrote) {
|
||||
failWith(id, tr("Empty response: %1").arg(url.toString()));
|
||||
} else {
|
||||
const QString name = fileNameFor(reply, url);
|
||||
QString finalPath = dir + QLatin1Char('/') + name;
|
||||
if (QFile::rename(file->fileName(), finalPath)) {
|
||||
if (QFileInfo(name).suffix().isEmpty()) {
|
||||
const QString sfx =
|
||||
QMimeDatabase()
|
||||
.mimeTypeForFile(
|
||||
finalPath, QMimeDatabase::MatchContent)
|
||||
.preferredSuffix();
|
||||
if (!sfx.isEmpty() &&
|
||||
QFile::rename(
|
||||
finalPath, finalPath + QLatin1Char('.') + sfx))
|
||||
finalPath += QLatin1Char('.') + sfx;
|
||||
}
|
||||
succeed(id, finalPath);
|
||||
} else {
|
||||
failWith(id, tr("Could not store download"));
|
||||
}
|
||||
}
|
||||
finishUnit(id);
|
||||
});
|
||||
}
|
||||
|
||||
void DropImporter::succeed(int id, const QString& path) {
|
||||
if (auto it = m_requests.find(id); it != m_requests.end())
|
||||
it->files.append(path);
|
||||
}
|
||||
|
||||
void DropImporter::failWith(int id, const QString& message) {
|
||||
if (auto it = m_requests.find(id); it != m_requests.end())
|
||||
it->errors.append(message);
|
||||
}
|
||||
|
||||
void DropImporter::finishUnit(int id) {
|
||||
auto it = m_requests.find(id);
|
||||
if (it == m_requests.end() || --it->pending > 0) return;
|
||||
const Request r = *it;
|
||||
m_requests.erase(it);
|
||||
emit importFinished(id, r.files, r.errors);
|
||||
}
|
||||
|
||||
QString DropImporter::newSubdir() {
|
||||
if (!m_tempDir.isValid()) return {};
|
||||
const QString p = m_tempDir.filePath(QString::number(++m_counter));
|
||||
return QDir().mkpath(p) ? p : QString();
|
||||
}
|
||||
|
||||
} // namespace ZShell::Services
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include <QHash>
|
||||
|
||||
#include <QList>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QStringList>
|
||||
#include <QTemporaryDir>
|
||||
#include <QUrl>
|
||||
|
||||
namespace ZShell::Services {
|
||||
|
||||
class DropImporter : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
|
||||
public:
|
||||
enum Decision { Ignore, InsertText, Attach };
|
||||
Q_ENUM(Decision)
|
||||
|
||||
explicit DropImporter(QObject* parent = nullptr);
|
||||
|
||||
Q_INVOKABLE Decision decide(
|
||||
const QList<QUrl>& urls,
|
||||
const QString& text,
|
||||
const QString& html,
|
||||
bool hasRawImage) const;
|
||||
|
||||
Q_INVOKABLE int importAttachments(
|
||||
const QList<QUrl>& urls,
|
||||
const QString& html,
|
||||
const QByteArray& rawImage);
|
||||
|
||||
signals:
|
||||
void importFinished(
|
||||
int id, const QStringList& paths, const QStringList& errors);
|
||||
|
||||
private:
|
||||
struct Request {
|
||||
int pending = 0;
|
||||
QStringList files;
|
||||
QStringList errors;
|
||||
};
|
||||
|
||||
void addLocal(int id, const QUrl& url);
|
||||
void download(int id, const QUrl& url);
|
||||
void saveBytes(int id, const QByteArray& bytes);
|
||||
void succeed(int id, const QString& path);
|
||||
void failWith(int id, const QString& message);
|
||||
void finishUnit(int id);
|
||||
QString newSubdir();
|
||||
|
||||
QNetworkAccessManager m_nam;
|
||||
QTemporaryDir m_tempDir;
|
||||
QHash<int, Request> m_requests;
|
||||
int m_nextId = 0;
|
||||
int m_counter = 0;
|
||||
};
|
||||
|
||||
} // namespace ZShell::Services
|
||||
Reference in New Issue
Block a user