464 lines
14 KiB
JavaScript
Raw Normal View History

2024-07-08 22:46:35 +02:00
// 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;