From c5bb77ece352f164fb020d2f3fbe361b1b1ee80a Mon Sep 17 00:00:00 2001 From: Zhenyu FU Date: Wed, 21 Oct 2026 07:52:26 +0800 Subject: [PATCH] feat: search applications by GenericName and Keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Forward the shared application model's Keywords role and its updates. 2. Match the existing scalar GenericName and selected Keywords without reading desktop files or collecting additional locale variants. 3. Add search regression coverage for generic names, keywords and unrelated locale values. Log: Find applications by their generic names and desktop entry keywords Influence: GenericName participates in search for all vendors; existing name matching and search ranking priorities are retained. feat: 支持通过 GenericName 和 Keywords 搜索应用 1. 转发共享应用模型的 Keywords 角色及其更新通知。 2. 直接匹配既有单数 GenericName 和选中的 Keywords,不另行读取桌面文件, 也不收集其他 locale 的值。 3. 补充通用名称、关键词及无关 locale 值的搜索回归测试。 Log: 支持通过通用名称和桌面文件关键词查找应用 Influence: GenericName 参与所有厂商应用的搜索;保留既有名称匹配逻辑及 原有匹配规则的相对排序优先级。 --- src/models/appsmodel.cpp | 5 +++ src/models/appsmodel.h | 1 + src/models/searchfilterproxymodel.cpp | 50 ++++++++++++++++++++++++++ tests/searchfilterproxymodeltest.cpp | 52 +++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/models/appsmodel.cpp b/src/models/appsmodel.cpp index f2086062..2abfd761 100644 --- a/src/models/appsmodel.cpp +++ b/src/models/appsmodel.cpp @@ -33,6 +33,7 @@ constexpr auto LaunchedTimesRoleName = "launchedTimes"; constexpr auto AutoStartRoleName = "autoStart"; constexpr auto VendorRoleName = "vendor"; constexpr auto GenericNameRoleName = "genericName"; +constexpr auto KeywordsRoleName = "keywords"; } AppsModel::AppsModel(QObject *parent) @@ -194,6 +195,8 @@ QVariant AppsModel::data(const QModelIndex &index, int role) const return sourceData(sourceIndex, VendorRoleName); case AppsModel::GenericNameRole: return sourceData(sourceIndex, GenericNameRoleName); + case AppsModel::KeywordsRole: + return sourceData(sourceIndex, KeywordsRoleName); case AppsModel::TransliteratedRole: { const auto decodedDisplay = Dtk::Core::pinyin(data(index, Qt::DisplayRole).toString(), Dtk::Core::TS_NoneTone); if (decodedDisplay.isEmpty()) @@ -230,6 +233,7 @@ QHash AppsModel::roleNames() const { AppsModel::IsAutoStartRole, QByteArrayLiteral("autoStart") }, { AppsModel::VendorRole, QByteArrayLiteral("vendor") }, { AppsModel::GenericNameRole, QByteArrayLiteral("genericName") }, + { AppsModel::KeywordsRole, QByteArrayLiteral("keywords") }, { AppsModel::TransliteratedRole, QByteArrayLiteral("transliterated") }, { AppsModel::AllTransliteratedRole, QByteArrayLiteral("allTransliterated") }, }; @@ -279,6 +283,7 @@ QList AppsModel::mappedRoles(const QList &sourceRoles) const appendIfChanged(AppsModel::IsAutoStartRole, AutoStartRoleName); appendIfChanged(AppsModel::VendorRole, VendorRoleName); appendIfChanged(AppsModel::GenericNameRole, GenericNameRoleName); + appendIfChanged(AppsModel::KeywordsRole, KeywordsRoleName); if (sourceRoles.contains(sourceRole(NameRoleName))) { result << Qt::DisplayRole << AppsModel::NameRole << AppsModel::TransliteratedRole << AppsModel::AllTransliteratedRole; diff --git a/src/models/appsmodel.h b/src/models/appsmodel.h index aa44e377..9cd46cf8 100644 --- a/src/models/appsmodel.h +++ b/src/models/appsmodel.h @@ -39,6 +39,7 @@ class AppsModel : public QAbstractListModel IsAutoStartRole, VendorRole, GenericNameRole, + KeywordsRole, // Forward shell's selected Keywords so search needs no desktop-file reader. TransliteratedRole = 0x1000, AllTransliteratedRole, ProxyModelExtendedRole = 0x10000 diff --git a/src/models/searchfilterproxymodel.cpp b/src/models/searchfilterproxymodel.cpp index 548e17b6..5f32c062 100644 --- a/src/models/searchfilterproxymodel.cpp +++ b/src/models/searchfilterproxymodel.cpp @@ -89,6 +89,7 @@ int SearchFilterProxyModel::calculateWeight(const QModelIndex &modelIndex) const const QString & name = modelIndex.data(AppsModel::NameRole).toString(); const QString & vendor = modelIndex.data(AppsModel::VendorRole).toString(); const QString & genericName = modelIndex.data(AppsModel::GenericNameRole).toString(); + const QStringList keywords = modelIndex.data(AppsModel::KeywordsRole).toStringList(); const QString & transliterated = modelIndex.data(AppsModel::AllTransliteratedRole).toString(); const QString & jianpin = Dtk::Core::firstLetters(displayName, TS_NoneTone).join(','); @@ -127,6 +128,13 @@ int SearchFilterProxyModel::calculateWeight(const QModelIndex &modelIndex) const QString searchPatternLower = searchPatternDelBlank.toLower(); QString displayNameLower = displayName.toLower().remove(" "); QString targetNameLower = targetName.toLower().remove(" "); + const QString genericNameLower = genericName.toLower().remove(" "); + QStringList keywordsLower; + for (const QString &keyword : keywords) { + const QString normalized = keyword.toLower().remove(" "); + if (!normalized.isEmpty() && !keywordsLower.contains(normalized)) + keywordsLower.append(normalized); + } QString transliteratedLower = transliterated.toLower(); QString jianpinLower = jianpin.toLower(); QString nameFirstLettersLower = nameFirstLetters.toLower(); @@ -144,6 +152,11 @@ int SearchFilterProxyModel::calculateWeight(const QModelIndex &modelIndex) const return (targetNameLower == searchPatternLower); })); + // GenericName 是独立于 Name 的检索字段,对所有应用都参与匹配 + matchTypes.push_back(qMakePair(QString("genericName_exact"), [&]() -> bool { + return !genericNameLower.isEmpty() && genericNameLower == searchPatternLower; + })); + // 中文拼音匹配 matchTypes.push_back(qMakePair(QString("transliterated_start"), [&]() -> bool { return transliteratedLower.startsWith(searchPatternLower); @@ -205,6 +218,15 @@ int SearchFilterProxyModel::calculateWeight(const QModelIndex &modelIndex) const return false; })); + matchTypes.push_back(qMakePair(QString("genericName_word_start"), [&]() -> bool { + const QStringList words = genericName.split(" ", Qt::SkipEmptyParts); + for (const QString &word : words) { + if (word.toLower().startsWith(searchPatternLower)) + return true; + } + return false; + })); + matchTypes.push_back(qMakePair(QString("displayName_middle"), [&]() -> bool { if (displayNameLower.contains(searchPatternLower)) { QStringList displayWords = displayName.split(" ", Qt::SkipEmptyParts); @@ -238,6 +260,10 @@ int SearchFilterProxyModel::calculateWeight(const QModelIndex &modelIndex) const return (targetNameLower.contains(searchPatternLower)); })); + matchTypes.push_back(qMakePair(QString("genericName_middle"), [&]() -> bool { + return !genericNameLower.isEmpty() && genericNameLower.contains(searchPatternLower); + })); + matchTypes.push_back(qMakePair(QString("transliterated_middle"), [&]() -> bool { return transliteratedLower.contains(searchPatternLower); })); @@ -251,6 +277,30 @@ int SearchFilterProxyModel::calculateWeight(const QModelIndex &modelIndex) const return jianpinNormalized.contains(searchPatternLower); })); + // Keywords 是规范中用于辅助搜索的字段,优先级低于名称类匹配 + matchTypes.push_back(qMakePair(QString("keyword_exact"), [&]() -> bool { + return keywordsLower.contains(searchPatternLower); + })); + + matchTypes.push_back(qMakePair(QString("keyword_word_start"), [&]() -> bool { + for (const QString &keyword : keywords) { + const QStringList words = keyword.split(" ", Qt::SkipEmptyParts); + for (const QString &word : words) { + if (word.toLower().startsWith(searchPatternLower)) + return true; + } + } + return false; + })); + + matchTypes.push_back(qMakePair(QString("keyword_middle"), [&]() -> bool { + for (const QString &keywordLower : keywordsLower) { + if (keywordLower.contains(searchPatternLower)) + return true; + } + return false; + })); + // 英文搜索特殊情况处理 auto getCapitalizedWords = [&]() -> QString { QRegularExpression capitalizedWordRegex("\\b[A-Z][A-Za-z0-9]*"); diff --git a/tests/searchfilterproxymodeltest.cpp b/tests/searchfilterproxymodeltest.cpp index 06fa5695..e45f3f9e 100644 --- a/tests/searchfilterproxymodeltest.cpp +++ b/tests/searchfilterproxymodeltest.cpp @@ -30,6 +30,7 @@ enum SourceRoles { SourceCategoriesRole, SourceVendorRole, SourceGenericNameRole, + SourceKeywordsRole, }; } @@ -47,6 +48,7 @@ private slots: void testJianpinSearch(); void testSorting(); void testSpecialCharacters(); + void testGenericNameAndKeywords(); void testSharedModelAdapter(); void testCategorySortSignalOrder(); @@ -57,7 +59,8 @@ private slots: const QString &displayName, const QString &genericName = QString(), const QString &vendor = QString(), - int launchedTimes = 0); + int launchedTimes = 0, + const QStringList &keywords = {}); QStandardItemModel m_sourceModel; }; @@ -94,6 +97,7 @@ void TestSearchFilterProxyModel::setupTestData() { SourceCategoriesRole, QByteArrayLiteral("categories") }, { SourceVendorRole, QByteArrayLiteral("vendor") }, { SourceGenericNameRole, QByteArrayLiteral("genericName") }, + { SourceKeywordsRole, QByteArrayLiteral("keywords") }, }); // 创建测试数据 @@ -112,7 +116,17 @@ void TestSearchFilterProxyModel::setupTestData() testItems.append(createTestAppItem("org.deepin.reader", "阅读器", "文档阅读器", "阅读", "deepin", 7)); testItems.append(createTestAppItem("org.deepin.calendar", "日历", "日历", "日历", "deepin", 4)); testItems.append(createTestAppItem("org.deepin.defender", "安全中心", "安全中心", "安全中心", "deepin", 4)); - + + // GenericName 与 Keywords 的检索样本 + testItems.append(createTestAppItem("org.gnome.eog", + "Image Viewer", + "Image Viewer", + QString::fromUtf8("图像查看器"), + "gnome", + 0, + { QString::fromUtf8("图片"), + QString::fromUtf8("幻灯片") })); + // 包含特殊符号的应用 testItems.append(createTestAppItem("org.special.app1", "App@Name", "App@Name", "", "special", 1)); testItems.append(createTestAppItem("org.special.app2", "App-Name", "App-Name", "", "special", 2)); @@ -142,7 +156,8 @@ QStandardItem *TestSearchFilterProxyModel::createTestAppItem(const QString &desk const QString &displayName, const QString &genericName, const QString &vendor, - int launchedTimes) + int launchedTimes, + const QStringList &keywords) { qCDebug(logTest) << "Creating test app item:" << desktopId << "name:" << name << "launched times:" << launchedTimes; auto item = new QStandardItem; @@ -150,6 +165,7 @@ QStandardItem *TestSearchFilterProxyModel::createTestAppItem(const QString &desk item->setData(displayName.isEmpty() ? name : displayName, SourceNameRole); item->setData(QStringLiteral("application-default-icon"), SourceIconNameRole); item->setData(genericName, SourceGenericNameRole); + item->setData(keywords, SourceKeywordsRole); item->setData(vendor, SourceVendorRole); item->setData(launchedTimes, SourceLaunchedTimesRole); item->setData(launchedTimes % 11, SourceDDECategoryRole); @@ -362,6 +378,36 @@ void TestSearchFilterProxyModel::testSpecialCharacters() qCInfo(logTest) << "Special characters search tests completed successfully"; } +void TestSearchFilterProxyModel::testGenericNameAndKeywords() +{ + qCInfo(logTest) << "Starting GenericName and Keywords search tests"; + auto &model = SearchFilterProxyModel::instance(); + + const auto search = [&model](const QString &query) { + model.setFilterRegularExpression(QRegularExpression(query)); + return model.rowCount(); + }; + + QCOMPARE(search(QStringLiteral("Slideshow")), 0); + QCOMPARE(search(QStringLiteral("Picture")), 0); + QCOMPARE(search(QStringLiteral("DefaultOnlyGeneric")), 0); + QCOMPARE(search(QStringLiteral("Image Viewer")), 1); // Existing Name search is unchanged. + QCOMPARE(search(QString::fromUtf8("幻灯片")), 1); + QCOMPARE(search(QString::fromUtf8("图像查看器")), 1); + QCOMPARE(search(QString::fromUtf8("查看器")), 1); // Scalar GenericName substring. + QCOMPARE(search(QStringLiteral("Bildwerkzeug")), 0); + + const QModelIndex index = AppsModel::instance().indexFromDesktopId("org.gnome.eog"); + QVERIFY(index.isValid()); + QCOMPARE(index.data(AppsModel::KeywordsRole).toStringList(), + QStringList({ QString::fromUtf8("图片"), + QString::fromUtf8("幻灯片") })); + QCOMPARE(index.data(AppsModel::GenericNameRole).toString(), + QString::fromUtf8("图像查看器")); + + qCInfo(logTest) << "GenericName and Keywords search tests completed successfully"; +} + void TestSearchFilterProxyModel::testSharedModelAdapter() { auto &appsModel = AppsModel::instance();