Clean up search folder implementation

Move SearchFolder and search EmailIdentifier implementation out of
ImapDb and into its own package. Decouple both from ImapDB, and improve
the implementation, fixing a few inefficiencies. Merge search
FolderProperties into the SearchFoldern implementation as an inner
class.

Merge SearchTerm into ImapDB.SearchQuery as an inner class and move the
outer class's source down a level, since it was the only file left in
the imap-db/search dir.
This commit is contained in:
Michael Gratton 2019-12-10 20:51:20 +11:00 committed by Michael James Gratton
parent b3488f6cf9
commit 924104c282
13 changed files with 778 additions and 617 deletions

View file

@ -229,11 +229,7 @@ src/engine/imap-db/imap-db-email-identifier.vala
src/engine/imap-db/imap-db-folder.vala
src/engine/imap-db/imap-db-gc.vala
src/engine/imap-db/imap-db-message-row.vala
src/engine/imap-db/search/imap-db-search-email-identifier.vala
src/engine/imap-db/search/imap-db-search-folder-properties.vala
src/engine/imap-db/search/imap-db-search-folder.vala
src/engine/imap-db/search/imap-db-search-query.vala
src/engine/imap-db/search/imap-db-search-term.vala
src/engine/imap-db/imap-db-search-query.vala
src/engine/imap-engine/gmail/imap-engine-gmail-account.vala
src/engine/imap-engine/gmail/imap-engine-gmail-all-mail-folder.vala
src/engine/imap-engine/gmail/imap-engine-gmail-drafts-folder.vala
@ -365,6 +361,8 @@ src/engine/rfc822/rfc822-message.vala
src/engine/rfc822/rfc822-part.vala
src/engine/rfc822/rfc822-utils.vala
src/engine/rfc822/rfc822.vala
src/engine/search/search-email-identifier.vala
src/engine/search/search-folder-impl.vala
src/engine/smtp/smtp-authenticator.vala
src/engine/smtp/smtp-capabilities.vala
src/engine/smtp/smtp-client-connection.vala

View file

@ -575,7 +575,7 @@ private class Geary.ImapDB.Account : BaseObject {
foreach (string? field in query.get_fields()) {
debug(" - Field \"%s\" terms:", field);
foreach (SearchTerm? term in query.get_search_terms(field)) {
foreach (SearchQuery.Term? term in query.get_search_terms(field)) {
if (term != null) {
debug(" - \"%s\": %s, %s",
term.original,
@ -613,7 +613,7 @@ private class Geary.ImapDB.Account : BaseObject {
// <http://redmine.yorba.org/issues/7372>.
StringBuilder sql = new StringBuilder();
sql.append("""
SELECT id, internaldate_time_t
SELECT id
FROM MessageTable
INDEXED BY MessageTableInternalDateTimeTIndex
""");
@ -650,11 +650,7 @@ private class Geary.ImapDB.Account : BaseObject {
Db.Result result = stmt.exec(cancellable);
while (!result.finished) {
int64 message_id = result.int64_at(0);
int64 internaldate_time_t = result.int64_at(1);
DateTime? internaldate = (internaldate_time_t == -1
? null : new DateTime.from_unix_local(internaldate_time_t));
ImapDB.EmailIdentifier id = new ImapDB.SearchEmailIdentifier(message_id, internaldate);
var id = new ImapDB.EmailIdentifier(message_id, null);
matching_ids.add(id);
id_map.set(message_id, id);
@ -739,7 +735,7 @@ private class Geary.ImapDB.Account : BaseObject {
Gee.Set<string>? result = results.get(id);
if (result != null) {
foreach (string match in result) {
foreach (SearchTerm term in query.get_all_terms()) {
foreach (SearchQuery.Term term in query.get_all_terms()) {
// if prefix-matches parsed term, then don't strip
if (match.has_prefix(term.parsed)) {
good_match_found = true;

View file

@ -43,6 +43,63 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
private const string SEARCH_OP_VALUE_UNREAD = "unread";
/**
* Various associated state with a single term in a search query.
*/
internal class Term : GLib.Object {
/**
* The original tokenized search term with minimal other processing performed.
*
* For example, punctuation might be removed, but no casefolding has occurred.
*/
public string original { get; private set; }
/**
* The parsed tokenized search term.
*
* Casefolding and other normalizing text operations have been performed.
*/
public string parsed { get; private set; }
/**
* The stemmed search term.
*
* Only used if stemming is being done ''and'' the stem is different than the {@link parsed}
* term.
*/
public string? stemmed { get; private set; }
/**
* A list of terms ready for binding to an SQLite statement.
*
* This should include prefix operators and quotes (i.e. ["party"] or [party*]). These texts
* are guaranteed not to be null or empty strings.
*/
public Gee.List<string> sql { get; private set; default = new Gee.ArrayList<string>(); }
/**
* Returns true if the {@link parsed} term is exact-match only (i.e. starts with quotes) and
* there is no {@link stemmed} variant.
*/
public bool is_exact { get { return parsed.has_prefix("\"") && stemmed == null; } }
public Term(string original, string parsed, string? stemmed, string? sql_parsed, string? sql_stemmed) {
this.original = original;
this.parsed = parsed;
this.stemmed = stemmed;
// for now, only two variations: the parsed string and the stemmed; since stem is usually
// shorter (and will be first in the OR statement), include it first
if (!String.is_empty(sql_stemmed))
sql.add(sql_stemmed);
if (!String.is_empty(sql_parsed))
sql.add(sql_parsed);
}
}
// Maps of localised search operator names and values to their
// internal forms
private static Gee.HashMap<string, string> search_op_names =
@ -255,11 +312,11 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
// their search term values. Note that terms without an operator
// are stored with null as the key. Not using a MultiMap because
// we (might) need a guarantee of order.
private Gee.HashMap<string?, Gee.ArrayList<SearchTerm>> field_map
= new Gee.HashMap<string?, Gee.ArrayList<SearchTerm>>();
private Gee.HashMap<string?, Gee.ArrayList<Term>> field_map
= new Gee.HashMap<string?, Gee.ArrayList<Term>>();
// A list of all search terms, regardless of search op field name
private Gee.ArrayList<SearchTerm> all = new Gee.ArrayList<SearchTerm>();
private Gee.ArrayList<Term> all = new Gee.ArrayList<Term>();
public async SearchQuery(Geary.Account owner,
ImapDB.Account local,
@ -306,11 +363,11 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
return field_map.keys;
}
public Gee.List<SearchTerm>? get_search_terms(string? field) {
public Gee.List<Term>? get_search_terms(string? field) {
return field_map.has_key(field) ? field_map.get(field) : null;
}
public Gee.List<SearchTerm>? get_all_terms() {
public Gee.List<Term>? get_all_terms() {
return all;
}
@ -330,7 +387,7 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
bool strip_results = true;
if (this.strategy == Geary.SearchQuery.Strategy.HORIZON)
strip_results = false;
else if (traverse<SearchTerm>(this.all).any(
else if (traverse<Term>(this.all).any(
term => term.stemmed == null || term.is_exact)) {
strip_results = false;
}
@ -342,8 +399,8 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
new Gee.HashMap<Geary.NamedFlag,bool>();
foreach (string? field in this.field_map.keys) {
if (field == SEARCH_OP_IS) {
Gee.List<SearchTerm>? terms = get_search_terms(field);
foreach (SearchTerm term in terms)
Gee.List<Term>? terms = get_search_terms(field);
foreach (Term term in terms)
if (term.parsed == SEARCH_OP_VALUE_READ)
conditions.set(new NamedFlag("UNREAD"), true);
else if (term.parsed == SEARCH_OP_VALUE_UNREAD)
@ -359,11 +416,11 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
internal Gee.HashMap<string, string> get_query_phrases() {
Gee.HashMap<string, string> phrases = new Gee.HashMap<string, string>();
foreach (string? field in field_map.keys) {
Gee.List<SearchTerm>? terms = get_search_terms(field);
Gee.List<Term>? terms = get_search_terms(field);
if (terms == null || terms.size == 0 || field == "is")
continue;
// Each SearchTerm is an AND but the SQL text within in are OR ... this allows for
// Each Term is an AND but the SQL text within in are OR ... this allows for
// each user term to be AND but the variants of each term are or. So, if terms are
// [party] and [eventful] and stems are [parti] and [event], the search would be:
//
@ -380,7 +437,7 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
//
// party* OR parti* eventful* OR event*
StringBuilder builder = new StringBuilder();
foreach (SearchTerm term in terms) {
foreach (Term term in terms) {
if (term.sql.size == 0)
continue;
@ -439,12 +496,12 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
--quotes;
}
SearchTerm? term;
Term? term;
if (in_quote) {
// HACK: this helps prevent a syntax error when the user types
// something like from:"somebody". If we ever properly support
// quotes after : we can get rid of this.
term = new SearchTerm(s, s, null, s.replace(":", " "), null);
term = new Term(s, s, null, s.replace(":", " "), null);
} else {
string original = s;
@ -480,7 +537,7 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
if (field == SEARCH_OP_IS) {
// s will have been de-translated
term = new SearchTerm(original, s, null, null, null);
term = new Term(original, s, null, null, null);
} else {
// SQL MATCH syntax for parsed term
string? sql_s = "%s*".printf(s);
@ -506,7 +563,7 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
if (String.contains_any_char(s, SEARCH_TERM_CONTINUATION_CHARS))
s = "\"%s\"".printf(s);
term = new SearchTerm(original, s, stemmed, sql_s, sql_stemmed);
term = new Term(original, s, stemmed, sql_s, sql_stemmed);
}
}
@ -515,7 +572,7 @@ private class Geary.ImapDB.SearchQuery : Geary.SearchQuery {
// Finally, add the term
if (!this.field_map.has_key(field)) {
this.field_map.set(field, new Gee.ArrayList<SearchTerm>());
this.field_map.set(field, new Gee.ArrayList<Term>());
}
this.field_map.get(field).add(term);
this.all.add(term);

View file

@ -1,75 +0,0 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.ImapDB.SearchEmailIdentifier : ImapDB.EmailIdentifier,
Gee.Comparable<SearchEmailIdentifier> {
public DateTime? date_received { get; private set; }
public SearchEmailIdentifier(int64 message_id, DateTime? date_received) {
base(message_id, null);
this.date_received = date_received;
}
public static int compare_descending(SearchEmailIdentifier a, SearchEmailIdentifier b) {
return b.compare_to(a);
}
public static Gee.ArrayList<SearchEmailIdentifier> array_list_from_results(
Gee.Collection<Geary.EmailIdentifier>? results) {
Gee.ArrayList<SearchEmailIdentifier> r = new Gee.ArrayList<SearchEmailIdentifier>();
if (results != null) {
foreach (Geary.EmailIdentifier id in results) {
SearchEmailIdentifier? search_id = id as SearchEmailIdentifier;
assert(search_id != null);
r.add(search_id);
}
}
return r;
}
// Searches for a generic EmailIdentifier in a collection of SearchEmailIdentifiers.
public static SearchEmailIdentifier? collection_get_email_identifier(
Gee.Collection<SearchEmailIdentifier> collection, Geary.EmailIdentifier id) {
foreach (SearchEmailIdentifier search_id in collection) {
if (id.equal_to(search_id))
return search_id;
}
return null;
}
public override int natural_sort_comparator(Geary.EmailIdentifier o) {
ImapDB.SearchEmailIdentifier? other = o as ImapDB.SearchEmailIdentifier;
if (other == null)
return 1;
return compare_to(other);
}
public virtual int compare_to(SearchEmailIdentifier other) {
// if both have date received, compare on that, using stable sort if the same
if (date_received != null && other.date_received != null) {
int compare = date_received.compare(other.date_received);
return (compare != 0) ? compare : stable_sort_comparator(other);
}
// if neither have date received, fall back on stable sort
if (date_received == null && other.date_received == null)
return stable_sort_comparator(other);
// put identifiers with no date ahead of those with
return (date_received == null ? -1 : 1);
}
public override string to_string() {
return "[%s/null/%s]".printf(message_id.to_string(),
(date_received == null ? "null" : date_received.to_string()));
}
}

View file

@ -1,16 +0,0 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.ImapDB.SearchFolderProperties : Geary.FolderProperties {
public SearchFolderProperties(int total, int unread) {
base(total, unread, Trillian.FALSE, Trillian.FALSE, Trillian.TRUE, true, true, false);
}
public void set_total(int total) {
this.email_total = total;
}
}

View file

@ -1,428 +0,0 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.ImapDB.SearchFolder : Geary.SearchFolder, Geary.FolderSupport.Remove {
/** Max number of emails that can ever be in the folder. */
public const int MAX_RESULT_EMAILS = 1000;
/** The canonical name of the search folder. */
public const string MAGIC_BASENAME = "$GearySearchFolder$";
private const Geary.SpecialFolderType[] EXCLUDE_TYPES = {
Geary.SpecialFolderType.SPAM,
Geary.SpecialFolderType.TRASH,
Geary.SpecialFolderType.DRAFTS,
// Orphan emails (without a folder) are also excluded; see ctor.
};
private Gee.HashSet<Geary.FolderPath?> exclude_folders = new Gee.HashSet<Geary.FolderPath?>();
private Gee.TreeSet<ImapDB.SearchEmailIdentifier> search_results;
private Geary.Nonblocking.Mutex result_mutex = new Geary.Nonblocking.Mutex();
public SearchFolder(Geary.Account account, FolderRoot root) {
base(
account,
new SearchFolderProperties(0, 0),
root.get_child(MAGIC_BASENAME, Trillian.TRUE)
);
account.folders_available_unavailable.connect(on_folders_available_unavailable);
account.folders_special_type.connect(on_folders_special_type);
account.email_locally_complete.connect(on_email_locally_complete);
account.email_removed.connect(on_account_email_removed);
clear_search_results();
// We always want to exclude emails that don't live anywhere from
// search results.
exclude_orphan_emails();
}
~SearchFolder() {
account.folders_available_unavailable.disconnect(on_folders_available_unavailable);
account.folders_special_type.disconnect(on_folders_special_type);
account.email_locally_complete.disconnect(on_email_locally_complete);
account.email_removed.disconnect(on_account_email_removed);
}
private async void append_new_email_async(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids,
GLib.Cancellable? cancellable)
throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
Error? error = null;
try {
yield do_search_async(ids, null, cancellable);
} catch(Error e) {
error = e;
}
result_mutex.release(ref result_mutex_token);
if (error != null)
throw error;
}
private async void handle_removed_email_async(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids,
GLib.Cancellable? cancellable)
throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
GLib.Error? error = null;
try {
Gee.ArrayList<ImapDB.SearchEmailIdentifier> relevant_ids
= Geary.traverse<Geary.EmailIdentifier>(ids)
.map_nonnull<ImapDB.SearchEmailIdentifier>(
id => ImapDB.SearchEmailIdentifier.collection_get_email_identifier(search_results, id))
.to_array_list();
if (relevant_ids.size > 0) {
yield do_search_async(null, relevant_ids, cancellable);
}
} catch (GLib.Error e) {
error = e;
}
result_mutex.release(ref result_mutex_token);
if (error != null)
throw error;
}
/**
* Clears the search query and results.
*/
public override void clear() {
var local_results = this.search_results;
clear_search_results();
notify_email_removed(local_results);
notify_email_count_changed(0, Geary.Folder.CountChangeReason.REMOVED);
if (this.query != null) {
this.query = null;
this.query_evaluation_complete();
}
}
/**
* Sets the keyword string for this search.
*/
public override async void search(Geary.SearchQuery query,
GLib.Cancellable? cancellable = null)
throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
this.query = query;
GLib.Error? error = null;
try {
yield do_search_async(null, null, cancellable);
} catch(Error e) {
error = e;
}
result_mutex.release(ref result_mutex_token);
if (error != null) {
throw error;
}
this.query_evaluation_complete();
}
// NOTE: you must call this ONLY after locking result_mutex_token.
// If both *_ids parameters are null, the results of this search are
// considered to be the full new set. If non-null, the results are
// considered to be a delta and are added or subtracted from the full set.
// add_ids are new ids to search for, remove_ids are ids in our result set
// that will be removed if this search doesn't turn them up.
private async void do_search_async(Gee.Collection<Geary.EmailIdentifier>? add_ids,
Gee.Collection<SearchEmailIdentifier>? remove_ids, Cancellable? cancellable) throws Error {
// There are three cases here: 1) replace full result set, where the
// *_ids parameters are both null, 2) add to result set, where just
// remove_ids is null, and 3) remove from result set, where just
// add_ids is null. We can't add and remove at the same time.
assert(add_ids == null || remove_ids == null);
// TODO: don't limit this to MAX_RESULT_EMAILS. Instead, we could be
// smarter about only fetching the search results in list_email_async()
// etc., but this leads to some more complications when redoing the
// search.
Gee.ArrayList<SearchEmailIdentifier> results =
SearchEmailIdentifier.array_list_from_results(
yield account.local_search_async(
this.query,
MAX_RESULT_EMAILS,
0,
exclude_folders,
add_ids ?? remove_ids,
cancellable
)
);
Gee.List<ImapDB.SearchEmailIdentifier> added
= Gee.List.empty<ImapDB.SearchEmailIdentifier>();
Gee.List<ImapDB.SearchEmailIdentifier> removed
= Gee.List.empty<ImapDB.SearchEmailIdentifier>();
if (remove_ids == null) {
added = Geary.traverse<ImapDB.SearchEmailIdentifier>(results)
.filter(id => !(id in search_results))
.to_array_list();
}
if (add_ids == null) {
removed = Geary.traverse<ImapDB.SearchEmailIdentifier>(remove_ids ?? search_results)
.filter(id => !(id in results))
.to_array_list();
}
search_results.remove_all(removed);
search_results.add_all(added);
((ImapDB.SearchFolderProperties) properties).set_total(search_results.size);
// Note that we probably shouldn't be firing these signals from inside
// our mutex lock. Keep an eye on it, and if there's ever a case where
// it might cause problems, it shouldn't be too hard to move the
// firings outside.
Geary.Folder.CountChangeReason reason = CountChangeReason.NONE;
if (added.size > 0) {
// TODO: we'd like to be able to use APPENDED here when applicable,
// but because of the potential to append a thousand results at
// once and the ConversationMonitor's inability to handle that
// gracefully (#7464), we always use INSERTED for now.
notify_email_inserted(added);
reason |= Geary.Folder.CountChangeReason.INSERTED;
}
if (removed.size > 0) {
notify_email_removed(removed);
reason |= Geary.Folder.CountChangeReason.REMOVED;
}
if (reason != CountChangeReason.NONE)
notify_email_count_changed(search_results.size, reason);
}
public override async Gee.List<Geary.Email>? list_email_by_id_async(Geary.EmailIdentifier? initial_id,
int count, Geary.Email.Field required_fields, Geary.Folder.ListFlags flags, Cancellable? cancellable = null)
throws Error {
if (count <= 0)
return null;
// TODO: as above, this is incomplete and inefficient.
int result_mutex_token = yield result_mutex.claim_async();
Geary.EmailIdentifier[] ids = new Geary.EmailIdentifier[search_results.size];
int initial_index = 0;
int i = 0;
foreach (ImapDB.SearchEmailIdentifier id in search_results) {
if (initial_id != null && id.equal_to(initial_id))
initial_index = i;
ids[i++] = id;
}
if (initial_id == null && flags.is_all_set(Geary.Folder.ListFlags.OLDEST_TO_NEWEST))
initial_index = ids.length - 1;
Gee.List<Geary.Email> results = new Gee.ArrayList<Geary.Email>();
Error? fetch_err = null;
if (initial_index >= 0) {
int increment = flags.is_oldest_to_newest() ? -1 : 1;
i = initial_index;
if (!flags.is_including_id() && initial_id != null)
i += increment;
int end = i + (count * increment);
for (; i >= 0 && i < search_results.size && i != end; i += increment) {
try {
results.add(yield fetch_email_async(ids[i], required_fields, flags, cancellable));
} catch (Error err) {
// Don't let missing or incomplete messages stop the list operation, which has
// different semantics from fetch
if (!(err is EngineError.NOT_FOUND) && !(err is EngineError.INCOMPLETE_MESSAGE)) {
fetch_err = err;
break;
}
}
}
}
result_mutex.release(ref result_mutex_token);
if (fetch_err != null)
throw fetch_err;
return (results.size == 0 ? null : results);
}
public override async Gee.List<Geary.Email>? list_email_by_sparse_id_async(
Gee.Collection<Geary.EmailIdentifier> ids, Geary.Email.Field required_fields,
Geary.Folder.ListFlags flags, Cancellable? cancellable = null) throws Error {
// TODO: Fetch emails in a batch.
Gee.List<Geary.Email> result = new Gee.ArrayList<Geary.Email>();
foreach(Geary.EmailIdentifier id in ids)
result.add(yield fetch_email_async(id, required_fields, flags, cancellable));
return (result.size == 0 ? null : result);
}
public override async Gee.Map<Geary.EmailIdentifier, Geary.Email.Field>? list_local_email_fields_async(
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error {
// TODO: This method is not currently called, but is required by the interface. Before completing
// this feature, it should either be implemented either here or in AbstractLocalFolder.
error("Search folder does not implement list_local_email_fields_async");
}
public override async Geary.Email fetch_email_async(Geary.EmailIdentifier id,
Geary.Email.Field required_fields, Geary.Folder.ListFlags flags,
Cancellable? cancellable = null) throws Error {
return yield account.local_fetch_email_async(id, required_fields, cancellable);
}
public virtual async void
remove_email_async(Gee.Collection<Geary.EmailIdentifier> email_ids,
GLib.Cancellable? cancellable = null)
throws GLib.Error {
Gee.MultiMap<Geary.EmailIdentifier, Geary.FolderPath>? ids_to_folders
= yield account.get_containing_folders_async(email_ids, cancellable);
if (ids_to_folders == null)
return;
Gee.MultiMap<Geary.FolderPath, Geary.EmailIdentifier> folders_to_ids
= Geary.Collection.reverse_multi_map<Geary.EmailIdentifier, Geary.FolderPath>(ids_to_folders);
foreach (Geary.FolderPath path in folders_to_ids.get_keys()) {
Geary.Folder folder = account.get_folder(path);
Geary.FolderSupport.Remove? remove = folder as Geary.FolderSupport.Remove;
if (remove == null)
continue;
Gee.Collection<Geary.EmailIdentifier> ids = folders_to_ids.get(path);
assert(ids.size > 0);
debug("Search folder removing %d emails from %s", ids.size, folder.to_string());
bool open = false;
try {
yield folder.open_async(Geary.Folder.OpenFlags.NONE, cancellable);
open = true;
yield remove.remove_email_async(
Collection.copy(ids), cancellable
);
} finally {
if (open) {
try {
yield folder.close_async();
} catch (Error e) {
debug("Error closing folder %s: %s", folder.to_string(), e.message);
}
}
}
}
}
/**
* Given a list of mail IDs, returns a set of casefolded words that match for the current
* search query.
*/
public override async Gee.Set<string>? get_search_matches_async(
Gee.Collection<Geary.EmailIdentifier> ids,
GLib.Cancellable? cancellable = null
) throws GLib.Error {
Gee.Set<string>? results = null;
if (this.query != null) {
results = yield account.get_search_matches_async(
this.query, ids, cancellable
);
}
return results;
}
private void include_folder(Geary.Folder folder) {
this.exclude_folders.remove(folder.path);
}
private void exclude_folder(Geary.Folder folder) {
this.exclude_folders.add(folder.path);
}
private void exclude_orphan_emails() {
this.exclude_folders.add(null);
}
private void clear_search_results() {
this.search_results = new Gee.TreeSet<ImapDB.SearchEmailIdentifier>(
SearchEmailIdentifier.compare_descending
);
}
private void on_folders_available_unavailable(Gee.Collection<Geary.Folder>? available,
Gee.Collection<Geary.Folder>? unavailable) {
if (available != null) {
// Exclude it from searching if it's got the right special type.
foreach(Geary.Folder folder in Geary.traverse<Geary.Folder>(available)
.filter(f => f.special_folder_type in EXCLUDE_TYPES))
exclude_folder(folder);
}
}
private void on_folders_special_type(Gee.Collection<Geary.Folder> folders) {
foreach (Geary.Folder folder in folders) {
if (folder.special_folder_type in EXCLUDE_TYPES) {
exclude_folder(folder);
} else {
include_folder(folder);
}
}
}
private void on_email_locally_complete(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids) {
if (this.query != null) {
this.append_new_email_async.begin(
folder, ids, null,
(obj, res) => {
try {
this.append_new_email_async.end(res);
} catch (GLib.Error error) {
this.account.report_problem(
new Geary.AccountProblemReport(
this.account.information, error
)
);
}
}
);
}
}
private void on_account_email_removed(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids) {
if (this.query != null) {
this.handle_removed_email_async.begin(
folder, ids, null,
(obj, res) => {
try {
this.handle_removed_email_async.end(res);
} catch (GLib.Error error) {
this.account.report_problem(
new Geary.AccountProblemReport(
this.account.information, error
)
);
}
}
);
}
}
}

View file

@ -1,62 +0,0 @@
/* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* Various associated state with a single term in a {@link ImapDB.SearchQuery}.
*/
private class Geary.ImapDB.SearchTerm : BaseObject {
/**
* The original tokenized search term with minimal other processing performed.
*
* For example, punctuation might be removed, but no casefolding has occurred.
*/
public string original { get; private set; }
/**
* The parsed tokenized search term.
*
* Casefolding and other normalizing text operations have been performed.
*/
public string parsed { get; private set; }
/**
* The stemmed search term.
*
* Only used if stemming is being done ''and'' the stem is different than the {@link parsed}
* term.
*/
public string? stemmed { get; private set; }
/**
* A list of terms ready for binding to an SQLite statement.
*
* This should include prefix operators and quotes (i.e. ["party"] or [party*]). These texts
* are guaranteed not to be null or empty strings.
*/
public Gee.List<string> sql { get; private set; default = new Gee.ArrayList<string>(); }
/**
* Returns true if the {@link parsed} term is exact-match only (i.e. starts with quotes) and
* there is no {@link stemmed} variant.
*/
public bool is_exact { get { return parsed.has_prefix("\"") && stemmed == null; } }
public SearchTerm(string original, string parsed, string? stemmed, string? sql_parsed, string? sql_stemmed) {
this.original = original;
this.parsed = parsed;
this.stemmed = stemmed;
// for now, only two variations: the parsed string and the stemmed; since stem is usually
// shorter (and will be first in the OR statement), include it first
if (!String.is_empty(sql_stemmed))
sql.add(sql_stemmed);
if (!String.is_empty(sql_parsed))
sql.add(sql_parsed);
}
}

View file

@ -8,7 +8,7 @@
/**
* Gmail-specific SearchFolder implementation.
*/
private class Geary.ImapEngine.GmailSearchFolder : ImapDB.SearchFolder {
private class Geary.ImapEngine.GmailSearchFolder : Search.FolderImpl {
private Geary.App.EmailStore email_store;

View file

@ -417,6 +417,8 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
char type = (char) serialised.get_child_value(0).get_byte();
if (type == 'i')
return new ImapDB.EmailIdentifier.from_variant(serialised);
if (type == 's')
return new Search.EmailIdentifier.from_variant(serialised, this);
if (type == 'o')
return new Outbox.EmailIdentifier.from_variant(serialised);
@ -808,7 +810,7 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
* override this to return the correct subclass.
*/
protected virtual SearchFolder new_search_folder() {
return new ImapDB.SearchFolder(this, this.local_folder_root);
return new Search.FolderImpl(this, this.local_folder_root);
}
/** {@inheritDoc} */

View file

@ -178,11 +178,7 @@ geary_engine_vala_sources = files(
'imap-db/imap-db-folder.vala',
'imap-db/imap-db-gc.vala',
'imap-db/imap-db-message-row.vala',
'imap-db/search/imap-db-search-email-identifier.vala',
'imap-db/search/imap-db-search-folder.vala',
'imap-db/search/imap-db-search-folder-properties.vala',
'imap-db/search/imap-db-search-query.vala',
'imap-db/search/imap-db-search-term.vala',
'imap-db/imap-db-search-query.vala',
'imap-engine/imap-engine.vala',
'imap-engine/imap-engine-account-operation.vala',
@ -274,6 +270,9 @@ geary_engine_vala_sources = files(
'rfc822/rfc822-part.vala',
'rfc822/rfc822-utils.vala',
'search/search-email-identifier.vala',
'search/search-folder-impl.vala',
'smtp/smtp-authenticator.vala',
'smtp/smtp-capabilities.vala',
'smtp/smtp-client-connection.vala',

View file

@ -0,0 +1,129 @@
/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2019 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
private class Geary.Search.EmailIdentifier :
Geary.EmailIdentifier, Gee.Comparable<EmailIdentifier> {
private const string VARIANT_TYPE = "(y(vx))";
public static int compare_descending(EmailIdentifier a, EmailIdentifier b) {
return b.compare_to(a);
}
public static Gee.Collection<Geary.EmailIdentifier> to_source_ids(
Gee.Collection<Geary.EmailIdentifier> ids
) {
var engine_ids = new Gee.LinkedList<Geary.EmailIdentifier>();
foreach (var id in ids) {
var search_id = id as EmailIdentifier;
engine_ids.add(search_id.source_id ?? id);
}
return engine_ids;
}
public static Geary.EmailIdentifier to_source_id(
Geary.EmailIdentifier id
) {
var search_id = id as EmailIdentifier;
return search_id.source_id ?? id;
}
public Geary.EmailIdentifier source_id { get; private set; }
public GLib.DateTime? date_received { get; private set; }
public EmailIdentifier(Geary.EmailIdentifier source_id,
GLib.DateTime? date_received) {
this.source_id = source_id;
this.date_received = date_received;
}
/** Reconstructs an identifier from its variant representation. */
public EmailIdentifier.from_variant(GLib.Variant serialised,
Account account)
throws EngineError.BAD_PARAMETERS {
if (serialised.get_type_string() != VARIANT_TYPE) {
throw new EngineError.BAD_PARAMETERS(
"Invalid serialised id type: %s", serialised.get_type_string()
);
}
GLib.Variant inner = serialised.get_child_value(1);
this(
account.to_email_identifier(
inner.get_child_value(0).get_variant()
),
new GLib.DateTime.from_unix_utc(
inner.get_child_value(1).get_int64()
)
);
}
/** {@inheritDoc} */
public override uint hash() {
return this.source_id.hash();
}
/** {@inheritDoc} */
public override bool equal_to(Geary.EmailIdentifier other) {
return (
this.get_type() == other.get_type() &&
this.source_id.equal_to(((EmailIdentifier) other).source_id)
);
}
/** {@inheritDoc} */
public override GLib.Variant to_variant() {
// Return a tuple to satisfy the API contract, add an 's' to
// inform GenericAccount that it's an IMAP id.
return new GLib.Variant.tuple(new Variant[] {
new GLib.Variant.byte('s'),
new GLib.Variant.tuple(new Variant[] {
new GLib.Variant.variant(this.source_id.to_variant()),
new GLib.Variant.int64(this.date_received.to_unix())
})
});
}
/** {@inheritDoc} */
public override string to_string() {
return "%s(%s,%lld)".printf(
this.get_type().name(),
this.source_id.to_string(),
this.date_received.to_unix()
);
}
public override int natural_sort_comparator(Geary.EmailIdentifier o) {
EmailIdentifier? other = o as EmailIdentifier;
if (other == null)
return 1;
return compare_to(other);
}
public virtual int compare_to(EmailIdentifier other) {
// if both have date received, compare on that, using stable sort if the same
if (date_received != null && other.date_received != null) {
int compare = date_received.compare(other.date_received);
return (compare != 0) ? compare : stable_sort_comparator(other);
}
// if neither have date received, fall back on stable sort
if (date_received == null && other.date_received == null)
return stable_sort_comparator(other);
// put identifiers with no date ahead of those with
return (date_received == null ? -1 : 1);
}
}

View file

@ -0,0 +1,546 @@
/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2019 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* A default implementation of a search folder.
*
* This implementation of {@link Geary.SearchFolder} uses the search
* methods on {@link Account} to implement account-wide email search.
*/
internal class Geary.Search.FolderImpl :
Geary.SearchFolder, Geary.FolderSupport.Remove {
/** Max number of emails that can ever be in the folder. */
public const int MAX_RESULT_EMAILS = 1000;
/** The canonical name of the search folder. */
public const string MAGIC_BASENAME = "$GearySearchFolder$";
private const Geary.SpecialFolderType[] EXCLUDE_TYPES = {
Geary.SpecialFolderType.SPAM,
Geary.SpecialFolderType.TRASH,
Geary.SpecialFolderType.DRAFTS,
// Orphan emails (without a folder) are also excluded; see ct or.
};
private class FolderProperties : Geary.FolderProperties {
public FolderProperties(int total, int unread) {
base(total, unread, Trillian.FALSE, Trillian.FALSE, Trillian.TRUE, true, true, false);
}
public void set_total(int total) {
this.email_total = total;
}
}
// Folders that should be excluded from search
private Gee.HashSet<Geary.FolderPath?> exclude_folders =
new Gee.HashSet<Geary.FolderPath?>();
// The email present in the folder, sorted
private Gee.TreeSet<EmailIdentifier> contents;
// Map of engine ids to search ids
private Gee.Map<Geary.EmailIdentifier,EmailIdentifier> id_map;
private Geary.Nonblocking.Mutex result_mutex = new Geary.Nonblocking.Mutex();
public FolderImpl(Geary.Account account, FolderRoot root) {
base(
account,
new FolderProperties(0, 0),
root.get_child(MAGIC_BASENAME, Trillian.TRUE)
);
account.folders_available_unavailable.connect(on_folders_available_unavailable);
account.folders_special_type.connect(on_folders_special_type);
account.email_locally_complete.connect(on_email_locally_complete);
account.email_removed.connect(on_account_email_removed);
clear_contents();
// We always want to exclude emails that don't live anywhere
// from search results.
exclude_orphan_emails();
}
~FolderImpl() {
account.folders_available_unavailable.disconnect(on_folders_available_unavailable);
account.folders_special_type.disconnect(on_folders_special_type);
account.email_locally_complete.disconnect(on_email_locally_complete);
account.email_removed.disconnect(on_account_email_removed);
}
/**
* Sets the keyword string for this search.
*/
public override async void search(Geary.SearchQuery query,
GLib.Cancellable? cancellable = null)
throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
this.query = query;
GLib.Error? error = null;
try {
yield do_search_async(null, null, cancellable);
} catch(Error e) {
error = e;
}
result_mutex.release(ref result_mutex_token);
if (error != null) {
throw error;
}
this.query_evaluation_complete();
}
/**
* Clears the search query and results.
*/
public override void clear() {
var old_contents = this.contents;
clear_contents();
notify_email_removed(old_contents);
notify_email_count_changed(0, Geary.Folder.CountChangeReason.REMOVED);
if (this.query != null) {
this.query = null;
this.query_evaluation_complete();
}
}
/**
* Given a list of mail IDs, returns a set of casefolded words that match for the current
* search query.
*/
public override async Gee.Set<string>? get_search_matches_async(
Gee.Collection<Geary.EmailIdentifier> ids,
GLib.Cancellable? cancellable = null
) throws GLib.Error {
Gee.Set<string>? results = null;
if (this.query != null) {
results = yield account.get_search_matches_async(
this.query,
EmailIdentifier.to_source_ids(ids),
cancellable
);
}
return results;
}
public override async Gee.List<Email>? list_email_by_id_async(
Geary.EmailIdentifier? initial_id,
int count,
Email.Field required_fields,
Geary.Folder.ListFlags flags,
Cancellable? cancellable = null
) throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
var engine_ids = new Gee.LinkedList<Geary.EmailIdentifier>();
if (Geary.Folder.ListFlags.OLDEST_TO_NEWEST in flags) {
EmailIdentifier? oldest = null;
if (!this.contents.is_empty) {
if (initial_id == null) {
oldest = this.contents.last();
} else {
oldest = this.id_map.get(initial_id);
if (oldest == null) {
throw new EngineError.NOT_FOUND(
"Initial id not found %s", initial_id.to_string()
);
}
if (!(Geary.Folder.ListFlags.INCLUDING_ID in flags)) {
oldest = contents.higher(oldest);
}
}
}
if (oldest != null) {
var iter = (
this.contents.iterator_at(oldest) as
Gee.BidirIterator<EmailIdentifier>
);
engine_ids.add(oldest.source_id);
while (engine_ids.size < count && iter.previous()) {
engine_ids.add(iter.get().source_id);
}
}
} else {
// Newest to oldest
EmailIdentifier? newest = null;
if (!this.contents.is_empty) {
if (initial_id == null) {
newest = this.contents.first();
} else {
newest = this.id_map.get(initial_id);
if (newest == null) {
throw new EngineError.NOT_FOUND(
"Initial id not found %s", initial_id.to_string()
);
}
if (!(Geary.Folder.ListFlags.INCLUDING_ID in flags)) {
newest = contents.lower(newest);
}
}
}
if (newest != null) {
var iter = (
this.contents.iterator_at(newest) as
Gee.BidirIterator<EmailIdentifier>
);
engine_ids.add(newest.source_id);
while (engine_ids.size < count && iter.next()) {
engine_ids.add(iter.get().source_id);
}
}
}
Gee.List<Email>? results = null;
GLib.Error? list_error = null;
if (!engine_ids.is_empty) {
try {
results = yield this.account.list_local_email_async(
engine_ids,
required_fields,
cancellable
);
} catch (GLib.Error error) {
list_error = error;
}
}
result_mutex.release(ref result_mutex_token);
if (list_error != null) {
throw list_error;
}
return results;
}
public override async Gee.List<Geary.Email>? list_email_by_sparse_id_async(
Gee.Collection<Geary.EmailIdentifier> ids,
Geary.Email.Field required_fields,
Geary.Folder.ListFlags flags,
Cancellable? cancellable = null
) throws GLib.Error {
return yield this.account.list_local_email_async(
EmailIdentifier.to_source_ids(ids),
required_fields,
cancellable
);
}
public override async Gee.Map<Geary.EmailIdentifier, Geary.Email.Field>? list_local_email_fields_async(
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error {
// TODO: This method is not currently called, but is required by the interface. Before completing
// this feature, it should either be implemented either here or in AbstractLocalFolder.
error("Search folder does not implement list_local_email_fields_async");
}
public override async Geary.Email fetch_email_async(Geary.EmailIdentifier id,
Geary.Email.Field required_fields,
Geary.Folder.ListFlags flags,
Cancellable? cancellable = null)
throws GLib.Error {
return yield this.account.local_fetch_email_async(
EmailIdentifier.to_source_id(id), required_fields, cancellable
);
}
public virtual async void remove_email_async(
Gee.Collection<Geary.EmailIdentifier> email_ids,
GLib.Cancellable? cancellable = null
) throws GLib.Error {
Gee.MultiMap<Geary.EmailIdentifier, Geary.FolderPath>? ids_to_folders =
yield account.get_containing_folders_async(
EmailIdentifier.to_source_ids(email_ids),
cancellable
);
if (ids_to_folders != null) {
Gee.MultiMap<Geary.FolderPath, Geary.EmailIdentifier> folders_to_ids =
Geary.Collection.reverse_multi_map<Geary.EmailIdentifier, Geary.FolderPath>(ids_to_folders);
foreach (Geary.FolderPath path in folders_to_ids.get_keys()) {
Geary.Folder folder = account.get_folder(path);
Geary.FolderSupport.Remove? remove = folder as Geary.FolderSupport.Remove;
if (remove != null) {
Gee.Collection<Geary.EmailIdentifier> ids = folders_to_ids.get(path);
debug("Search folder removing %d emails from %s", ids.size, folder.to_string());
bool open = false;
try {
yield folder.open_async(Geary.Folder.OpenFlags.NONE, cancellable);
open = true;
yield remove.remove_email_async(ids, cancellable);
} finally {
if (open) {
try {
yield folder.close_async();
} catch (Error e) {
debug("Error closing folder %s: %s", folder.to_string(), e.message);
}
}
}
}
}
}
}
// NOTE: you must call this ONLY after locking result_mutex_token.
// If both *_ids parameters are null, the results of this search are
// considered to be the full new set. If non-null, the results are
// considered to be a delta and are added or subtracted from the full set.
// add_ids are new ids to search for, remove_ids are ids in our result set
// and will be removed.
private async void do_search_async(Gee.Collection<Geary.EmailIdentifier>? add_ids,
Gee.Collection<Geary.EmailIdentifier>? remove_ids,
GLib.Cancellable? cancellable)
throws GLib.Error {
var id_map = this.id_map;
var contents = this.contents;
var added = new Gee.LinkedList<EmailIdentifier>();
var removed = new Gee.LinkedList<EmailIdentifier>();
if (remove_ids == null) {
// Adding email to the search, either searching all local
// email if to_add is null, or adding only a matching
// subset of the given in to_add
//
// TODO: don't limit this to MAX_RESULT_EMAILS. Instead,
// we could be smarter about only fetching the search
// results in list_email_async() etc., but this leads to
// some more complications when redoing the search.
Gee.Collection<Geary.EmailIdentifier>? id_results =
yield this.account.local_search_async(
this.query,
MAX_RESULT_EMAILS,
0,
this.exclude_folders,
add_ids, // If null, will search all local email
cancellable
);
if (id_results != null) {
// Fetch email to get the received date for
// correct ordering in the search folder
Gee.Collection<Email> email_results =
yield this.account.list_local_email_async(
id_results,
PROPERTIES,
cancellable
);
if (add_ids == null) {
// Not appending new email, so remove any not
// found in the results. Add to a set first to
// avoid O(N^2) lookup complexity.
var hashed_results = new Gee.HashSet<Geary.EmailIdentifier>();
hashed_results.add_all(id_results);
var existing = id_map.map_iterator();
while (existing.next()) {
if (!hashed_results.contains(existing.get_key())) {
var search_id = existing.get_value();
existing.unset();
contents.remove(search_id);
removed.add(search_id);
}
}
}
foreach (var email in email_results) {
if (!id_map.has_key(email.id)) {
var search_id = new EmailIdentifier(
email.id, email.properties.date_received
);
id_map.set(email.id, search_id);
contents.add(search_id);
added.add(search_id);
}
}
}
} else {
// Removing email, can just remove them directly
foreach (var id in remove_ids) {
EmailIdentifier search_id;
if (id_map.unset(id, out search_id)) {
contents.remove(search_id);
removed.add(search_id);
}
}
}
((FolderProperties) this.properties).set_total(this.contents.size);
// Note that we probably shouldn't be firing these signals from inside
// our mutex lock. Keep an eye on it, and if there's ever a case where
// it might cause problems, it shouldn't be too hard to move the
// firings outside.
Geary.Folder.CountChangeReason reason = CountChangeReason.NONE;
if (added.size > 0) {
// TODO: we'd like to be able to use APPENDED here when applicable,
// but because of the potential to append a thousand results at
// once and the ConversationMonitor's inability to handle that
// gracefully (#7464), we always use INSERTED for now.
notify_email_inserted(added);
reason |= Geary.Folder.CountChangeReason.INSERTED;
}
if (removed.size > 0) {
notify_email_removed(removed);
reason |= Geary.Folder.CountChangeReason.REMOVED;
}
if (reason != CountChangeReason.NONE)
notify_email_count_changed(this.contents.size, reason);
}
private async void do_append(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids,
GLib.Cancellable? cancellable)
throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
GLib.Error? error = null;
try {
if (!this.exclude_folders.contains(folder.path)) {
yield do_search_async(ids, null, cancellable);
}
} catch (GLib.Error e) {
error = e;
}
result_mutex.release(ref result_mutex_token);
if (error != null)
throw error;
}
private async void do_remove(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids,
GLib.Cancellable? cancellable)
throws GLib.Error {
int result_mutex_token = yield result_mutex.claim_async();
GLib.Error? error = null;
try {
var id_map = this.id_map;
var relevant_ids = (
traverse(ids)
.filter(id => id_map.has_key(id))
.to_linked_list()
);
if (relevant_ids.size > 0) {
yield do_search_async(null, relevant_ids, cancellable);
}
} catch (GLib.Error e) {
error = e;
}
result_mutex.release(ref result_mutex_token);
if (error != null)
throw error;
}
private void clear_contents() {
this.contents = new Gee.TreeSet<EmailIdentifier>(
EmailIdentifier.compare_descending
);
this.id_map = new Gee.HashMap<Geary.EmailIdentifier,EmailIdentifier>();
}
private void include_folder(Geary.Folder folder) {
this.exclude_folders.remove(folder.path);
}
private void exclude_folder(Geary.Folder folder) {
this.exclude_folders.add(folder.path);
}
private void exclude_orphan_emails() {
this.exclude_folders.add(null);
}
private void on_folders_available_unavailable(Gee.Collection<Geary.Folder>? available,
Gee.Collection<Geary.Folder>? unavailable) {
if (available != null) {
// Exclude it from searching if it's got the right special type.
foreach(Geary.Folder folder in Geary.traverse<Geary.Folder>(available)
.filter(f => f.special_folder_type in EXCLUDE_TYPES))
exclude_folder(folder);
}
}
private void on_folders_special_type(Gee.Collection<Geary.Folder> folders) {
foreach (Geary.Folder folder in folders) {
if (folder.special_folder_type in EXCLUDE_TYPES) {
exclude_folder(folder);
} else {
include_folder(folder);
}
}
}
private void on_email_locally_complete(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids) {
if (this.query != null) {
this.do_append.begin(
folder, ids, null,
(obj, res) => {
try {
this.do_append.end(res);
} catch (GLib.Error error) {
this.account.report_problem(
new Geary.AccountProblemReport(
this.account.information, error
)
);
}
}
);
}
}
private void on_account_email_removed(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids) {
if (this.query != null) {
this.do_remove.begin(
folder, ids, null,
(obj, res) => {
try {
this.do_remove.end(res);
} catch (GLib.Error error) {
this.account.report_problem(
new Geary.AccountProblemReport(
this.account.information, error
)
);
}
}
);
}
}
}

View file

@ -99,6 +99,21 @@ public class Geary.ImapEngine.GenericAccountTest : TestCase {
)
)
);
assert_non_null(
test_article.to_email_identifier(
new GLib.Variant(
"(yr)",
's',
new GLib.Variant(
"(vx)",
new GLib.Variant(
"(yr)", 'o', new GLib.Variant("(xx)", 1, 2)
),
3
)
)
)
);
}
}