Redesign AddressBook

This commit is contained in:
the-djmaze 2022-05-29 00:22:50 +02:00
parent 5f6ea3cf29
commit a71874a8c4
18 changed files with 1403 additions and 1669 deletions

227
dev/DAV/JCard.js Normal file
View file

@ -0,0 +1,227 @@
/**
* https://datatracker.ietf.org/doc/html/rfc7095
*
* Inspired by https://github.com/mcpar-land/vcfer
*/
import { VCardProperty } from './VCardProperty'
export class JCard {
constructor(input)
{
this.props = new Map()
this.version = '4.0'
if (input) {
// read from jCard
if (typeof input !== 'object') {
throw new Error('error reading vcard')
}
this.parseFromJCard(input)
}
}
parseFromJCard(json)
{
json = JSON.parse(JSON.stringify(json));
if (!/vcard/i.test(json[0])) {
throw new SyntaxError('Incorrect jCard format');
}
json[1].forEach(jprop => this.add(new VCardProperty(jprop)));
}
/**
* Retrieve an array of {@link VCardProperty} objects under the specified field.
* Returns [] if there are no VCardProperty objects found.
* Properites are always stored in an array.
* @param field to get.
* @param type If provided, only return {@link VCardProperty}s with the specified
* type as a param.
*/
get(field, type)
{
if (type) {
let props = this.props.get(field);
return props
? props.filter(prop => {
let types = prop.type;
return (Array.isArray(types) ? types : [types]).includes(type);
})
: [];
}
return this.props.get(field) || [];
// TODO with type filter-er
}
/**
* Retrieve a _single_ VCardProperty of the specified field. Attempts to pick based
* on the following priorities, in order:
* - `TYPE={type}` of the value specified in the `type` argument. Ignored
* if the argument isn't supplied.
* - `TYPE=pref` is present.
* - is the VCardProperty at index 0 from get(field)
* @param field
* @param type
*/
getOne(field, type)
{
return this.get(field, type || 'pref')[0] || this.get(field)[0];
}
/**
* Set the contents of a field to contain a single {@link VCardProperty}.
*
* Accepts either 2-4 arguments to construct a VCardProperty,
* or 1 argument of a preexisting VCardProperty object.
*
* This will always overwrite all existing properties of the given
* field. For just adding a new VCardProperty, see {@link VCard#add}
* @param arg the field, or a VCardProperty object
* @param value the value for the VCardProperty object
* @param params the parameters for the VCardProperty object
* @param group the group for the VCardProperty object
*/
set(arg, value, params, group)
{
if (typeof arg === 'string') {
arg = new VCardProperty(String(arg), value, params, group);
}
if (!(arg instanceof VCardProperty)) {
throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
}
let field = arg.getField();
this.props.set(field, [arg]);
return arg;
}
add(arg, value, params, group)
{
// string arguments
if (typeof arg === 'string') {
arg = new VCardProperty(String(arg), value, params, group);
}
if (!(arg instanceof VCardProperty)) {
throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
}
// VCardProperty arguments
let field = arg.getField();
if (this.props.get(field)) this.props.get(field)?.push(arg)
else this.props.set(field, [arg])
return arg;
}
/**
* Removes a {@link VCardProperty}, or all properties of the supplied field.
* @param arg the field, or a {@link VCardProperty} object
* @param paramFilter (incomplete)
*/
remove(arg) {
// string arguments
if (typeof arg === 'string') {
// TODO filter by param
this.props.delete(arg);
}
// VCardProperty argument
else if (arg instanceof VCardProperty) {
let propArray = this.props.get(arg.getField());
if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
propArray.splice(propArray.indexOf(arg), 1);
if (propArray.length === 0)
this.props.delete(arg.getField());
}
// incorrect arguments
else
throw new Error('invalid argument of VCard.remove(), expects ' +
'string and optional param filter or a VCardProperty');
}
/**
* Returns true if the vCard has at least one @{link VCardProperty}
* of the given field.
* @param field The field to query
*/
has(field)
{
return (!!this.props.get(field) && this.props.get(field).length > 0);
}
/**
* Returns stringified JSON
*/
toString()
{
return JSON.stringify(this);
}
/**
* Returns a {@link JCard} object as a JSON array.
*/
toJSON()
{
let data = [['version', {}, 'text', '4.0']];
/*
this.props.forEach((props, field) =>
(field === 'version') || props.forEach(prop => prop.isEmpty() || data.push(prop.toJSON()))
);
*/
for (const [field, props] of this.props.entries()) {
if ('version' !== field) {
for (const prop of props) {
prop.isEmpty() || data.push(prop.toJSON());
}
}
}
return ['vcard', data];
}
/**
* Automatically generate the 'fn' VCardProperty from the preferred 'n' VCardProperty.
*
* #### `set` (`boolean`)
*
* - `false`: (default) return the generated full name string without
* modifying the VCard.
*
* - `true`: modify the VCard's `fn` VCardProperty directly, as specified
* by `append`
*
* #### `append` (`boolean`)
*
* (ignored if `set` is `false`)
*
* - `false`: (default) replace the existing 'fn' VCardProperty/properties with
* a new one.
*
* - `true`: append a new `fn` VCardProperty to the array.
*
* see: [RFC 6350 section 6.2.1](https://tools.ietf.org/html/rfc6350#section-6.2.1)
*/
parseFullName(options) {
let n = this.getOne('n');
if (n === undefined) {
throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
}
let fnString = '';
// Position in n -> position in fn
[3, 1, 2, 0, 4].forEach(pos => {
let splitStr = n.value[pos];
if (splitStr) {
// comma separated values separated by spaces
fnString += ' ' + splitStr.replace(',', ' ');
}
});
fnString = fnString.trim();
let fn = new VCardProperty('fn', fnString);
if (options?.set) {
if (options.append) {
this.add(fn);
} else {
this.set(fn);
}
}
return fn;
}
}

152
dev/DAV/VCardProperty.js Normal file
View file

@ -0,0 +1,152 @@
/**
* Inspired by https://github.com/mcpar-land/vcfer
*/
const
camelCase = str =>
str.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
index === 0 ? word.toLowerCase() : word.toUpperCase()
).replace(/\s+/g, '');
export class VCardProperty {
/**
* A class describing a single vCard property.
* Will almost always be a member of a
* {@link VCard}'s [props]{@link VCard.props} map.
*
* Accepts either 2-4 arguments, or 1 argument in jCard property format.
* @param arg the field, or a jCard property
* @param value
* @param params
* @param type
*/
constructor(arg, value, params, type = 'text')
{
this.field = '';
/**
* the value of the property.
* @example '(123) 456 7890'
*/
this.value = '';
/**
* the type of the property value.
* @example 'text'
*/
this.type = '';
/**
* https://www.rfc-editor.org/rfc/rfc6350.html#section-5
* An jCard parameters object.
* @example
* {
* type: ['work', 'voice', 'pref'],
* value: 'uri'
* }
*/
this.params = {};
// Construct from arguments
if (value !== undefined && typeof arg === 'string') {
this.field = arg;
this.value = value;
this.params = params || {};
this.type = type;
}
// construct from jcard
else if (value === undefined && params === undefined && typeof arg === 'object') {
this.parseFromJCardProperty(arg);
}
// invalid property
else {
throw new Error('invalid Property constructor');
}
}
parseFromJCardProperty(jCardProp)
{
jCardProp = JSON.parse(JSON.stringify(jCardProp));
this.field = camelCase(jCardProp[0]);
this.params = jCardProp[1];
this.type = jCardProp[2];
this.value = jCardProp[3];
}
addParam(key, value)
{
if (Array.isArray(this.params[key])) {
this.params[key].push(value);
}
else if (this.params[key] != null) {
this.params[key] = [this.params[key], value];
}
else {
this.params[key] = value;
}
}
/** Returns a copy of the Property's string value. */
getValue()
{
return '' + this.value;
}
/**
* https://www.rfc-editor.org/rfc/rfc6350.html#section-5.3
*/
pref()
{
return this.params.pref || 100;
}
/**
* https://www.rfc-editor.org/rfc/rfc6350.html#section-5.6
*/
tags()
{
return this.params.type || [];
}
/** Returns `true` if all the following are true:
* - the property's value contains charactes other than `;`
* - the property has no parameters
*/
isEmpty()
{
return ((null == this.value || !/[^;]+/.test(this.value)) && !Object.keys(this.params).length);
}
notEmpty()
{
return !this.isEmpty();
}
/** Returns a readonly string copy of the property's field. */
getField()
{
return '' + this.field;
}
/**
* Returns stringified JSON
*/
toString()
{
return JSON.stringify(this);
}
/**
* Returns a JSON array in a [jCard property]{@link JCardProperty} format
*/
toJSON()
{
return [
this.field,
this.params,
this.type || 'text',
this.value
];
}
}

View file

@ -1,21 +1,63 @@
import { arrayLength } from 'Common/Utils';
import { ContactPropertyModel, ContactPropertyType } from 'Model/ContactProperty';
import { AbstractModel } from 'Knoin/AbstractModel';
import { JCard } from 'DAV/JCard';
//import { VCardProperty } from 'DAV/VCardProperty';
const nProps = [
'surName',
'givenName',
'middleName',
'namePrefix',
'nameSuffix'
];
export class ContactModel extends AbstractModel {
constructor() {
super();
this.id = 0;
this.display = '';
this.properties = [];
this.readOnly = false;
this.jCard = new JCard();
this.addObservables({
focused: false,
selected: false,
checked: false,
deleted: false
deleted: false,
readOnly: false,
id: 0,
givenName: '', // FirstName
surName: '', // LastName
middleName: '', // MiddleName
namePrefix: '', // NamePrefix
nameSuffix: '', // NameSuffix
nickname: null
});
// this.email = koArrayWithDestroy();
this.email = ko.observableArray();
this.tel = ko.observableArray();
this.url = ko.observableArray();
this.addComputables({
hasValidName: () => !!(this.givenName() || this.surName()),
fullName: () => (this.givenName() + ' ' + this.surName()).trim(),
display: () => {
let a = this.jCard.getOne('fn')?.value,
b = this.fullName(),
c = this.jCard.getOne('email')?.value,
d = this.nickname();
return a || b || c || d;
}
/*
fullName: {
read: () => this.givenName() + " " + this.surName(),
write: value => {
this.jCard.set('fn', value/*, params, group* /)
}
}
*/
});
}
@ -23,23 +65,15 @@ export class ContactModel extends AbstractModel {
* @returns {Array|null}
*/
getNameAndEmailHelper() {
let name = '',
email = '';
if (arrayLength(this.properties)) {
this.properties.forEach(property => {
if (property) {
if (ContactPropertyType.FirstName === property.type()) {
name = (property.value() + ' ' + name).trim();
} else if (ContactPropertyType.LastName === property.type()) {
name = (name + ' ' + property.value()).trim();
} else if (!email && ContactPropertyType.Email === property.type()) {
email = property.value();
}
}
});
}
let name = (this.givenName() + ' ' + this.surName()).trim(),
email = this.email()[0];
/*
// this.jCard.getOne('fn')?.notEmpty() ||
this.jCard.parseFullName({set:true});
// let name = this.jCard.getOne('nickname'),
let name = this.jCard.getOne('fn'),
email = this.jCard.getOne('email');
*/
return email ? [email, name] : null;
}
@ -52,47 +86,32 @@ export class ContactModel extends AbstractModel {
const contact = super.reviveFromJson(json);
if (contact) {
let list = [];
if (arrayLength(json.properties)) {
json.properties.forEach(property => {
property = ContactPropertyModel.reviveFromJson(property);
property && list.push(property);
let jCard = new JCard(json.jCard),
props = jCard.getOne('n')?.value;
props && props.forEach((value, index) =>
value && contact[nProps[index]](value)
);
props = jCard.getOne('nickname');
props && contact.nickname(props.value);
['email', 'tel', 'url'].forEach(field => {
props = jCard.get(field);
props && props.forEach(prop => {
contact[field].push({
value: ko.observable(prop.value)
// type: prop.params.type
});
});
}
});
contact.properties = list;
contact.initDefaultProperties();
contact.jCard = jCard;
}
return contact;
}
initDefaultProperties() {
let list = this.properties;
list.sort((p1,p2) =>{
if (p2.type() == ContactPropertyType.FirstName) {
return 1;
}
if (p1.type() == ContactPropertyType.FirstName || p1.type() == ContactPropertyType.LastName) {
return -1;
}
if (p2.type() == ContactPropertyType.LastName) {
return 1;
}
return 0;
});
let found = list.find(prop => prop.type() == ContactPropertyType.LastName);
if (!found) {
found = new ContactPropertyModel(ContactPropertyType.LastName);
list.unshift(found);
}
found.placeholder('CONTACTS/PLACEHOLDER_ENTER_LAST_NAME');
found = list.find(prop => prop.type() == ContactPropertyType.FirstName);
if (!found) {
found = new ContactPropertyModel(ContactPropertyType.FirstName);
list.unshift(found);
}
found.placeholder('CONTACTS/PLACEHOLDER_ENTER_FIRST_NAME');
this.properties = list;
}
/**
* @returns {string}
*/
@ -100,6 +119,71 @@ export class ContactModel extends AbstractModel {
return ''+this.id;
}
addEmail() {
// home, work
this.email.push({
value: ko.observable('')
// type: prop.params.type
});
}
addTel() {
// home, work, text, voice, fax, cell, video, pager, textphone, iana-token, x-name
this.tel.push({
value: ko.observable('')
// type: prop.params.type
});
}
addUrl() {
// home, work
this.url.push({
value: ko.observable('')
// type: prop.params.type
});
}
addNickname() {
// home, work
this.nickname() || this.nickname('');
}
toJSON()
{
let jCard = this.jCard;
jCard.set('n', [
this.surName(),
this.givenName(),
this.middleName(),
this.namePrefix(),
this.nameSuffix()
]/*, params, group*/);
// jCard.parseFullName({set:true});
this.nickname() ? jCard.set('nickname', this.nickname()/*, params, group*/) : jCard.remove('nickname');
['email', 'tel', 'url'].forEach(field => {
let values = this[field].map(item => item.value());
jCard.get(field).forEach(prop => {
let i = values.indexOf(prop.value);
if (0 > i || !prop.value) {
jCard.remove(prop);
} else {
values.splice(i, 1);
}
});
values.forEach(value => value && jCard.add(field, value));
});
// Done by server
// jCard.set('rev', '2022-05-21T10:59:52Z')
return {
Uid: this.id,
jCard: JSON.stringify(jCard)
};
}
/**
* @return string
*/

View file

@ -345,7 +345,7 @@ export class FolderModel extends AbstractModel {
canBeEdited: () => FolderType.User === folder.type() && folder.exists/* && folder.selectable()*/,
isSystemFolder: () => FolderType.User !== folder.type() | !!folder.kolabType(),
isSystemFolder: () => FolderType.User !== folder.type() /*| !!folder.kolabType()*/,
canBeSelected: () => folder.selectable() && !folder.isSystemFolder(),

View file

@ -193,13 +193,6 @@
height: 30px;
}
.add-link {
margin-left: 2px;
padding: 5px;
font-size: 12px;
color: #aaa;
}
input, textarea {
box-shadow: none;
border-color: #fff;

View file

@ -17,7 +17,6 @@ import Remote from 'Remote/User/Fetch';
import { EmailModel } from 'Model/Email';
import { ContactModel } from 'Model/Contact';
import { ContactPropertyModel, ContactPropertyType } from 'Model/ContactProperty';
import { decorateKoCommands, showScreenPopup } from 'Knoin/Knoin';
import { AbstractViewPopup } from 'Knoin/AbstractViews';
@ -25,10 +24,9 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
import { AskPopupView } from 'View/Popup/Ask';
const
viewProperties = koArrayWithDestroy(),
CONTACTS_PER_PAGE = 50,
ScopeContacts = 'Contacts',
propertyIsMail = prop => prop.isType(ContactPropertyType.Email),
propertyIsName = prop => prop.isType(ContactPropertyType.FirstName) || prop.isType(ContactPropertyType.LastName);
ScopeContacts = 'Contacts';
export class ContactsPopupView extends AbstractViewPopup {
constructor() {
@ -47,10 +45,8 @@ export class ContactsPopupView extends AbstractViewPopup {
contactsPage: 1,
emptySelection: true,
viewClearSearch: false,
viewID: '',
viewReadOnly: false,
viewSaveTrigger: SaveSettingsStep.Idle,
@ -58,13 +54,13 @@ export class ContactsPopupView extends AbstractViewPopup {
viewSaving: false,
watchDirty: false,
watchHash: false
watchHash: false,
contact: null
});
this.contacts = ContactUserStore;
this.viewProperties = koArrayWithDestroy();
this.useCheckboxesInList = SettingsUserStore.useCheckboxesInList;
this.selector = new Selector(
@ -78,19 +74,13 @@ export class ContactsPopupView extends AbstractViewPopup {
this.selector.on('ItemSelect', contact => {
this.populateViewContact(contact);
if (!contact) {
this.emptySelection(true);
}
});
this.selector.on('ItemGetUid', contact => contact ? contact.generateUid() : '');
this.bDropPageAfterDelete = false;
// this.saveCommandDebounce = this.saveCommand.bind(this).debounce(1000);
const
// propertyFocused = property => !property.isValid() && !property.focused(),
pagecount = () => Math.max(1, Math.ceil(this.contactsCount() / CONTACTS_PER_PAGE));
this.addComputables({
@ -98,18 +88,6 @@ export class ContactsPopupView extends AbstractViewPopup {
contactsPaginator: computedPaginatorHelper(this.contactsPage, pagecount),
viewPropertiesNames: () => this.viewProperties.filter(propertyIsName),
viewPropertiesEmails: () => this.viewProperties.filter(propertyIsMail),
viewPropertiesOther: () => this.viewProperties.filter(property => property.isType(ContactPropertyType.Nick)),
viewPropertiesWeb: () => this.viewProperties.filter(property => property.isType(ContactPropertyType.Web)),
viewPropertiesPhones: () => this.viewProperties.filter(property => property.isType(ContactPropertyType.Phone)),
contactHasValidName: () => !!this.viewProperties.find(prop => propertyIsName(prop) && prop.isValid()),
contactsCheckedOrSelected: () => {
const checked = ContactUserStore.filter(item => item.checked && item.checked()),
selected = this.currentContact();
@ -119,11 +97,11 @@ export class ContactsPopupView extends AbstractViewPopup {
: checked;
},
contactsCheckedOrSelectedUids: () => this.contactsCheckedOrSelected().map(contact => contact.id),
contactsCheckedOrSelectedUids: () => this.contactsCheckedOrSelected().map(contact => contact.id()),
contactsSyncEnabled: () => ContactUserStore.allowSync() && ContactUserStore.syncMode(),
viewHash: () => '' + this.viewProperties.map(property => property.value && property.value()).join('')
viewHash: () => '' + viewProperties.map(property => property.value && property.value()).join('')
});
this.search.subscribe(() => this.reloadContactList());
@ -134,12 +112,14 @@ export class ContactsPopupView extends AbstractViewPopup {
}
});
this.saveCommand = this.saveCommand.bind(this);
decorateKoCommands(this, {
// close: self => !self.watchDirty(),
deleteCommand: self => 0 < self.contactsCheckedOrSelected().length,
newMessageCommand: self => 0 < self.contactsCheckedOrSelected().length,
saveCommand: self => !self.viewSaving() && !self.viewReadOnly()
&& (self.contactHasValidName() || self.viewProperties.find(prop => propertyIsMail(prop) && prop.isValid())),
&& (self.contact()?.hasValidName() || self.contact()?.email().length),
syncCommand: self => !self.contacts.syncing() && !self.contacts.importing()
});
}
@ -151,7 +131,6 @@ export class ContactsPopupView extends AbstractViewPopup {
deleteCommand() {
this.deleteSelectedContacts();
this.emptySelection(true);
}
newMessageCommand() {
@ -213,22 +192,20 @@ export class ContactsPopupView extends AbstractViewPopup {
this.viewSaving(true);
this.viewSaveTrigger(SaveSettingsStep.Animate);
const requestUid = Jua.randomId();
const
contact = this.contact(),
requestUid = Jua.randomId();
Remote.request('ContactSave',
(iError, oData) => {
let res = false;
this.viewSaving(false);
if (
!iError &&
oData.Result.RequestUid === requestUid &&
0 < pInt(oData.Result.ResultID)
if (!iError
&& oData.Result.RequestUid === requestUid
&& oData.Result.ResultID
) {
if (!this.viewID()) {
this.viewID(pInt(oData.Result.ResultID));
}
contact.id(oData.Result.ResultID);
this.reloadContactList(); // TODO: remove when e-contact-foreach is dynamic
res = true;
}
@ -243,8 +220,9 @@ export class ContactsPopupView extends AbstractViewPopup {
}
}, {
RequestUid: requestUid,
Uid: this.viewID(),
Properties: this.viewProperties.map(oItem => oItem.toJSON())
Contact: contact
// Uid: contact.id(),
// jCard: contact.jCard
}
);
}
@ -257,63 +235,6 @@ export class ContactsPopupView extends AbstractViewPopup {
});
}
getPropertyPlaceholder(type) {
let result = '';
switch (type) {
case ContactPropertyType.LastName:
result = 'CONTACTS/PLACEHOLDER_ENTER_LAST_NAME';
break;
case ContactPropertyType.FirstName:
result = 'CONTACTS/PLACEHOLDER_ENTER_FIRST_NAME';
break;
case ContactPropertyType.Nick:
result = 'CONTACTS/PLACEHOLDER_ENTER_NICK_NAME';
break;
// no default
}
return result;
}
addNewProperty(type, typeStr) {
this.viewProperties.push(
new ContactPropertyModel(type, typeStr || '', '', true, this.getPropertyPlaceholder(type))
);
}
addNewOrFocusProperty(type, typeStr) {
const item = this.viewProperties.find(prop => prop.isType(type));
if (item) {
item.focused(true);
} else {
this.addNewProperty(type, typeStr);
}
}
addNewEmail() {
this.addNewProperty(ContactPropertyType.Email, 'Home');
}
addNewPhone() {
this.addNewProperty(ContactPropertyType.Phone, 'Mobile');
}
addNewWeb() {
this.addNewProperty(ContactPropertyType.Web);
}
addNewNickname() {
this.addNewOrFocusProperty(ContactPropertyType.Nick);
}
addNewNotes() {
this.addNewOrFocusProperty(ContactPropertyType.Note);
}
addNewBirthday() {
this.addNewOrFocusProperty(ContactPropertyType.Birthday);
}
exportVcf() {
download(serverRequestRaw('ContactsVcf'), 'contacts.vcf');
}
@ -330,7 +251,7 @@ export class ContactsPopupView extends AbstractViewPopup {
if (contacts.length) {
contacts.forEach(contact => {
if (currentContact && currentContact.id === contact.id) {
if (currentContact && currentContact.id() === contact.id()) {
currentContact = null;
this.currentContact(null);
}
@ -366,33 +287,19 @@ export class ContactsPopupView extends AbstractViewPopup {
}
}
removeProperty(oProp) {
this.viewProperties.remove(oProp);
}
/**
* @param {?ContactModel} contact
*/
populateViewContact(contact) {
let id = '';
this.watchHash(false);
this.emptySelection(false);
this.viewReadOnly(false);
if (contact) {
id = contact.id;
this.viewReadOnly(!!contact.readOnly);
} else {
if (!contact) {
contact = new ContactModel;
contact.initDefaultProperties();
}
this.viewReadOnly(contact.readOnly());
this.contact(contact);
this.viewID(id);
// this.viewProperties([]);
this.viewProperties(contact.properties);
viewProperties(contact.properties);
this.watchDirty(false);
this.watchHash(true);
@ -509,8 +416,8 @@ export class ContactsPopupView extends AbstractViewPopup {
}
onHide() {
this.contact(null);
this.currentContact(null);
this.emptySelection(true);
this.search('');
this.contactsCount(0);