Files
gxde-ai-see/src/factory/linktools.cpp
T
shenmo7192 3a73a8c5d1 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 源/目标)
2026-10-30 20:09:01 +08:00

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