464 lines
14 KiB
JavaScript
464 lines
14 KiB
JavaScript
|
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
||
|
//
|
||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||
|
|
||
|
import GLib from 'gi://GLib';
|
||
|
import GObject from 'gi://GObject';
|
||
|
|
||
|
import Plugin from '../plugin.js';
|
||
|
import Contacts from '../components/contacts.js';
|
||
|
|
||
|
/*
|
||
|
* We prefer libebook's vCard parser if it's available
|
||
|
*/
|
||
|
let EBookContacts;
|
||
|
export const setEBookContacts = (ebook) => { // This function is only for tests to call!
|
||
|
EBookContacts = ebook;
|
||
|
};
|
||
|
|
||
|
try {
|
||
|
EBookContacts = (await import('gi://EBookContacts')).default;
|
||
|
} catch (e) {
|
||
|
EBookContacts = null;
|
||
|
}
|
||
|
|
||
|
|
||
|
export const Metadata = {
|
||
|
label: _('Contacts'),
|
||
|
description: _('Access contacts of the paired device'),
|
||
|
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
|
||
|
*/
|
||
|
const ContactsPlugin = GObject.registerClass({
|
||
|
GTypeName: 'GSConnectContactsPlugin',
|
||
|
}, class ContactsPlugin extends Plugin {
|
||
|
|
||
|
_init(device) {
|
||
|
super._init(device, 'contacts');
|
||
|
|
||
|
this._store = new Contacts(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();
|
||
|
}
|
||
|
|
||
|
clearCache() {
|
||
|
this._store.clear();
|
||
|
}
|
||
|
|
||
|
connected() {
|
||
|
super.connected();
|
||
|
this._requestUids();
|
||
|
}
|
||
|
|
||
|
handlePacket(packet) {
|
||
|
switch (packet.type) {
|
||
|
case 'kdeconnect.contacts.response_uids_timestamps':
|
||
|
this._handleUids(packet);
|
||
|
break;
|
||
|
|
||
|
case 'kdeconnect.contacts.response_vcards':
|
||
|
this._handleVCards(packet);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_handleUids(packet) {
|
||
|
try {
|
||
|
const contacts = this._store.contacts;
|
||
|
const 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++) {
|
||
|
const 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
|
||
|
const uids = [];
|
||
|
|
||
|
for (const [uid, timestamp] of Object.entries(packet.body)) {
|
||
|
const 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
|
||
|
*/
|
||
|
_decodeQuotedPrintable(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) => {
|
||
|
const 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
|
||
|
*/
|
||
|
_decodeUTF8(input) {
|
||
|
try {
|
||
|
const 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 &= 0x7F;
|
||
|
seqlen = 1;
|
||
|
} else if (c1 <= 0xDF) {
|
||
|
c1 &= 0x1F;
|
||
|
seqlen = 2;
|
||
|
} else if (c1 <= 0xEF) {
|
||
|
c1 &= 0x0F;
|
||
|
seqlen = 3;
|
||
|
} else {
|
||
|
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 only) and return a dictionary of the fields
|
||
|
*
|
||
|
* See: http://jsfiddle.net/ARTsinn/P2t2P/
|
||
|
*
|
||
|
* @param {string} vcard_data - The raw VCard data
|
||
|
* @return {Object} dictionary of vCard data
|
||
|
*/
|
||
|
_parseVCard21(vcard_data) {
|
||
|
// vcard skeleton
|
||
|
const vcard = {
|
||
|
fn: _('Unknown Contact'),
|
||
|
tel: [],
|
||
|
};
|
||
|
|
||
|
// Remove line folding and split
|
||
|
const unfolded = vcard_data.replace(VCARD_FOLDING, '');
|
||
|
const lines = unfolded.split(/\r\n|\r|\n/);
|
||
|
|
||
|
for (let i = 0, len = lines.length; i < len; i++) {
|
||
|
const 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))) {
|
||
|
[, key, value] = results;
|
||
|
vcard[key.toLowerCase()] = value;
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Typed Fields (tel, adr, etc)
|
||
|
if ((results = line.match(VCARD_TYPED))) {
|
||
|
[, key, type, value] = results;
|
||
|
key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
|
||
|
value = value.split(';');
|
||
|
type = type.split(';');
|
||
|
|
||
|
// Type(s)
|
||
|
const meta = {};
|
||
|
|
||
|
for (let i = 0, len = type.length; i < len; i++) {
|
||
|
const 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._decodeQuotedPrintable(v));
|
||
|
}
|
||
|
|
||
|
// Decode UTF-8
|
||
|
if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
|
||
|
delete meta.CHARSET;
|
||
|
value = value.map(v => this._decodeUTF8(v));
|
||
|
}
|
||
|
|
||
|
// Special case for FN (full name)
|
||
|
if (key === 'fn')
|
||
|
vcard[key] = value[0];
|
||
|
else
|
||
|
vcard[key].push({meta: meta, value: value});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return vcard;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parse a vCard (v2.1 only) using native JavaScript and add it to the
|
||
|
* contact store.
|
||
|
*
|
||
|
* @param {string} uid - The contact UID
|
||
|
* @param {string} vcard_data - The raw vCard data
|
||
|
*/
|
||
|
async _parseVCardNative(uid, vcard_data) {
|
||
|
try {
|
||
|
const vcard = this._parseVCard21(vcard_data);
|
||
|
|
||
|
const 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) {
|
||
|
const data = GLib.base64_decode(vcard.photo[0].value[0]);
|
||
|
contact.avatar = await this._store.storeAvatar(data);
|
||
|
}
|
||
|
|
||
|
this._store.add(contact);
|
||
|
} catch (e) {
|
||
|
debug(e, `Failed to parse VCard contact ${uid}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parse a vCard using libebook and add it to the contact store.
|
||
|
*
|
||
|
* @param {string} uid - The contact UID
|
||
|
* @param {string} vcard_data - The raw vCard data
|
||
|
*/
|
||
|
async _parseVCard(uid, vcard_data) {
|
||
|
try {
|
||
|
const contact = {
|
||
|
id: uid,
|
||
|
name: _('Unknown Contact'),
|
||
|
numbers: [],
|
||
|
origin: 'device',
|
||
|
timestamp: 0,
|
||
|
};
|
||
|
|
||
|
const evcard = EBookContacts.VCard.new_from_string(vcard_data);
|
||
|
const attrs = evcard.get_attributes();
|
||
|
|
||
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
||
|
const attr = attrs[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;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this._store.add(contact);
|
||
|
} catch (e) {
|
||
|
debug(e, `Failed to parse VCard contact ${uid}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handle an incoming list of contact vCards and pass them to the best
|
||
|
* available parser.
|
||
|
*
|
||
|
* @param {Core.Packet} packet - A `kdeconnect.contacts.response_vcards`
|
||
|
*/
|
||
|
_handleVCards(packet) {
|
||
|
try {
|
||
|
// We don't use this
|
||
|
delete packet.body.uids;
|
||
|
|
||
|
// Parse each vCard and add the contact
|
||
|
for (const [uid, vcard] of Object.entries(packet.body)) {
|
||
|
if (EBookContacts)
|
||
|
this._parseVCard(uid, vcard);
|
||
|
else
|
||
|
this._parseVCardNative(uid, vcard);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e, this.device.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Request a list of contact UIDs with timestamps.
|
||
|
*/
|
||
|
_requestUids() {
|
||
|
this.device.sendPacket({
|
||
|
type: 'kdeconnect.contacts.request_all_uids_timestamps',
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Request the vCards for @uids.
|
||
|
*
|
||
|
* @param {string[]} uids - A list of contact UIDs
|
||
|
*/
|
||
|
_requestVCards(uids) {
|
||
|
this.device.sendPacket({
|
||
|
type: 'kdeconnect.contacts.request_vcards_by_uid',
|
||
|
body: {
|
||
|
uids: uids,
|
||
|
},
|
||
|
});
|
||
|
}
|
||
|
|
||
|
destroy() {
|
||
|
this._store.disconnect(this._contactsStoreReadyId);
|
||
|
this.settings.disconnect(this._contactsSourceChangedId);
|
||
|
|
||
|
super.destroy();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
export default ContactsPlugin;
|