feat(factory): 内容工厂与链接处理
- 第三栏内容工厂:按拖入类型展示动作,动作按钮本身可接收 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 源/目标)
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
// 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
|
||||
Reference in New Issue
Block a user