436 lines
13 KiB
JavaScript
436 lines
13 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
const Gio = imports.gi.Gio;
|
||
|
const GLib = imports.gi.GLib;
|
||
|
const GObject = imports.gi.GObject;
|
||
|
|
||
|
const PluginsBase = imports.service.plugins.base;
|
||
|
const Contacts = imports.service.components.contacts;
|
||
|
|
||
|
/*
|
||
|
* We prefer libebook's vCard parser if it's available
|
||
|
*/
|
||
|
var EBookContacts;
|
||
|
|
||
|
try {
|
||
|
EBookContacts = imports.gi.EBookContacts;
|
||
|
} catch (e) {
|
||
|
EBookContacts = null;
|
||
|
}
|
||
|
|
||
|
|
||
|
var Metadata = {
|
||
|
label: _('Contacts'),
|
||
|
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts',
|
||
|
incomingCapabilities: [
|
||
|
'kdeconnect.contacts.response_uids_timestamps',
|
||
|
'kdeconnect.contacts.response_vcards'
|
||
|
],
|
||
|
outgoingCapabilities: [
|
||
|
'kdeconnect.contacts.request_all_uids_timestamps',
|
||
|
'kdeconnect.contacts.request_vcards_by_uid'
|
||
|
],
|
||
|
actions: {}
|
||
|
};
|
||
|
|
||
|
|
||
|
/**
|
||
|
* vCard 2.1 Patterns
|
||
|
*/
|
||
|
const VCARD_FOLDING = /\r\n |\r |\n |=\n/g;
|
||
|
const VCARD_SUPPORTED = /^fn|tel|photo|x-kdeconnect/i;
|
||
|
const VCARD_BASIC = /^([^:;]+):(.+)$/;
|
||
|
const VCARD_TYPED = /^([^:;]+);([^:]+):(.+)$/;
|
||
|
const VCARD_TYPED_KEY = /item\d{1,2}\./;
|
||
|
const VCARD_TYPED_META = /([a-z]+)=(.*)/i;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Contacts Plugin
|
||
|
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/contacts
|
||
|
*/
|
||
|
var Plugin = GObject.registerClass({
|
||
|
GTypeName: 'GSConnectContactsPlugin'
|
||
|
}, class Plugin extends PluginsBase.Plugin {
|
||
|
|
||
|
_init(device) {
|
||
|
super._init(device, 'contacts');
|
||
|
this._store = new Contacts.Store(device.id);
|
||
|
this._store.fetch = this.requestUids.bind(this);
|
||
|
|
||
|
// Notify when the store is ready
|
||
|
this._contactsStoreReadyId = this._store.connect(
|
||
|
'notify::context',
|
||
|
() => this.device.notify('contacts')
|
||
|
);
|
||
|
|
||
|
// Notify if the contacts source changes
|
||
|
this._contactsSourceChangedId = this.settings.connect(
|
||
|
'changed::contacts-source',
|
||
|
() => this.device.notify('contacts')
|
||
|
);
|
||
|
|
||
|
// Load the cache
|
||
|
this._store.load();
|
||
|
}
|
||
|
|
||
|
connected() {
|
||
|
super.connected();
|
||
|
this.requestUids();
|
||
|
}
|
||
|
|
||
|
clearCache() {
|
||
|
this._store.clear();
|
||
|
}
|
||
|
|
||
|
handlePacket(packet) {
|
||
|
if (packet.type === 'kdeconnect.contacts.response_uids_timestamps') {
|
||
|
this._handleUids(packet);
|
||
|
} else if (packet.type === 'kdeconnect.contacts.response_vcards') {
|
||
|
this._handleVCards(packet);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_handleUids(packet) {
|
||
|
try {
|
||
|
let contacts = this._store.contacts;
|
||
|
let remote_uids = packet.body.uids;
|
||
|
let removed = false;
|
||
|
delete packet.body.uids;
|
||
|
|
||
|
// Usually a failed request, so avoid wiping the cache
|
||
|
if (remote_uids.length === 0) return;
|
||
|
|
||
|
// Delete any contacts that were removed on the device
|
||
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
||
|
let contact = contacts[i];
|
||
|
|
||
|
if (!remote_uids.includes(contact.id)) {
|
||
|
this._store.remove(contact.id, false);
|
||
|
removed = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Build a list of new or updated contacts
|
||
|
let uids = [];
|
||
|
|
||
|
for (let [uid, timestamp] of Object.entries(packet.body)) {
|
||
|
let contact = this._store.get_contact(uid);
|
||
|
|
||
|
if (!contact || contact.timestamp !== timestamp) {
|
||
|
uids.push(uid);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Send a request for any new or updated contacts
|
||
|
if (uids.length) {
|
||
|
this.requestVCards(uids);
|
||
|
}
|
||
|
|
||
|
// If we removed any contacts, save the cache
|
||
|
if (removed) {
|
||
|
this._store.save();
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Decode a string encoded as "QUOTED-PRINTABLE" and return a regular string
|
||
|
*
|
||
|
* See: https://github.com/mathiasbynens/quoted-printable/blob/master/src/quoted-printable.js
|
||
|
*
|
||
|
* @param {string} input - The QUOTED-PRINTABLE string
|
||
|
* @return {string} - The decoded string
|
||
|
*/
|
||
|
decode_quoted_printable(input) {
|
||
|
return input
|
||
|
// https://tools.ietf.org/html/rfc2045#section-6.7, rule 3
|
||
|
.replace(/[\t\x20]$/gm, '')
|
||
|
// Remove hard line breaks preceded by `=`
|
||
|
.replace(/=(?:\r\n?|\n|$)/g, '')
|
||
|
// https://tools.ietf.org/html/rfc2045#section-6.7, note 1.
|
||
|
.replace(/=([a-fA-F0-9]{2})/g, ($0, $1) => {
|
||
|
let codePoint = parseInt($1, 16);
|
||
|
return String.fromCharCode(codePoint);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Decode a string encoded as "UTF-8" and return a regular string
|
||
|
*
|
||
|
* See: https://github.com/kvz/locutus/blob/master/src/php/xml/utf8_decode.js
|
||
|
*
|
||
|
* @param {string} input - The UTF-8 string
|
||
|
* @return {string} - The decoded string
|
||
|
*/
|
||
|
decode_utf8(input) {
|
||
|
try {
|
||
|
let output = [];
|
||
|
let i = 0;
|
||
|
let c1 = 0;
|
||
|
let seqlen = 0;
|
||
|
|
||
|
while (i < input.length) {
|
||
|
c1 = input.charCodeAt(i) & 0xFF;
|
||
|
seqlen = 0;
|
||
|
|
||
|
if (c1 <= 0xBF) {
|
||
|
c1 = (c1 & 0x7F);
|
||
|
seqlen = 1;
|
||
|
} else if (c1 <= 0xDF) {
|
||
|
c1 = (c1 & 0x1F);
|
||
|
seqlen = 2;
|
||
|
} else if (c1 <= 0xEF) {
|
||
|
c1 = (c1 & 0x0F);
|
||
|
seqlen = 3;
|
||
|
} else {
|
||
|
c1 = (c1 & 0x07);
|
||
|
seqlen = 4;
|
||
|
}
|
||
|
|
||
|
for (let ai = 1; ai < seqlen; ++ai) {
|
||
|
c1 = ((c1 << 0x06) | (input.charCodeAt(ai + i) & 0x3F));
|
||
|
}
|
||
|
|
||
|
if (seqlen === 4) {
|
||
|
c1 -= 0x10000;
|
||
|
output.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF)));
|
||
|
output.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF)));
|
||
|
} else {
|
||
|
output.push(String.fromCharCode(c1));
|
||
|
}
|
||
|
|
||
|
i += seqlen;
|
||
|
}
|
||
|
|
||
|
return output.join('');
|
||
|
|
||
|
// Fallback to old unfaithful
|
||
|
} catch (e) {
|
||
|
try {
|
||
|
return decodeURIComponent(escape(input));
|
||
|
|
||
|
// Say "chowdah" frenchie!
|
||
|
} catch (e) {
|
||
|
debug(e, `Failed to decode UTF-8 VCard field ${input}`);
|
||
|
return input;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parse a VCard v2.1 and return a dictionary of data
|
||
|
*
|
||
|
* See: http://jsfiddle.net/ARTsinn/P2t2P/
|
||
|
*
|
||
|
* @param {string} vcard_data - The raw VCard data
|
||
|
*/
|
||
|
parseVCard21(vcard_data) {
|
||
|
// vcard skeleton
|
||
|
let vcard = {
|
||
|
fn: _('Unknown Contact'),
|
||
|
tel: []
|
||
|
};
|
||
|
|
||
|
// Remove line folding and split
|
||
|
let lines = vcard_data.replace(VCARD_FOLDING, '').split(/\r\n|\r|\n/);
|
||
|
|
||
|
for (let i = 0, len = lines.length; i < len; i++) {
|
||
|
let line = lines[i];
|
||
|
let results, key, type, value;
|
||
|
|
||
|
// Empty line or a property we aren't interested in
|
||
|
if (!line || !line.match(VCARD_SUPPORTED)) continue;
|
||
|
|
||
|
// Basic Fields (fn, x-kdeconnect-timestamp, etc)
|
||
|
if ((results = line.match(VCARD_BASIC))) {
|
||
|
[results, key, value] = results;
|
||
|
vcard[key.toLowerCase()] = value;
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Typed Fields (tel, adr, etc)
|
||
|
if ((results = line.match(VCARD_TYPED))) {
|
||
|
[results, key, type, value] = results;
|
||
|
key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
|
||
|
value = value.split(';');
|
||
|
type = type.split(';');
|
||
|
|
||
|
// Type(s)
|
||
|
let meta = {};
|
||
|
|
||
|
for (let i = 0, len = type.length; i < len; i++) {
|
||
|
let res = type[i].match(VCARD_TYPED_META);
|
||
|
|
||
|
if (res) {
|
||
|
meta[res[1]] = res[2];
|
||
|
} else {
|
||
|
meta['type' + (i === 0 ? '' : i)] = type[i].toLowerCase();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Value(s)
|
||
|
if (vcard[key] === undefined) vcard[key] = [];
|
||
|
|
||
|
// Decode QUOTABLE-PRINTABLE
|
||
|
if (meta.ENCODING && meta.ENCODING === 'QUOTED-PRINTABLE') {
|
||
|
delete meta.ENCODING;
|
||
|
value = value.map(v => this.decode_quoted_printable(v));
|
||
|
}
|
||
|
|
||
|
// Decode UTF-8
|
||
|
if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
|
||
|
delete meta.CHARSET;
|
||
|
value = value.map(v => this.decode_utf8(v));
|
||
|
}
|
||
|
|
||
|
// Special case for FN (full name)
|
||
|
if (key === 'fn') {
|
||
|
vcard[key] = value[0];
|
||
|
} else {
|
||
|
vcard[key].push({meta: meta, value: value});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return vcard;
|
||
|
}
|
||
|
|
||
|
async parseVCardNative(uid, vcard_data) {
|
||
|
try {
|
||
|
let vcard = this.parseVCard21(vcard_data);
|
||
|
|
||
|
let contact = {
|
||
|
id: uid,
|
||
|
name: vcard.fn,
|
||
|
numbers: [],
|
||
|
origin: 'device',
|
||
|
timestamp: parseInt(vcard['x-kdeconnect-timestamp'])
|
||
|
};
|
||
|
|
||
|
// Phone Numbers
|
||
|
contact.numbers = vcard.tel.map(entry => {
|
||
|
let type = 'unknown';
|
||
|
|
||
|
if (entry.meta && entry.meta.type) {
|
||
|
type = entry.meta.type;
|
||
|
}
|
||
|
|
||
|
return {type: type, value: entry.value[0]};
|
||
|
});
|
||
|
|
||
|
// Avatar
|
||
|
if (vcard.photo) {
|
||
|
let data = GLib.base64_decode(vcard.photo[0].value[0]);
|
||
|
contact.avatar = await this._store.storeAvatar(data);
|
||
|
}
|
||
|
|
||
|
return contact;
|
||
|
} catch (e) {
|
||
|
debug(e, `Failed to parse VCard contact ${uid}`);
|
||
|
return undefined;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async parseVCard(uid, vcard_data) {
|
||
|
try {
|
||
|
let contact = {
|
||
|
id: uid,
|
||
|
name: _('Unknown Contact'),
|
||
|
numbers: [],
|
||
|
origin: 'device',
|
||
|
timestamp: 0
|
||
|
};
|
||
|
|
||
|
let evcard = EBookContacts.VCard.new_from_string(vcard_data);
|
||
|
let evattrs = evcard.get_attributes();
|
||
|
|
||
|
for (let i = 0, len = evattrs.length; i < len; i++) {
|
||
|
let attr = evattrs[i];
|
||
|
let data, number;
|
||
|
|
||
|
switch (attr.get_name().toLowerCase()) {
|
||
|
case 'fn':
|
||
|
contact.name = attr.get_value();
|
||
|
break;
|
||
|
|
||
|
case 'tel':
|
||
|
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);
|
||
|
break;
|
||
|
|
||
|
case 'x-kdeconnect-timestamp':
|
||
|
contact.timestamp = parseInt(attr.get_value());
|
||
|
break;
|
||
|
|
||
|
case 'photo':
|
||
|
data = GLib.base64_decode(attr.get_value());
|
||
|
contact.avatar = await this._store.storeAvatar(data);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return contact;
|
||
|
} catch (e) {
|
||
|
debug(e, `Failed to parse VCard contact ${uid}`);
|
||
|
return undefined;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async _handleVCards(packet) {
|
||
|
try {
|
||
|
// We don't use this
|
||
|
delete packet.body.uids;
|
||
|
|
||
|
// Parse each vCard and add the contact
|
||
|
for (let [uid, vcard] of Object.entries(packet.body)) {
|
||
|
let contact;
|
||
|
|
||
|
if (EBookContacts) {
|
||
|
contact = await this.parseVCard(uid, vcard);
|
||
|
} else {
|
||
|
contact = await this.parseVCardNative(uid, vcard);
|
||
|
}
|
||
|
|
||
|
if (contact) {
|
||
|
this._store.add(contact);
|
||
|
}
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
requestUids() {
|
||
|
this.device.sendPacket({
|
||
|
type: 'kdeconnect.contacts.request_all_uids_timestamps'
|
||
|
});
|
||
|
}
|
||
|
|
||
|
requestVCards(uids) {
|
||
|
this.device.sendPacket({
|
||
|
type: 'kdeconnect.contacts.request_vcards_by_uid',
|
||
|
body: {
|
||
|
uids: uids
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
destroy() {
|
||
|
this.settings.disconnect(this._contactsStoreReadyId);
|
||
|
this.settings.disconnect(this._contactsSourceChangedId);
|
||
|
super.destroy();
|
||
|
}
|
||
|
});
|
||
|
|