summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.vue31
-rw-r--r--src/components/ContactDetails.vue435
-rw-r--r--src/components/ContactDetails/ContactDetailsAddNewProp.vue82
-rw-r--r--src/components/ContactDetails/ContactDetailsAvatar.vue86
-rw-r--r--src/components/ContactDetails/ContactDetailsProperty.vue276
-rw-r--r--src/components/ContentList.vue54
-rw-r--r--src/components/ContentList/ContentListItem.vue91
-rw-r--r--src/components/ImportScreen.vue69
-rw-r--r--src/components/Properties/PropertyDateTime.vue282
-rw-r--r--src/components/Properties/PropertyGroups.vue163
-rw-r--r--src/components/Properties/PropertyMultipleText.vue173
-rw-r--r--src/components/Properties/PropertySelect.vue155
-rw-r--r--src/components/Properties/PropertyText.vue145
-rw-r--r--src/components/Properties/PropertyTitle.vue54
-rw-r--r--src/components/Settings/SettingsAddressbook.vue246
-rw-r--r--src/components/Settings/SettingsAddressbookShare.vue185
-rw-r--r--src/components/Settings/SettingsAddressbookSharee.vue73
-rw-r--r--src/components/Settings/SettingsImportContacts.vue108
-rw-r--r--src/components/Settings/SettingsNewAddressbook.vue74
-rw-r--r--src/components/Settings/SettingsRenameAddressbookField.vue0
-rw-r--r--src/components/Settings/SettingsSortContacts.vue92
-rw-r--r--src/components/SettingsSection.vue64
-rw-r--r--src/main.js51
-rw-r--r--src/models/contact.js343
-rw-r--r--src/models/rfcProps.js274
-rw-r--r--src/router/index.js60
-rw-r--r--src/services/api.js127
-rw-r--r--src/services/cdav.js47
-rw-r--r--src/services/parseVcf.js51
-rw-r--r--src/store/addressbooks.js446
-rw-r--r--src/store/contacts.js316
-rw-r--r--src/store/groups.js126
-rw-r--r--src/store/importState.js152
-rw-r--r--src/store/index.js55
-rw-r--r--src/views/Contacts.vue308
35 files changed, 5294 insertions, 0 deletions
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 00000000..ff2579e9
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,31 @@
+<!--
+ - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <router-view />
+</template>
+
+<script>
+export default {
+ name: 'App'
+}
+</script>
diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue
new file mode 100644
index 00000000..6bc24567
--- /dev/null
+++ b/src/components/ContactDetails.vue
@@ -0,0 +1,435 @@
+<!--
+ - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <div id="contact-details" class="app-content-details">
+
+ <!-- nothing selected or contact not found -->
+ <div v-if="!contact && !loading" id="emptycontent">
+ <div class="icon-contacts" />
+ <h2>{{ t('contacts', 'No contact selected') }}</h2>
+ <p>{{ t('contacts', 'Select a contact on the list to begin') }}</p>
+ </div>
+
+ <!-- loading -->
+ <div v-else-if="!contact && loading" id="emptycontent">
+ <div class="icon-contacts" />
+ <h2>{{ t('contacts', 'Loading') }}</h2>
+ </div>
+
+ <template v-else>
+ <!-- contact header -->
+ <header :style="{ 'backgroundColor': colorAvatar }">
+
+ <!-- avatar and upload photo -->
+ <contact-avatar :contact="contact" />
+ <!-- QUESTION: is it better to pass contact as a prop or get it from the store inside
+ contact-avatar ? :avatar="contact.photo"-->
+
+ <!-- fullname, org, title -->
+ <div id="contact-header-infos">
+ <h2>
+ <input id="contact-fullname" v-model="contact.fullName" :disabled="!contact.addressbook.readOnly"
+ :placeholder="t('contacts', 'Name')" type="text" autocomplete="off"
+ autocorrect="off" spellcheck="false" name="fullname"
+ value="" @input="debounceUpdateContact">
+ </h2>
+ <div id="details-org-container">
+ <input id="contact-org" v-model="contact.org" :disabled="!contact.addressbook.readOnly"
+ :placeholder="t('contacts', 'Company')" type="text" autocomplete="off"
+ autocorrect="off" spellcheck="false" name="org"
+ value="" @input="debounceUpdateContact">
+ <input id="contact-title" v-model="contact.title" :disabled="!contact.addressbook.readOnly"
+ :placeholder="t('contacts', 'Title')" type="text" autocomplete="off"
+ autocorrect="off" spellcheck="false" name="title"
+ value="" @input="debounceUpdateContact">
+ </div>
+ </div>
+
+ <!-- actions -->
+ <div id="contact-header-actions">
+ <div v-tooltip.bottom="warning" :class="{'icon-loading-small': loadingUpdate, 'menu-icon--pulse icon-error-white': warning}" class="menu-icon" />
+ <div v-tooltip="{
+ content: conflict,
+ show: true,
+ trigger: 'manual',
+ }" v-if="conflict" class="menu-icon menu-icon--pulse icon-history-white"
+ @click="refreshContact" />
+ <div class="menu-icon">
+ <div v-click-outside="closeMenu" class="icon-more-white" @click="toggleMenu" />
+ <div :class="{ 'open': openedMenu }" class="popovermenu">
+ <popover-menu :menu="contactActions" />
+ </div>
+ </div>
+ </div>
+ </header>
+
+ <!-- contact details loading -->
+ <section v-if="loadingData" class="icon-loading contact-details" />
+
+ <!-- contact details -->
+ <section v-else class="contact-details">
+
+ <!-- properties iteration -->
+ <!-- using contact.key in the key and index as key to avoid conflicts between similar data and exact key -->
+ <contact-property v-for="(property, index) in sortedProperties" :key="index+contact.key" :index="index"
+ :sorted-properties="sortedProperties" :property="property" :contact="contact"
+ @updatedcontact="updateContact" />
+
+ <!-- addressbook change select - no last property because class is not applied here-->
+ <property-select :prop-model="addressbookModel" :value.sync="addressbook" :is-first-property="true"
+ :is-last-property="false" class="property--addressbooks" />
+
+ <!-- new property select -->
+ <add-new-prop :contact="contact" />
+ </section>
+ </template>
+ </div>
+</template>
+
+<script>
+import { PopoverMenu } from 'nextcloud-vue'
+import ClickOutside from 'vue-click-outside'
+import Vue from 'vue'
+import VTooltip from 'v-tooltip'
+import debounce from 'debounce'
+
+import Contact from '../models/contact'
+import rfcProps from '../models/rfcProps.js'
+
+import ContactProperty from './ContactDetails/ContactDetailsProperty'
+import AddNewProp from './ContactDetails/ContactDetailsAddNewProp'
+import PropertySelect from './Properties/PropertySelect'
+import PropertyGroups from './Properties/PropertyGroups'
+import ContactAvatar from './ContactDetails/ContactDetailsAvatar'
+
+Vue.use(VTooltip)
+
+export default {
+ name: 'ContactDetails',
+
+ components: {
+ PopoverMenu,
+ ContactProperty,
+ PropertySelect,
+ PropertyGroups,
+ AddNewProp,
+ ContactAvatar
+ },
+
+ directives: {
+ ClickOutside
+ },
+
+ props: {
+ loading: {
+ type: Boolean,
+ default: true
+ },
+ uid: {
+ type: String,
+ default: undefined
+ }
+ },
+
+ data() {
+ return {
+ /**
+ * Local off-store clone of the selected contact for edition
+ * because we can't edit contacts data outside the store.
+ * Every change will be dispatched and updated on the real
+ * store contact after a debounce.
+ */
+ localContact: undefined,
+ loadingData: true,
+ loadingUpdate: false,
+ openedMenu: false
+ }
+ },
+
+ computed: {
+
+ /**
+ * Warning message
+ *
+ * @returns {string|undefined}
+ */
+ warning() {
+ if (!this.contact.dav) {
+ return t('contacts', 'This contact is not yet synced. Edit it to trigger a change.')
+ }
+ },
+
+ /**
+ * Conflict message
+ *
+ * @returns {string|undefined}
+ */
+ conflict() {
+ if (this.contact.conflict) {
+ return t('contacts', 'The contact you were trying to edit has changed. Please manually refresh the contact. Any further edits will be discarded.')
+ }
+ },
+
+ /**
+ * Contact color based on uid
+ *
+ * @returns {string}
+ */
+ colorAvatar() {
+ try {
+ let color = this.contact.uid.toRgb()
+ return `rgb(${color.r}, ${color.g}, ${color.b})`
+ } catch (e) {
+ return 'grey'
+ }
+ },
+
+ /**
+ * Header actions for the contact
+ *
+ * @returns {Array}
+ */
+ contactActions() {
+ let actions = [
+ {
+ icon: 'icon-download',
+ text: t('contacts', 'Download'),
+ href: this.contact.url
+ }
+ ]
+ if (this.contact.addressbook.readOnly) {
+ actions.push({
+ icon: 'icon-delete',
+ text: t('contacts', 'Delete'),
+ action: this.deleteContact
+ })
+ }
+
+ return actions
+ },
+
+ /**
+ * Contact properties copied and sorted by rfcProps.fieldOrder
+ *
+ * @returns {Array}
+ */
+ sortedProperties() {
+ return this.contact.properties.slice(0).sort((a, b) => {
+ return (
+ rfcProps.fieldOrder.indexOf(a.name) - rfcProps.fieldOrder.indexOf(b.name)
+ )
+ })
+ },
+
+ /**
+ * Fake model to use the propertySelect component
+ *
+ * @returns {Object}
+ */
+ addressbookModel() {
+ return {
+ readableName: t('contacts', 'Addressbook'),
+ icon: 'icon-addressbook',
+ options: this.addressbooksOptions
+ }
+ },
+
+ /**
+ * Usable addressbook object linked to the local contact
+ *
+ * @param {string} [addressbookId] set the addressbook id
+ * @returns {string}
+ */
+ addressbook: {
+ get: function() {
+ return this.contact.addressbook.id
+ },
+ set: function(addressbookId) {
+ this.moveContactToAddressbook(addressbookId)
+ }
+ },
+
+ /**
+ * Store getters filtered and mapped to usable object
+ *
+ * @returns {Array}
+ */
+ addressbooksOptions() {
+ return this.addressbooks
+ .filter(addressbook => addressbook.readOnly)
+ .map(addressbook => {
+ return {
+ id: addressbook.id,
+ name: addressbook.displayName
+ }
+ })
+ },
+
+ // store getter
+ addressbooks() {
+ return this.$store.getters.getAddressbooks
+ },
+ contact() {
+ return this.$store.getters.getContact(this.uid)
+ }
+ },
+
+ watch: {
+ contact: function() {
+ if (this.uid) {
+ this.selectContact(this.uid)
+ }
+ }
+ },
+
+ beforeMount() {
+ // load the desired data if we already selected a contact
+ if (this.uid) {
+ this.selectContact(this.uid)
+ }
+ },
+
+ methods: {
+ /**
+ * Executed on the 'updatedcontact' event
+ * Send the local clone of contact to the store
+ */
+ updateContact() {
+ this.loadingUpdate = true
+ this.$store.dispatch('updateContact', this.contact)
+ .then(() => {
+ this.loadingUpdate = false
+ })
+ },
+
+ /**
+ * Debounce the contact update for the header props
+ * photo, fn, org, title
+ */
+ debounceUpdateContact: debounce(function(e) {
+ this.updateContact()
+ }, 500),
+
+ // menu handling
+ closeMenu() {
+ this.openedMenu = false
+ },
+ toggleMenu() {
+ this.openedMenu = !this.openedMenu
+ },
+
+ /**
+ * Select a contac, and update the localContact
+ * Fetch updated data if necessary
+ * Scroll to the selected contact if exists
+ *
+ * @param {string} uid the contact uid
+ */
+ selectContact(uid) {
+ // local version of the contact
+ this.loadingData = true
+ let contact = this.$store.getters.getContact(uid)
+
+ if (contact) {
+ // if contact exists AND if exists on server
+ if (contact.dav) {
+ this.$store.dispatch('fetchFullContact', { contact })
+ .then(() => {
+ // create empty contact and copy inner data
+ let localContact = new Contact(
+ 'BEGIN:VCARD\nUID:' + contact.uid + '\nEND:VCARD',
+ contact.addressbook
+ )
+ localContact.updateContact(contact.jCal)
+ this.localContact = localContact
+ this.loadingData = false
+ })
+ .catch((error) => {
+ OC.Notification.showTemporary(t('contacts', 'The contact doesn\'t exists anymore on the server.'))
+ console.error(error)
+ // trigger a local deletion from the store only
+ this.$store.dispatch('deleteContact', { contact: this.contact, dav: false })
+ })
+ } else {
+ // create empty contact and copy inner data
+ // wait for an update to really push the contact on the server!
+ this.localContact = new Contact(
+ 'BEGIN:VCARD\nUID:' + contact.uid + '\nEND:VCARD',
+ contact.addressbook
+ )
+ this.loadingData = false
+ }
+
+ // scroll to selected contact if any
+ let list = document.getElementById('contacts-list')
+ let item = document.querySelector('#' + btoa(contact.key).slice(0, -2))
+ let isAbove = list.scrollTop > item.offsetTop
+ let isUnder = item.offsetTop + item.offsetHeight > list.scrollTop + list.offsetHeight
+ // check if contact outside visible list area
+ if (item && (isAbove || isUnder)) {
+ list.scrollTo(0, item.offsetTop - item.offsetHeight / 2)
+ }
+ }
+ },
+
+ /**
+ * Dispatch contact deletion request
+ */
+ deleteContact() {
+ this.$store.dispatch('deleteContact', { contact: this.contact })
+ },
+
+ /**
+ * Move contact to the specified addressbook
+ *
+ * @param {string} addressbookId the desired addressbook ID
+ */
+ moveContactToAddressbook(addressbookId) {
+ let addressbook = this.addressbooks.find(search => search.id === addressbookId)
+ this.loadingUpdate = true
+ // TODO Properly implement the MOVE request
+ if (addressbook) {
+ this.$store.dispatch('moveContactToAddressbook', {
+ // we need to use the store contact, not the local contact
+ // using this.contact and not this.localContact
+ contact: this.contact,
+ addressbook
+ }).then(() => {
+ this.updateContact()
+ this.loadingUpdate = false
+ })
+ }
+ },
+
+ /**
+ * Refresh the data of a contact
+ */
+ refreshContact() {
+ this.$store.dispatch('fetchFullContact', { contact: this.contact, etag: this.conflict })
+ .then(() => {
+ this.contact.conflict = false
+ })
+ }
+ }
+}
+</script>
diff --git a/src/components/ContactDetails/ContactDetailsAddNewProp.vue b/src/components/ContactDetails/ContactDetailsAddNewProp.vue
new file mode 100644
index 00000000..5d87ceab
--- /dev/null
+++ b/src/components/ContactDetails/ContactDetailsAddNewProp.vue
@@ -0,0 +1,82 @@
+<!--
+ - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <div class="grid-span-2 property">
+
+ <!-- title -->
+ <property-title :icon="'icon-add'" :readable-name="t('contacts', 'Add new property')" />
+
+ <div class="property__row">
+ <div class="property__label" />
+
+ <!-- type selector -->
+ <multiselect :options="availableProperties" :placeholder="t('contacts', 'Choose property type')" class="multiselect-vue property__value"
+ track-by="id" label="name" @input="addProp" />
+ </div>
+ </div>
+</template>
+
+<script>
+import rfcProps from '../../models/rfcProps.js'
+import Contact from '../../models/contact'
+import propertyTitle from '../Properties/PropertyTitle'
+
+import Multiselect from 'vue-multiselect'
+
+export default {
+ name: 'ContactDetailsAddNewProp',
+
+ components: {
+ propertyTitle,
+ Multiselect
+ },
+
+ props: {
+ contact: {
+ type: Contact,
+ default: null
+ }
+ },
+
+ computed: {
+ availableProperties() {
+ return Object.keys(rfcProps.properties).map(key => {
+ return {
+ id: key,
+ name: rfcProps.properties[key].readableName
+ }
+ })
+ }
+ },
+
+ methods: {
+ addProp({ id }) {
+ let defaultData = rfcProps.properties[id].defaultValue
+ let property = this.contact.vCard.addPropertyWithValue(id, defaultData ? defaultData.value : '')
+ if (defaultData && defaultData.type) {
+ property.setParameter('type', defaultData.type)
+ }
+ }
+ }
+}
+</script>
diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue
new file mode 100644
index 00000000..0a249d4a
--- /dev/null
+++ b/src/components/ContactDetails/ContactDetailsAvatar.vue
@@ -0,0 +1,86 @@
+<!--
+import rfcProps from '../../models/rfcProps';
+ * @copyright Copyright (c) 2018 Team Popcorn <teampopcornberlin@gmail.com>
+ *
+ * @author Team Popcorn <teampopcornberlin@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <div :class="{'maximised':maximizeAvatar }" class="contact-header-avatar">
+ <div class="contact-header-avatar__background" @click="toggleSize" />
+ <div v-if="contact.photo" :style="{ 'backgroundImage': `url(${contact.photo})` }"
+ class="contact-header-avatar__photo"
+ @click="toggleSize" />
+ <div class="contact-header-avatar__options">
+ <input id="contact-avatar-upload" type="file" class="hidden"
+ accept="image/*" @change="processFile">
+ <label v-tooltip.auto="t('contacts', 'Upload a new picture')" for="contact-avatar-upload"
+ class="icon-upload-white" @click="processFile" />
+ <div v-if="maximizeAvatar" class="icon-delete-white" @click="removePhoto" />
+ <a v-if="maximizeAvatar" :href="contact.url + '?photo'" class="icon-download-white" />
+ </div>
+ </div>
+</template>
+
+<script>
+
+export default {
+ name: 'ContactAvatar',
+
+ props: {
+ contact: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ maximizeAvatar: false
+ }
+ },
+ methods: {
+ processFile(event) {
+ if (event.target.files) {
+ let file = event.target.files[0]
+ let reader = new FileReader()
+ let self = this
+ // check if photo property exists to decide whether to add/update it
+ reader.onload = function(e) {
+ self.contact.photo
+ ? self.contact.photo = reader.result
+ : self.contact.vCard.addPropertyWithValue('photo', reader.result)
+
+ self.$store.dispatch('updateContact', self.contact)
+ }
+ reader.readAsDataURL(file)
+ }
+ },
+ toggleSize() {
+ // maximise or minimise avatar photo
+ this.maximizeAvatar = !this.maximizeAvatar
+ },
+ removePhoto() {
+ this.contact.vCard.removeProperty('photo')
+ this.maximizeAvatar = !this.maximizeAvatar
+ this.$store.dispatch('updateContact', this.contact)
+ }
+ }
+
+}
+</script>
diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue
new file mode 100644
index 00000000..bfafbd06
--- /dev/null
+++ b/src/components/ContactDetails/ContactDetailsProperty.vue
@@ -0,0 +1,276 @@
+<!--
+ - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <!-- If not in the rfcProps then we don't want to display it -->
+ <compo