dot/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/service/components/contacts.js

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;