This mostly aims to make the Geary.Attachment and ImapDB.Attachment objects usable more generally for managing attachments, allowing these to be instantiated once, persisted, and then reused, rather than going through a number of representations (GMime, SQlite, Geary) and having to be saved and re-loaded. * src/engine/api/geary-attachment.vala (Attachment): Remove id property and allow both file and filesize properties to be set after instances are constructed. Update call sites. * src/engine/api/geary-email.vala (Email): Remove get_attachment_by_id since it unused. * src/engine/imap-db/imap-db-attachment.vala (Attachment): Chase Geary.Attachment API changes, move object-specific persistence code into methods on the actual object class itself and modernise a bit. Rename static methods to be a bit more terse. Update call sites and add unit tests. * src/engine/imap-db/imap-db-folder.vala (Folder): Rather than saving attachments to the db then reloading them to add them to their email objects, just instantiate Attachment instances once, save and then add them. * src/engine/imap-db/imap-db-gc.vala (GC): Replace custom SQL with existing accessor for listing attachments. * src/engine/util/util-stream.vala (MimeOutputStream): New adaptor class for GMime streams to GIO output streams.
1958 lines
84 KiB
Vala
1958 lines
84 KiB
Vala
/* 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.Account : BaseObject {
|
|
private const int POPULATE_SEARCH_TABLE_DELAY_SEC = 5;
|
|
|
|
// These characters are chosen for being commonly used to continue a single word (such as
|
|
// extended last names, i.e. "Lars-Eric") or in terms commonly searched for in an email client,
|
|
// i.e. unadorned mailbox addresses. Note that characters commonly used for wildcards or that
|
|
// would be interpreted as wildcards by SQLite are not included here.
|
|
private const unichar[] SEARCH_TERM_CONTINUATION_CHARS = { '-', '_', '.', '@' };
|
|
|
|
// Search operator field names, eg: "to:foo@example.com" or "is:unread"
|
|
private const string SEARCH_OP_ATTACHMENT = "attachment";
|
|
private const string SEARCH_OP_BCC = "bcc";
|
|
private const string SEARCH_OP_BODY = "body";
|
|
private const string SEARCH_OP_CC = "cc";
|
|
private const string SEARCH_OP_FROM = "from_field";
|
|
private const string SEARCH_OP_IS = "is";
|
|
private const string SEARCH_OP_SUBJECT = "subject";
|
|
private const string SEARCH_OP_TO = "receivers";
|
|
|
|
// Operators allowing finding mail addressed to "me"
|
|
private const string[] SEARCH_OP_TO_ME_FIELDS = {
|
|
SEARCH_OP_BCC,
|
|
SEARCH_OP_CC,
|
|
SEARCH_OP_TO,
|
|
};
|
|
|
|
// The addressable op value for "me"
|
|
private const string SEARCH_OP_ADDRESSABLE_VALUE_ME = "me";
|
|
|
|
// Search operator field values
|
|
private const string SEARCH_OP_VALUE_READ = "read";
|
|
private const string SEARCH_OP_VALUE_STARRED = "starred";
|
|
private const string SEARCH_OP_VALUE_UNREAD = "unread";
|
|
|
|
// Storage path names
|
|
private const string DB_FILENAME = "geary.db";
|
|
private const string ATTACHMENTS_DIR = "attachments";
|
|
|
|
/**
|
|
* Returns the on-disk paths used for storage by this account.
|
|
*/
|
|
public static void get_imap_db_storage_locations(File user_data_dir, out File db_file,
|
|
out File attachments_dir) {
|
|
db_file = user_data_dir.get_child(DB_FILENAME);
|
|
attachments_dir = user_data_dir.get_child(ATTACHMENTS_DIR);
|
|
}
|
|
|
|
|
|
private class FolderReference : Geary.SmartReference {
|
|
public Geary.FolderPath path;
|
|
|
|
public FolderReference(ImapDB.Folder folder, Geary.FolderPath path) {
|
|
base (folder);
|
|
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
// Maps of localised search operator names and values to their
|
|
// internal forms
|
|
private static Gee.HashMap<string, string> search_op_names =
|
|
new Gee.HashMap<string, string>();
|
|
private static Gee.ArrayList<string> search_op_to_me_values =
|
|
new Gee.ArrayList<string>();
|
|
private static Gee.ArrayList<string> search_op_from_me_values =
|
|
new Gee.ArrayList<string>();
|
|
private static Gee.HashMap<string, string> search_op_is_values =
|
|
new Gee.HashMap<string, string>();
|
|
|
|
public signal void email_sent(Geary.RFC822.Message rfc822);
|
|
|
|
public signal void contacts_loaded();
|
|
|
|
// Only available when the Account is opened
|
|
public SmtpOutboxFolder? outbox { get; private set; default = null; }
|
|
public ImapEngine.ContactStore contact_store { get; private set; }
|
|
public IntervalProgressMonitor search_index_monitor { get; private set;
|
|
default = new IntervalProgressMonitor(ProgressType.SEARCH_INDEX, 0, 0); }
|
|
public SimpleProgressMonitor upgrade_monitor { get; private set; default = new SimpleProgressMonitor(
|
|
ProgressType.DB_UPGRADE); }
|
|
public SimpleProgressMonitor vacuum_monitor { get; private set; default = new SimpleProgressMonitor(
|
|
ProgressType.DB_VACUUM); }
|
|
public SimpleProgressMonitor sending_monitor { get; private set;
|
|
default = new SimpleProgressMonitor(ProgressType.ACTIVITY); }
|
|
|
|
private string name;
|
|
private AccountInformation account_information;
|
|
private ImapDB.Database? db = null;
|
|
private Gee.HashMap<Geary.FolderPath, FolderReference> folder_refs =
|
|
new Gee.HashMap<Geary.FolderPath, FolderReference>();
|
|
private Cancellable? background_cancellable = null;
|
|
|
|
static construct {
|
|
// Map of possibly translated search operator names and values
|
|
// to English/internal names and values. We include the
|
|
// English version anyway so that when translations provide a
|
|
// localised version of the operator names but have not also
|
|
// translated the user manual, the English version in the
|
|
// manual still works.
|
|
|
|
// Can be typed in the search box like "attachment:file.txt"
|
|
// to find messages with attachments with a particular name.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "attachment"), SEARCH_OP_ATTACHMENT);
|
|
// Can be typed in the search box like
|
|
// "bcc:johndoe@example.com" to find messages bcc'd to a
|
|
// particular person.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "bcc"), SEARCH_OP_BCC);
|
|
// Can be typed in the search box like "body:word" to find
|
|
// "word" only if it occurs in the body of a message.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "body"), SEARCH_OP_BODY);
|
|
// Can be typed in the search box like
|
|
// "cc:johndoe@example.com" to find messages cc'd to a
|
|
// particular person.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "cc"), SEARCH_OP_CC);
|
|
// Can be typed in the search box like
|
|
// "from:johndoe@example.com" to find messages from a
|
|
// particular sender.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "from"), SEARCH_OP_FROM);
|
|
// Can be typed in the search box like "is:unread" to find
|
|
// messages that are read, unread, or starred.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "is"), SEARCH_OP_IS);
|
|
// Can be typed in the search box like "subject:word" to find
|
|
// "word" only if it occurs in the subject of a message.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary
|
|
// User Guide.
|
|
search_op_names.set(C_("Search operator", "subject"), SEARCH_OP_SUBJECT);
|
|
// Can be typed in the search box like
|
|
// "to:johndoe@example.com" to find messages received by a
|
|
// particular person.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_names.set(C_("Search operator", "to"), SEARCH_OP_TO);
|
|
|
|
// And the English language versions
|
|
search_op_names.set("attachment", SEARCH_OP_ATTACHMENT);
|
|
search_op_names.set("bcc", SEARCH_OP_BCC);
|
|
search_op_names.set("body", SEARCH_OP_BODY);
|
|
search_op_names.set("cc", SEARCH_OP_CC);
|
|
search_op_names.set("from", SEARCH_OP_FROM);
|
|
search_op_names.set("is", SEARCH_OP_IS);
|
|
search_op_names.set("subject", SEARCH_OP_SUBJECT);
|
|
search_op_names.set("to", SEARCH_OP_TO);
|
|
|
|
// Can be typed in the search box after "to:", "cc:" and
|
|
// "bcc:" e.g.: "to:me". Matches conversations that are
|
|
// addressed to the user.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_to_me_values.add(
|
|
C_("Search operator value - mail addressed to the user", "me")
|
|
);
|
|
search_op_to_me_values.add(SEARCH_OP_ADDRESSABLE_VALUE_ME);
|
|
|
|
// Can be typed in the search box after "from:" i.e.:
|
|
// "from:me". Matches conversations were sent by the user.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_from_me_values.add(
|
|
C_("Search operator value - mail sent by the user", "me")
|
|
);
|
|
search_op_from_me_values.add(SEARCH_OP_ADDRESSABLE_VALUE_ME);
|
|
|
|
// Can be typed in the search box after "is:" i.e.:
|
|
// "is:read". Matches conversations that are flagged as read.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_is_values.set(
|
|
C_("'is:' search operator value", "read"), SEARCH_OP_VALUE_READ
|
|
);
|
|
// Can be typed in the search box after "is:" i.e.:
|
|
// "is:starred". Matches conversations that are flagged as
|
|
// starred.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_is_values.set(
|
|
C_("'is:' search operator value", "starred"), SEARCH_OP_VALUE_STARRED
|
|
);
|
|
// Can be typed in the search box after "is:" i.e.:
|
|
// "is:unread". Matches conversations that are flagged unread.
|
|
//
|
|
// The translated string must be a single word (use '-', '_'
|
|
// or similar to combine words into one), should be short, and
|
|
// also match the translation in "search.page" of the Geary User
|
|
// Guide.
|
|
search_op_is_values.set(
|
|
C_("'is:' search operator value", "unread"), SEARCH_OP_VALUE_UNREAD
|
|
);
|
|
search_op_is_values.set(SEARCH_OP_VALUE_READ, SEARCH_OP_VALUE_READ);
|
|
search_op_is_values.set(SEARCH_OP_VALUE_STARRED, SEARCH_OP_VALUE_STARRED);
|
|
search_op_is_values.set(SEARCH_OP_VALUE_UNREAD, SEARCH_OP_VALUE_UNREAD);
|
|
}
|
|
|
|
public Account(Geary.AccountInformation account_information) {
|
|
this.account_information = account_information;
|
|
this.contact_store = new ImapEngine.ContactStore(this);
|
|
this.name = account_information.id + ":db";
|
|
}
|
|
|
|
private void check_open() throws Error {
|
|
if (db == null)
|
|
throw new EngineError.OPEN_REQUIRED("Database not open");
|
|
}
|
|
|
|
private ImapDB.SearchQuery check_search_query(Geary.SearchQuery q) throws Error {
|
|
ImapDB.SearchQuery? query = q as ImapDB.SearchQuery;
|
|
if (query == null || query.account != this)
|
|
throw new EngineError.BAD_PARAMETERS("Geary.SearchQuery not associated with %s", name);
|
|
|
|
return query;
|
|
}
|
|
|
|
public async void open_async(File user_data_dir, File schema_dir, Cancellable? cancellable)
|
|
throws Error {
|
|
if (this.db != null)
|
|
throw new EngineError.ALREADY_OPEN("IMAP database already open");
|
|
|
|
File db_file;
|
|
File attachments_dir;
|
|
Account.get_imap_db_storage_locations(
|
|
user_data_dir, out db_file, out attachments_dir
|
|
);
|
|
|
|
this.db = new ImapDB.Database(
|
|
db_file,
|
|
schema_dir,
|
|
attachments_dir,
|
|
upgrade_monitor,
|
|
vacuum_monitor,
|
|
account_information.primary_mailbox.address
|
|
);
|
|
|
|
try {
|
|
yield db.open(
|
|
Db.DatabaseFlags.CREATE_DIRECTORY | Db.DatabaseFlags.CREATE_FILE | Db.DatabaseFlags.CHECK_CORRUPTION,
|
|
cancellable);
|
|
} catch (Error err) {
|
|
warning("Unable to open database: %s", err.message);
|
|
|
|
// close database before exiting
|
|
db.close(null);
|
|
db = null;
|
|
|
|
throw err;
|
|
}
|
|
|
|
// have seen cases where multiple "Inbox" folders are created in the root with different
|
|
// case names, leading to trouble ... this clears out all Inboxes that don't match our
|
|
// "canonical" name
|
|
try {
|
|
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
|
|
Db.Statement stmt = cx.prepare("""
|
|
SELECT id, name
|
|
FROM FolderTable
|
|
WHERE parent_id IS NULL
|
|
""");
|
|
|
|
Db.Result results = stmt.exec(cancellable);
|
|
while (!results.finished) {
|
|
string name = results.string_for("name");
|
|
if (Imap.MailboxSpecifier.is_inbox_name(name)
|
|
&& !Imap.MailboxSpecifier.is_canonical_inbox_name(name)) {
|
|
debug("%s: Removing duplicate INBOX \"%s\"", this.name, name);
|
|
do_delete_folder(cx, results.rowid_for("id"), cancellable);
|
|
}
|
|
|
|
results.next(cancellable);
|
|
}
|
|
|
|
return Db.TransactionOutcome.COMMIT;
|
|
}, cancellable);
|
|
} catch (Error err) {
|
|
debug("Error trimming duplicate INBOX from database: %s", err.message);
|
|
|
|
// drop database to indicate closed
|
|
db = null;
|
|
|
|
throw err;
|
|
}
|
|
|
|
Geary.Account account;
|
|
try {
|
|
account = Geary.Engine.instance.get_account_instance(account_information);
|
|
} catch (Error e) {
|
|
// If they're opening an account, the engine should already be
|
|
// open, and there should be no reason for this to fail. Thus, if
|
|
// we get here, it's a programmer error.
|
|
|
|
error("Error finding account from its information: %s", e.message);
|
|
}
|
|
|
|
background_cancellable = new Cancellable();
|
|
|
|
// Kick off a background update of the search table, but since the database is getting
|
|
// hammered at startup, wait a bit before starting the update ... use the ordinal to
|
|
// stagger these being fired off (important for users with many accounts registered)
|
|
int account_sec = account_information.ordinal.clamp(0, 10);
|
|
Timeout.add_seconds(POPULATE_SEARCH_TABLE_DELAY_SEC + account_sec, () => {
|
|
populate_search_table_async.begin(background_cancellable);
|
|
|
|
return false;
|
|
});
|
|
|
|
initialize_contacts(cancellable);
|
|
|
|
// ImapDB.Account holds the Outbox, which is tied to the database it maintains
|
|
outbox = new SmtpOutboxFolder(db, account, sending_monitor);
|
|
outbox.email_sent.connect(on_outbox_email_sent);
|
|
}
|
|
|
|
public async void close_async(Cancellable? cancellable) throws Error {
|
|
if (db == null)
|
|
return;
|
|
|
|
// close and always drop reference
|
|
try {
|
|
db.close(cancellable);
|
|
} finally {
|
|
db = null;
|
|
}
|
|
|
|
this.background_cancellable.cancel();
|
|
this.background_cancellable = null;
|
|
|
|
this.folder_refs.clear();
|
|
|
|
this.outbox.email_sent.disconnect(on_outbox_email_sent);
|
|
this.outbox = null;
|
|
}
|
|
|
|
private void on_outbox_email_sent(Geary.RFC822.Message rfc822) {
|
|
email_sent(rfc822);
|
|
}
|
|
|
|
public async void clone_folder_async(Geary.Imap.Folder imap_folder, Cancellable? cancellable = null)
|
|
throws Error {
|
|
check_open();
|
|
|
|
Geary.Imap.FolderProperties properties = imap_folder.properties;
|
|
Geary.FolderPath path = imap_folder.path;
|
|
|
|
// XXX this should really be a db table constraint
|
|
Geary.ImapDB.Folder? folder = get_local_folder(path);
|
|
if (folder != null)
|
|
throw new EngineError.ALREADY_EXISTS(path.to_string());
|
|
|
|
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
|
|
// get the parent of this folder, creating parents if necessary ... ok if this fails,
|
|
// that just means the folder has no parents
|
|
int64 parent_id = Db.INVALID_ROWID;
|
|
if (!do_fetch_parent_id(cx, path, true, out parent_id, cancellable)) {
|
|
debug("Unable to find parent ID to %s clone folder", path.to_string());
|
|
|
|
return Db.TransactionOutcome.ROLLBACK;
|
|
}
|
|
|
|
// create the folder object
|
|
Db.Statement stmt = cx.prepare(
|
|
"INSERT INTO FolderTable (name, parent_id, last_seen_total, last_seen_status_total, "
|
|
+ "uid_validity, uid_next, attributes, unread_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
stmt.bind_string(0, path.basename);
|
|
stmt.bind_rowid(1, parent_id);
|
|
stmt.bind_int(2, Numeric.int_floor(properties.select_examine_messages, 0));
|
|
stmt.bind_int(3, Numeric.int_floor(properties.status_messages, 0));
|
|
stmt.bind_int64(4, (properties.uid_validity != null) ? properties.uid_validity.value
|
|
: Imap.UIDValidity.INVALID);
|
|
stmt.bind_int64(5, (properties.uid_next != null) ? properties.uid_next.value
|
|
: Imap.UID.INVALID);
|
|
stmt.bind_string(6, properties.attrs.serialize());
|
|
stmt.bind_int(7, properties.email_unread);
|
|
|
|
stmt.exec(cancellable);
|
|
|
|
return Db.TransactionOutcome.COMMIT;
|
|
}, cancellable);
|
|
}
|
|
|
|
public async void delete_folder_async(Geary.Folder folder, Cancellable? cancellable)
|
|
throws Error {
|
|
check_open();
|
|
|
|
Geary.FolderPath path = folder.path;
|
|
|
|
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
|
|
int64 folder_id;
|
|
do_fetch_folder_id(cx, path, false, out folder_id, cancellable);
|
|
if (folder_id == Db.INVALID_ROWID)
|
|
return Db.TransactionOutcome.ROLLBACK;
|
|
|
|
if (do_has_children(cx, folder_id, cancellable)) {
|
|
debug("Can't delete folder %s because it has children", folder.to_string());
|
|
return Db.TransactionOutcome.ROLLBACK;
|
|
}
|
|
|
|
do_delete_folder(cx, folder_id, cancellable);
|
|
|
|
return Db.TransactionOutcome.COMMIT;
|
|
}, cancellable);
|
|
}
|
|
|
|
private void initialize_contacts(Cancellable? cancellable = null) throws Error {
|
|
check_open();
|
|
|
|
Gee.Collection<Contact> contacts = new Gee.LinkedList<Contact>();
|
|
Db.TransactionOutcome outcome = db.exec_transaction(Db.TransactionType.RO,
|
|
(context) => {
|
|
Db.Statement statement = context.prepare(
|
|
"SELECT email, real_name, highest_importance, normalized_email, flags " +
|
|
"FROM ContactTable");
|
|
|
|
Db.Result result = statement.exec(cancellable);
|
|
while (!result.finished) {
|
|
try {
|
|
Contact contact = new Contact(result.nonnull_string_at(0), result.string_at(1),
|
|
result.int_at(2), result.string_at(3), ContactFlags.deserialize(result.string_at(4)));
|
|
contacts.add(contact);
|
|
} catch (Geary.DatabaseError err) {
|
|
// We don't want to abandon loading all contacts just because there was a
|
|
// problem with one.
|
|
debug("Problem loading contact: %s", err.message);
|
|
}
|
|
|
|
result.next();
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
if (outcome == Db.TransactionOutcome.DONE) {
|
|
contact_store.update_contacts(contacts);
|
|
contacts_loaded();
|
|
}
|
|
}
|
|
|
|
public async Gee.Collection<Geary.ImapDB.Folder> list_folders_async(Geary.FolderPath? parent,
|
|
Cancellable? cancellable = null) throws Error {
|
|
check_open();
|
|
|
|
// TODO: A better solution here would be to only pull the FolderProperties if the Folder
|
|
// object itself doesn't already exist
|
|
Gee.HashMap<Geary.FolderPath, int64?> id_map = new Gee.HashMap<
|
|
Geary.FolderPath, int64?>();
|
|
Gee.HashMap<Geary.FolderPath, Geary.Imap.FolderProperties> prop_map = new Gee.HashMap<
|
|
Geary.FolderPath, Geary.Imap.FolderProperties>();
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
int64 parent_id = Db.INVALID_ROWID;
|
|
if (parent != null) {
|
|
if (!do_fetch_folder_id(cx, parent, false, out parent_id, cancellable)) {
|
|
debug("Unable to find folder ID for %s to list folders", parent.to_string());
|
|
|
|
return Db.TransactionOutcome.ROLLBACK;
|
|
}
|
|
|
|
if (parent_id == Db.INVALID_ROWID)
|
|
throw new EngineError.NOT_FOUND("Folder %s not found", parent.to_string());
|
|
}
|
|
|
|
Db.Statement stmt;
|
|
if (parent_id != Db.INVALID_ROWID) {
|
|
stmt = cx.prepare(
|
|
"SELECT id, name, last_seen_total, unread_count, last_seen_status_total, "
|
|
+ "uid_validity, uid_next, attributes FROM FolderTable WHERE parent_id=?");
|
|
stmt.bind_rowid(0, parent_id);
|
|
} else {
|
|
stmt = cx.prepare(
|
|
"SELECT id, name, last_seen_total, unread_count, last_seen_status_total, "
|
|
+ "uid_validity, uid_next, attributes FROM FolderTable WHERE parent_id IS NULL");
|
|
}
|
|
|
|
Db.Result result = stmt.exec(cancellable);
|
|
while (!result.finished) {
|
|
string basename = result.string_for("name");
|
|
|
|
// ignore anything that's not canonical Inbox
|
|
if (parent == null
|
|
&& Imap.MailboxSpecifier.is_inbox_name(basename)
|
|
&& !Imap.MailboxSpecifier.is_canonical_inbox_name(basename)) {
|
|
result.next(cancellable);
|
|
|
|
continue;
|
|
}
|
|
|
|
Geary.FolderPath path = (parent != null)
|
|
? parent.get_child(basename)
|
|
: new Imap.FolderRoot(basename);
|
|
|
|
Geary.Imap.FolderProperties properties = new Geary.Imap.FolderProperties.from_imapdb(
|
|
Geary.Imap.MailboxAttributes.deserialize(result.string_for("attributes")),
|
|
result.int_for("last_seen_total"),
|
|
result.int_for("unread_count"),
|
|
new Imap.UIDValidity(result.int64_for("uid_validity")),
|
|
new Imap.UID(result.int64_for("uid_next"))
|
|
);
|
|
// due to legacy code, can't set last_seen_total to -1 to indicate that the folder
|
|
// hasn't been SELECT/EXAMINE'd yet, so the STATUS count should be used as the
|
|
// authoritative when the other is zero ... this is important when first creating a
|
|
// folder, as the STATUS is the count that is known first
|
|
properties.set_status_message_count(result.int_for("last_seen_status_total"),
|
|
(properties.select_examine_messages == 0));
|
|
|
|
id_map.set(path, result.rowid_for("id"));
|
|
prop_map.set(path, properties);
|
|
|
|
result.next(cancellable);
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
assert(id_map.size == prop_map.size);
|
|
|
|
if (id_map.size == 0) {
|
|
throw new EngineError.NOT_FOUND("No local folders in %s",
|
|
(parent != null) ? parent.to_string() : "root");
|
|
}
|
|
|
|
Gee.Collection<Geary.ImapDB.Folder> folders = new Gee.ArrayList<Geary.ImapDB.Folder>();
|
|
foreach (Geary.FolderPath path in id_map.keys) {
|
|
Geary.ImapDB.Folder? folder = get_local_folder(path);
|
|
if (folder == null && id_map.has_key(path) && prop_map.has_key(path))
|
|
folder = create_local_folder(path, id_map.get(path), prop_map.get(path));
|
|
|
|
folders.add(folder);
|
|
}
|
|
|
|
return folders;
|
|
}
|
|
|
|
public async bool folder_exists_async(Geary.FolderPath path, Cancellable? cancellable = null)
|
|
throws Error {
|
|
check_open();
|
|
|
|
bool exists = false;
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
try {
|
|
int64 folder_id;
|
|
do_fetch_folder_id(cx, path, false, out folder_id, cancellable);
|
|
|
|
exists = (folder_id != Db.INVALID_ROWID);
|
|
} catch (EngineError err) {
|
|
// treat NOT_FOUND as non-exceptional situation
|
|
if (!(err is EngineError.NOT_FOUND))
|
|
throw err;
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
return exists;
|
|
}
|
|
|
|
public async Geary.ImapDB.Folder fetch_folder_async(Geary.FolderPath path, Cancellable? cancellable)
|
|
throws Error {
|
|
check_open();
|
|
|
|
// check references table first
|
|
Geary.ImapDB.Folder? folder = get_local_folder(path);
|
|
if (folder != null)
|
|
return folder;
|
|
|
|
int64 folder_id = Db.INVALID_ROWID;
|
|
Imap.FolderProperties? properties = null;
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
if (!do_fetch_folder_id(cx, path, false, out folder_id, cancellable))
|
|
return Db.TransactionOutcome.DONE;
|
|
|
|
if (folder_id == Db.INVALID_ROWID)
|
|
return Db.TransactionOutcome.DONE;
|
|
|
|
Db.Statement stmt = cx.prepare(
|
|
"SELECT last_seen_total, unread_count, last_seen_status_total, uid_validity, uid_next, "
|
|
+ "attributes FROM FolderTable WHERE id=?");
|
|
stmt.bind_rowid(0, folder_id);
|
|
|
|
Db.Result results = stmt.exec(cancellable);
|
|
if (!results.finished) {
|
|
properties = new Imap.FolderProperties.from_imapdb(
|
|
Geary.Imap.MailboxAttributes.deserialize(results.string_for("attributes")),
|
|
results.int_for("last_seen_total"),
|
|
results.int_for("unread_count"),
|
|
new Imap.UIDValidity(results.int64_for("uid_validity")),
|
|
new Imap.UID(results.int64_for("uid_next"))
|
|
);
|
|
// due to legacy code, can't set last_seen_total to -1 to indicate that the folder
|
|
// hasn't been SELECT/EXAMINE'd yet, so the STATUS count should be used as the
|
|
// authoritative when the other is zero ... this is important when first creating a
|
|
// folder, as the STATUS is the count that is known first
|
|
properties.set_status_message_count(
|
|
results.int_for("last_seen_status_total"),
|
|
(properties.select_examine_messages == 0)
|
|
);
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
if (folder_id == Db.INVALID_ROWID || properties == null)
|
|
throw new EngineError.NOT_FOUND("%s not found in local database", path.to_string());
|
|
|
|
return create_local_folder(path, folder_id, properties);
|
|
}
|
|
|
|
private Geary.ImapDB.Folder? get_local_folder(Geary.FolderPath path) {
|
|
FolderReference? folder_ref = folder_refs.get(path);
|
|
if (folder_ref == null)
|
|
return null;
|
|
|
|
ImapDB.Folder? folder = (Geary.ImapDB.Folder?) folder_ref.get_reference();
|
|
if (folder == null)
|
|
return null;
|
|
|
|
return folder;
|
|
}
|
|
|
|
private Geary.ImapDB.Folder create_local_folder(Geary.FolderPath path, int64 folder_id,
|
|
Imap.FolderProperties properties) throws Error {
|
|
// return current if already created
|
|
ImapDB.Folder? folder = get_local_folder(path);
|
|
if (folder != null) {
|
|
folder.set_properties(properties);
|
|
} else {
|
|
folder = new Geary.ImapDB.Folder(
|
|
db,
|
|
path,
|
|
db.attachments_path,
|
|
contact_store,
|
|
account_information.primary_mailbox.address,
|
|
folder_id,
|
|
properties
|
|
);
|
|
|
|
// build a reference to it
|
|
FolderReference folder_ref = new FolderReference(folder, path);
|
|
folder_ref.reference_broken.connect(on_folder_reference_broken);
|
|
|
|
// add to the references table
|
|
folder_refs.set(folder_ref.path, folder_ref);
|
|
|
|
folder.unread_updated.connect(on_unread_updated);
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
private void on_folder_reference_broken(Geary.SmartReference reference) {
|
|
FolderReference folder_ref = (FolderReference) reference;
|
|
|
|
// drop from folder references table, all cleaned up
|
|
folder_refs.unset(folder_ref.path);
|
|
}
|
|
|
|
public async Gee.MultiMap<Geary.Email, Geary.FolderPath?>? search_message_id_async(
|
|
Geary.RFC822.MessageID message_id, Geary.Email.Field requested_fields, bool partial_ok,
|
|
Gee.Collection<Geary.FolderPath?>? folder_blacklist, Geary.EmailFlags? flag_blacklist,
|
|
Cancellable? cancellable = null) throws Error {
|
|
check_open();
|
|
|
|
Gee.HashMultiMap<Geary.Email, Geary.FolderPath?> messages
|
|
= new Gee.HashMultiMap<Geary.Email, Geary.FolderPath?>();
|
|
|
|
if (flag_blacklist != null)
|
|
requested_fields = requested_fields | Geary.Email.Field.FLAGS;
|
|
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
Db.Statement stmt = cx.prepare("SELECT id FROM MessageTable WHERE message_id = ? OR in_reply_to = ?");
|
|
stmt.bind_string(0, message_id.value);
|
|
stmt.bind_string(1, message_id.value);
|
|
|
|
Db.Result result = stmt.exec(cancellable);
|
|
while (!result.finished) {
|
|
int64 id = result.int64_at(0);
|
|
Geary.Email.Field db_fields;
|
|
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
|
|
cx, id, requested_fields, out db_fields, cancellable);
|
|
|
|
// Ignore any messages that don't have the required fields.
|
|
if (partial_ok || row.fields.fulfills(requested_fields)) {
|
|
Geary.Email email = row.to_email(new Geary.ImapDB.EmailIdentifier(id, null));
|
|
Attachment.add_attachments(
|
|
cx, this.db.attachments_path, email, id, cancellable
|
|
);
|
|
|
|
Gee.Set<Geary.FolderPath>? folders = do_find_email_folders(cx, id, true, cancellable);
|
|
if (folders == null) {
|
|
if (folder_blacklist == null || !folder_blacklist.contains(null))
|
|
messages.set(email, null);
|
|
} else {
|
|
foreach (Geary.FolderPath path in folders) {
|
|
// If it's in a blacklisted folder, we don't report
|
|
// it at all.
|
|
if (folder_blacklist != null && folder_blacklist.contains(path)) {
|
|
messages.remove_all(email);
|
|
break;
|
|
} else {
|
|
messages.set(email, path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for blacklisted flags.
|
|
if (flag_blacklist != null && email.email_flags != null &&
|
|
email.email_flags.contains_any(flag_blacklist))
|
|
messages.remove_all(email);
|
|
}
|
|
|
|
result.next(cancellable);
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
return (messages.size == 0 ? null : messages);
|
|
}
|
|
|
|
private string? extract_field_from_token(string[] parts, ref string token) {
|
|
string? field = null;
|
|
if (Geary.String.is_empty_or_whitespace(parts[1])) {
|
|
// User stopped at "field:", treat it as if they hadn't
|
|
// typed the ':'
|
|
token = parts[0];
|
|
} else {
|
|
field = search_op_names.get(parts[0].down());
|
|
if (field == SEARCH_OP_IS) {
|
|
string? value = search_op_is_values.get(parts[1].down());
|
|
if (value != null) {
|
|
token = value;
|
|
} else {
|
|
// Unknown op value, pretend there is no search op
|
|
field = null;
|
|
}
|
|
} else if (field == SEARCH_OP_FROM &&
|
|
parts[1].down() in search_op_from_me_values) {
|
|
// Search for all addresses on the account. Bug 768779
|
|
token = account_information.primary_mailbox.address;
|
|
} else if (field in SEARCH_OP_TO_ME_FIELDS &&
|
|
parts[1].down() in search_op_to_me_values) {
|
|
// Search for all addresses on the account. Bug 768779
|
|
token = account_information.primary_mailbox.address;
|
|
} else if (field != null) {
|
|
token = parts[1];
|
|
}
|
|
}
|
|
return field;
|
|
}
|
|
|
|
/**
|
|
* This method is used to convert an unquoted user-entered search terms into a stemmed search
|
|
* term.
|
|
*
|
|
* Prior experience with the Unicode Snowball stemmer indicates it's too aggressive for our
|
|
* tastes when coupled with prefix-matching of all unquoted terms (see
|
|
* https://bugzilla.gnome.org/show_bug.cgi?id=713179) This method is part of a larger strategy
|
|
* designed to dampen that aggressiveness without losing the benefits of stemming entirely.
|
|
*
|
|
* Database upgrade 23 removes the old Snowball-stemmed FTS table and replaces it with one
|
|
* with no stemming (using only SQLite's "simple" tokenizer). It also creates a "magic" SQLite
|
|
* table called TokenizerTable which allows for uniform queries to the Snowball stemmer, which
|
|
* is still installed in Geary. Thus, we are now in the position to search for the original
|
|
* term and its stemmed variant, then do post-search processing to strip results which are
|
|
* too "greedy" due to prefix-matching the stemmed variant.
|
|
*
|
|
* Some heuristics are in place simply to determine if stemming should occur:
|
|
*
|
|
* # If stemming is unallowed, no stemming occurs.
|
|
* # If the term is < min. term length for stemming, no stemming occurs.
|
|
* # If the stemmer returns a stem that is the same as the original term, no stemming occurs.
|
|
* # If the difference between the stemmed word and the original term is more than
|
|
* maximum allowed, no stemming occurs. This works under the assumption that if
|
|
* the user has typed a long word, they do not want to "go back" to searching for a much
|
|
* shorter version of it. (For example, "accountancies" stems to "account").
|
|
*
|
|
* Otherwise, the stem for the term is returned.
|
|
*/
|
|
private string? stem_search_term(ImapDB.SearchQuery query, string term) {
|
|
if (!query.allow_stemming)
|
|
return null;
|
|
|
|
int term_length = term.length;
|
|
if (term_length < query.min_term_length_for_stemming)
|
|
return null;
|
|
|
|
string? stemmed = null;
|
|
try {
|
|
Db.Statement stmt = db.prepare("""
|
|
SELECT token
|
|
FROM TokenizerTable
|
|
WHERE input=?
|
|
""");
|
|
stmt.bind_string(0, term);
|
|
|
|
// get stemmed string; if no result, fall through
|
|
Db.Result result = stmt.exec();
|
|
if (!result.finished)
|
|
stemmed = result.string_at(0);
|
|
else
|
|
debug("No stemmed term returned for \"%s\"", term);
|
|
} catch (Error err) {
|
|
debug("Unable to query tokenizer table for stemmed term for \"%s\": %s", term, err.message);
|
|
|
|
// fall-through
|
|
}
|
|
|
|
if (String.is_empty(stemmed)) {
|
|
debug("Empty stemmed term returned for \"%s\"", term);
|
|
|
|
return null;
|
|
}
|
|
|
|
// If same term returned, treat as non-stemmed
|
|
if (stemmed == term)
|
|
return null;
|
|
|
|
// Don't search for stemmed words that are significantly shorter than the user's search term
|
|
if (term_length - stemmed.length > query.max_difference_term_stem_lengths) {
|
|
debug("Stemmed \"%s\" dropped searching for \"%s\": too much distance in terms",
|
|
stemmed, term);
|
|
|
|
return null;
|
|
}
|
|
|
|
debug("Search processing: term -> stem is \"%s\" -> \"%s\"", term, stemmed);
|
|
|
|
return stemmed;
|
|
}
|
|
|
|
private void prepare_search_query(ImapDB.SearchQuery query) {
|
|
if (query.parsed)
|
|
return;
|
|
|
|
// A few goals here:
|
|
// 1) Append an * after every term so it becomes a prefix search
|
|
// (see <https://www.sqlite.org/fts3.html#section_3>)
|
|
// 2) Strip out common words/operators that might get interpreted as
|
|
// search operators
|
|
// 3) Parse each word into a list of which field it applies to, so
|
|
// you can do "to:johndoe@example.com thing" (quotes excluded)
|
|
// to find messages to John containing the word thing
|
|
// We ignore everything inside quotes to give the user a way to
|
|
// override our algorithm here. The idea is to offer one search query
|
|
// syntax for Geary that we can use locally and via IMAP, etc.
|
|
|
|
string quote_balanced = query.raw;
|
|
if (Geary.String.count_char(query.raw, '"') % 2 != 0) {
|
|
// Remove the last quote if it's not balanced. This has the
|
|
// benefit of showing decent results as you type a quoted phrase.
|
|
int last_quote = query.raw.last_index_of_char('"');
|
|
assert(last_quote >= 0);
|
|
quote_balanced = query.raw.splice(last_quote, last_quote + 1, " ");
|
|
}
|
|
|
|
string[] words = quote_balanced.split_set(" \t\r\n()%*\\");
|
|
bool in_quote = false;
|
|
foreach (string s in words) {
|
|
string? field = null;
|
|
|
|
s = s.strip();
|
|
|
|
int quotes = Geary.String.count_char(s, '"');
|
|
if (!in_quote && quotes > 0) {
|
|
in_quote = true;
|
|
--quotes;
|
|
}
|
|
|
|
SearchTerm? 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);
|
|
} else {
|
|
string original = s;
|
|
|
|
// Some common search phrases we don't respect and
|
|
// therefore don't want to fall through to search
|
|
// results
|
|
// XXX translate these
|
|
string lower = s.down();
|
|
switch (lower) {
|
|
case "":
|
|
case "and":
|
|
case "or":
|
|
case "not":
|
|
case "near":
|
|
continue;
|
|
|
|
default:
|
|
if (lower.has_prefix("near/"))
|
|
continue;
|
|
break;
|
|
}
|
|
|
|
if (s.has_prefix("-"))
|
|
s = s.substring(1);
|
|
|
|
if (s == "")
|
|
continue;
|
|
|
|
// TODO: support quotes after :
|
|
string[] parts = s.split(":", 2);
|
|
if (parts.length > 1)
|
|
field = extract_field_from_token(parts, ref s);
|
|
|
|
if (field == SEARCH_OP_IS) {
|
|
// s will have been de-translated
|
|
term = new SearchTerm(original, s, null, null, null);
|
|
} else {
|
|
// SQL MATCH syntax for parsed term
|
|
string? sql_s = "%s*".printf(s);
|
|
|
|
// stem the word, but if stemmed and stem is
|
|
// simply shorter version of original term, only
|
|
// prefix-match search for it (i.e. avoid
|
|
// searching for [archive* OR archiv*] when that's
|
|
// the same as [archiv*]), otherwise search for
|
|
// both
|
|
string? stemmed = stem_search_term(query, s);
|
|
|
|
string? sql_stemmed = null;
|
|
if (stemmed != null) {
|
|
sql_stemmed = "%s*".printf(stemmed);
|
|
if (s.has_prefix(stemmed))
|
|
sql_s = null;
|
|
}
|
|
|
|
// if term contains continuation characters, treat
|
|
// as exact search to reduce effects of tokenizer
|
|
// splitting terms w/ punctuation in them
|
|
if (String.contains_any_char(s, SEARCH_TERM_CONTINUATION_CHARS))
|
|
s = "\"%s\"".printf(s);
|
|
|
|
term = new SearchTerm(original, s, stemmed, sql_s, sql_stemmed);
|
|
}
|
|
}
|
|
|
|
if (in_quote && quotes % 2 != 0)
|
|
in_quote = false;
|
|
|
|
query.add_search_term(field, term);
|
|
}
|
|
|
|
assert(!in_quote);
|
|
|
|
query.parsed = true;
|
|
}
|
|
|
|
// Return a map of column -> phrase, to use as WHERE column MATCH 'phrase'.
|
|
private Gee.HashMap<string, string> get_query_phrases(ImapDB.SearchQuery query) {
|
|
prepare_search_query(query);
|
|
|
|
Gee.HashMap<string, string> phrases = new Gee.HashMap<string, string>();
|
|
foreach (string? field in query.get_fields()) {
|
|
Gee.List<SearchTerm>? terms = query.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 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:
|
|
//
|
|
// (party* OR parti*) AND (eventful* OR event*)
|
|
//
|
|
// Obviously with stemming there's the possibility of the stemmed variant being nothing
|
|
// but a broader search of the original term (such as event* and eventful*) but do both
|
|
// to determine from each hit result which term caused the hit, and if it's too greedy
|
|
// a match of the stemmed variant, it can be stripped from the results.
|
|
//
|
|
// Note that this uses SQLite's "standard" query syntax for MATCH, where AND is implied
|
|
// (and would be treated as search term if included), parentheses are not allowed, and
|
|
// OR has a higher precendence than AND. So the above example in standard syntax is:
|
|
//
|
|
// party* OR parti* eventful* OR event*
|
|
StringBuilder builder = new StringBuilder();
|
|
foreach (SearchTerm term in terms) {
|
|
if (term.sql.size == 0)
|
|
continue;
|
|
|
|
if (term.is_exact) {
|
|
builder.append_printf("%s ", term.parsed);
|
|
} else {
|
|
bool is_first_sql = true;
|
|
foreach (string sql in term.sql) {
|
|
if (!is_first_sql)
|
|
builder.append(" OR ");
|
|
|
|
builder.append_printf("%s ", sql);
|
|
is_first_sql = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
phrases.set(field ?? "MessageSearchTable", builder.str);
|
|
}
|
|
|
|
return phrases;
|
|
}
|
|
|
|
private void sql_add_query_phrases(StringBuilder sql, Gee.HashMap<string, string> query_phrases,
|
|
string operator, string columns, string condition) {
|
|
bool is_first_field = true;
|
|
foreach (string field in query_phrases.keys) {
|
|
if (!is_first_field)
|
|
sql.append_printf("""
|
|
%s
|
|
SELECT %s
|
|
FROM MessageSearchTable
|
|
WHERE %s
|
|
MATCH ?
|
|
%s
|
|
""", operator, columns, field, condition);
|
|
else
|
|
sql.append_printf(" AND %s MATCH ?", field);
|
|
is_first_field = false;
|
|
}
|
|
}
|
|
|
|
private int sql_bind_query_phrases(Db.Statement stmt, int start_index,
|
|
Gee.HashMap<string, string> query_phrases) throws Geary.DatabaseError {
|
|
int i = start_index;
|
|
// This relies on the keys being returned in the same order every time
|
|
// from the same map. It might not be guaranteed, but I feel pretty
|
|
// confident it'll work unless you change the map in between.
|
|
foreach (string field in query_phrases.keys)
|
|
stmt.bind_string(i++, query_phrases.get(field));
|
|
return i - start_index;
|
|
}
|
|
|
|
// Append each id in the collection to the StringBuilder, in a format
|
|
// suitable for use in an SQL statement IN (...) clause.
|
|
private void sql_append_ids(StringBuilder s, Gee.Iterable<int64?> ids) {
|
|
bool first = true;
|
|
foreach (int64? id in ids) {
|
|
assert(id != null);
|
|
|
|
if (!first)
|
|
s.append(", ");
|
|
s.append(id.to_string());
|
|
first = false;
|
|
}
|
|
}
|
|
|
|
private string? get_search_ids_sql(Gee.Collection<Geary.EmailIdentifier>? search_ids) throws Error {
|
|
if (search_ids == null)
|
|
return null;
|
|
|
|
Gee.ArrayList<int64?> ids = new Gee.ArrayList<int64?>();
|
|
foreach (Geary.EmailIdentifier id in search_ids) {
|
|
ImapDB.EmailIdentifier? imapdb_id = id as ImapDB.EmailIdentifier;
|
|
if (imapdb_id == null) {
|
|
throw new EngineError.BAD_PARAMETERS(
|
|
"search_ids must contain only Geary.ImapDB.EmailIdentifiers");
|
|
}
|
|
|
|
ids.add(imapdb_id.message_id);
|
|
}
|
|
|
|
StringBuilder sql = new StringBuilder();
|
|
sql_append_ids(sql, ids);
|
|
return sql.str;
|
|
}
|
|
|
|
public async Gee.Collection<Geary.EmailIdentifier>? search_async(Geary.SearchQuery q,
|
|
int limit = 100, int offset = 0, Gee.Collection<Geary.FolderPath?>? folder_blacklist = null,
|
|
Gee.Collection<Geary.EmailIdentifier>? search_ids = null, Cancellable? cancellable = null)
|
|
throws Error {
|
|
|
|
debug("Search: %s", q.to_string());
|
|
|
|
check_open();
|
|
ImapDB.SearchQuery query = check_search_query(q);
|
|
|
|
Gee.HashMap<string, string> query_phrases = get_query_phrases(query);
|
|
Gee.Map<Geary.NamedFlag, bool> removal_conditions = get_removal_conditions(query);
|
|
if (query_phrases.size == 0 && removal_conditions.is_empty)
|
|
return null;
|
|
|
|
foreach (string? field in query.get_fields()) {
|
|
debug(" - Field \"%s\" terms:", field);
|
|
foreach (SearchTerm? term in query.get_search_terms(field)) {
|
|
if (term != null) {
|
|
debug(" - \"%s\": %s, %s",
|
|
term.original,
|
|
term.parsed,
|
|
term.stemmed
|
|
);
|
|
debug(" SQL terms:");
|
|
foreach (string sql in term.sql) {
|
|
debug(" - \"%s\"", sql);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Do this outside of transaction to catch invalid search ids up-front
|
|
string? search_ids_sql = get_search_ids_sql(search_ids);
|
|
|
|
// for some searches, results are stripped if they're too "greedy", but this requires
|
|
// examining the matched text, which has an expense to fetch, so avoid doing so unless
|
|
// necessary
|
|
bool strip_results = true;
|
|
|
|
// HORIZON strategy is configured in such a way to allow all stemmed variants to match,
|
|
// so don't do any stripping in that case
|
|
//
|
|
// If any of the search terms is exact-match (no prefix matching) or none have stemmed
|
|
// variants, then don't do stripping of "greedy" stemmed matching (because in both cases,
|
|
// there are none)
|
|
if (query.strategy == Geary.SearchQuery.Strategy.HORIZON)
|
|
strip_results = false;
|
|
else if (traverse<SearchTerm>(query.get_all_terms()).any(term => term.stemmed == null || term.is_exact))
|
|
strip_results = false;
|
|
|
|
debug(strip_results ? "Stripping results..." : "Not stripping results...");
|
|
|
|
Gee.Set<ImapDB.EmailIdentifier> unstripped_ids = new Gee.HashSet<ImapDB.EmailIdentifier>();
|
|
Gee.Map<ImapDB.EmailIdentifier, Gee.Set<string>>? search_results = null;
|
|
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
string blacklisted_ids_sql = do_get_blacklisted_message_ids_sql(
|
|
folder_blacklist, cx, cancellable);
|
|
|
|
// Every mutation of this query we could think of has been tried,
|
|
// and this version was found to minimize running time. We
|
|
// discovered that just doing a JOIN between the MessageTable and
|
|
// MessageSearchTable was causing a full table scan to order the
|
|
// results. When it's written this way, and we force SQLite to use
|
|
// the correct index (not sure why it can't figure it out on its
|
|
// own), it cuts the running time roughly in half of how it was
|
|
// before. The short version is: modify with extreme caution. See
|
|
// <http://redmine.yorba.org/issues/7372>.
|
|
StringBuilder sql = new StringBuilder();
|
|
sql.append("""
|
|
SELECT id, internaldate_time_t
|
|
FROM MessageTable
|
|
INDEXED BY MessageTableInternalDateTimeTIndex
|
|
""");
|
|
if (query_phrases.size != 0) {
|
|
sql.append("""
|
|
WHERE id IN (
|
|
SELECT docid
|
|
FROM MessageSearchTable
|
|
WHERE 1=1
|
|
""");
|
|
sql_add_query_phrases(sql, query_phrases, "INTERSECT", "docid", "");
|
|
sql.append(")");
|
|
} else
|
|
sql.append(" WHERE 1=1");
|
|
|
|
if (blacklisted_ids_sql != "")
|
|
sql.append(" AND id NOT IN (%s)".printf(blacklisted_ids_sql));
|
|
if (!Geary.String.is_empty(search_ids_sql))
|
|
sql.append(" AND id IN (%s)".printf(search_ids_sql));
|
|
sql.append(" ORDER BY internaldate_time_t DESC");
|
|
if (limit > 0)
|
|
sql.append(" LIMIT ? OFFSET ?");
|
|
|
|
Db.Statement stmt = cx.prepare(sql.str);
|
|
int bind_index = sql_bind_query_phrases(stmt, 0, query_phrases);
|
|
if (limit > 0) {
|
|
stmt.bind_int(bind_index++, limit);
|
|
stmt.bind_int(bind_index++, offset);
|
|
}
|
|
|
|
Gee.HashMap<int64?, ImapDB.EmailIdentifier> id_map = new Gee.HashMap<int64?, ImapDB.EmailIdentifier>(
|
|
Collection.int64_hash_func, Collection.int64_equal_func);
|
|
|
|
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);
|
|
|
|
unstripped_ids.add(id);
|
|
id_map.set(message_id, id);
|
|
|
|
result.next(cancellable);
|
|
}
|
|
|
|
if (!strip_results)
|
|
return Db.TransactionOutcome.DONE;
|
|
|
|
search_results = do_get_search_matches(cx, query, id_map, cancellable);
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
if (unstripped_ids == null || unstripped_ids.size == 0)
|
|
return null;
|
|
|
|
// at this point, there should be some "full" search results to strip from
|
|
if (strip_results)
|
|
assert(search_results != null && search_results.size > 0);
|
|
|
|
if (!removal_conditions.is_empty) {
|
|
if (!strip_results) {
|
|
search_results = new Gee.HashMap<ImapDB.EmailIdentifier, Gee.Set<string>>();
|
|
foreach (ImapDB.EmailIdentifier id in unstripped_ids)
|
|
search_results.set(id, new Gee.HashSet<string>());
|
|
}
|
|
yield remove_results(query, search_results, removal_conditions, cancellable);
|
|
if (!strip_results)
|
|
return search_results.size == 0 ? null : search_results.keys;
|
|
}
|
|
|
|
if (!strip_results)
|
|
return unstripped_ids;
|
|
|
|
strip_greedy_results(query, search_results);
|
|
|
|
return search_results.size == 0 ? null : search_results.keys;
|
|
}
|
|
|
|
private Gee.Map<Geary.NamedFlag, bool> get_removal_conditions(ImapDB.SearchQuery query) {
|
|
Gee.Map<Geary.NamedFlag, bool> removal_conditions = new Gee.HashMap<Geary.NamedFlag, bool>();
|
|
foreach (string? field in query.get_fields())
|
|
if (field == SEARCH_OP_IS) {
|
|
Gee.List<SearchTerm>? terms = query.get_search_terms(field);
|
|
foreach (SearchTerm term in terms)
|
|
if (term.parsed == SEARCH_OP_VALUE_READ)
|
|
removal_conditions.set(new NamedFlag("UNREAD"), true);
|
|
else if (term.parsed == SEARCH_OP_VALUE_UNREAD)
|
|
removal_conditions.set(new NamedFlag("UNREAD"), false);
|
|
else if (term.parsed == SEARCH_OP_VALUE_STARRED)
|
|
removal_conditions.set(new NamedFlag("FLAGGED"), false);
|
|
return removal_conditions;
|
|
}
|
|
return removal_conditions;
|
|
}
|
|
|
|
private async void remove_results(ImapDB.SearchQuery query,
|
|
Gee.Map<ImapDB.EmailIdentifier, Gee.Set<string>> search_results,
|
|
Gee.Map<Geary.NamedFlag, bool> removal_conditions, Cancellable? cancellable = null) {
|
|
Geary.Email.Field required_fields = Geary.Email.Field.FLAGS;
|
|
Gee.MapIterator<ImapDB.EmailIdentifier, Gee.Set<string>> iter = search_results.map_iterator();
|
|
while (iter.next()) {
|
|
try {
|
|
ImapDB.EmailIdentifier id = iter.get_key();
|
|
Geary.Email email = yield fetch_email_async(id, required_fields, cancellable);
|
|
foreach (Geary.NamedFlag flag in removal_conditions.keys)
|
|
if (email.email_flags.contains(flag) == removal_conditions.get(flag)) {
|
|
iter.unset();
|
|
break;
|
|
}
|
|
} catch (Error e) {
|
|
debug("Error fetching email: %s", e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip out search results that only contain a hit due to "greedy" matching of the stemmed
|
|
// variants on all search terms
|
|
private void strip_greedy_results(ImapDB.SearchQuery query,
|
|
Gee.Map<ImapDB.EmailIdentifier, Gee.Set<string>> search_results) {
|
|
int prestripped_results = search_results.size;
|
|
Gee.MapIterator<ImapDB.EmailIdentifier, Gee.Set<string>> iter = search_results.map_iterator();
|
|
while (iter.next()) {
|
|
// For each matched string in this message, retain the message in the search results
|
|
// if it prefix-matches any of the straight-up parsed terms or matches a stemmed
|
|
// variant (with only max. difference in their lengths allowed, i.e. not a "greedy"
|
|
// match)
|
|
bool good_match_found = false;
|
|
foreach (string match in iter.get_value()) {
|
|
foreach (SearchTerm 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;
|
|
|
|
break;
|
|
}
|
|
|
|
// if prefix-matches stemmed term w/o doing so greedily, then don't strip
|
|
if (term.stemmed != null && match.has_prefix(term.stemmed)) {
|
|
int diff = match.length - term.stemmed.length;
|
|
if (diff <= query.max_difference_match_stem_lengths) {
|
|
good_match_found = true;
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (good_match_found)
|
|
break;
|
|
}
|
|
|
|
if (!good_match_found)
|
|
iter.unset();
|
|
}
|
|
|
|
debug("Stripped %d emails from search for [%s] due to greedy stem matching",
|
|
prestripped_results - search_results.size, query.raw);
|
|
}
|
|
|
|
public async Gee.Set<string>? get_search_matches_async(Geary.SearchQuery q,
|
|
Gee.Collection<ImapDB.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error {
|
|
check_open();
|
|
ImapDB.SearchQuery query = check_search_query(q);
|
|
|
|
Gee.Set<string>? search_matches = null;
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
Gee.HashMap<int64?, ImapDB.EmailIdentifier> id_map = new Gee.HashMap<
|
|
int64?, ImapDB.EmailIdentifier>(Collection.int64_hash_func, Collection.int64_equal_func);
|
|
foreach (ImapDB.EmailIdentifier id in ids)
|
|
id_map.set(id.message_id, id);
|
|
|
|
Gee.Map<ImapDB.EmailIdentifier, Gee.Set<string>>? match_map =
|
|
do_get_search_matches(cx, query, id_map, cancellable);
|
|
if (match_map == null || match_map.size == 0)
|
|
return Db.TransactionOutcome.DONE;
|
|
|
|
strip_greedy_results(query, match_map);
|
|
|
|
search_matches = new Gee.HashSet<string>();
|
|
foreach (Gee.Set<string> matches in match_map.values)
|
|
search_matches.add_all(matches);
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
return search_matches;
|
|
}
|
|
|
|
public async Geary.Email fetch_email_async(ImapDB.EmailIdentifier email_id,
|
|
Geary.Email.Field required_fields, Cancellable? cancellable = null) throws Error {
|
|
check_open();
|
|
|
|
Geary.Email? email = null;
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
// TODO: once we have a way of deleting messages, we won't be able
|
|
// to assume that a row id will point to the same email outside of
|
|
// transactions, because SQLite will reuse row ids.
|
|
Geary.Email.Field db_fields;
|
|
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
|
|
cx, email_id.message_id, required_fields, out db_fields, cancellable);
|
|
|
|
if (!row.fields.fulfills(required_fields))
|
|
throw new EngineError.INCOMPLETE_MESSAGE(
|
|
"Message %s only fulfills %Xh fields (required: %Xh)",
|
|
email_id.to_string(), row.fields, required_fields);
|
|
|
|
email = row.to_email(email_id);
|
|
Attachment.add_attachments(
|
|
cx, this.db.attachments_path, email, email_id.message_id, cancellable
|
|
);
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
assert(email != null);
|
|
return email;
|
|
}
|
|
|
|
public async void update_contact_flags_async(Geary.Contact contact, Cancellable? cancellable)
|
|
throws Error{
|
|
check_open();
|
|
|
|
yield db.exec_transaction_async(Db.TransactionType.RW, (cx, cancellable) => {
|
|
Db.Statement update_stmt =
|
|
cx.prepare("UPDATE ContactTable SET flags=? WHERE email=?");
|
|
update_stmt.bind_string(0, contact.contact_flags.serialize());
|
|
update_stmt.bind_string(1, contact.email);
|
|
update_stmt.exec(cancellable);
|
|
|
|
return Db.TransactionOutcome.COMMIT;
|
|
}, cancellable);
|
|
}
|
|
|
|
public async int get_email_count_async(Cancellable? cancellable) throws Error {
|
|
check_open();
|
|
|
|
int count = 0;
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
|
|
count = do_get_email_count(cx, cancellable);
|
|
|
|
return Db.TransactionOutcome.SUCCESS;
|
|
}, cancellable);
|
|
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Return a map of each passed-in email identifier to the set of folders
|
|
* that contain it. If an email id doesn't appear in the resulting map,
|
|
* it isn't contained in any folders. Return null if the resulting map
|
|
* would be empty. Only throw database errors et al., not errors due to
|
|
* the email id not being found.
|
|
*/
|
|
public async Gee.MultiMap<Geary.EmailIdentifier, Geary.FolderPath>? get_containing_folders_async(
|
|
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable) throws Error {
|
|
check_open();
|
|
|
|
Gee.HashMultiMap<Geary.EmailIdentifier, Geary.FolderPath> map
|
|
= new Gee.HashMultiMap<Geary.EmailIdentifier, Geary.FolderPath>();
|
|
yield db.exec_transaction_async(Db.TransactionType.RO, (cx, cancellable) => {
|
|
foreach (Geary.EmailIdentifier id in ids) {
|
|
ImapDB.EmailIdentifier? imap_db_id = id as ImapDB.EmailIdentifier;
|
|
if (imap_db_id == null)
|
|
continue;
|
|
|
|
Gee.Set<Geary.FolderPath>? folders = do_find_email_folders(
|
|
cx, imap_db_id.message_id, false, cancellable);
|
|
if (folders != null) {
|
|
Geary.Collection.multi_map_set_all<Geary.EmailIdentifier,
|
|
Geary.FolderPath>(map, id, folders);
|
|
}
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
yield outbox.add_to_containing_folders_async(ids, map, cancellable);
|
|
|
|
return (map.size == 0 ? null : map);
|
|
}
|
|
|
|
private async void populate_search_table_async(Cancellable? cancellable) {
|
|
debug("%s: Populating search table", account_information.id);
|
|
try {
|
|
while (!yield populate_search_table_batch_async(50, cancellable)) {
|
|
// With multiple accounts, meaning multiple background threads
|
|
// doing such CPU- and disk-heavy work, this process can cause
|
|
// the main thread to slow to a crawl. This delay means the
|
|
// update takes more time, but leaves the main thread nice and
|
|
// snappy the whole time.
|
|
yield Geary.Scheduler.sleep_ms_async(50);
|
|
}
|
|
} catch (Error e) {
|
|
debug("Error populating %s search table: %s", account_information.id, e.message);
|
|
}
|
|
|
|
if (search_index_monitor.is_in_progress)
|
|
search_index_monitor.notify_finish();
|
|
|
|
debug("%s: Done populating search table", account_information.id);
|
|
}
|
|
|
|
private static Gee.HashSet<int64?> do_build_rowid_set(Db.Result result, Cancellable? cancellable)
|
|
throws Error {
|
|
Gee.HashSet<int64?> rowid_set = new Gee.HashSet<int64?>(Collection.int64_hash_func,
|
|
Collection.int64_equal_func);
|
|
while (!result.finished) {
|
|
rowid_set.add(result.rowid_at(0));
|
|
result.next(cancellable);
|
|
}
|
|
|
|
return rowid_set;
|
|
}
|
|
|
|
private async bool populate_search_table_batch_async(int limit, Cancellable? cancellable)
|
|
throws Error {
|
|
check_open();
|
|
debug("%s: Searching for up to %d missing indexed messages...", account_information.id,
|
|
limit);
|
|
|
|
int count = 0, total_unindexed = 0;
|
|
yield db.exec_transaction_async(Db.TransactionType.RW, (cx, cancellable) => {
|
|
// Embedding a SELECT within a SELECT is painfully slow
|
|
// with SQLite, and a LEFT OUTER JOIN will still take in
|
|
// the order of seconds, so manually perform the operation
|
|
|
|
Db.Statement stmt = cx.prepare("""
|
|
SELECT docid FROM MessageSearchTable
|
|
""");
|
|
Gee.HashSet<int64?> search_ids = do_build_rowid_set(stmt.exec(cancellable), cancellable);
|
|
|
|
stmt = cx.prepare("""
|
|
SELECT id FROM MessageTable WHERE (fields & ?) = ?
|
|
""");
|
|
stmt.bind_uint(0, Geary.ImapDB.Folder.REQUIRED_FTS_FIELDS);
|
|
stmt.bind_uint(1, Geary.ImapDB.Folder.REQUIRED_FTS_FIELDS);
|
|
Gee.HashSet<int64?> message_ids = do_build_rowid_set(stmt.exec(cancellable), cancellable);
|
|
|
|
// This is hard to calculate correctly without doing a
|
|
// join (which we should above, but is currently too
|
|
// slow), and if we do get it wrong the progress monitor
|
|
// will crash and burn, so just something too big to fail
|
|
// for now. See Bug 776383.
|
|
total_unindexed = message_ids.size;
|
|
|
|
// chaff out any MessageTable entries not present in the MessageSearchTable ... since
|
|
// we're given a limit, stuff messages req'ing search into separate set and stop when limit
|
|
// reached
|
|
Gee.HashSet<int64?> unindexed_message_ids = new Gee.HashSet<int64?>(Collection.int64_hash_func,
|
|
Collection.int64_equal_func);
|
|
foreach (int64 message_id in message_ids) {
|
|
if (search_ids.contains(message_id))
|
|
continue;
|
|
|
|
unindexed_message_ids.add(message_id);
|
|
if (unindexed_message_ids.size >= limit)
|
|
break;
|
|
}
|
|
|
|
// For all remaining MessageTable rowid's, generate search table entry
|
|
foreach (int64 message_id in unindexed_message_ids) {
|
|
try {
|
|
Geary.Email.Field search_fields = Geary.Email.REQUIRED_FOR_MESSAGE |
|
|
Geary.Email.Field.ORIGINATORS | Geary.Email.Field.RECEIVERS |
|
|
Geary.Email.Field.SUBJECT;
|
|
|
|
Geary.Email.Field db_fields;
|
|
MessageRow row = Geary.ImapDB.Folder.do_fetch_message_row(
|
|
cx, message_id, search_fields, out db_fields, cancellable);
|
|
Geary.Email email = row.to_email(new Geary.ImapDB.EmailIdentifier(message_id, null));
|
|
Attachment.add_attachments(
|
|
cx, this.db.attachments_path, email, message_id, cancellable
|
|
);
|
|
|
|
Geary.ImapDB.Folder.do_add_email_to_search_table(cx, message_id, email, cancellable);
|
|
} catch (Error e) {
|
|
// This is a somewhat serious issue since we rely on
|
|
// there always being a row in the search table for
|
|
// every message.
|
|
warning("Error adding message %s to the search table: %s", message_id.to_string(),
|
|
e.message);
|
|
}
|
|
|
|
++count;
|
|
}
|
|
|
|
return Db.TransactionOutcome.DONE;
|
|
}, cancellable);
|
|
|
|
if (count > 0) {
|
|
debug("%s: Found %d/%d missing indexed messages, %d remaining...",
|
|
account_information.id, count, limit, total_unindexed);
|
|
|
|
if (!search_index_monitor.is_in_progress) {
|
|
search_index_monitor.set_interval(0, total_unindexed);
|
|
search_index_monitor.notify_start();
|
|
}
|
|
|
|
search_index_monitor.increment(count);
|
|
}
|
|
|
|
return (count < limit);
|
|
}
|
|
|
|
//
|
|
// Transaction helper methods
|
|
//
|
|
|
|
private void do_delete_folder(Db.Connection cx, int64 folder_id, Cancellable? cancellable)
|
|
throws Error {
|
|
Db.Statement msg_loc_stmt = cx.prepare("""
|
|
DELETE FROM MessageLocationTable
|
|
WHERE folder_id = ?
|
|
""");
|
|
msg_loc_stmt.bind_rowid(0, folder_id);
|
|
|
|
msg_loc_stmt.exec(cancellable);
|
|
|
|
Db.Statement folder_stmt = cx.prepare("""
|
|
DELETE FROM FolderTable
|
|
WHERE id = ?
|
|
""");
|
|
folder_stmt.bind_rowid(0, folder_id);
|
|
|
|
folder_stmt.exec(cancellable);
|
|
}
|
|
|
|
// If the FolderPath has no parent, returns true and folder_id will be set to Db.INVALID_ROWID.
|
|
// If cannot create path or there is a logical problem traversing it, returns false with folder_id
|
|
// set to Db.INVALID_ROWID.
|
|
internal bool do_fetch_folder_id(Db.Connection cx, Geary.FolderPath path, bool create, out int64 folder_id,
|
|
Cancellable? cancellable) throws Error {
|
|
int length = path.get_path_length();
|
|
if (length < 0)
|
|
throw new EngineError.BAD_PARAMETERS("Invalid path %s", path.to_string());
|
|
|
|
folder_id = Db.INVALID_ROWID;
|
|
int64 parent_id = Db.INVALID_ROWID;
|
|
|
|
// walk the folder tree to the final node (which is at length - 1 - 1)
|
|
for (int ctr = 0; ctr < length; ctr++) {
|
|
string basename = path.get_folder_at(ctr).basename;
|
|
|
|
Db.Statement stmt;
|
|
if (parent_id != Db.INVALID_ROWID) {
|
|
stmt = cx.prepare("SELECT id FROM FolderTable WHERE parent_id=? AND name=?");
|
|
stmt.bind_rowid(0, parent_id);
|
|
stmt.bind_string(1, basename);
|
|
} else {
|
|
stmt = cx.prepare("SELECT id FROM FolderTable WHERE parent_id IS NULL AND name=?");
|
|
stmt.bind_string(0, basename);
|
|
}
|
|
|
|
int64 id = Db.INVALID_ROWID;
|
|
|
|
Db.Result result = stmt.exec(cancellable);
|
|
if (!result.finished) {
|
|
id = result.rowid_at(0);
|
|
} else if (!create) {
|
|
return false;
|
|
} else {
|
|
// not found, create it
|
|
Db.Statement create_stmt = cx.prepare(
|
|
"INSERT INTO FolderTable (name, parent_id) VALUES (?, ?)");
|
|
create_stmt.bind_string(0, basename);
|
|
create_stmt.bind_rowid(1, parent_id);
|
|
|
|
id = create_stmt.exec_insert(cancellable);
|
|
}
|
|
|
|
// watch for path loops, real bad if it happens ... could be more thorough here, but at
|
|
// least one level of checking is better than none
|
|
if (id == parent_id) {
|
|
warning("Loop found in database: parent of %s is %s in FolderTable",
|
|
parent_id.to_string(), id.to_string());
|
|
|
|
return false;
|
|
}
|
|
|
|
parent_id = id;
|
|
}
|
|
|
|
// parent_id is now the folder being searched for
|
|
folder_id = parent_id;
|
|
|
|
return true;
|
|
}
|
|
|
|
// See do_fetch_folder_id() for return semantics.
|
|
internal bool do_fetch_parent_id(Db.Connection cx, Geary.FolderPath path, bool create, out int64 parent_id,
|
|
Cancellable? cancellable = null) throws Error {
|
|
if (path.is_root()) {
|
|
parent_id = Db.INVALID_ROWID;
|
|
|
|
return true;
|
|
}
|
|
|
|
return do_fetch_folder_id(cx, path.get_parent(), create, out parent_id, cancellable);
|
|
}
|
|
|
|
private bool do_has_children(Db.Connection cx, int64 folder_id, Cancellable? cancellable) throws Error {
|
|
Db.Statement stmt = cx.prepare("SELECT 1 FROM FolderTable WHERE parent_id = ?");
|
|
stmt.bind_rowid(0, folder_id);
|
|
Db.Result result = stmt.exec(cancellable);
|
|
return !result.finished;
|
|
}
|
|
|
|
// Turn the collection of folder paths into actual folder ids. As a
|
|
// special case, if "folderless" or orphan emails are to be blacklisted,
|
|
// set the out bool to true.
|
|
private Gee.Collection<int64?> do_get_blacklisted_folder_ids(Gee.Collection<Geary.FolderPath?>? folder_blacklist,
|
|
Db.Connection cx, out bool blacklist_folderless, Cancellable? cancellable) throws Error {
|
|
blacklist_folderless = false;
|
|
Gee.ArrayList<int64?> ids = new Gee.ArrayList<int64?>();
|
|
|
|
if (folder_blacklist != null) {
|
|
foreach (Geary.FolderPath? folder_path in folder_blacklist) {
|
|
if (folder_path == null) {
|
|
blacklist_folderless = true;
|
|
} else {
|
|
int64 id;
|
|
do_fetch_folder_id(cx, folder_path, true, out id, cancellable);
|
|
if (id != Db.INVALID_ROWID)
|
|
ids.add(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
// Return a parameterless SQL statement that selects any message ids that
|
|
// are in a blacklisted folder. This is used as a sub-select for the
|
|
// search query to omit results from blacklisted folders.
|
|
private string do_get_blacklisted_message_ids_sql(Gee.Collection<Geary.FolderPath?>? folder_blacklist,
|
|
Db.Connection cx, Cancellable? cancellable) throws Error {
|
|
bool blacklist_folderless;
|
|
Gee.Collection<int64?> blacklisted_ids = do_get_blacklisted_folder_ids(
|
|
folder_blacklist, cx, out blacklist_folderless, cancellable);
|
|
|
|
StringBuilder sql = new StringBuilder();
|
|
if (blacklisted_ids.size > 0) {
|
|
sql.append("""
|
|
SELECT message_id
|
|
FROM MessageLocationTable
|
|
WHERE remove_marker = 0
|
|
AND folder_id IN (
|
|
""");
|
|
sql_append_ids(sql, blacklisted_ids);
|
|
sql.append(")");
|
|
|
|
if (blacklist_folderless)
|
|
sql.append(" UNION ");
|
|
}
|
|
if (blacklist_folderless) {
|
|
sql.append("""
|
|
SELECT id
|
|
FROM MessageTable
|
|
WHERE id NOT IN (
|
|
SELECT message_id
|
|
FROM MessageLocationTable
|
|
WHERE remove_marker = 0
|
|
)
|
|
""");
|
|
}
|
|
|
|
return sql.str;
|
|
}
|
|
|
|
// For a message row id, return a set of all folders it's in, or null if
|
|
// it's not in any folders.
|
|
private static Gee.Set<Geary.FolderPath>? do_find_email_folders(Db.Connection cx, int64 message_id,
|
|
bool include_removed, Cancellable? cancellable) throws Error {
|
|
string sql = "SELECT folder_id FROM MessageLocationTable WHERE message_id=?";
|
|
if (!include_removed)
|
|
sql += " AND remove_marker=0";
|
|
Db.Statement stmt = cx.prepare(sql);
|
|
stmt.bind_int64(0, message_id);
|
|
Db.Result result = stmt.exec(cancellable);
|
|
|
|
if (result.finished)
|
|
return null;
|
|
|
|
Gee.HashSet<Geary.FolderPath> folder_paths = new Gee.HashSet<Geary.FolderPath>();
|
|
while (!result.finished) {
|
|
int64 folder_id = result.int64_at(0);
|
|
Geary.FolderPath? path = do_find_folder_path(cx, folder_id, cancellable);
|
|
if (path != null)
|
|
folder_paths.add(path);
|
|
|
|
result.next(cancellable);
|
|
}
|
|
|
|
return (folder_paths.size == 0 ? null : folder_paths);
|
|
}
|
|
|
|
// For a folder row id, return the folder path (constructed with default
|
|
// separator and case sensitivity) of that folder, or null in the event
|
|
// it's not found.
|
|
private static Geary.FolderPath? do_find_folder_path(Db.Connection cx, int64 folder_id,
|
|
Cancellable? cancellable) throws Error {
|
|
Db.Statement stmt = cx.prepare("SELECT parent_id, name FROM FolderTable WHERE id=?");
|
|
stmt.bind_int64(0, folder_id);
|
|
Db.Result result = stmt.exec(cancellable);
|
|
|
|
if (result.finished)
|
|
return null;
|
|
|
|
int64 parent_id = result.int64_at(0);
|
|
string name = result.nonnull_string_at(1);
|
|
|
|
// Here too, one level of loop detection is better than nothing.
|
|
if (folder_id == parent_id) {
|
|
warning("Loop found in database: parent of %s is %s in FolderTable",
|
|
folder_id.to_string(), parent_id.to_string());
|
|
return null;
|
|
}
|
|
|
|
if (parent_id <= 0)
|
|
return new Imap.FolderRoot(name);
|
|
|
|
Geary.FolderPath? parent_path = do_find_folder_path(cx, parent_id, cancellable);
|
|
return (parent_path == null ? null : parent_path.get_child(name));
|
|
}
|
|
|
|
private int do_get_email_count(Db.Connection cx, Cancellable? cancellable)
|
|
throws Error {
|
|
Db.Statement stmt = cx.prepare(
|
|
"SELECT COUNT(*) FROM MessageTable");
|
|
|
|
Db.Result results = stmt.exec(cancellable);
|
|
if (results.finished)
|
|
return 0;
|
|
|
|
return results.int_at(0);
|
|
}
|
|
|
|
private void on_unread_updated(ImapDB.Folder source, Gee.Map<ImapDB.EmailIdentifier, bool>
|
|
unread_status) {
|
|
update_unread_async.begin(source, unread_status, null);
|
|
}
|
|
|
|
// Updates unread count on all folders.
|
|
private async void update_unread_async(ImapDB.Folder source, Gee.Map<ImapDB.EmailIdentifier, bool>
|
|
unread_status, Cancellable? cancellable) throws Error {
|
|
Gee.Map<Geary.FolderPath, int> unread_change = new Gee.HashMap<Geary.FolderPath, int>();
|
|
|
|
yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
|
|
foreach (ImapDB.EmailIdentifier id in unread_status.keys) {
|
|
Gee.Set<Geary.FolderPath>? paths = do_find_email_folders(
|
|
cx, id.message_id, true, cancellable);
|
|
if (paths == null)
|
|
continue;
|
|
|
|
// Remove the folder that triggered this event.
|
|
paths.remove(source.get_path());
|
|
if (paths.size == 0)
|
|
continue;
|
|
|
|
foreach (Geary.FolderPath path in paths) {
|
|
int current_unread = unread_change.has_key(path) ? unread_change.get(path) : 0;
|
|
current_unread += unread_status.get(id) ? 1 : -1;
|
|
unread_change.set(path, current_unread);
|
|
}
|
|
}
|
|
|
|
// Update each folder's unread count in the database.
|
|
foreach (Geary.FolderPath path in unread_change.keys) {
|
|
Geary.ImapDB.Folder? folder = get_local_folder(path);
|
|
if (folder == null)
|
|
continue;
|
|
|
|
folder.do_add_to_unread_count(cx, unread_change.get(path), cancellable);
|
|
}
|
|
|
|
return Db.TransactionOutcome.SUCCESS;
|
|
}, cancellable);
|
|
|
|
// Update each folder's unread count property.
|
|
foreach (Geary.FolderPath path in unread_change.keys) {
|
|
Geary.ImapDB.Folder? folder = get_local_folder(path);
|
|
if (folder == null)
|
|
continue;
|
|
|
|
folder.get_properties().set_status_unseen(folder.get_properties().email_unread +
|
|
unread_change.get(path));
|
|
}
|
|
}
|
|
|
|
// Not using a MultiMap because when traversing want to process all values at once per iteration,
|
|
// not per key-value
|
|
public Gee.Map<ImapDB.EmailIdentifier, Gee.Set<string>>? do_get_search_matches(Db.Connection cx,
|
|
ImapDB.SearchQuery query, Gee.Map<int64?, ImapDB.EmailIdentifier> id_map, Cancellable? cancellable)
|
|
throws Error {
|
|
if (id_map.size == 0)
|
|
return null;
|
|
|
|
Gee.HashMap<string, string> query_phrases = get_query_phrases(query);
|
|
if (query_phrases.size == 0)
|
|
return null;
|
|
|
|
StringBuilder sql = new StringBuilder();
|
|
sql.append("""
|
|
SELECT docid, offsets(MessageSearchTable), *
|
|
FROM MessageSearchTable
|
|
WHERE docid IN (
|
|
""");
|
|
sql_append_ids(sql, id_map.keys);
|
|
sql.append(")");
|
|
|
|
StringBuilder condition = new StringBuilder("AND docid IN (");
|
|
sql_append_ids(condition, id_map.keys);
|
|
condition.append(")");
|
|
sql_add_query_phrases(sql, query_phrases, "UNION", "docid, offsets(MessageSearchTable), *",
|
|
condition.str);
|
|
|
|
Db.Statement stmt = cx.prepare(sql.str);
|
|
sql_bind_query_phrases(stmt, 0, query_phrases);
|
|
|
|
Gee.Map<ImapDB.EmailIdentifier, Gee.Set<string>> search_matches = new Gee.HashMap<
|
|
ImapDB.EmailIdentifier, Gee.Set<string>>();
|
|
|
|
Db.Result result = stmt.exec(cancellable);
|
|
while (!result.finished) {
|
|
int64 docid = result.rowid_at(0);
|
|
assert(id_map.has_key(docid));
|
|
ImapDB.EmailIdentifier id = id_map.get(docid);
|
|
|
|
// XXX Avoid a crash when "database disk image is
|
|
// malformed" error occurs. Remove this when the SQLite
|
|
// bug is fixed. See b.g.o #765515 for more info.
|
|
if (result.string_at(1) == null) {
|
|
debug("Avoiding a crash from 'database disk image is malformed' error");
|
|
result.next(cancellable);
|
|
continue;
|
|
}
|
|
|
|
// offsets() function returns a list of 4 strings that are ints indicating position
|
|
// and length of match string in search table corpus
|
|
string[] offset_array = result.nonnull_string_at(1).split(" ");
|
|
|
|
Gee.Set<string> matches = new Gee.HashSet<string>();
|
|
|
|
int j = 0;
|
|
while (true) {
|
|
unowned string[] offset_string = offset_array[j:j+4];
|
|
|
|
int column = int.parse(offset_string[0]);
|
|
int byte_offset = int.parse(offset_string[2]);
|
|
int size = int.parse(offset_string[3]);
|
|
|
|
unowned string text = result.nonnull_string_at(column + 2);
|
|
matches.add(text[byte_offset : byte_offset + size].down());
|
|
|
|
j += 4;
|
|
if (j >= offset_array.length)
|
|
break;
|
|
}
|
|
|
|
if (search_matches.has_key(id))
|
|
matches.add_all(search_matches.get(id));
|
|
search_matches.set(id, matches);
|
|
|
|
result.next(cancellable);
|
|
}
|
|
|
|
return search_matches.size > 0 ? search_matches : null;
|
|
}
|
|
}
|
|
|