diff --git a/khard/query.py b/khard/query.py index 97fe207..cebf55e 100644 --- a/khard/query.py +++ b/khard/query.py @@ -167,7 +167,7 @@ def __str__(self) -> str: class AndQuery(Query): - """A query to combine multiple queries with "and".""" + """A query to combine multiple queries with \"and\".""" def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) @@ -198,7 +198,7 @@ def __str__(self) -> str: class OrQuery(Query): - """A query to combine multiple queries with "or".""" + """A query to combine multiple queries with \"or\".""" def __init__(self, first: Query, second: Query, *queries: Query) -> None: self._queries = (first, second, *queries) @@ -254,6 +254,34 @@ def __str__(self) -> str: return 'name:{}'.format(self._term) +class UidQuery(FieldQuery): + + """A query to match the UID field using prefix matching. + + khard list shows a short unique UID prefix for each contact. This + query matches only contacts whose UID starts with the given term so + that the displayed prefix selects exactly the expected contact. + """ + + def __init__(self, value: str) -> None: + super().__init__("uid", value) + + def _match_union(self, value: "str | datetime | list | dict[str, Any]" + ) -> bool: + if isinstance(value, str): + return value.lower().startswith(self._term) + return super()._match_union(value) + + def __eq__(self, other: object) -> bool: + return isinstance(other, UidQuery) and self._term == other._term + + def __hash__(self) -> int: + return hash((UidQuery, self._term)) + + def __str__(self) -> str: + return 'uid:{}'.format(self._term) + + class PhoneNumberQuery(FieldQuery): """A special query to match against phone numbers.""" @@ -353,6 +381,8 @@ def parse(string: str) -> TermQuery | FieldQuery: if kind.startswith(term.lower()): return FieldQuery(field, kind) return TermQuery(string) + if field == "uid": + return UidQuery(term) if field in contacts.Contact.get_properties(): return FieldQuery(field, term) return TermQuery(string) diff --git a/test/test_query.py b/test/test_query.py index 6b0133e..2f434d4 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -8,6 +8,7 @@ NullQuery, OrQuery, TermQuery, + UidQuery, parse, ) @@ -203,6 +204,52 @@ def test_kind_query_with_explicit_mismatch(self): self.assertFalse(query.match(contact)) +class TestUidQuery(unittest.TestCase): + def setUp(self): + self.uid_a = "aaabbbb" + self.uid_b = "bbbaaaa" + self.contact_a = TestContact(uid=self.uid_a) + self.contact_b = TestContact(uid=self.uid_b) + + def test_matches_uid_by_prefix(self): + q = UidQuery("a") + self.assertTrue(q.match(self.contact_a)) + + def test_does_not_match_uid_with_term_as_substring_only(self): + # "a" is a substring of "bbbaaaa" but not a prefix + q = UidQuery("a") + self.assertFalse(q.match(self.contact_b)) + + def test_matches_full_uid(self): + q = UidQuery(self.uid_a) + self.assertTrue(q.match(self.contact_a)) + self.assertFalse(q.match(self.contact_b)) + + def test_matching_is_case_insensitive(self): + q = UidQuery("AAA") + self.assertTrue(q.match(self.contact_a)) + self.assertFalse(q.match(self.contact_b)) + + def test_empty_term_matches_any_contact_with_uid_set(self): + q = UidQuery("") + self.assertTrue(q.match(self.contact_a)) + self.assertTrue(q.match(self.contact_b)) + + def test_empty_term_does_not_match_contact_without_uid(self): + q = UidQuery("") + self.assertFalse(q.match(TestContact())) + + def test_shared_prefix_can_be_disambiguated(self): + uid_a = "aaabbbb" + uid_a2 = "aaacccc" + contact_a = TestContact(uid=uid_a) + contact_a2 = TestContact(uid=uid_a2) + self.assertTrue(UidQuery("aaab").match(contact_a)) + self.assertFalse(UidQuery("aaab").match(contact_a2)) + self.assertFalse(UidQuery("aaac").match(contact_a)) + self.assertTrue(UidQuery("aaac").match(contact_a2)) + + class TestNameQuery(unittest.TestCase): def test_matches_formatted_name_field(self): vcard = load_contact("minimal.vcf") @@ -271,3 +318,9 @@ def test_kind_queries_only_need_a_substring_of_the_enum(self): self.assertEqual(parse("kind:i"), FieldQuery("kind", "individual")) self.assertEqual(parse("kind:org"), FieldQuery("kind", "org")) self.assertEqual(parse("kind:o"), FieldQuery("kind", "org")) + + def test_uid_field_creates_uid_query_instance(self): + actual = parse("uid:abc123") + expected = UidQuery("abc123") + self.assertEqual(actual, expected) + self.assertEqual(type(actual), UidQuery)