701 lines
20 KiB
JavaScript
701 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
const GdkPixbuf = imports.gi.GdkPixbuf;
|
|
const Gio = imports.gi.Gio;
|
|
const GLib = imports.gi.GLib;
|
|
const GObject = imports.gi.GObject;
|
|
|
|
var HAVE_EDS = true;
|
|
|
|
try {
|
|
var EBook = imports.gi.EBook;
|
|
var EBookContacts = imports.gi.EBookContacts;
|
|
var EDataServer = imports.gi.EDataServer;
|
|
} catch (e) {
|
|
HAVE_EDS = false;
|
|
}
|
|
|
|
/**
|
|
* A store for contacts
|
|
*/
|
|
var Store = GObject.registerClass({
|
|
GTypeName: 'GSConnectContactsStore',
|
|
Properties: {
|
|
'context': GObject.ParamSpec.string(
|
|
'context',
|
|
'Context',
|
|
'Used as the cache directory, relative to gsconnect.cachedir',
|
|
GObject.ParamFlags.CONSTRUCT_ONLY | GObject.ParamFlags.READWRITE,
|
|
null
|
|
)
|
|
},
|
|
Signals: {
|
|
'contact-added': {
|
|
flags: GObject.SignalFlags.RUN_FIRST,
|
|
param_types: [GObject.TYPE_STRING]
|
|
},
|
|
'contact-removed': {
|
|
flags: GObject.SignalFlags.RUN_FIRST,
|
|
param_types: [GObject.TYPE_STRING]
|
|
},
|
|
'contact-changed': {
|
|
flags: GObject.SignalFlags.RUN_FIRST,
|
|
param_types: [GObject.TYPE_STRING]
|
|
}
|
|
}
|
|
}, class Store extends GObject.Object {
|
|
|
|
_init(context = null) {
|
|
super._init({
|
|
context: context
|
|
});
|
|
|
|
this.__cache_data = {};
|
|
|
|
// If Evolution Data Server is available, load it now
|
|
if (context === null && HAVE_EDS) {
|
|
this._ebooks = new Map();
|
|
this._initEvolutionDataServer();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse an EContact and return a small Object
|
|
*
|
|
* @param {EBookContacts.Contact} econtact - an EContact to parse
|
|
* @param {string} [origin] - an optional origin string
|
|
* @returns {Object} - a small JSON serializable object
|
|
*/
|
|
async _parseEContact(econtact, origin = 'desktop') {
|
|
try {
|
|
let contact = {
|
|
id: econtact.id,
|
|
name: _('Unknown Contact'),
|
|
numbers: [],
|
|
origin: origin,
|
|
timestamp: 0
|
|
};
|
|
|
|
// Try to get a contact name
|
|
if (econtact.full_name)
|
|
contact.name = econtact.full_name;
|
|
|
|
// Parse phone numbers
|
|
let nums = econtact.get_attributes(EBookContacts.ContactField.TEL);
|
|
|
|
for (let attr of nums) {
|
|
let number = {
|
|
value: attr.get_value(),
|
|
type: 'unknown'
|
|
};
|
|
|
|
if (attr.has_type('CELL'))
|
|
number.type = 'cell';
|
|
else if (attr.has_type('HOME'))
|
|
number.type = 'home';
|
|
else if (attr.has_type('WORK'))
|
|
number.type = 'work';
|
|
|
|
contact.numbers.push(number);
|
|
}
|
|
|
|
// Try and get a contact photo
|
|
let photo = econtact.photo;
|
|
|
|
if (photo) {
|
|
if (photo.type === EBookContacts.ContactPhotoType.INLINED) {
|
|
let data = photo.get_inlined()[0];
|
|
contact.avatar = await this.storeAvatar(data);
|
|
|
|
} else if (photo.type === EBookContacts.ContactPhotoType.URI) {
|
|
let uri = econtact.photo.get_uri();
|
|
contact.avatar = uri.replace('file://', '');
|
|
}
|
|
}
|
|
|
|
return contact;
|
|
} catch (e) {
|
|
logError(e, `Failed to parse VCard contact ${econtact.id}`);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* EDS Helpers
|
|
*/
|
|
_getEBookClient(source, cancellable = null) {
|
|
return new Promise((resolve, reject) => {
|
|
EBook.BookClient.connect(source, 0, cancellable, (source, res) => {
|
|
try {
|
|
resolve(EBook.BookClient.connect_finish(res));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
_getEBookView(client, query = '', cancellable = null) {
|
|
return new Promise((resolve, reject) => {
|
|
client.get_view(query, cancellable, (client, res) => {
|
|
try {
|
|
resolve(client.get_view_finish(res)[1]);
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
_getEContacts(client, query = '', cancellable = null) {
|
|
return new Promise((resolve, reject) => {
|
|
client.get_contacts(query, cancellable, (client, res) => {
|
|
try {
|
|
resolve(client.get_contacts_finish(res)[1]);
|
|
} catch (e) {
|
|
debug(e);
|
|
reject([]);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
_getESourceRegistry(cancellable = null) {
|
|
return new Promise ((resolve, reject) => {
|
|
EDataServer.SourceRegistry.new(cancellable, (registry, res) => {
|
|
try {
|
|
resolve(EDataServer.SourceRegistry.new_finish(res));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/*
|
|
* AddressBook DBus callbacks
|
|
*/
|
|
async _onObjectsAdded(connection, sender, path, iface, signal, params) {
|
|
try {
|
|
let adds = params.get_child_value(0).get_strv();
|
|
|
|
// NOTE: sequential pairs of vcard, id
|
|
for (let i = 0, len = adds.length; i < len; i += 2) {
|
|
try {
|
|
let vcard = adds[i];
|
|
let econtact = EBookContacts.Contact.new_from_vcard(vcard);
|
|
let contact = await this._parseEContact(econtact);
|
|
|
|
if (contact !== undefined) {
|
|
this.add(contact, false);
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
_onObjectsRemoved(connection, sender, path, iface, signal, params) {
|
|
try {
|
|
let changes = params.get_child_value(0).get_strv();
|
|
|
|
for (let id of changes) {
|
|
try {
|
|
this.remove(id, false);
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
async _onObjectsModified(connection, sender, path, iface, signal, params) {
|
|
try {
|
|
let changes = params.get_child_value(0).get_strv();
|
|
|
|
// NOTE: sequential pairs of vcard, id
|
|
for (let i = 0, len = changes.length; i < len; i += 2) {
|
|
try {
|
|
let vcard = changes[i];
|
|
let econtact = EBookContacts.Contact.new_from_vcard(vcard);
|
|
let contact = await this._parseEContact(econtact);
|
|
|
|
if (contact !== undefined) {
|
|
this.add(contact, false);
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* SourceRegistryWatcher callbacks
|
|
*/
|
|
async _onAppeared(watcher, source) {
|
|
try {
|
|
// Get an EBookClient and EBookView
|
|
let uid = source.get_uid();
|
|
let client = await this._getEBookClient(source);
|
|
let view = await this._getEBookView(client, 'exists "tel"');
|
|
|
|
// Watch the view for changes to the address book
|
|
let connection = view.get_connection();
|
|
let objectPath = view.get_object_path();
|
|
|
|
view._objectsAddedId = connection.signal_subscribe(
|
|
null,
|
|
'org.gnome.evolution.dataserver.AddressBookView',
|
|
'ObjectsAdded',
|
|
objectPath,
|
|
null,
|
|
Gio.DBusSignalFlags.NONE,
|
|
this._onObjectsAdded.bind(this)
|
|
);
|
|
|
|
view._objectsRemovedId = connection.signal_subscribe(
|
|
null,
|
|
'org.gnome.evolution.dataserver.AddressBookView',
|
|
'ObjectsRemoved',
|
|
objectPath,
|
|
null,
|
|
Gio.DBusSignalFlags.NONE,
|
|
this._onObjectsRemoved.bind(this)
|
|
);
|
|
|
|
view._objectsModifiedId = connection.signal_subscribe(
|
|
null,
|
|
'org.gnome.evolution.dataserver.AddressBookView',
|
|
'ObjectsModified',
|
|
objectPath,
|
|
null,
|
|
Gio.DBusSignalFlags.NONE,
|
|
this._onObjectsModified.bind(this)
|
|
);
|
|
|
|
view.start();
|
|
|
|
// Store the EBook in a map
|
|
this._ebooks.set(uid, {
|
|
source: source,
|
|
client: client,
|
|
view: view
|
|
});
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
_onDisappeared(watcher, source) {
|
|
try {
|
|
let uid = source.get_uid();
|
|
let ebook = this._ebooks.get(uid);
|
|
|
|
if (ebook === undefined)
|
|
return;
|
|
|
|
// Disconnect and dispose the EBookView
|
|
if (ebook.view) {
|
|
let connection = ebook.view.get_connection();
|
|
connection.signal_unsubscribe(ebook.view._objectsAddedId);
|
|
connection.signal_unsubscribe(ebook.view._objectsRemovedId);
|
|
connection.signal_unsubscribe(ebook.view._objectsModifiedId);
|
|
ebook.view.stop();
|
|
|
|
ebook.view.run_dispose();
|
|
ebook.view = null;
|
|
}
|
|
|
|
// Dispose the EBookClient
|
|
if (ebook.client) {
|
|
ebook.client.run_dispose();
|
|
ebook.client = null;
|
|
}
|
|
|
|
// Drop the EBook
|
|
this._ebooks.delete(uid);
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
async _initEvolutionDataServer() {
|
|
try {
|
|
// Get the current EBooks
|
|
let registry = await this._getESourceRegistry();
|
|
|
|
for (let source of registry.list_sources('Address Book')) {
|
|
await this._onAppeared(null, source);
|
|
}
|
|
|
|
// Watch for new and removed sources
|
|
this._watcher = new EDataServer.SourceRegistryWatcher({
|
|
registry: registry,
|
|
extension_name: 'Address Book'
|
|
});
|
|
|
|
this._appearedId = this._watcher.connect(
|
|
'appeared',
|
|
this._onAppeared.bind(this)
|
|
);
|
|
this._disappearedId = this._watcher.connect(
|
|
'disappeared',
|
|
this._onDisappeared.bind(this)
|
|
);
|
|
} catch (e) {
|
|
logError(e);
|
|
}
|
|
}
|
|
|
|
*[Symbol.iterator]() {
|
|
let contacts = this.contacts;
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
yield contacts[i];
|
|
}
|
|
}
|
|
|
|
get contacts() {
|
|
return Object.values(this.__cache_data);
|
|
}
|
|
|
|
get context() {
|
|
if (this._context === undefined) {
|
|
this._context = null;
|
|
}
|
|
|
|
return this._context;
|
|
}
|
|
|
|
set context(context) {
|
|
this._context = context;
|
|
|
|
if (context === null) {
|
|
this.__cache_dir = Gio.File.new_for_path(gsconnect.cachedir);
|
|
} else {
|
|
this.__cache_dir = Gio.File.new_for_path(
|
|
GLib.build_filenamev([gsconnect.cachedir, context])
|
|
);
|
|
}
|
|
|
|
GLib.mkdir_with_parents(this.__cache_dir.get_path(), 448);
|
|
this.__cache_file = this.__cache_dir.get_child('contacts.json');
|
|
}
|
|
|
|
/**
|
|
* Save a ByteArray to file and return the path
|
|
*
|
|
* @param {ByteArray} contents - An image ByteArray
|
|
* @return {string|undefined} - File path or %undefined on failure
|
|
*/
|
|
storeAvatar(contents) {
|
|
return new Promise((resolve, reject) => {
|
|
let md5 = GLib.compute_checksum_for_data(
|
|
GLib.ChecksumType.MD5,
|
|
contents
|
|
);
|
|
let file = this.__cache_dir.get_child(`${md5}`);
|
|
|
|
if (file.query_exists(null)) {
|
|
resolve(file.get_path());
|
|
} else {
|
|
file.replace_contents_bytes_async(
|
|
new GLib.Bytes(contents),
|
|
null,
|
|
false,
|
|
Gio.FileCreateFlags.REPLACE_DESTINATION,
|
|
null,
|
|
(file, res) => {
|
|
try {
|
|
file.replace_contents_finish(res);
|
|
resolve(file.get_path());
|
|
} catch (e) {
|
|
debug(e, 'Storing avatar');
|
|
resolve(undefined);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Query the Store for a contact by name and/or number.
|
|
*
|
|
* @param {object} query - A query object
|
|
* @param {string} [query.name] - The contact's name
|
|
* @param {string} query.number - The contact's number
|
|
* @return {object} - A contact object
|
|
*/
|
|
query(query) {
|
|
// First look for an existing contact by number
|
|
let contacts = this.contacts;
|
|
let matches = [];
|
|
let qnumber = query.number.toPhoneNumber();
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
let contact = contacts[i];
|
|
|
|
for (let num of contact.numbers) {
|
|
let cnumber = num.value.toPhoneNumber();
|
|
|
|
if (qnumber.endsWith(cnumber) || cnumber.endsWith(qnumber)) {
|
|
// If no query name or exact match, return immediately
|
|
if (!query.name || query.name === contact.name) {
|
|
return contact;
|
|
}
|
|
|
|
// Otherwise we might find an exact name match that shares
|
|
// the number with another contact
|
|
matches.push(contact);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the first match (pretty much what Android does)
|
|
if (matches.length > 0) return matches[0];
|
|
|
|
// No match; return a mock contact with a unique ID
|
|
let id = GLib.uuid_string_random();
|
|
while (this.__cache_data.hasOwnProperty(id)) {
|
|
id = GLib.uuid_string_random();
|
|
}
|
|
|
|
return {
|
|
id: id,
|
|
name: query.name || query.number,
|
|
numbers: [{value: query.number, type: 'unknown'}],
|
|
origin: 'gsconnect'
|
|
};
|
|
}
|
|
|
|
get_contact(position) {
|
|
try {
|
|
return (this.__cache_data[position]) ? this.__cache_data[position] : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a contact, checking for validity
|
|
*
|
|
* @param {object} contact - A contact object
|
|
* @param {boolean} write - Write to disk
|
|
*/
|
|
add(contact, write = true) {
|
|
// Ensure the contact has a unique id
|
|
if (!contact.id) {
|
|
let id = GLib.uuid_string_random();
|
|
|
|
while (this.__cache_data[id]) {
|
|
id = GLib.uuid_string_random();
|
|
}
|
|
|
|
contact.id = id;
|
|
}
|
|
|
|
// Ensure the contact has an origin
|
|
if (!contact.origin) {
|
|
contact.origin = 'gsconnect';
|
|
}
|
|
|
|
// This is an updated contact
|
|
if (this.__cache_data[contact.id]) {
|
|
this.__cache_data[contact.id] = contact;
|
|
this.emit('contact-changed', contact.id);
|
|
|
|
// This is a new contact
|
|
} else {
|
|
this.__cache_data[contact.id] = contact;
|
|
this.emit('contact-added', contact.id);
|
|
}
|
|
|
|
// Write if requested
|
|
if (write) {
|
|
this.save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a contact by id
|
|
*
|
|
* @param {string} id - The id of the contact to delete
|
|
* @param {boolean} write - Write to disk
|
|
*/
|
|
remove(id, write = true) {
|
|
// Only remove if the contact actually exists
|
|
if (this.__cache_data[id]) {
|
|
delete this.__cache_data[id];
|
|
this.emit('contact-removed', id);
|
|
|
|
// Write if requested
|
|
if (write) {
|
|
this.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lookup a contact for each address object in @addresses and return a
|
|
* dictionary of address (eg. phone number) to contact object.
|
|
*
|
|
* { "555-5555": { "name": "...", "numbers": [], ... } }
|
|
*
|
|
* @param {Array of object} addresses - A list of address objects
|
|
* @return {object} - A dictionary of phone numbers and contacts
|
|
*/
|
|
lookupAddresses(addresses) {
|
|
let contacts = {};
|
|
|
|
// Lookup contacts for each address
|
|
for (let i = 0, len = addresses.length; i < len; i++) {
|
|
let address = addresses[i].address;
|
|
|
|
contacts[address] = this.query({
|
|
number: address
|
|
});
|
|
}
|
|
|
|
return contacts;
|
|
}
|
|
|
|
async clear() {
|
|
try {
|
|
let contacts = this.contacts;
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
await this.remove(contacts[i].id, false);
|
|
}
|
|
|
|
await this.save();
|
|
} catch (e) {
|
|
debug(e, 'Clearing contacts');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the contact store from a dictionary of our custom contact objects.
|
|
*
|
|
* @param {Object} json - an Object of contact Objects
|
|
*/
|
|
async update(json = {}) {
|
|
try {
|
|
let contacts = Object.values(json);
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
let new_contact = contacts[i];
|
|
let contact = this.__cache_data[new_contact.id];
|
|
|
|
if (!contact || new_contact.timestamp !== contact.timestamp) {
|
|
await this.add(new_contact, false);
|
|
}
|
|
}
|
|
|
|
// Prune contacts
|
|
contacts = this.contacts;
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
let contact = contacts[i];
|
|
|
|
if (!json[contact.id]) {
|
|
await this.remove(contact.id, false);
|
|
}
|
|
}
|
|
|
|
await this.save();
|
|
} catch (e) {
|
|
debug(e, 'Updating contacts');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and update the contact store from its source. The default function
|
|
* simply logs a debug message if EDS is unavailable, while derived classes
|
|
* should request an update from the remote source.
|
|
*/
|
|
async fetch() {
|
|
try {
|
|
if (HAVE_EDS === false) {
|
|
throw new Error('Evolution Data Server not available');
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the contacts from disk.
|
|
*/
|
|
async load() {
|
|
try {
|
|
this.__cache_data = await JSON.load(this.__cache_file);
|
|
} catch (e) {
|
|
debug(e);
|
|
} finally {
|
|
this.notify('context');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save the contacts to disk.
|
|
*/
|
|
async save() {
|
|
if (this.context === null && HAVE_EDS) {
|
|
return;
|
|
}
|
|
|
|
if (this.__cache_lock) {
|
|
this.__cache_queue = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.__cache_lock = true;
|
|
await JSON.dump(this.__cache_data, this.__cache_file);
|
|
} catch (e) {
|
|
debug(e);
|
|
} finally {
|
|
this.__cache_lock = false;
|
|
|
|
if (this.__cache_queue) {
|
|
this.__cache_queue = false;
|
|
this.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.__disposed === undefined) {
|
|
this.__disposed = true;
|
|
|
|
if (HAVE_EDS) {
|
|
this._watcher.disconnect(this._appearedId);
|
|
this._watcher.disconnect(this._disappearedId);
|
|
this._watcher.run_dispose();
|
|
|
|
for (let ebook of this._ebooks.values()) {
|
|
this._onDisappeared(null, ebook.source);
|
|
}
|
|
}
|
|
|
|
this.run_dispose();
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
* The service class for this component
|
|
*/
|
|
var Component = Store;
|
|
|