- 第三栏内容工厂:按拖入类型展示动作,动作按钮本身可接收 drop - 链接分类:http(s) 按扩展名判定图片/音视频/文档/压缩包/网页 - http 链接动作:打开、下载、另存为、复制、Markdown、复制文件名、待办、问小埃 - 非标准 scheme:尝试打开、复制;thunder 解析 base64 真实地址,magnet 解析 dn - 文字动作:ICU 分词、正则提取链接、朗读(spd-say)、复制、待办 - 文件/图片动作:打开、提取文字(占位)、生成描述、保存图片、复制路径、待办 - Downloader:异步下载到 XDG 下载目录 / 另存为,面板 toast 提示 - TodoModel:持久化到 todo.json,供「添加到待办」使用 - 无头自测 gxde-ai-see-selftest:链接分类/分词/提取链接/动作匹配 - 合成器 portal 自测脚本 tests/portal(虚拟指针 + GTK 源/目标)
242 lines
8.0 KiB
C++
242 lines
8.0 KiB
C++
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "linktools.h"
|
|
|
|
#include <QFileInfo>
|
|
#include <QRegularExpression>
|
|
#include <QUrlQuery>
|
|
|
|
namespace {
|
|
|
|
const QStringList kImageExtensions = {
|
|
QStringLiteral("jpg"), QStringLiteral("jpeg"), QStringLiteral("png"),
|
|
QStringLiteral("gif"), QStringLiteral("webp"), QStringLiteral("svg"),
|
|
QStringLiteral("bmp"), QStringLiteral("avif"), QStringLiteral("heic"),
|
|
QStringLiteral("ico"),
|
|
};
|
|
|
|
const QStringList kAudioExtensions = {
|
|
QStringLiteral("mp3"), QStringLiteral("flac"), QStringLiteral("wav"),
|
|
QStringLiteral("ogg"), QStringLiteral("oga"), QStringLiteral("m4a"),
|
|
QStringLiteral("aac"), QStringLiteral("opus"), QStringLiteral("wma"),
|
|
};
|
|
|
|
const QStringList kVideoExtensions = {
|
|
QStringLiteral("mp4"), QStringLiteral("mkv"), QStringLiteral("webm"),
|
|
QStringLiteral("avi"), QStringLiteral("mov"), QStringLiteral("flv"),
|
|
QStringLiteral("m4v"), QStringLiteral("ts"),
|
|
};
|
|
|
|
const QStringList kDocumentExtensions = {
|
|
QStringLiteral("pdf"), QStringLiteral("doc"), QStringLiteral("docx"),
|
|
QStringLiteral("xls"), QStringLiteral("xlsx"), QStringLiteral("ppt"),
|
|
QStringLiteral("pptx"), QStringLiteral("odt"), QStringLiteral("ods"),
|
|
QStringLiteral("txt"), QStringLiteral("md"), QStringLiteral("rtf"),
|
|
QStringLiteral("epub"),
|
|
};
|
|
|
|
const QStringList kArchiveExtensions = {
|
|
QStringLiteral("zip"), QStringLiteral("7z"), QStringLiteral("rar"),
|
|
QStringLiteral("tar"), QStringLiteral("gz"), QStringLiteral("bz2"),
|
|
QStringLiteral("xz"), QStringLiteral("zst"), QStringLiteral("deb"),
|
|
QStringLiteral("rpm"), QStringLiteral("apk"), QStringLiteral("iso"),
|
|
};
|
|
|
|
const QStringList kCodeExtensions = {
|
|
QStringLiteral("json"), QStringLiteral("xml"), QStringLiteral("html"),
|
|
QStringLiteral("htm"), QStringLiteral("js"), QStringLiteral("css"),
|
|
QStringLiteral("py"), QStringLiteral("c"), QStringLiteral("cpp"),
|
|
QStringLiteral("h"), QStringLiteral("sh"), QStringLiteral("go"),
|
|
QStringLiteral("rs"), QStringLiteral("java"),
|
|
};
|
|
|
|
QString extensionOfPath(const QString &path)
|
|
{
|
|
const QString suffix = QFileInfo(path).suffix().toLower();
|
|
return suffix;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
QString LinkInfo::kindLabel() const
|
|
{
|
|
switch (kind) {
|
|
case Http:
|
|
return fileType == QStringLiteral("page") ? QStringLiteral("网页链接")
|
|
: QStringLiteral("文件链接");
|
|
case Standard:
|
|
return QStringLiteral("链接");
|
|
case Custom:
|
|
return QStringLiteral("应用链接");
|
|
default:
|
|
return QString();
|
|
}
|
|
}
|
|
|
|
namespace LinkTools {
|
|
|
|
QString fileTypeForExtension(const QString &extension)
|
|
{
|
|
if (extension.isEmpty()) {
|
|
return QStringLiteral("page");
|
|
}
|
|
if (kImageExtensions.contains(extension)) {
|
|
return QStringLiteral("image");
|
|
}
|
|
if (kAudioExtensions.contains(extension)) {
|
|
return QStringLiteral("audio");
|
|
}
|
|
if (kVideoExtensions.contains(extension)) {
|
|
return QStringLiteral("video");
|
|
}
|
|
if (kDocumentExtensions.contains(extension)) {
|
|
return QStringLiteral("document");
|
|
}
|
|
if (kArchiveExtensions.contains(extension)) {
|
|
return QStringLiteral("archive");
|
|
}
|
|
if (kCodeExtensions.contains(extension)) {
|
|
return QStringLiteral("code");
|
|
}
|
|
return QStringLiteral("unknown");
|
|
}
|
|
|
|
QString decodeThunder(const QString &text)
|
|
{
|
|
static const QString prefix = QStringLiteral("thunder://");
|
|
if (!text.startsWith(prefix, Qt::CaseInsensitive)) {
|
|
return QString();
|
|
}
|
|
|
|
QString payload = text.mid(prefix.size()).trimmed();
|
|
payload.replace(QLatin1Char('-'), QLatin1Char('+'));
|
|
payload.replace(QLatin1Char('_'), QLatin1Char('/'));
|
|
|
|
QByteArray encoded = payload.toLatin1();
|
|
while (encoded.size() % 4 != 0) {
|
|
encoded.append('=');
|
|
}
|
|
|
|
const QByteArray decoded = QByteArray::fromBase64(encoded);
|
|
QString value = QString::fromUtf8(decoded);
|
|
if (value.startsWith(QStringLiteral("AA"))) {
|
|
value.remove(0, 2);
|
|
}
|
|
if (value.endsWith(QStringLiteral("ZZ"))) {
|
|
value.chop(2);
|
|
}
|
|
return value.trimmed();
|
|
}
|
|
|
|
QString displayNameFor(const QUrl &url)
|
|
{
|
|
if (url.scheme().compare(QStringLiteral("magnet"), Qt::CaseInsensitive) == 0) {
|
|
const QUrlQuery query(url);
|
|
const QString name = query.queryItemValue(QStringLiteral("dn"));
|
|
if (!name.isEmpty()) {
|
|
return name;
|
|
}
|
|
}
|
|
|
|
QString path = url.path();
|
|
const QString name = path.section(QLatin1Char('/'), -1, -1, QString::SectionSkipEmpty);
|
|
if (!name.isEmpty()) {
|
|
return name;
|
|
}
|
|
return url.host();
|
|
}
|
|
|
|
LinkInfo parseUrl(const QUrl &url)
|
|
{
|
|
LinkInfo info;
|
|
if (!url.isValid() || url.scheme().isEmpty()) {
|
|
return info;
|
|
}
|
|
|
|
info.url = url;
|
|
info.scheme = url.scheme().toLower();
|
|
info.displayName = displayNameFor(url);
|
|
|
|
if (info.scheme == QStringLiteral("http") || info.scheme == QStringLiteral("https")) {
|
|
info.kind = LinkInfo::Http;
|
|
info.extension = extensionOfPath(url.path());
|
|
info.fileType = fileTypeForExtension(info.extension);
|
|
info.looksLikeFile = info.fileType != QStringLiteral("page") &&
|
|
info.fileType != QStringLiteral("unknown");
|
|
} else if (info.scheme == QStringLiteral("thunder")) {
|
|
info.kind = LinkInfo::Custom;
|
|
info.decodedUrl = decodeThunder(url.toString());
|
|
if (!info.decodedUrl.isEmpty()) {
|
|
const QUrl real(info.decodedUrl);
|
|
info.extension = extensionOfPath(real.path());
|
|
info.fileType = fileTypeForExtension(info.extension);
|
|
info.displayName = displayNameFor(real);
|
|
}
|
|
} else if (info.scheme == QStringLiteral("magnet") || info.scheme == QStringLiteral("ed2k") ||
|
|
info.scheme == QStringLiteral("spk") ||
|
|
info.scheme == QStringLiteral("x-scheme-handler")) {
|
|
info.kind = LinkInfo::Custom;
|
|
} else if (info.scheme == QStringLiteral("ftp") || info.scheme == QStringLiteral("mailto") ||
|
|
info.scheme == QStringLiteral("tel") || info.scheme == QStringLiteral("file")) {
|
|
info.kind = LinkInfo::Standard;
|
|
} else {
|
|
info.kind = LinkInfo::Custom;
|
|
}
|
|
|
|
return info;
|
|
}
|
|
|
|
LinkInfo parse(const QString &text)
|
|
{
|
|
const QString trimmed = text.trimmed();
|
|
if (trimmed.isEmpty() || trimmed.contains(QLatin1Char('\n'))) {
|
|
return LinkInfo();
|
|
}
|
|
|
|
if (trimmed.startsWith(QStringLiteral("www."), Qt::CaseInsensitive)) {
|
|
return parseUrl(QUrl(QStringLiteral("https://") + trimmed));
|
|
}
|
|
|
|
/* QUrl lowercases the host part, which destroys base64 payloads such as
|
|
* thunder://. Handle these schemes on the raw string. */
|
|
if (trimmed.startsWith(QStringLiteral("thunder://"), Qt::CaseInsensitive)) {
|
|
LinkInfo info;
|
|
info.kind = LinkInfo::Custom;
|
|
info.raw = trimmed;
|
|
info.scheme = QStringLiteral("thunder");
|
|
info.url = QUrl(trimmed);
|
|
info.decodedUrl = decodeThunder(trimmed);
|
|
if (!info.decodedUrl.isEmpty()) {
|
|
const QUrl real(info.decodedUrl);
|
|
info.extension = extensionOfPath(real.path());
|
|
info.fileType = fileTypeForExtension(info.extension);
|
|
info.displayName = displayNameFor(real);
|
|
}
|
|
return info;
|
|
}
|
|
|
|
static const QRegularExpression schemePattern(
|
|
QStringLiteral("^[A-Za-z][A-Za-z0-9+.-]*://?"), QRegularExpression::CaseInsensitiveOption);
|
|
if (!schemePattern.match(trimmed).hasMatch() &&
|
|
!trimmed.startsWith(QStringLiteral("magnet:"), Qt::CaseInsensitive) &&
|
|
!trimmed.startsWith(QStringLiteral("mailto:"), Qt::CaseInsensitive) &&
|
|
!trimmed.startsWith(QStringLiteral("tel:"), Qt::CaseInsensitive)) {
|
|
return LinkInfo();
|
|
}
|
|
|
|
LinkInfo info = parseUrl(QUrl(trimmed));
|
|
if (info.isValid() && info.raw.isEmpty()) {
|
|
info.raw = trimmed;
|
|
}
|
|
return info;
|
|
}
|
|
|
|
bool isHttpLink(const QString &text)
|
|
{
|
|
const LinkInfo info = parse(text);
|
|
return info.kind == LinkInfo::Http;
|
|
}
|
|
|
|
} // namespace LinkTools
|