639 lines
22 KiB
Vala
639 lines
22 KiB
Vala
/*
|
|
* 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 folder for executing and listing an account-wide email search.
|
|
*
|
|
* This uses the search methods on {@link Account} to implement the
|
|
* search, then collects search results and presents them via the
|
|
* folder interface.
|
|
*/
|
|
public class Geary.App.SearchFolder :
|
|
AbstractLocalFolder, FolderSupport.Remove {
|
|
|
|
|
|
/** Number of messages to include in the initial search. */
|
|
public const int MAX_RESULT_EMAILS = 1000;
|
|
|
|
/** The canonical name of the search folder. */
|
|
public const string MAGIC_BASENAME = "$GearyAccountSearchFolder$";
|
|
|
|
private const Folder.SpecialUse[] EXCLUDE_TYPES = {
|
|
DRAFTS,
|
|
JUNK,
|
|
TRASH,
|
|
// Orphan emails (without a folder) are also excluded; see ctor.
|
|
};
|
|
|
|
|
|
private class FolderPropertiesImpl : FolderProperties {
|
|
|
|
|
|
public FolderPropertiesImpl(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;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
// Represents an entry in the folder. Does not implement
|
|
// Gee.Comparable since that would require extending GLib.Object
|
|
// and hence make them very heavyweight.
|
|
private class EmailEntry {
|
|
|
|
|
|
public static int compare_to(EmailEntry a, EmailEntry b) {
|
|
int cmp = 0;
|
|
if (a != b && a.id != b.id && !a.id.equal_to(b.id)) {
|
|
cmp = a.received.compare(b.received);
|
|
if (cmp == 0) {
|
|
cmp = a.id.stable_sort_comparator(b.id);
|
|
}
|
|
}
|
|
return cmp;
|
|
}
|
|
|
|
|
|
public EmailIdentifier id;
|
|
public GLib.DateTime received;
|
|
|
|
|
|
public EmailEntry(EmailIdentifier id, GLib.DateTime received) {
|
|
this.id = id;
|
|
this.received = received;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/** {@inheritDoc} */
|
|
public override Account account {
|
|
get { return _account; }
|
|
}
|
|
private weak Account _account;
|
|
|
|
/** {@inheritDoc} */
|
|
public override FolderProperties properties {
|
|
get { return _properties; }
|
|
}
|
|
private FolderPropertiesImpl _properties;
|
|
|
|
/** {@inheritDoc} */
|
|
public override FolderPath path {
|
|
get { return _path; }
|
|
}
|
|
private FolderPath? _path = null;
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*
|
|
* Always returns {@link Folder.SpecialUse.SEARCH}.
|
|
*/
|
|
public override Folder.SpecialUse used_as {
|
|
get { return SEARCH; }
|
|
}
|
|
|
|
/** The query being evaluated by this folder, if any. */
|
|
public SearchQuery? query { get; protected set; default = null; }
|
|
|
|
// Folders that should be excluded from search
|
|
private Gee.HashSet<FolderPath?> exclude_folders =
|
|
new Gee.HashSet<FolderPath?>();
|
|
|
|
// The email present in the folder, sorted
|
|
private Gee.TreeSet<EmailEntry> contents;
|
|
|
|
// Map of engine ids to search ids
|
|
private Gee.Map<EmailIdentifier,EmailEntry> id_map;
|
|
|
|
private Nonblocking.Mutex result_mutex = new Nonblocking.Mutex();
|
|
|
|
private GLib.Cancellable executing = new GLib.Cancellable();
|
|
|
|
|
|
public SearchFolder(Account account, FolderRoot root) {
|
|
this._account = account;
|
|
this._properties = new FolderPropertiesImpl(0, 0);
|
|
this._path = root.get_child(MAGIC_BASENAME, Trillian.TRUE);
|
|
|
|
account.folders_available_unavailable.connect(on_folders_available_unavailable);
|
|
account.folders_use_changed.connect(on_folders_use_changed);
|
|
account.email_locally_complete.connect(on_email_locally_complete);
|
|
account.email_removed.connect(on_account_email_removed);
|
|
account.email_locally_removed.connect(on_account_email_removed);
|
|
|
|
new_contents();
|
|
|
|
// Always 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_use_changed.disconnect(on_folders_use_changed);
|
|
account.email_locally_complete.disconnect(on_email_locally_complete);
|
|
account.email_removed.disconnect(on_account_email_removed);
|
|
account.email_locally_removed.disconnect(on_account_email_removed);
|
|
}
|
|
|
|
/**
|
|
* Executes the given query over the account's local email.
|
|
*
|
|
* Calling this will block until the search is complete.
|
|
*/
|
|
public async void search(SearchQuery query, GLib.Cancellable? cancellable)
|
|
throws GLib.Error {
|
|
int result_mutex_token = yield result_mutex.claim_async();
|
|
|
|
clear();
|
|
|
|
if (cancellable != null) {
|
|
GLib.Cancellable @internal = this.executing;
|
|
cancellable.cancelled.connect(() => { @internal.cancel(); });
|
|
}
|
|
|
|
this.query = query;
|
|
GLib.Error? error = null;
|
|
try {
|
|
yield do_search_async(null, null, this.executing);
|
|
} catch(Error e) {
|
|
error = e;
|
|
}
|
|
|
|
result_mutex.release(ref result_mutex_token);
|
|
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels and clears the search query and results.
|
|
*
|
|
* The {@link query} property will be cleared.
|
|
*/
|
|
public void clear() {
|
|
this.executing.cancel();
|
|
this.executing = new GLib.Cancellable();
|
|
|
|
var old_ids = this.id_map;
|
|
new_contents();
|
|
notify_email_removed(old_ids.keys);
|
|
notify_email_count_changed(0, REMOVED);
|
|
|
|
this.query = null;
|
|
}
|
|
|
|
/**
|
|
* Returns a set of case-folded words matched by the current query.
|
|
*
|
|
* The set contains words from the given collection of email that
|
|
* match any of the non-negated text operators in {@link query}.
|
|
*/
|
|
public async Gee.Set<string>? get_search_matches_async(
|
|
Gee.Collection<EmailIdentifier> targets,
|
|
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, check_ids(targets), cancellable
|
|
);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
public override async Gee.List<Email>? list_email_by_id_async(
|
|
EmailIdentifier? initial_id,
|
|
int count,
|
|
Email.Field required_fields,
|
|
Folder.ListFlags flags,
|
|
Cancellable? cancellable = null
|
|
) throws GLib.Error {
|
|
int result_mutex_token = yield result_mutex.claim_async();
|
|
|
|
var engine_ids = new Gee.LinkedList<EmailIdentifier>();
|
|
|
|
if (Folder.ListFlags.OLDEST_TO_NEWEST in flags) {
|
|
EmailEntry? 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 (!(Folder.ListFlags.INCLUDING_ID in flags)) {
|
|
oldest = contents.higher(oldest);
|
|
}
|
|
}
|
|
}
|
|
if (oldest != null) {
|
|
var iter = (
|
|
this.contents.iterator_at(oldest) as
|
|
Gee.BidirIterator<EmailEntry>
|
|
);
|
|
engine_ids.add(oldest.id);
|
|
while (engine_ids.size < count && iter.previous()) {
|
|
engine_ids.add(iter.get().id);
|
|
}
|
|
}
|
|
} else {
|
|
// Newest to oldest
|
|
EmailEntry? 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 (!(Folder.ListFlags.INCLUDING_ID in flags)) {
|
|
newest = contents.lower(newest);
|
|
}
|
|
}
|
|
}
|
|
if (newest != null) {
|
|
var iter = (
|
|
this.contents.iterator_at(newest) as
|
|
Gee.BidirIterator<EmailEntry>
|
|
);
|
|
engine_ids.add(newest.id);
|
|
while (engine_ids.size < count && iter.next()) {
|
|
engine_ids.add(iter.get().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<Email>? list_email_by_sparse_id_async(
|
|
Gee.Collection<EmailIdentifier> list,
|
|
Email.Field required_fields,
|
|
Folder.ListFlags flags,
|
|
Cancellable? cancellable = null
|
|
) throws GLib.Error {
|
|
return yield this.account.list_local_email_async(
|
|
check_ids(list), required_fields, cancellable
|
|
);
|
|
}
|
|
|
|
public override async Email fetch_email_async(EmailIdentifier fetch,
|
|
Email.Field required_fields,
|
|
Folder.ListFlags flags,
|
|
GLib.Cancellable? cancellable = null)
|
|
throws GLib.Error {
|
|
require_id(fetch);
|
|
return yield this.account.local_fetch_email_async(
|
|
fetch, required_fields, cancellable
|
|
);
|
|
}
|
|
|
|
public virtual async void remove_email_async(
|
|
Gee.Collection<EmailIdentifier> remove,
|
|
GLib.Cancellable? cancellable = null
|
|
) throws GLib.Error {
|
|
Gee.MultiMap<EmailIdentifier,FolderPath>? ids_to_folders =
|
|
yield account.get_containing_folders_async(
|
|
check_ids(remove), cancellable
|
|
);
|
|
if (ids_to_folders != null) {
|
|
Gee.MultiMap<FolderPath,EmailIdentifier> folders_to_ids =
|
|
Collection.reverse_multi_map<EmailIdentifier,FolderPath>(
|
|
ids_to_folders
|
|
);
|
|
|
|
foreach (FolderPath path in folders_to_ids.get_keys()) {
|
|
Folder folder = account.get_folder(path);
|
|
FolderSupport.Remove? removable = folder as FolderSupport.Remove;
|
|
if (removable != null) {
|
|
Gee.Collection<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(NONE, cancellable);
|
|
open = true;
|
|
yield removable.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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void set_used_as_custom(bool enabled)
|
|
throws EngineError.UNSUPPORTED {
|
|
throw new EngineError.UNSUPPORTED("Folder special use cannot be changed");
|
|
}
|
|
|
|
private void require_id(EmailIdentifier id)
|
|
throws EngineError.NOT_FOUND {
|
|
if (!this.id_map.has_key(id)) {
|
|
throw new EngineError.NOT_FOUND(
|
|
"Id not found: %s", id.to_string()
|
|
);
|
|
}
|
|
}
|
|
|
|
private Gee.List<EmailIdentifier> check_ids(
|
|
Gee.Collection<EmailIdentifier> to_check
|
|
) {
|
|
var available = new Gee.LinkedList<EmailIdentifier>();
|
|
var id_map = this.id_map;
|
|
var iter = to_check.iterator();
|
|
while (iter.next()) {
|
|
var id = iter.get();
|
|
if (id_map.has_key(id)) {
|
|
available.add(id);
|
|
}
|
|
}
|
|
return available;
|
|
}
|
|
|
|
// 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<EmailIdentifier>? add_ids,
|
|
Gee.Collection<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<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<EmailIdentifier>();
|
|
hashed_results.add_all(id_results);
|
|
|
|
var existing = id_map.map_iterator();
|
|
while (existing.next()) {
|
|
if (!hashed_results.contains(existing.get_key())) {
|
|
var entry = existing.get_value();
|
|
existing.unset();
|
|
contents.remove(entry);
|
|
removed.add(entry.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var email in email_results) {
|
|
if (!id_map.has_key(email.id)) {
|
|
var entry = new EmailEntry(
|
|
email.id, email.properties.date_received
|
|
);
|
|
contents.add(entry);
|
|
id_map.set(email.id, entry);
|
|
added.add(email.id);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Removing email, can just remove them directly
|
|
foreach (var id in remove_ids) {
|
|
EmailEntry entry;
|
|
if (id_map.unset(id, out entry)) {
|
|
contents.remove(entry);
|
|
removed.add(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
|
|
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 |= Folder.CountChangeReason.INSERTED;
|
|
}
|
|
if (removed.size > 0) {
|
|
notify_email_removed(removed);
|
|
reason |= Folder.CountChangeReason.REMOVED;
|
|
}
|
|
if (reason != CountChangeReason.NONE)
|
|
notify_email_count_changed(this.contents.size, reason);
|
|
}
|
|
|
|
private async void do_append(Folder folder,
|
|
Gee.Collection<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(Folder folder,
|
|
Gee.Collection<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 inline void new_contents() {
|
|
this.contents = new Gee.TreeSet<EmailEntry>(EmailEntry.compare_to);
|
|
this.id_map = new Gee.HashMap<EmailIdentifier,EmailEntry>();
|
|
}
|
|
|
|
private void include_folder(Folder folder) {
|
|
this.exclude_folders.remove(folder.path);
|
|
}
|
|
|
|
private void exclude_folder(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<Folder>? available,
|
|
Gee.Collection<Folder>? unavailable
|
|
) {
|
|
if (available != null) {
|
|
// Exclude it from searching if it's got the right special type.
|
|
foreach(var folder in traverse<Folder>(available)
|
|
.filter(f => f.used_as in EXCLUDE_TYPES))
|
|
exclude_folder(folder);
|
|
}
|
|
}
|
|
|
|
private void on_folders_use_changed(Gee.Collection<Folder> folders) {
|
|
foreach (Folder folder in folders) {
|
|
if (folder.used_as in EXCLUDE_TYPES) {
|
|
exclude_folder(folder);
|
|
} else {
|
|
include_folder(folder);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void on_email_locally_complete(Folder folder,
|
|
Gee.Collection<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 AccountProblemReport(
|
|
this.account.information, error
|
|
)
|
|
);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
private void on_account_email_removed(Folder folder,
|
|
Gee.Collection<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 AccountProblemReport(
|
|
this.account.information, error
|
|
)
|
|
);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
}
|