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:
2026-10-30 20:09:01 +08:00
parent 68d3eb1a24
commit 3a73a8c5d1
26 changed files with 2822 additions and 6 deletions
+128
View File
@@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "contentclassifier.h"
#include <QFileInfo>
#include <QImageReader>
#include <QMimeData>
#include <QUrl>
namespace {
QString extensionOfFile(const QString &path)
{
return QFileInfo(path).suffix().toLower();
}
} // namespace
QString DropContent::summary() const
{
switch (kind) {
case Files: {
if (filePaths.size() == 1) {
const QFileInfo info(filePaths.first());
return QStringLiteral("文件 · %1").arg(info.fileName());
}
return QStringLiteral("%1 个文件").arg(filePaths.size());
}
case Image:
return QStringLiteral("图片");
case Text:
return QStringLiteral("文字 · %1 字").arg(text.size());
case Link:
case CustomLink: {
const QString label = link.fileType != QStringLiteral("page") && !link.fileType.isEmpty()
? link.fileType
: link.kindLabel();
return link.displayName.isEmpty() ? label
: QStringLiteral("%1 · %2").arg(label, link.displayName);
}
default:
return QString();
}
}
namespace ContentClassifier {
DropContent classifyText(const QString &text)
{
DropContent content;
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) {
return content;
}
const LinkInfo info = LinkTools::parse(trimmed);
if (info.isValid()) {
content.kind = info.kind == LinkInfo::Http ? DropContent::Link : DropContent::CustomLink;
content.link = info;
content.text = trimmed;
return content;
}
content.kind = DropContent::Text;
content.text = text;
return content;
}
DropContent classify(const QMimeData *mime)
{
DropContent content;
if (!mime) {
return content;
}
content.mimes = mime->formats();
if (mime->hasUrls()) {
QStringList localFiles;
LinkInfo link;
for (const QUrl &url : mime->urls()) {
if (url.isLocalFile()) {
localFiles.append(url.toLocalFile());
} else if (!link.isValid()) {
link = LinkTools::parseUrl(url);
}
}
if (!localFiles.isEmpty()) {
content.kind = DropContent::Files;
content.filePaths = localFiles;
if (localFiles.size() == 1) {
const QString suffix = extensionOfFile(localFiles.first());
if (LinkTools::fileTypeForExtension(suffix) == QStringLiteral("image") &&
QImageReader(localFiles.first()).canRead()) {
content.kind = DropContent::Image;
content.image = QImage(localFiles.first());
}
}
return content;
}
if (link.isValid()) {
content.kind =
link.kind == LinkInfo::Http ? DropContent::Link : DropContent::CustomLink;
content.link = link;
content.text = link.url.toString();
return content;
}
}
if (mime->hasImage()) {
content.image = qvariant_cast<QImage>(mime->imageData());
if (!content.image.isNull()) {
content.kind = DropContent::Image;
return content;
}
}
if (mime->hasText()) {
return classifyText(mime->text());
}
return content;
}
} // namespace ContentClassifier