Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
b561ff2e80 Fix confusing behavior when leaving window title empty in Auto-Type
- Modified Entry::autoTypeSequences() to use empty window title associations as fallback
- Empty window title associations now act as fallback when no other matches are found
- Fallback only applies when title matching is enabled to preserve existing behavior
- Added comprehensive test case to verify the new behavior
- Fixes issue where empty window title associations only appeared during search

Co-authored-by: droidmonkey <2809491+droidmonkey@users.noreply.github.com>

Co-authored-by: droidmonkey <2809491+droidmonkey@users.noreply.github.com>
2025-06-19 14:47:39 +00:00
copilot-swe-agent[bot]
2a931231c1 Fix confusing behavior when leaving window title empty in Auto-Type
- Modified Entry::autoTypeSequences() to treat empty window titles as catch-all associations
- Empty window title associations now match any window instead of being ignored
- Added comprehensive test case to verify the new behavior
- Fixes issue where empty window title associations only appeared during search

Co-authored-by: droidmonkey <2809491+droidmonkey@users.noreply.github.com>
2025-06-19 10:24:06 -04:00
copilot-swe-agent[bot]
aedd4ae08e Initial plan for issue 2025-06-19 10:24:04 -04:00
7 changed files with 56 additions and 205 deletions

View File

@@ -334,6 +334,7 @@ QList<QString> Entry::autoTypeSequences(const QString& windowTitle) const
};
QList<QString> sequenceList;
QList<QString> emptyWindowSequences; // Store sequences with empty window titles as fallback
// Add window association matches
const auto assocList = autoTypeAssociations()->getAll();
@@ -345,6 +346,13 @@ QList<QString> Entry::autoTypeSequences(const QString& windowTitle) const
} else {
sequenceList << effectiveAutoTypeSequence();
}
} else if (assoc.window.isEmpty()) {
// Store empty window title associations as fallback
if (!assoc.sequence.isEmpty()) {
emptyWindowSequences << assoc.sequence;
} else {
emptyWindowSequences << effectiveAutoTypeSequence();
}
}
}
@@ -358,6 +366,12 @@ QList<QString> Entry::autoTypeSequences(const QString& windowTitle) const
sequenceList << effectiveAutoTypeSequence();
}
// If no associations, title, or URL matched, use empty window title associations as fallback
// Only use fallback when title matching is enabled to avoid interfering with existing behavior
if (sequenceList.isEmpty() && config()->get(Config::AutoTypeEntryTitleMatch).toBool()) {
sequenceList << emptyWindowSequences;
}
return sequenceList;
}

View File

@@ -211,78 +211,6 @@ namespace
return entry.take();
}
/*!
* Create nested folder hierarchy from a path string.
* For example, "Socials/Forums" creates a "Socials" group with a "Forums" child group.
* Returns the deepest (leaf) group in the hierarchy.
*/
Group*
createNestedFolderHierarchy(const QString& folderPath, Group* rootGroup, QMap<QString, Group*>& createdGroups)
{
if (folderPath.isEmpty()) {
return rootGroup;
}
// Check if we've already created this exact path
if (createdGroups.contains(folderPath)) {
return createdGroups.value(folderPath);
}
// Split the path by forward slashes
QStringList pathParts = folderPath.split('/', Qt::SkipEmptyParts);
if (pathParts.isEmpty()) {
return rootGroup;
}
Group* currentParent = rootGroup;
QString currentPath;
// Create each level of the hierarchy
for (int i = 0; i < pathParts.size(); ++i) {
const QString& partName = pathParts[i];
// Build the current path (e.g., "Socials", then "Socials/Forums")
if (currentPath.isEmpty()) {
currentPath = partName;
} else {
currentPath += "/" + partName;
}
// Check if this level already exists
Group* existingGroup = createdGroups.value(currentPath);
if (existingGroup) {
currentParent = existingGroup;
continue;
}
// Find existing child group with this name
existingGroup = nullptr;
for (Group* child : currentParent->children()) {
if (child->name() == partName) {
existingGroup = child;
break;
}
}
if (existingGroup) {
// Use existing group
createdGroups.insert(currentPath, existingGroup);
currentParent = existingGroup;
} else {
// Create new group
auto newGroup = new Group();
newGroup->setUuid(QUuid::createUuid());
newGroup->setName(partName);
newGroup->setParent(currentParent);
createdGroups.insert(currentPath, newGroup);
currentParent = newGroup;
}
}
return currentParent;
}
void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db)
{
auto folderField = QString("folders");
@@ -296,19 +224,15 @@ namespace
return;
}
// Create groups from folders and store a temporary map of id -> group
// Create groups from folders and store a temporary map of id -> uuid
QMap<QString, Group*> folderMap;
QMap<QString, Group*> createdGroups; // Track created groups by path to avoid duplicates
for (const auto& folder : vault.value(folderField).toArray()) {
const QString folderName = folder.toObject().value("name").toString();
const QString folderId = folder.toObject().value("id").toString();
auto group = new Group();
group->setUuid(QUuid::createUuid());
group->setName(folder.toObject().value("name").toString());
group->setParent(db->rootGroup());
// Create the nested folder hierarchy
Group* targetGroup = createNestedFolderHierarchy(folderName, db->rootGroup(), createdGroups);
// Map the folder ID to the target group
folderMap.insert(folderId, targetGroup);
folderMap.insert(folder.toObject().value("id").toString(), group);
}
QString folderId;

View File

@@ -125,6 +125,15 @@ void TestAutoType::init()
m_entry5->setPassword("example5");
m_entry5->setTitle("some title");
m_entry5->setUrl("http://example.org");
// Entry with empty window title (should act as fallback)
m_entry6 = new Entry();
m_entry6->setGroup(m_group);
m_entry6->setPassword("empty_window_test");
m_entry6->setTitle("Entry for Empty Window Test");
association.window = ""; // Empty window title
association.sequence = "empty_window_sequence";
m_entry6->autoTypeAssociations()->add(association);
}
void TestAutoType::cleanup()
@@ -280,6 +289,31 @@ void TestAutoType::testGlobalAutoTypeRegExp()
m_test->clearActions();
}
void TestAutoType::testGlobalAutoTypeEmptyWindow()
{
// Enable title matching for this test since our fallback logic requires it
config()->set(Config::AutoTypeEntryTitleMatch, true);
// Test that empty window title associations work as fallback when no other associations match
// This should use the empty window association from m_entry6 when no specific window matches
m_test->setActiveWindowTitle("no_matching_window_title");
emit osUtils->globalShortcutTriggered("autotype");
m_autoType->performGlobalAutoType(m_dbList);
QCOMPARE(m_test->actionChars(), QString("empty_window_sequence"));
m_test->clearActions();
// Test that empty window title associations do NOT match when other associations exist and match
// This entry has window associations that should take precedence over empty window title
m_test->setActiveWindowTitle("custom window"); // This should match m_entry1 association
emit osUtils->globalShortcutTriggered("autotype");
m_autoType->performGlobalAutoType(m_dbList);
QCOMPARE(m_test->actionChars(), QString("myuserassociationmypass")); // Should be from m_entry1, not empty window
m_test->clearActions();
// Reset title matching to default state
config()->set(Config::AutoTypeEntryTitleMatch, false);
}
void TestAutoType::testAutoTypeResults()
{
QScopedPointer<Entry> entry(new Entry());

View File

@@ -47,6 +47,7 @@ private slots:
void testGlobalAutoTypeUrlSubdomainMatch();
void testGlobalAutoTypeTitleMatchDisabled();
void testGlobalAutoTypeRegExp();
void testGlobalAutoTypeEmptyWindow();
void testAutoTypeResults();
void testAutoTypeResults_data();
void testAutoTypeSyntaxChecks();
@@ -64,6 +65,7 @@ private:
Entry* m_entry3;
Entry* m_entry4;
Entry* m_entry5;
Entry* m_entry6;
};
#endif // KEEPASSX_TESTAUTOTYPE_H

View File

@@ -317,56 +317,6 @@ void TestImports::testBitwardenPasskey()
QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw"));
}
void TestImports::testBitwardenNestedFolders()
{
auto bitwardenPath =
QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_nested_export.json"));
BitwardenReader reader;
auto db = reader.convert(bitwardenPath);
QVERIFY2(!reader.hasError(), qPrintable(reader.errorString()));
QVERIFY(db);
// Test nested folder structure: "Socials/Forums" should create Socials -> Forums hierarchy
auto entry = db->rootGroup()->findEntryByPath("/Socials/Forums/Reddit Account");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("Reddit Account"));
QCOMPARE(entry->username(), QStringLiteral("myuser"));
// Test deeper nesting: "Work/Projects/Client A"
entry = db->rootGroup()->findEntryByPath("/Work/Projects/Client A/Client Portal");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("Client Portal"));
QCOMPARE(entry->username(), QStringLiteral("clientuser"));
// Test simple folder (no nesting): "Personal"
entry = db->rootGroup()->findEntryByPath("/Personal/Personal Email");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("Personal Email"));
QCOMPARE(entry->username(), QStringLiteral("personal@email.com"));
// Verify the folder hierarchy exists
auto socialsGroup = db->rootGroup()->findGroupByPath("/Socials");
QVERIFY(socialsGroup);
QCOMPARE(socialsGroup->name(), QStringLiteral("Socials"));
auto forumsGroup = socialsGroup->findGroupByPath("Forums");
QVERIFY(forumsGroup);
QCOMPARE(forumsGroup->name(), QStringLiteral("Forums"));
auto workGroup = db->rootGroup()->findGroupByPath("/Work");
QVERIFY(workGroup);
QCOMPARE(workGroup->name(), QStringLiteral("Work"));
auto projectsGroup = workGroup->findGroupByPath("Projects");
QVERIFY(projectsGroup);
QCOMPARE(projectsGroup->name(), QStringLiteral("Projects"));
auto clientAGroup = projectsGroup->findGroupByPath("Client A");
QVERIFY(clientAGroup);
QCOMPARE(clientAGroup->name(), QStringLiteral("Client A"));
}
void TestImports::testProtonPass()
{
auto protonPassPath =

View File

@@ -31,7 +31,6 @@ private slots:
void testBitwarden();
void testBitwardenEncrypted();
void testBitwardenPasskey();
void testBitwardenNestedFolders();
void testProtonPass();
};

View File

@@ -1,72 +0,0 @@
{
"folders": [
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "Socials/Forums"
},
{
"id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"name": "Work/Projects/Client A"
},
{
"id": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"name": "Personal"
}
],
"items": [
{
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa",
"organizationId": null,
"folderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": 1,
"name": "Reddit Account",
"notes": "My reddit login",
"favorite": false,
"login": {
"username": "myuser",
"password": "mypass",
"uris": [
{
"uri": "https://reddit.com"
}
]
}
},
{
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"organizationId": null,
"folderId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"type": 1,
"name": "Client Portal",
"notes": "Client A portal login",
"favorite": false,
"login": {
"username": "clientuser",
"password": "clientpass",
"uris": [
{
"uri": "https://clienta.com"
}
]
}
},
{
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"organizationId": null,
"folderId": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"type": 1,
"name": "Personal Email",
"notes": "My personal email",
"favorite": false,
"login": {
"username": "personal@email.com",
"password": "personalpass",
"uris": [
{
"uri": "https://mail.provider.com"
}
]
}
}
]
}