mirror of https://github.com/nextcloud/contacts
Circles listing base
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
parent
9acea39cb9
commit
d6030761c3
|
@ -1,6 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
['@babel/plugin-proposal-class-properties', { loose: true }],
|
||||
],
|
||||
presets: [
|
||||
[
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
@include icon-black-white('clone', 'contacts', 2);
|
||||
@include icon-black-white('sync', 'contacts', 2);
|
||||
@include icon-black-white('recent-actors', 'contacts', 1);
|
||||
@include icon-black-white('circles', 'contacts', 1);
|
||||
|
||||
// social network icons:
|
||||
@include icon-black-white('facebook', 'contacts', 2); // “facebook (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/facebook?style=brands)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 57 57" width="64" height="64"><path d="M7.1 28.5A21.4 21.4 0 0 1 28.5 7.1m10.7 40A21.4 21.4 0 0 1 10 39M39.2 10A21.4 21.4 0 0 1 47 39.2" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><circle cx="28.5" cy="7.1" r="6.5" fill="#fff" /><circle cx="39.2" cy="-10" r="6.5" transform="rotate(90)" fill="#fff" /><circle cx="39.2" cy="-47" r="6.5" transform="rotate(90)" fill="#fff" /></svg>
|
After Width: | Height: | Size: 480 B |
|
@ -37,6 +37,7 @@
|
|||
"@nextcloud/auth": "^1.3.0",
|
||||
"@nextcloud/axios": "^1.6.0",
|
||||
"@nextcloud/dialogs": "^3.1.2",
|
||||
"@nextcloud/event-bus": "^1.2.0",
|
||||
"@nextcloud/initial-state": "^1.2.0",
|
||||
"@nextcloud/l10n": "^1.4.1",
|
||||
"@nextcloud/moment": "^1.1.1",
|
||||
|
@ -45,6 +46,7 @@
|
|||
"@nextcloud/vue": "^3.9.0",
|
||||
"axios": "^0.21.1",
|
||||
"b64-to-blob": "^1.2.19",
|
||||
"camelcase": "^5.3.1",
|
||||
"cdav-library": "git+https://github.com/nextcloud/cdav-library.git",
|
||||
"core-js": "^3.13.0",
|
||||
"debounce": "^1.2.1",
|
||||
|
@ -61,6 +63,7 @@
|
|||
"vue-click-outside": "^1.1.0",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
"vue-masonry": "^0.13.0",
|
||||
"vue-material-design-icons": "^4.11.0",
|
||||
"vue-router": "^3.5.1",
|
||||
"vue-virtual-scroll-list": "^2.3.2",
|
||||
"vue-virtual-scroller": "^1.0.10",
|
||||
|
|
|
@ -0,0 +1,164 @@
|
|||
<!--
|
||||
- @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>
|
||||
<AppContent>
|
||||
<div v-if="!circle">
|
||||
<EmptyContent icon="icon-circles">
|
||||
{{ t('contacts', 'Please select a circle') }}
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else id="app-content-wrapper">
|
||||
<!-- loading members -->
|
||||
<AppContentDetails v-if="loading">
|
||||
<EmptyContent icon="icon-loading">
|
||||
{{ t('contacts', 'Loading circle members…') }}
|
||||
</EmptyContent>
|
||||
</AppContentDetails>
|
||||
|
||||
<!-- not a member -->
|
||||
<AppContentDetails v-else-if="!circle.isMember">
|
||||
<EmptyContent v-if="!loadingJoin" icon="icon-circles">
|
||||
{{ t('contacts', 'You are not a member of this circle') }}
|
||||
|
||||
<!-- Only show the join button if the circle is accepting requests -->
|
||||
<template v-if="circle.canJoin" #desc>
|
||||
<button :disabled="loadingJoin" class="primary" @click="requestJoin">
|
||||
{{ t('contacts', 'Request to join') }}
|
||||
</button>
|
||||
</template>
|
||||
</EmptyContent>
|
||||
|
||||
<EmptyContent v-else-if="circle.isPendingJoin" icon="icon-loading">
|
||||
{{ t('contacts', 'Joining circle') }}
|
||||
</EmptyContent>
|
||||
|
||||
<EmptyContent v-else icon="icon-loading">
|
||||
{{ t('contacts', 'Joining circle') }}
|
||||
</EmptyContent>
|
||||
</AppContentDetails>
|
||||
|
||||
<template v-else>
|
||||
<!-- member list -->
|
||||
<MemberList :list="members" />
|
||||
|
||||
<!-- main contacts details -->
|
||||
<CircleDetails :circle-id="selectedCircle" />
|
||||
</template>
|
||||
</div>
|
||||
</AppContent>
|
||||
</template>
|
||||
<script>
|
||||
import AppContentDetails from '@nextcloud/vue/dist/Components/AppContentDetails'
|
||||
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
|
||||
import CircleDetails from '../CircleDetails'
|
||||
import MemberList from '../MemberList'
|
||||
import RouterMixin from '../../mixins/RouterMixin'
|
||||
import { MEMBER_LEVEL_NONE } from '../../models/constants'
|
||||
|
||||
export default {
|
||||
name: 'CircleContent',
|
||||
|
||||
components: {
|
||||
AppContent,
|
||||
AppContentDetails,
|
||||
CircleDetails,
|
||||
EmptyContent,
|
||||
MemberList,
|
||||
},
|
||||
|
||||
mixins: [RouterMixin],
|
||||
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loadingJoin: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// store variables
|
||||
circles() {
|
||||
return this.$store.getters.getCircles
|
||||
},
|
||||
circle() {
|
||||
return this.$store.getters.getCircle(this.selectedCircle)
|
||||
},
|
||||
members() {
|
||||
return Object.values(this.circle?.members || [])
|
||||
},
|
||||
|
||||
/**
|
||||
* Is the current circle empty
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmptyCircle() {
|
||||
return this.members.length === 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Is the current user member of this circle?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMemberOfCircle() {
|
||||
return this.circle.initiator?.level > MEMBER_LEVEL_NONE
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
circle(newCircle) {
|
||||
if (newCircle?.id) {
|
||||
console.debug('Circles list is done loading, fetching members for', newCircle.id)
|
||||
this.fetchCircleMembers(newCircle.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchCircleMembers(circleId) {
|
||||
this.$store.dispatch('getCircleMembers', circleId)
|
||||
},
|
||||
|
||||
/**
|
||||
* Request to join this circle
|
||||
*/
|
||||
requestJoin() {
|
||||
this.loadingJoin = true
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#app-content-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,159 @@
|
|||
<!--
|
||||
- @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>
|
||||
<AppContent>
|
||||
<div v-if="loading">
|
||||
<EmptyContent icon="icon-loading">
|
||||
{{ t('contacts', 'Loading contacts …') }}
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isEmptyGroup && !isRealGroup">
|
||||
<EmptyContent icon="icon-contacts-dark">
|
||||
{{ t('contacts', 'There are no contacts yet') }}
|
||||
<template #desc>
|
||||
<button class="primary" @click="newContact">
|
||||
{{ t('contacts', 'Create contact') }}
|
||||
</button>
|
||||
</template>
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isEmptyGroup && isRealGroup">
|
||||
<EmptyContent icon="icon-contacts-dark">
|
||||
{{ t('contacts', 'There are no contacts in this group') }}
|
||||
<template #desc>
|
||||
<button v-if="contacts.length === 0" class="primary" @click="addContactsToGroup(selectedGroup)">
|
||||
{{ t('contacts', 'Create contacts') }}
|
||||
</button>
|
||||
<button v-else class="primary" @click="addContactsToGroup(selectedGroup)">
|
||||
{{ t('contacts', 'Add contacts') }}
|
||||
</button>
|
||||
</template>
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else id="app-content-wrapper">
|
||||
<!-- contacts list -->
|
||||
<ContactsList
|
||||
:list="contactsList"
|
||||
:contacts="contacts"
|
||||
:search-query="searchQuery" />
|
||||
|
||||
<!-- main contacts details -->
|
||||
<ContactDetails :contact-key="selectedContact" />
|
||||
</div>
|
||||
</AppContent>
|
||||
</template>
|
||||
<script>
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
|
||||
import ContactDetails from '../ContactDetails'
|
||||
import ContactsList from '../ContactsList'
|
||||
import RouterMixin from '../../mixins/RouterMixin'
|
||||
|
||||
export default {
|
||||
name: 'ContactsContent',
|
||||
|
||||
components: {
|
||||
AppContent,
|
||||
ContactDetails,
|
||||
ContactsList,
|
||||
EmptyContent,
|
||||
},
|
||||
|
||||
mixins: [RouterMixin],
|
||||
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
contactsList: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// store variables
|
||||
contacts() {
|
||||
return this.$store.getters.getContacts
|
||||
},
|
||||
groups() {
|
||||
return this.$store.getters.getGroups
|
||||
},
|
||||
sortedContacts() {
|
||||
return this.$store.getters.getSortedContacts
|
||||
},
|
||||
|
||||
/**
|
||||
* Is this a real group ?
|
||||
* Aka not a dynamically generated one like `All contacts`
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRealGroup() {
|
||||
return this.groups.findIndex(group => group.name === this.selectedGroup) > -1
|
||||
},
|
||||
/**
|
||||
* Is the current group empty
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmptyGroup() {
|
||||
return this.contactsList.length === 0
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Forward the addContactsToGroup event to the parent
|
||||
* @param {string} groupName the group name
|
||||
*/
|
||||
addContactsToGroup(groupName) {
|
||||
emit('contacts:group:append', groupName)
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward the newContact event to the parent
|
||||
*/
|
||||
newContact() {
|
||||
this.$emit('newContact')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#app-content-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,183 @@
|
|||
<!--
|
||||
- @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>
|
||||
<AppNavigationItem
|
||||
:key="circle.key"
|
||||
:to="circle.router"
|
||||
:title="circle.displayName"
|
||||
:icon="circle.icon">
|
||||
<template v-if="loading" slot="actions">
|
||||
<ActionText icon="icon-loading-small">
|
||||
{{ t('contacts', 'Loading …') }}
|
||||
</ActionText>
|
||||
</template>
|
||||
|
||||
<template v-else slot="actions">
|
||||
<ActionButton
|
||||
v-if="circle.canManageMembers"
|
||||
icon="icon-add"
|
||||
@click="addMemberToCircle">
|
||||
{{ t('contacts', 'Add member') }}
|
||||
</ActionButton>
|
||||
|
||||
<!-- copy circle link -->
|
||||
<ActionLink
|
||||
:href="circle.url"
|
||||
:icon="copyLoading ? 'icon-loading-small' : 'icon-public'"
|
||||
@click.stop.prevent="copyToClipboard(circleUrl)">
|
||||
{{ copyButtonText }}
|
||||
</ActionLink>
|
||||
|
||||
<!-- leave circle -->
|
||||
<ActionButton
|
||||
v-if="circle.isMember"
|
||||
@click="leaveCircle">
|
||||
{{ t('contacts', 'Leave circle') }}
|
||||
<ExitToApp slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionButton>
|
||||
|
||||
<!-- join circle -->
|
||||
<ActionButton
|
||||
v-else-if="circle.canJoin"
|
||||
@click="joinCircle">
|
||||
{{ joinButtonTitle }}
|
||||
<LocationEnter slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionButton>
|
||||
|
||||
<!-- delete circle -->
|
||||
<ActionButton
|
||||
v-if="circle.canDelete"
|
||||
icon="icon-delete"
|
||||
@click="deleteCircle">
|
||||
{{ t('contacts', 'Delete') }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<AppNavigationCounter v-if="circle.members.length > 0" slot="counter">
|
||||
{{ circle.members.length }}
|
||||
</AppNavigationCounter>
|
||||
</AppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import ActionLink from '@nextcloud/vue/dist/Components/ActionLink'
|
||||
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
|
||||
import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter'
|
||||
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
|
||||
import ExitToApp from 'vue-material-design-icons/ExitToApp'
|
||||
import LocationEnter from 'vue-material-design-icons/LocationEnter'
|
||||
|
||||
import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin'
|
||||
import { deleteCircle, joinCircle } from '../../services/circles'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
export default {
|
||||
name: 'CircleNavigationItem',
|
||||
|
||||
components: {
|
||||
ActionButton,
|
||||
ActionLink,
|
||||
ActionText,
|
||||
AppNavigationCounter,
|
||||
AppNavigationItem,
|
||||
ExitToApp,
|
||||
LocationEnter,
|
||||
},
|
||||
|
||||
mixins: [CopyToClipboardMixin],
|
||||
|
||||
props: {
|
||||
circle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
copyButtonText() {
|
||||
if (this.copied) {
|
||||
return this.copySuccess
|
||||
? t('contacts', 'Copied')
|
||||
: t('contacts', 'Could not copy')
|
||||
}
|
||||
return t('contacts', 'Copy link')
|
||||
},
|
||||
|
||||
circleUrl() {
|
||||
return window.location.origin + this.circle.url
|
||||
},
|
||||
|
||||
joinButtonTitle() {
|
||||
if (this.circle.requireJoinAccept) {
|
||||
return t('contacts', 'Request to join')
|
||||
}
|
||||
return t('contacts', 'Join circle')
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Trigger the entity picker view
|
||||
addMemberToCircle() {
|
||||
emit('contacts:circles:append', this.circle.id)
|
||||
},
|
||||
|
||||
async joinCircle() {
|
||||
try {
|
||||
await joinCircle(this.circle.id)
|
||||
} catch (error) {
|
||||
showError(t('contacts', 'Unable to join the circle'))
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
leaveCircle() {
|
||||
|
||||
},
|
||||
|
||||
async deleteCircle() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await deleteCircle(this.circle.id)
|
||||
this.$store.dispatch('deleteCircle', this.circle.id)
|
||||
} catch (error) {
|
||||
showError(t('contacts', 'Unable to delete the circle'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,126 @@
|
|||
<!--
|
||||
- @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>
|
||||
<AppNavigationItem
|
||||
:key="group.key"
|
||||
:to="group.router"
|
||||
:title="group.name"
|
||||
:icon="group.icon">
|
||||
<template slot="actions">
|
||||
<ActionButton
|
||||
icon="icon-add"
|
||||
@click="addContactsToGroup(group)">
|
||||
{{ t('contacts', 'Add contacts') }}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
icon="icon-download"
|
||||
@click="downloadGroup(group)">
|
||||
{{ t('contacts', 'Download') }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<AppNavigationCounter v-if="group.contacts.length > 0" slot="counter">
|
||||
{{ group.contacts.length }}
|
||||
</AppNavigationCounter>
|
||||
</AppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import download from 'downloadjs'
|
||||
import moment from 'moment'
|
||||
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter'
|
||||
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
|
||||
|
||||
export default {
|
||||
name: 'GroupNavigationItem',
|
||||
|
||||
components: {
|
||||
ActionButton,
|
||||
AppNavigationCounter,
|
||||
AppNavigationItem,
|
||||
},
|
||||
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Trigger the entity picker view
|
||||
addContactsToGroup() {
|
||||
emit('contacts:group:append', this.group.name)
|
||||
},
|
||||
|
||||
/**
|
||||
* Download group of contacts
|
||||
*
|
||||
* @param {Object} group of contacts to be downloaded
|
||||
*/
|
||||
downloadGroup(group) {
|
||||
// get grouped contacts
|
||||
let groupedContacts = {}
|
||||
group.contacts.forEach(key => {
|
||||
const id = this.contacts[key].addressbook.id
|
||||
groupedContacts = Object.assign({
|
||||
[id]: {
|
||||
addressbook: this.contacts[key].addressbook,
|
||||
contacts: [],
|
||||
},
|
||||
}, groupedContacts)
|
||||
groupedContacts[id].contacts.push(this.contacts[key].url)
|
||||
})
|
||||
|
||||
// create vcard promise with the requested contacts
|
||||
const vcardPromise = Promise.all(
|
||||
Object.keys(groupedContacts).map(key =>
|
||||
groupedContacts[key].addressbook.dav.addressbookMultigetExport(groupedContacts[key].contacts)))
|
||||
.then(response => ({
|
||||
groupName: group.name,
|
||||
data: response.map(data => data.body).join(''),
|
||||
}))
|
||||
|
||||
// download vcard
|
||||
this.downloadVcardPromise(vcardPromise)
|
||||
},
|
||||
|
||||
/**
|
||||
* Download vcard promise as vcard file
|
||||
*
|
||||
* @param {Promise} vcardPromise the full vcf file promise
|
||||
*/
|
||||
async downloadVcardPromise(vcardPromise) {
|
||||
vcardPromise.then(response => {
|
||||
const filename = moment().format('YYYY-MM-DD_HH-mm') + '_' + response.groupName + '.vcf'
|
||||
download(response.data, filename, 'text/vcard')
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -1,3 +1,25 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2021 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>
|
||||
<AppNavigation>
|
||||
<slot />
|
||||
|
@ -47,37 +69,11 @@
|
|||
</AppNavigationCounter>
|
||||
</AppNavigationItem>
|
||||
|
||||
<AppNavigationSpacer />
|
||||
|
||||
<!-- Custom groups -->
|
||||
<AppNavigationItem v-for="group in groupsMenu"
|
||||
:key="group.key"
|
||||
:to="group.router"
|
||||
:title="group.name"
|
||||
:icon="group.icon">
|
||||
<template slot="actions">
|
||||
<ActionButton
|
||||
icon="icon-add"
|
||||
@click="addContactsToGroup(group)">
|
||||
{{ t('contacts', 'Add contacts') }}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
icon="icon-download"
|
||||
@click="downloadGroup(group)">
|
||||
{{ t('contacts', 'Download') }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<AppNavigationCounter v-if="group.contacts.length > 0" slot="counter">
|
||||
{{ group.contacts.length }}
|
||||
</AppNavigationCounter>
|
||||
</AppNavigationItem>
|
||||
|
||||
<AppNavigationItem
|
||||
id="newgroup"
|
||||
:force-menu="true"
|
||||
:menu-open.sync="isNewGroupMenuOpen"
|
||||
:title="t('contacts', '+ New group')"
|
||||
:title="t('contacts', 'Groups')"
|
||||
menu-icon="icon-add"
|
||||
@click.prevent.stop="toggleNewGroupMenu">
|
||||
<template slot="actions">
|
||||
|
@ -90,6 +86,52 @@
|
|||
@submit.prevent.stop="createNewGroup" />
|
||||
</template>
|
||||
</AppNavigationItem>
|
||||
|
||||
<!-- Custom groups -->
|
||||
<GroupNavigationItem
|
||||
v-for="group in ellipsisGroupsMenu"
|
||||
:key="group.key"
|
||||
:group="group" />
|
||||
|
||||
<!-- Toggle groups ellipsis -->
|
||||
<AppNavigationItem
|
||||
v-if="groupsMenu.length > ELLIPSIS_COUNT"
|
||||
:title="collapseGroupsTitle"
|
||||
class="app-navigation__collapse"
|
||||
icon=""
|
||||
@click="onToggleGroups" />
|
||||
|
||||
<AppNavigationItem
|
||||
id="newcircle"
|
||||
:force-menu="true"
|
||||
:menu-open.sync="isNewCircleMenuOpen"
|
||||
:title="t('contacts', 'Circles')"
|
||||
menu-icon="icon-add"
|
||||
@click.prevent.stop="toggleNewCircleMenu">
|
||||
<template slot="actions">
|
||||
<ActionText :icon="createCircleError ? 'icon-error' : 'icon-contacts-dark'">
|
||||
{{ createCircleError ? createCircleError : t('contacts', 'Create a new circle') }}
|
||||
</ActionText>
|
||||
<ActionInput
|
||||
icon=""
|
||||
:placeholder="t('contacts','Circle name')"
|
||||
@submit.prevent.stop="createNewCircle" />
|
||||
</template>
|
||||
</AppNavigationItem>
|
||||
|
||||
<!-- Circles -->
|
||||
<CircleNavigationItem
|
||||
v-for="circle in ellipsisCirclesMenu"
|
||||
:key="circle.key"
|
||||
:circle="circle" />
|
||||
|
||||
<!-- Toggle circles ellipsis -->
|
||||
<AppNavigationItem
|
||||
v-if="circlesMenu.length > ELLIPSIS_COUNT"
|
||||
:title="collapseCirclesTitle"
|
||||
class="app-navigation__collapse"
|
||||
icon=""
|
||||
@click="onToggleCircles" />
|
||||
</template>
|
||||
|
||||
<!-- settings -->
|
||||
|
@ -102,54 +144,46 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED } from '../../models/groups'
|
||||
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT } from '../../models/constants'
|
||||
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
|
||||
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
|
||||
import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation'
|
||||
import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter'
|
||||
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
|
||||
import AppNavigationSettings from '@nextcloud/vue/dist/Components/AppNavigationSettings'
|
||||
import AppNavigationSpacer from '@nextcloud/vue/dist/Components/AppNavigationSpacer'
|
||||
|
||||
import download from 'downloadjs'
|
||||
import moment from 'moment'
|
||||
import naturalCompare from 'string-natural-compare'
|
||||
|
||||
import CircleNavigationItem from './CircleNavigationItem'
|
||||
import GroupNavigationItem from './GroupNavigationItem'
|
||||
import SettingsSection from './SettingsSection'
|
||||
import isContactsInteractionEnabled from '../../services/isContactsInteractionEnabled'
|
||||
import RouterMixin from '../../mixins/RouterMixin'
|
||||
|
||||
export default {
|
||||
name: 'RootNavigation',
|
||||
|
||||
components: {
|
||||
ActionButton,
|
||||
ActionInput,
|
||||
ActionText,
|
||||
AppNavigation,
|
||||
AppNavigationCounter,
|
||||
AppNavigationItem,
|
||||
AppNavigationSettings,
|
||||
AppNavigationSpacer,
|
||||
CircleNavigationItem,
|
||||
GroupNavigationItem,
|
||||
SettingsSection,
|
||||
},
|
||||
|
||||
mixins: [RouterMixin],
|
||||
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
selectedGroup: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
selectedContact: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
contactsList: {
|
||||
type: Array,
|
||||
required: true,
|
||||
|
@ -158,19 +192,31 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
ELLIPSIS_COUNT,
|
||||
GROUP_ALL_CONTACTS,
|
||||
GROUP_NO_GROUP_CONTACTS,
|
||||
GROUP_RECENTLY_CONTACTED,
|
||||
|
||||
// Create group
|
||||
// create group
|
||||
isNewGroupMenuOpen: false,
|
||||
createGroupError: null,
|
||||
|
||||
// create circle
|
||||
isNewCircleMenuOpen: false,
|
||||
createCircleError: null,
|
||||
|
||||
isContactsInteractionEnabled,
|
||||
|
||||
collapsedGroups: true,
|
||||
collapsedCircles: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// store variables
|
||||
circles() {
|
||||
return this.$store.getters.getCircles
|
||||
},
|
||||
contacts() {
|
||||
return this.$store.getters.getContacts
|
||||
},
|
||||
|
@ -181,14 +227,15 @@ export default {
|
|||
return this.$store.getters.getSortedContacts
|
||||
},
|
||||
|
||||
// list all the contacts that doesn't have a group
|
||||
ungroupedContacts() {
|
||||
return this.sortedContacts.filter(contact => this.contacts[contact.key].groups && this.contacts[contact.key].groups.length === 0)
|
||||
},
|
||||
|
||||
// generate groups menu from groups store
|
||||
// generate groups menu from the groups store
|
||||
groupsMenu() {
|
||||
const menu = this.groups.map(group => {
|
||||
return Object.assign(group, {
|
||||
return Object.assign({}, group, {
|
||||
id: group.name.replace(' ', '_'),
|
||||
key: group.name.replace(' ', '_'),
|
||||
router: {
|
||||
|
@ -208,70 +255,53 @@ export default {
|
|||
|
||||
return menu
|
||||
},
|
||||
ellipsisGroupsMenu() {
|
||||
if (this.collapsedGroups) {
|
||||
return this.groupsMenu.slice(0, ELLIPSIS_COUNT)
|
||||
}
|
||||
return this.groupsMenu
|
||||
},
|
||||
|
||||
// generate circles menu from the circles store
|
||||
circlesMenu() {
|
||||
const menu = this.circles
|
||||
menu.sort((a, b) => naturalCompare(a.toString(), b.toString(), { caseInsensitive: true }))
|
||||
|
||||
return menu
|
||||
},
|
||||
ellipsisCirclesMenu() {
|
||||
if (this.collapsedCircles) {
|
||||
return this.circlesMenu.slice(0, ELLIPSIS_COUNT)
|
||||
}
|
||||
return this.circlesMenu
|
||||
},
|
||||
|
||||
// Recently contacted data
|
||||
recentlyContactedContacts() {
|
||||
return this.groups.find(group => group.name === GROUP_RECENTLY_CONTACTED)
|
||||
},
|
||||
|
||||
// Titles for the ellipsis toggle buttons
|
||||
collapseGroupsTitle() {
|
||||
return this.collapsedGroups
|
||||
? t('contacts', 'Show all groups')
|
||||
: t('contacts', 'Collapse groups')
|
||||
},
|
||||
collapseCirclesTitle() {
|
||||
return this.collapsedCircles
|
||||
? t('contacts', 'Show all circles')
|
||||
: t('contacts', 'Collapse circles')
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Download group of contacts
|
||||
*
|
||||
* @param {Object} group of contacts to be downloaded
|
||||
*/
|
||||
downloadGroup(group) {
|
||||
// get grouped contacts
|
||||
let groupedContacts = {}
|
||||
group.contacts.forEach(key => {
|
||||
const id = this.contacts[key].addressbook.id
|
||||
groupedContacts = Object.assign({
|
||||
[id]: {
|
||||
addressbook: this.contacts[key].addressbook,
|
||||
contacts: [],
|
||||
},
|
||||
}, groupedContacts)
|
||||
groupedContacts[id].contacts.push(this.contacts[key].url)
|
||||
})
|
||||
|
||||
// create vcard promise with the requested contacts
|
||||
const vcardPromise = Promise.all(
|
||||
Object.keys(groupedContacts).map(key =>
|
||||
groupedContacts[key].addressbook.dav.addressbookMultigetExport(groupedContacts[key].contacts)))
|
||||
.then(response => ({
|
||||
groupName: group.name,
|
||||
data: response.map(data => data.body).join(''),
|
||||
}))
|
||||
// download vcard
|
||||
this.downloadVcardPromise(vcardPromise)
|
||||
},
|
||||
|
||||
/**
|
||||
* Download vcard promise as vcard file
|
||||
*
|
||||
* @param {Promise} vcardPromise the full vcf file promise
|
||||
*/
|
||||
async downloadVcardPromise(vcardPromise) {
|
||||
vcardPromise.then(response => {
|
||||
const filename = moment().format('YYYY-MM-DD_HH-mm') + '_' + response.groupName + '.vcf'
|
||||
download(response.data, filename, 'text/vcard')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward the addContactsToGroup event to the parent
|
||||
*/
|
||||
addContactsToGroup() {
|
||||
this.$emit('addContactsToGroup', ...arguments)
|
||||
},
|
||||
|
||||
toggleNewGroupMenu() {
|
||||
this.isNewGroupMenuOpen = !this.isNewGroupMenuOpen
|
||||
},
|
||||
createNewGroup(e) {
|
||||
const input = e.target.querySelector('input[type=text]')
|
||||
const groupName = input.value.trim()
|
||||
console.debug('Creating new group', groupName)
|
||||
|
||||
// Check if already exists
|
||||
if (this.groups.find(group => group.name === groupName)) {
|
||||
|
@ -293,6 +323,57 @@ export default {
|
|||
},
|
||||
})
|
||||
},
|
||||
|
||||
// Ellipsis item toggles
|
||||
onToggleGroups() {
|
||||
this.collapsedGroups = !this.collapsedGroups
|
||||
},
|
||||
onToggleCircles() {
|
||||
this.collapsedCircles = !this.collapsedCircles
|
||||
},
|
||||
|
||||
toggleNewCircleMenu() {
|
||||
this.isNewCircleMenuOpen = !this.isNewCircleMenuOpen
|
||||
},
|
||||
async createNewCircle(e) {
|
||||
const input = e.target.querySelector('input[type=text]')
|
||||
const circleName = input.value.trim()
|
||||
console.debug('Creating new circle', circleName)
|
||||
|
||||
// Check if already exists
|
||||
if (this.circles.find(circle => circle.name === circleName)) {
|
||||
this.createGroupError = t('contacts', 'This circle already exists')
|
||||
return
|
||||
}
|
||||
|
||||
this.createCircleError = null
|
||||
|
||||
const circleId = await this.$store.dispatch('createCircle', circleName)
|
||||
this.isNewCircleMenuOpen = false
|
||||
|
||||
// Select group
|
||||
this.$router.push({
|
||||
name: 'circle',
|
||||
params: {
|
||||
selectedCircle: circleId,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#newgroup,
|
||||
#newcircle {
|
||||
margin-top: 22px;
|
||||
|
||||
/deep/ a {
|
||||
color: var(--color-text-maxcontrast)
|
||||
}
|
||||
}
|
||||
|
||||
.app-navigation__collapse /deep/ a {
|
||||
color: var(--color-text-maxcontrast)
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
<ActionLink
|
||||
:href="addressbook.url"
|
||||
:icon="copyLoading ? 'icon-loading-small' : 'icon-public'"
|
||||
@click.stop.prevent="copyLink">
|
||||
@click.stop.prevent="copyToClipboard(addressbookUrl)">
|
||||
{{ copyButtonText }}
|
||||
</ActionLink>
|
||||
|
||||
|
@ -99,7 +99,9 @@ import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
|||
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
|
||||
import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox'
|
||||
import ShareAddressBook from './SettingsAddressbookShare'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
import CopyToClipboardMixin from '../../../mixins/CopyToClipboardMixin'
|
||||
|
||||
export default {
|
||||
name: 'SettingsAddressbook',
|
||||
|
@ -113,6 +115,8 @@ export default {
|
|||
ShareAddressBook,
|
||||
},
|
||||
|
||||
mixins: [CopyToClipboardMixin],
|
||||
|
||||
props: {
|
||||
addressbook: {
|
||||
type: Object,
|
||||
|
@ -121,11 +125,9 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
copied: false,
|
||||
copyLoading: false,
|
||||
copySuccess: false,
|
||||
deleteAddressbookLoading: false,
|
||||
editingName: false,
|
||||
menuOpen: false,
|
||||
|
@ -134,6 +136,7 @@ export default {
|
|||
toggleEnabledLoading: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
enabled() {
|
||||
return this.addressbook.enabled
|
||||
|
@ -147,6 +150,7 @@ export default {
|
|||
hasMultipleAddressbooks() {
|
||||
return this.addressbooks.length > 1
|
||||
},
|
||||
|
||||
// info tooltip about number of shares
|
||||
sharedWithTooltip() {
|
||||
return this.hasShares
|
||||
|
@ -158,6 +162,7 @@ export default {
|
|||
})
|
||||
: '' // disable the tooltip
|
||||
},
|
||||
|
||||
copyButtonText() {
|
||||
if (this.copied) {
|
||||
return this.copySuccess
|
||||
|
@ -166,6 +171,10 @@ export default {
|
|||
}
|
||||
return t('contacts', 'Copy link')
|
||||
},
|
||||
|
||||
addressbookUrl() {
|
||||
return window.location.origin + this.addressbook.url
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
menuOpen() {
|
||||
|
@ -251,30 +260,6 @@ export default {
|
|||
this.menuOpen = false
|
||||
}
|
||||
},
|
||||
async copyLink(event) {
|
||||
// change to loading status
|
||||
this.copyLoading = true
|
||||
|
||||
// copy link for addressbook to clipboard
|
||||
try {
|
||||
await this.$copyText(window.location.origin + this.addressbook.url)
|
||||
this.copySuccess = true
|
||||
this.copied = true
|
||||
// Notify addressbook was copied
|
||||
showSuccess(t('contacts', 'Address book copied to clipboard'))
|
||||
} catch (error) {
|
||||
this.copySuccess = false
|
||||
this.copied = true
|
||||
showError(t('contacts', 'Address book was not copied to clipboard.'))
|
||||
} finally {
|
||||
this.copyLoading = false
|
||||
setTimeout(() => {
|
||||
// stop loading status regardless of outcome
|
||||
this.copied = false
|
||||
this.copySuccess = false
|
||||
}, 2000)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2021 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>
|
||||
<AppContentDetails>
|
||||
{{ circleId }}
|
||||
</AppContentDetails>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppContentDetails from '@nextcloud/vue/dist/Components/AppContentDetails'
|
||||
|
||||
export default {
|
||||
name: 'CircleDetails',
|
||||
|
||||
components: {
|
||||
AppContentDetails,
|
||||
},
|
||||
|
||||
props: {
|
||||
circleId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<!-- Bulk contacts edit modal -->
|
||||
<Modal v-if="isProcessing || isProcessDone"
|
||||
:clear-view-delay="-1"
|
||||
:can-close="isProcessDone"
|
||||
@close="closeProcess">
|
||||
<AddToGroupView v-bind="processStatus" @close="closeProcess" />
|
||||
</Modal>
|
||||
|
||||
<!-- contacts picker -->
|
||||
<EntityPicker v-else-if="showPicker"
|
||||
:confirm-label="t('contacts', 'Add to group {group}', { group: pickerforGroup.name})"
|
||||
:data-types="pickerTypes"
|
||||
:data-set="pickerData"
|
||||
@close="onContactPickerClose"
|
||||
@submit="onContactPickerPick" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
import Modal from '@nextcloud/vue/dist/Components/Modal'
|
||||
|
||||
import AddToGroupView from '../../views/Processing/AddToGroupView'
|
||||
import appendContactToGroup from '../../services/appendContactToGroup'
|
||||
import EntityPicker from './EntityPicker'
|
||||
|
||||
export default {
|
||||
name: 'ContactsPicker',
|
||||
|
||||
components: {
|
||||
AddToGroupView,
|
||||
EntityPicker,
|
||||
Modal,
|
||||
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Entity picker
|
||||
showPicker: false,
|
||||
pickerforGroup: null,
|
||||
pickerData: [],
|
||||
pickerTypes: [{
|
||||
id: 'contact',
|
||||
label: t('contacts', 'Contacts'),
|
||||
}],
|
||||
|
||||
// Bulk processing
|
||||
isProcessing: false,
|
||||
isProcessDone: false,
|
||||
processStatus: {
|
||||
failed: 0,
|
||||
progress: 0,
|
||||
success: 0,
|
||||
total: 0,
|
||||
name: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
contacts() {
|
||||
return this.$store.getters.getContacts
|
||||
},
|
||||
groups() {
|
||||
return this.$store.getters.getGroups
|
||||
},
|
||||
sortedContacts() {
|
||||
return this.$store.getters.getSortedContacts
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Watch for a add-to-group event
|
||||
subscribe('contacts:group:append', this.addContactsToGroup)
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Bulk contacts group management handlers
|
||||
addContactsToGroup(group) {
|
||||
console.debug('Contacts picker opened for group', group)
|
||||
|
||||
// Get the full group if we provided the group name only
|
||||
if (typeof group === 'string') {
|
||||
group = this.groups.find(a => a.name === group)
|
||||
if (!group) {
|
||||
console.error('Cannot add contact to an undefined group', group)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Init data set
|
||||
this.pickerData = this.sortedContacts
|
||||
.map(({ key }) => {
|
||||
const contact = this.contacts[key]
|
||||
return {
|
||||
id: contact.key,
|
||||
label: contact.displayName,
|
||||
type: 'contact',
|
||||
readOnly: contact.addressbook.readOnly,
|
||||
groups: contact.groups,
|
||||
}
|
||||
})
|
||||
// No read only contacts
|
||||
.filter(contact => !contact.readOnly)
|
||||
// No contacts already present in group
|
||||
.filter(contact => contact.groups.indexOf(group.name) === -1)
|
||||
|
||||
this.showPicker = true
|
||||
this.pickerforGroup = group
|
||||
},
|
||||
|
||||
onContactPickerClose() {
|
||||
this.pickerData = []
|
||||
this.showPicker = false
|
||||
},
|
||||
|
||||
onContactPickerPick(selection) {
|
||||
console.debug('Adding', selection, 'to group', this.pickerforGroup)
|
||||
const groupName = this.pickerforGroup.name
|
||||
|
||||
this.isProcessing = true
|
||||
this.showPicker = false
|
||||
|
||||
this.processStatus.total = selection.length
|
||||
this.processStatus.name = this.pickerforGroup.name
|
||||
this.processStatus.progress = 0
|
||||
this.processStatus.failed = 0
|
||||
|
||||
// max simultaneous requests
|
||||
const limit = pLimit(3)
|
||||
const requests = []
|
||||
|
||||
// create the array of requests to send
|
||||
selection.map(async entity => {
|
||||
try {
|
||||
// Get contact
|
||||
const contact = this.contacts[entity.id]
|
||||
|
||||
// push contact to server and use limit
|
||||
requests.push(limit(() => appendContactToGroup(contact, groupName)
|
||||
.then((response) => {
|
||||
this.$store.dispatch('addContactToGroup', { contact, groupName })
|
||||
this.processStatus.progress++
|
||||
this.processStatus.success++
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processStatus.progress++
|
||||
this.processStatus.error++
|
||||
console.error(error)
|
||||
})
|
||||
))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all(requests).then(() => {
|
||||
this.isProcessDone = true
|
||||
this.showPicker = false
|
||||
|
||||
// Auto close after 3 seconds if no errors
|
||||
if (this.processStatus.failed === 0) {
|
||||
setTimeout(this.closeProcess, 3000)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
closeProcess() {
|
||||
this.pickerforGroup = null
|
||||
this.isProcessing = false
|
||||
this.isProcessDone = false
|
||||
|
||||
// Reset
|
||||
this.processStatus.failed = 0
|
||||
this.processStatus.progress = 0
|
||||
this.processStatus.success = 0
|
||||
this.processStatus.total = 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,185 @@
|
|||
<template>
|
||||
<!-- Bulk contacts edit modal -->
|
||||
<Modal v-if="isProcessing || isProcessDone"
|
||||
:clear-view-delay="-1"
|
||||
:can-close="isProcessDone"
|
||||
@close="closeProcess">
|
||||
<AddToGroupView v-bind="processStatus" @close="closeProcess" />
|
||||
</Modal>
|
||||
|
||||
<!-- contacts picker -->
|
||||
<EntityPicker v-else-if="showPicker"
|
||||
:confirm-label="t('contacts', 'Add to circle {circle}', { circle: pickerforCircle.name})"
|
||||
:data-types="pickerTypes"
|
||||
:data-set="pickerData"
|
||||
@close="onContactPickerClose"
|
||||
@submit="onContactPickerPick" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
import Modal from '@nextcloud/vue/dist/Components/Modal'
|
||||
|
||||
import AddToGroupView from '../../views/Processing/AddToGroupView'
|
||||
import appendContactToGroup from '../../services/appendContactToGroup'
|
||||
import EntityPicker from './EntityPicker'
|
||||
|
||||
export default {
|
||||
name: 'MembersPicker',
|
||||
|
||||
components: {
|
||||
AddToGroupView,
|
||||
EntityPicker,
|
||||
Modal,
|
||||
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Entity picker
|
||||
showPicker: false,
|
||||
pickerforCircle: null,
|
||||
pickerData: [],
|
||||
pickerTypes: [{
|
||||
id: 'contact',
|
||||
label: t('contacts', 'Contacts'),
|
||||
}],
|
||||
|
||||
// Bulk processing
|
||||
isProcessing: false,
|
||||
isProcessDone: false,
|
||||
processStatus: {
|
||||
failed: 0,
|
||||
progress: 0,
|
||||
success: 0,
|
||||
total: 0,
|
||||
name: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
contacts() {
|
||||
return this.$store.getters.getContacts
|
||||
},
|
||||
groups() {
|
||||
return this.$store.getters.getGroups
|
||||
},
|
||||
sortedContacts() {
|
||||
return this.$store.getters.getSortedContacts
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Watch for a add-to-group event
|
||||
subscribe('contacts:circles:append', this.addMemberToCircle)
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Bulk contacts group management handlers
|
||||
addMemberToCircle(circleId) {
|
||||
const circle = this.$store.getters.getCircle(circleId)
|
||||
console.debug('Member picker opened for circle', circleId)
|
||||
|
||||
// Get the full group if we provided the group name only
|
||||
if (circle?.id !== circleId) {
|
||||
console.error('Cannot add member to an undefined circle', circle, circleId)
|
||||
return
|
||||
}
|
||||
|
||||
// Init data set
|
||||
this.pickerData = this.sortedContacts
|
||||
.map(({ key }) => {
|
||||
const contact = this.contacts[key]
|
||||
return {
|
||||
id: contact.key,
|
||||
label: contact.displayName,
|
||||
type: 'contact',
|
||||
readOnly: contact.addressbook.readOnly,
|
||||
groups: contact.groups,
|
||||
}
|
||||
})
|
||||
// No read only contacts
|
||||
.filter(contact => !contact.readOnly)
|
||||
// No contacts already present in group
|
||||
.filter(contact => contact.groups.indexOf(circle.name) === -1)
|
||||
|
||||
this.showPicker = true
|
||||
this.pickerforCircle = circle
|
||||
},
|
||||
|
||||
onContactPickerClose() {
|
||||
this.pickerData = []
|
||||
this.showPicker = false
|
||||
},
|
||||
|
||||
onContactPickerPick(selection) {
|
||||
console.debug('Adding', selection, 'to circle', this.pickerforCircle)
|
||||
const groupName = this.pickerforCircle.name
|
||||
|
||||
this.isProcessing = true
|
||||
this.showPicker = false
|
||||
|
||||
this.processStatus.total = selection.length
|
||||
this.processStatus.name = this.pickerforCircle.name
|
||||
this.processStatus.progress = 0
|
||||
this.processStatus.failed = 0
|
||||
|
||||
// max simultaneous requests
|
||||
const limit = pLimit(3)
|
||||
const requests = []
|
||||
|
||||
// create the array of requests to send
|
||||
selection.map(async entity => {
|
||||
try {
|
||||
// Get contact
|
||||
const contact = this.contacts[entity.id]
|
||||
|
||||
// push contact to server and use limit
|
||||
requests.push(limit(() => appendContactToGroup(contact, groupName)
|
||||
.then((response) => {
|
||||
this.$store.dispatch('addContactToGroup', { contact, groupName })
|
||||
this.processStatus.progress++
|
||||
this.processStatus.success++
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processStatus.progress++
|
||||
this.processStatus.error++
|
||||
console.error(error)
|
||||
})
|
||||
))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all(requests).then(() => {
|
||||
this.isProcessDone = true
|
||||
this.showPicker = false
|
||||
|
||||
// Auto close after 3 seconds if no errors
|
||||
if (this.processStatus.failed === 0) {
|
||||
setTimeout(this.closeProcess, 3000)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
closeProcess() {
|
||||
this.pickerforCircle = null
|
||||
this.isProcessing = false
|
||||
this.isProcessDone = false
|
||||
|
||||
// Reset
|
||||
this.processStatus.failed = 0
|
||||
this.processStatus.progress = 0
|
||||
this.processStatus.success = 0
|
||||
this.processStatus.total = 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
|
@ -1,8 +1,7 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
- @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
-
|
||||
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
- @author Charismatic Claire <charismatic.claire@noservice.noreply>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
|
@ -22,44 +21,48 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<AppNavigationItem v-for="group in groupsMenu"
|
||||
:key="group.key"
|
||||
:to="group.router"
|
||||
:title="group.name"
|
||||
:icon="group.icon">
|
||||
<template slot="actions">
|
||||
<ActionButton
|
||||
icon="icon-add"
|
||||
@click="addContactsToGroup(group)">
|
||||
{{ t('contacts', 'Add contacts') }}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
icon="icon-download"
|
||||
@click="downloadGroup(group)">
|
||||
{{ t('contacts', 'Download') }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<AppNavigationCounter v-if="group.contacts.length > 0" slot="counter">
|
||||
{{ group.contacts.length }}
|
||||
</AppNavigationCounter>
|
||||
</AppNavigationItem>
|
||||
<VirtualList class="member-list app-content-list"
|
||||
data-key="id"
|
||||
:data-sources="list"
|
||||
:data-component="MemberListItem"
|
||||
:estimate-size="68"
|
||||
item-class="member-list__item" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
|
||||
import MemberListItem from './MemberList/MemberListItem'
|
||||
import VirtualList from 'vue-virtual-scroll-list'
|
||||
|
||||
export default {
|
||||
name: 'GroupsNavigation',
|
||||
name: 'MemberList',
|
||||
|
||||
components: {
|
||||
AppNavigationItem,
|
||||
VirtualList,
|
||||
},
|
||||
|
||||
props: {
|
||||
groupsMenu: {
|
||||
list: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
MemberListItem,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
watch: {
|
||||
},
|
||||
|
||||
methods: {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
|
@ -0,0 +1,222 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2021 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>
|
||||
<ListItemIcon
|
||||
:id="source.id"
|
||||
:key="source.id"
|
||||
:avatar-size="44"
|
||||
:is-no-user="!source.isUser"
|
||||
:subtitle="levelName"
|
||||
:title="source.displayName"
|
||||
:user="source.userId">
|
||||
<Actions @close="onMenuClose">
|
||||
<template v-if="loading">
|
||||
<ActionText icon="icon-loading-small">
|
||||
{{ t('contacts', 'Loading …') }}
|
||||
</ActionText>
|
||||
</template>
|
||||
|
||||
<!-- Level picker -->
|
||||
<template v-else-if="showLevelMenu">
|
||||
<ActionButton @click="toggleLevelMenu">
|
||||
{{ t('contacts', 'Back to the menu') }}
|
||||
<ArrowLeft slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
v-for="level in availableLevelsChange"
|
||||
:key="level"
|
||||
icon=""
|
||||
@click="changeLevel(level)">
|
||||
{{ CIRCLES_MEMBER_LEVELS[level] }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<!-- Normal menu -->
|
||||
<template v-else>
|
||||
<ActionButton v-if="canChangeLevel" @click="toggleLevelMenu">
|
||||
{{ t('contacts', 'Change level') }}
|
||||
<ShieldCheck slot="icon"
|
||||
:size="16"
|
||||
decorative />
|
||||
</ActionButton>
|
||||
<ActionButton v-if="canDelete" icon="icon-delete" @click="deleteMember">
|
||||
{{ deleteMemberName }}
|
||||
</ActionButton>
|
||||
</template>
|
||||
</Actions>
|
||||
</ListItemIcon>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MEMBER_LEVEL_MEMBER, CIRCLES_MEMBER_LEVELS } from '../../models/constants'
|
||||
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ListItemIcon from '@nextcloud/vue/dist/Components/ListItemIcon'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
|
||||
|
||||
import ShieldCheck from 'vue-material-design-icons/ShieldCheck'
|
||||
import ArrowLeft from 'vue-material-design-icons/ArrowLeft'
|
||||
|
||||
import RouterMixin from '../../mixins/RouterMixin'
|
||||
import Member from '../../models/member'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
export default {
|
||||
name: 'MemberListItem',
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
ActionText,
|
||||
ArrowLeft,
|
||||
ListItemIcon,
|
||||
ShieldCheck,
|
||||
},
|
||||
mixins: [RouterMixin],
|
||||
|
||||
props: {
|
||||
source: {
|
||||
type: Member,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
CIRCLES_MEMBER_LEVELS,
|
||||
|
||||
loading: false,
|
||||
showLevelMenu: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
circle() {
|
||||
return this.$store.getters.getCircle(this.selectedCircle)
|
||||
},
|
||||
|
||||
avatarUrl() {
|
||||
if (this.contact.url) {
|
||||
return `${this.contact.url}?photo`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
|
||||
/**
|
||||
* Current member level translated name
|
||||
* @returns {string}
|
||||
*/
|
||||
levelName() {
|
||||
return CIRCLES_MEMBER_LEVELS[this.source.level]
|
||||
|| CIRCLES_MEMBER_LEVELS[MEMBER_LEVEL_MEMBER]
|
||||
},
|
||||
|
||||
deleteMemberName() {
|
||||
return this.currentUserId === this.source.id
|
||||
? t('contacts', 'Leave this circle')
|
||||
: t('contacts', 'Remove member')
|
||||
},
|
||||
|
||||
/**
|
||||
* Current user member level
|
||||
* @returns {number}
|
||||
*/
|
||||
currentUserLevel() {
|
||||
return this.circle?.initiator?.level || MEMBER_LEVEL_MEMBER
|
||||
},
|
||||
|
||||
/**
|
||||
* Current user member level
|
||||
* @returns {string}
|
||||
*/
|
||||
currentUserId() {
|
||||
return this.circle?.initiator?.id
|
||||
},
|
||||
|
||||
/**
|
||||
* Available levels change to the current user
|
||||
* @returns {Array}
|
||||
*/
|
||||
availableLevelsChange() {
|
||||
return Object.keys(CIRCLES_MEMBER_LEVELS).filter(level => level < this.currentUserLevel)
|
||||
},
|
||||
|
||||
/**
|
||||
* Can the current user change the level of others?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canChangeLevel() {
|
||||
// we can change if the member is at the same
|
||||
// or lower level as the current user
|
||||
return this.availableLevelsChange.length > 0
|
||||
&& this.currentUserLevel >= this.source.level
|
||||
&& this.circle.canManageMembers
|
||||
},
|
||||
|
||||
/**
|
||||
* Can the current user delete members?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canDelete() {
|
||||
return this.currentUserLevel > MEMBER_LEVEL_MEMBER
|
||||
&& this.source.level <= this.currentUserLevel
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleLevelMenu() {
|
||||
this.showLevelMenu = !this.showLevelMenu
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the current member
|
||||
*/
|
||||
async deleteMember() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('deleteMemberFromCircle', this.source)
|
||||
} catch (error) {
|
||||
console.error('Could not delete the member', this.source, error)
|
||||
showError(t('contacts', 'Could not delete the member {displayName}', this.source))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset menu on close
|
||||
*/
|
||||
onMenuClose() {
|
||||
this.showLevelMenu = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.member-list__item {
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
|
@ -32,7 +32,6 @@ import store from './store'
|
|||
/** GLOBAL COMPONENTS AND DIRECTIVE */
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip'
|
||||
import VueClipboard from 'vue-clipboard2'
|
||||
|
||||
// Dialogs css
|
||||
import '@nextcloud/dialogs/styles/toast.scss'
|
||||
|
@ -52,8 +51,6 @@ __webpack_public_path__ = generateFilePath('contacts', '', 'js/')
|
|||
Vue.directive('ClickOutside', ClickOutside)
|
||||
Vue.directive('Tooltip', VTooltip)
|
||||
|
||||
Vue.use(VueClipboard)
|
||||
|
||||
sync(store, router)
|
||||
|
||||
Vue.prototype.t = t
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import Vue from 'vue'
|
||||
import VueClipboard from 'vue-clipboard2'
|
||||
|
||||
Vue.use(VueClipboard)
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
copied: false,
|
||||
copyLoading: false,
|
||||
copySuccess: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async copyToClipboard(url) {
|
||||
// change to loading status
|
||||
this.copyLoading = true
|
||||
|
||||
// copy link to clipboard
|
||||
try {
|
||||
await this.$copyText(url)
|
||||
this.copySuccess = true
|
||||
this.copied = true
|
||||
|
||||
// Notify success
|
||||
showSuccess(t('contacts', 'Link copied to the clipboard'))
|
||||
} catch (error) {
|
||||
this.copySuccess = false
|
||||
this.copied = true
|
||||
showError(t('contacts', 'Could not copy link to the clipboard.'))
|
||||
} finally {
|
||||
this.copyLoading = false
|
||||
setTimeout(() => {
|
||||
// stop loading status regardless of outcome
|
||||
this.copied = false
|
||||
this.copySuccess = false
|
||||
}, 2000)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
|
@ -19,7 +19,17 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
export const GROUP_ALL_CONTACTS = t('contacts', 'All contacts')
|
||||
export const GROUP_NO_GROUP_CONTACTS = t('contacts', 'Not grouped')
|
||||
export const GROUP_RECENTLY_CONTACTED = t('contactsinteraction', 'Recently contacted')
|
||||
export default {
|
||||
computed: {
|
||||
// router variables
|
||||
selectedContact() {
|
||||
return this.$route.params.selectedContact
|
||||
},
|
||||
selectedGroup() {
|
||||
return this.$route.params.selectedGroup
|
||||
},
|
||||
selectedCircle() {
|
||||
return this.$route.params.selectedCircle
|
||||
},
|
||||
},
|
||||
}
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/** @typedef { import('./member') } Member */
|
||||
|
||||
import {
|
||||
MEMBER_LEVEL_MODERATOR, MEMBER_LEVEL_NONE, MEMBER_LEVEL_OWNER,
|
||||
CIRCLE_CONFIG_REQUEST, CIRCLE_CONFIG_INVITE, CIRCLE_CONFIG_OPEN,
|
||||
} from './constants'
|
||||
|
||||
import Vue from 'vue'
|
||||
import Member from './member'
|
||||
|
||||
export default class Circle {
|
||||
|
||||
_data = {}
|
||||
|
||||
/**
|
||||
* Creates an instance of Contact
|
||||
*
|
||||
* @param {Object} data the vcard data as string with proper new lines
|
||||
* @param {object} circle the addressbook which the contat belongs to
|
||||
* @memberof Circle
|
||||
*/
|
||||
constructor(data) {
|
||||
if (typeof data !== 'object') {
|
||||
throw new Error('Invalid circle')
|
||||
}
|
||||
|
||||
// if no uid set, fail
|
||||
if (!data.id) {
|
||||
throw new Error('This circle do not have a proper uid')
|
||||
}
|
||||
|
||||
this._data = data
|
||||
this._data.initiator = new Member(data.initiator)
|
||||
this._data.owner = new Member(data.owner)
|
||||
this._data.members = {}
|
||||
}
|
||||
|
||||
// METADATA -----------------------------------------
|
||||
/**
|
||||
* Circle id
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() {
|
||||
return this._data.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted display name
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
get displayName() {
|
||||
return this._data.displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle creation date
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {number}
|
||||
*/
|
||||
get creation() {
|
||||
return this._data.creation
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle description
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
get description() {
|
||||
return this._data.description
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle description
|
||||
* @param {string} text circle description
|
||||
* @memberof Circle
|
||||
*/
|
||||
set description(text) {
|
||||
this._data.description = text
|
||||
}
|
||||
|
||||
// MEMBERSHIP -----------------------------------------
|
||||
/**
|
||||
* Circle initiator. This is the current
|
||||
* user info for this circle
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Member}
|
||||
*/
|
||||
get initiator() {
|
||||
return this._data.initiator
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle ownership
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Member}
|
||||
*/
|
||||
get owner() {
|
||||
return this._data.owner
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new circle owner
|
||||
* @param {Member} owner circle owner
|
||||
* @memberof Circle
|
||||
*/
|
||||
set owner(owner) {
|
||||
if (owner.constructor.name !== Member.name) {
|
||||
throw new Error('Owner must be a Member type')
|
||||
}
|
||||
this._data.owner = owner
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle members
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Member[]}
|
||||
*/
|
||||
get members() {
|
||||
return this._data.members
|
||||
}
|
||||
|
||||
/**
|
||||
* Define members circle
|
||||
* @param {Member[]} members the members list
|
||||
* @memberof Circle
|
||||
*/
|
||||
set members(members) {
|
||||
this._data.members = members
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to this circle
|
||||
* @param {Member} member the member to add
|
||||
*/
|
||||
addMember(member) {
|
||||
if (member.constructor.name !== Member.name) {
|
||||
throw new Error('Member must be a Member type')
|
||||
}
|
||||
|
||||
const uid = member.id
|
||||
if (this._data.members[uid]) {
|
||||
console.warn('Duplicate member overrided', this._data.members[uid], member)
|
||||
}
|
||||
Vue.set(this._data.members, uid, member)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from this circle
|
||||
* @param {Member} member the member to delete
|
||||
*/
|
||||
deleteMember(member) {
|
||||
if (member.constructor.name !== Member.name) {
|
||||
throw new Error('Member must be a Member type')
|
||||
}
|
||||
|
||||
const uid = member.id
|
||||
if (!this._data.members[uid]) {
|
||||
console.warn('The member was not in this circle. Nothing was done.', member)
|
||||
}
|
||||
|
||||
// Delete and clear memory
|
||||
Vue.delete(this._data.members, uid)
|
||||
}
|
||||
|
||||
// CONFIGS --------------------------------------------
|
||||
get settings() {
|
||||
return this._data.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle config
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {number}
|
||||
*/
|
||||
get config() {
|
||||
return this._data.config
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle requires invite to be confirmed by moderator or above
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get requireJoinAccept() {
|
||||
return (this._data.config & CIRCLE_CONFIG_REQUEST) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle can be requested to join
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canJoin() {
|
||||
return (this._data.config & CIRCLE_CONFIG_OPEN) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle requires invite to be accepted by the member
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get requireInviteAccept() {
|
||||
return (this._data.config & CIRCLE_CONFIG_INVITE) !== 0
|
||||
}
|
||||
|
||||
// PERMISSIONS SHORTCUTS ------------------------------
|
||||
/**
|
||||
* Can the initiator add members to this circle?
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isOwner() {
|
||||
return this.initiator.level === MEMBER_LEVEL_OWNER
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the initiator a member of this circle?
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isMember() {
|
||||
return this.initiator.level > MEMBER_LEVEL_NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the initiator delete this circle?
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canDelete() {
|
||||
return this.isOwner
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the initiator add/remove members to this circle?
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canManageMembers() {
|
||||
return this.initiator.level >= MEMBER_LEVEL_MODERATOR
|
||||
}
|
||||
|
||||
// PARAMS ---------------------------------------------
|
||||
/**
|
||||
* Vue router param
|
||||
* @readonly
|
||||
* @memberof Circle
|
||||
* @returns {Object}
|
||||
*/
|
||||
get router() {
|
||||
return {
|
||||
name: 'circle',
|
||||
params: { selectedCircle: this.id },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default javascript fallback
|
||||
* Used for sorting as well
|
||||
* @memberof Circle
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
return this.displayName
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2021 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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable no-tabs */
|
||||
|
||||
// Dynamic groups
|
||||
export const GROUP_ALL_CONTACTS = t('contacts', 'All contacts')
|
||||
export const GROUP_NO_GROUP_CONTACTS = t('contacts', 'Not grouped')
|
||||
export const GROUP_RECENTLY_CONTACTED = t('contactsinteraction', 'Recently contacted')
|
||||
|
||||
// Default max number of items to show in the navigation
|
||||
export const ELLIPSIS_COUNT = 5
|
||||
|
||||
// Circles member levels
|
||||
export const MEMBER_LEVEL_NONE = 0
|
||||
export const MEMBER_LEVEL_MEMBER = 1
|
||||
export const MEMBER_LEVEL_MODERATOR = 4
|
||||
export const MEMBER_LEVEL_ADMIN = 8
|
||||
export const MEMBER_LEVEL_OWNER = 9
|
||||
|
||||
// Circles member types
|
||||
export const MEMBER_TYPE_CIRCLE = 16
|
||||
export const MEMBER_TYPE_USER = 1
|
||||
export const MEMBER_TYPE_GROUP = 2
|
||||
export const MEMBER_TYPE_MAIL = 3
|
||||
export const MEMBER_TYPE_CONTACT = 4
|
||||
|
||||
// Circles config flags
|
||||
export const CIRCLE_CONFIG_SYSTEM = 4 // System Circle (not managed by the official front-end). Meaning some config are limited
|
||||
export const CIRCLE_CONFIG_VISIBLE = 8 // Visible to everyone, if not visible, people have to know its name to be able to find it
|
||||
export const CIRCLE_CONFIG_OPEN = 16 // Circle is open, people can join
|
||||
export const CIRCLE_CONFIG_INVITE = 32 // Adding a member generate an invitation that needs to be accepted
|
||||
export const CIRCLE_CONFIG_REQUEST = 64 // Request to join Circles needs to be confirmed by a moderator
|
||||
export const CIRCLE_CONFIG_FRIEND = 128 // Members of the circle can invite their friends
|
||||
export const CIRCLE_CONFIG_PROTECTED = 256 // Password protected to join/request
|
||||
export const CIRCLE_CONFIG_NO_OWNER = 512 // no owner, only members
|
||||
export const CIRCLE_CONFIG_HIDDEN = 1024 // hidden from listing, but available as a share entity
|
||||
export const CIRCLE_CONFIG_BACKEND = 2048 // Fully hidden, only backend Circles
|
||||
export const CIRCLE_CONFIG_ROOT = 4096 // Circle cannot be inside another Circle
|
||||
export const CIRCLE_CONFIG_CIRCLE_INVITE = 8192 // Circle must confirm when invited in another circle
|
||||
export const CIRCLE_CONFIG_FEDERATED = 16384 // Federated
|
||||
|
||||
export const CIRCLES_MEMBER_TYPES = {
|
||||
[MEMBER_TYPE_CIRCLE]: t('circles', 'Circle'),
|
||||
[MEMBER_TYPE_USER]: t('circles', 'User'),
|
||||
[MEMBER_TYPE_GROUP]: t('circles', 'Group'),
|
||||
[MEMBER_TYPE_MAIL]: t('circles', 'Mail'),
|
||||
[MEMBER_TYPE_CONTACT]: t('circles', 'Contact'),
|
||||
}
|
||||
|
||||
export const CIRCLES_MEMBER_LEVELS = {
|
||||
// [MEMBER_LEVEL_NONE]: t('circles', 'None'),
|
||||
[MEMBER_LEVEL_MEMBER]: t('circles', 'Member'),
|
||||
[MEMBER_LEVEL_MODERATOR]: t('circles', 'Moderator'),
|
||||
[MEMBER_LEVEL_ADMIN]: t('circles', 'Admin'),
|
||||
[MEMBER_LEVEL_OWNER]: t('circles', 'Owner'),
|
||||
}
|
|
@ -20,487 +20,126 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import ICAL from 'ical.js'
|
||||
import b64toBlob from 'b64-to-blob'
|
||||
/** @typedef { import('./circle') } Circle */
|
||||
|
||||
import { MEMBER_TYPE_USER } from './constants'
|
||||
|
||||
import Circle from './circle'
|
||||
import Vue from 'vue'
|
||||
export default class Member {
|
||||
|
||||
/** @typedef Circle */
|
||||
_circle
|
||||
_data = {}
|
||||
|
||||
/**
|
||||
* Creates an instance of Contact
|
||||
*
|
||||
* @param {string} vcard the vcard data as string with proper new lines
|
||||
* @param {object} addressbook the addressbook which the contat belongs to
|
||||
* @memberof Contact
|
||||
* @param {Object} data the vcard data as string with proper new lines
|
||||
* @param {Circle} circle the addressbook which the contat belongs to
|
||||
* @memberof Member
|
||||
*/
|
||||
constructor(vcard, addressbook) {
|
||||
if (typeof vcard !== 'string' || vcard.length === 0) {
|
||||
throw new Error('Invalid vCard')
|
||||
constructor(data, circle) {
|
||||
if (typeof data !== 'object') {
|
||||
throw new Error('Invalid member')
|
||||
}
|
||||
|
||||
let jCal = ICAL.parse(vcard)
|
||||
if (jCal[0] !== 'vcard') {
|
||||
throw new Error('Only one contact is allowed in the vcard data')
|
||||
// if no uid set, fail
|
||||
if (data.id && typeof data.id !== 'string') {
|
||||
console.error('This member do not have a proper uid', data)
|
||||
throw new Error('This member do not have a proper uid')
|
||||
}
|
||||
|
||||
if (updateDesignSet(jCal)) {
|
||||
jCal = ICAL.parse(vcard)
|
||||
}
|
||||
|
||||
this.jCal = jCal
|
||||
this.addressbook = addressbook
|
||||
this.vCard = new ICAL.Component(this.jCal)
|
||||
|
||||
// used to state a contact is not up to date with
|
||||
// the server and cannot be pushed (etag)
|
||||
this.conflict = false
|
||||
|
||||
// if no uid set, create one
|
||||
if (!this.vCard.hasProperty('uid')) {
|
||||
console.info('This contact did not have a proper uid. Setting a new one for ', this)
|
||||
this.vCard.addPropertyWithValue('uid', uuid())
|
||||
}
|
||||
|
||||
// if no rev set, init one
|
||||
if (!this.vCard.hasProperty('rev')) {
|
||||
const rev = new ICAL.VCardTime(null, null, 'date-time')
|
||||
rev.fromUnixTime(Date.now() / 1000)
|
||||
this.vCard.addPropertyWithValue('rev', rev)
|
||||
|
||||
this._circle = circle
|
||||
this._data = data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the circle of this member
|
||||
* @readonly
|
||||
* @memberof Member
|
||||
*/
|
||||
get circle() {
|
||||
return this._circle
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the circle of this member
|
||||
* @param {Circle} circle the circle
|
||||
* @memberof Member
|
||||
*/
|
||||
set circle(circle) {
|
||||
if (circle.constructor.name !== Circle.name) {
|
||||
throw new Error('circle must be a Circle type')
|
||||
}
|
||||
this._circle = circle
|
||||
}
|
||||
|
||||
/**
|
||||
* Update linked addressbook of this contact
|
||||
*
|
||||
* @param {Object} addressbook the addressbook
|
||||
* @memberof Contact
|
||||
*/
|
||||
updateAddressbook(addressbook) {
|
||||
this.addressbook = addressbook
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the url
|
||||
*
|
||||
* Member id
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @memberof Member
|
||||
*/
|
||||
get url() {
|
||||
if (this.dav) {
|
||||
return this.dav.url
|
||||
}
|
||||
return ''
|
||||
get id() {
|
||||
return this._data.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the version
|
||||
*
|
||||
* Formatted display name
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get version() {
|
||||
return this.vCard.getFirstPropertyValue('version')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the version
|
||||
*
|
||||
* @param {string} version the version to set
|
||||
* @memberof Contact
|
||||
*/
|
||||
set version(version) {
|
||||
this.vCard.updatePropertyWithValue('version', version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the uid
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get uid() {
|
||||
return this.vCard.getFirstPropertyValue('uid')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the uid
|
||||
*
|
||||
* @param {string} uid the uid to set
|
||||
* @memberof Contact
|
||||
*/
|
||||
set uid(uid) {
|
||||
this.vCard.updatePropertyWithValue('uid', uid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the rev
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get rev() {
|
||||
return this.vCard.getFirstPropertyValue('rev')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the rev
|
||||
*
|
||||
* @param {string} rev the rev to set
|
||||
* @memberof Contact
|
||||
*/
|
||||
set rev(rev) {
|
||||
this.vCard.updatePropertyWithValue('rev', rev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get key() {
|
||||
return this.uid + '~' + this.addressbook.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the photo
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get photo() {
|
||||
return this.vCard.getFirstPropertyValue('photo')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the photo
|
||||
*
|
||||
* @param {string} photo the photo to set
|
||||
* @memberof Contact
|
||||
*/
|
||||
set photo(photo) {
|
||||
this.vCard.updatePropertyWithValue('photo', photo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the photo usable url
|
||||
* We cannot fetch external url because of csp policies
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get photoUrl() {
|
||||
const photo = this.vCard.getFirstProperty('photo')
|
||||
const encoding = photo.getFirstParameter('encoding')
|
||||
let photoType = photo.getFirstParameter('type')
|
||||
let photoB64 = this.photo
|
||||
|
||||
const isBinary = photo.type === 'binary' || encoding === 'b'
|
||||
|
||||
if (photo && photoB64.startsWith('data') && !isBinary) {
|
||||
// get the last part = base64
|
||||
photoB64 = photoB64.split(',').pop()
|
||||
// 'data:image/png' => 'png'
|
||||
photoType = photoB64.split(';')[0].split('/')
|
||||
}
|
||||
|
||||
try {
|
||||
// Create blob from url
|
||||
const blob = b64toBlob(photoB64, `image/${photoType}`)
|
||||
return URL.createObjectURL(blob)
|
||||
} catch {
|
||||
console.error('Invalid photo for the following contact. Ignoring...', this.contact, { photoB64, photoType })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the groups
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get groups() {
|
||||
const groupsProp = this.vCard.getFirstProperty('categories')
|
||||
if (groupsProp) {
|
||||
return groupsProp.getValues()
|
||||
.filter(group => typeof group === 'string')
|
||||
.filter(group => group.trim() !== '')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the groups
|
||||
*
|
||||
* @param {Array} groups the groups to set
|
||||
* @memberof Contact
|
||||
*/
|
||||
set groups(groups) {
|
||||
// delete the title if empty
|
||||
if (isEmpty(groups)) {
|
||||
this.vCard.removeProperty('categories')
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(groups)) {
|
||||
let property = this.vCard.getFirstProperty('categories')
|
||||
if (!property) {
|
||||
// Init with empty group since we set everything afterwise
|
||||
property = this.vCard.addPropertyWithValue('categories', '')
|
||||
}
|
||||
property.setValues(groups)
|
||||
} else {
|
||||
throw new Error('groups data is not an Array')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the groups
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get kind() {
|
||||
return this.firstIfArray(this.vCard.getFirstPropertyValue('kind'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first email
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get email() {
|
||||
return this.firstIfArray(this.vCard.getFirstPropertyValue('email'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first org
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get org() {
|
||||
return this.firstIfArray(this.vCard.getFirstPropertyValue('org'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the org
|
||||
*
|
||||
* @param {string} org the org data
|
||||
* @memberof Contact
|
||||
*/
|
||||
set org(org) {
|
||||
// delete the org if empty
|
||||
if (isEmpty(org)) {
|
||||
this.vCard.removeProperty('org')
|
||||
return
|
||||
}
|
||||
this.vCard.updatePropertyWithValue('org', org)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first title
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get title() {
|
||||
return this.firstIfArray(this.vCard.getFirstPropertyValue('title'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the title
|
||||
*
|
||||
* @param {string} title the title
|
||||
* @memberof Contact
|
||||
*/
|
||||
set title(title) {
|
||||
// delete the title if empty
|
||||
if (isEmpty(title)) {
|
||||
this.vCard.removeProperty('title')
|
||||
return
|
||||
}
|
||||
this.vCard.updatePropertyWithValue('title', title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full name
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
*/
|
||||
get fullName() {
|
||||
return this.vCard.getFirstPropertyValue('fn')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the full name
|
||||
*
|
||||
* @param {string} name the fn data
|
||||
* @memberof Contact
|
||||
*/
|
||||
set fullName(name) {
|
||||
this.vCard.updatePropertyWithValue('fn', name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted display name based on the order key
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @memberof Member
|
||||
*/
|
||||
get displayName() {
|
||||
const orderKey = store.getters.getOrderKey
|
||||
const n = this.vCard.getFirstPropertyValue('n')
|
||||
const fn = this.vCard.getFirstPropertyValue('fn')
|
||||
const org = this.vCard.getFirstPropertyValue('org')
|
||||
|
||||
// if ordered by last or first name we need the N property
|
||||
// ! by checking the property we check for null AND empty string
|
||||
// ! that means we can then check for empty array and be safe not to have
|
||||
// ! 'xxxx'.join('') !== ''
|
||||
if (orderKey && n && !isEmpty(n)) {
|
||||
switch (orderKey) {
|
||||
case 'firstName':
|
||||
// Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
|
||||
// -> John Stevenson
|
||||
if (isEmpty(n[0])) {
|
||||
return n[1]
|
||||
}
|
||||
return n.slice(0, 2).reverse().join(' ')
|
||||
|
||||
case 'lastName':
|
||||
// Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
|
||||
// -> Stevenson, John
|
||||
if (isEmpty(n[0])) {
|
||||
return n[1]
|
||||
}
|
||||
return n.slice(0, 2).join(', ')
|
||||
}
|
||||
}
|
||||
// otherwise the FN is enough
|
||||
if (fn) {
|
||||
return fn
|
||||
}
|
||||
// BUT if no FN property use the N anyway
|
||||
if (n && !isEmpty(n)) {
|
||||
// Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
|
||||
// -> John Stevenson
|
||||
if (isEmpty(n[0])) {
|
||||
return n[1]
|
||||
}
|
||||
return n.slice(0, 2).reverse().join(' ')
|
||||
}
|
||||
// LAST chance, use the org ir that's the only thing we have
|
||||
if (org && !isEmpty(org)) {
|
||||
// org is supposed to be an array but is also used as plain string
|
||||
return Array.isArray(org) ? org[0] : org
|
||||
}
|
||||
return ''
|
||||
|
||||
return this._data.displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first name if exists
|
||||
* Returns the displayName otherwise
|
||||
*
|
||||
* Member userId
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @returns {string} firstName|displayName
|
||||
* @memberof Member
|
||||
*/
|
||||
get firstName() {
|
||||
if (this.vCard.hasProperty('n')) {
|
||||
// reverse and join
|
||||
return this.vCard.getFirstPropertyValue('n')[1]
|
||||
}
|
||||
return this.displayName
|
||||
get userId() {
|
||||
return this._data.userId
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last name if exists
|
||||
* Returns the displayName otherwise
|
||||
*
|
||||
* Member level
|
||||
* @see file src/models/constants.js
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @returns {string} lastName|displayName
|
||||
* @memberof Member
|
||||
*/
|
||||
get lastName() {
|
||||
if (this.vCard.hasProperty('n')) {
|
||||
// reverse and join
|
||||
return this.vCard.getFirstPropertyValue('n')[0]
|
||||
}
|
||||
return this.displayName
|
||||
get level() {
|
||||
return this._data.level
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the phonetic first name if exists
|
||||
* Returns the first name or displayName otherwise
|
||||
*
|
||||
* Is the current member a user?
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @returns {string} phoneticFirstName|firstName|displayName
|
||||
* @memberof Member
|
||||
*/
|
||||
get phoneticFirstName() {
|
||||
if (this.vCard.hasProperty('x-phonetic-first-name')) {
|
||||
return this.vCard.getFirstPropertyValue('x-phonetic-first-name')
|
||||
}
|
||||
return this.firstName
|
||||
get isUser() {
|
||||
return this._data.userType === MEMBER_TYPE_USER
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the phonetic last name if exists
|
||||
* Returns the displayName otherwise
|
||||
*
|
||||
* Is the current member without a circle?
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @returns {string} lastName|displayName
|
||||
* @memberof Member
|
||||
*/
|
||||
get phoneticLastName() {
|
||||
if (this.vCard.hasProperty('x-phonetic-last-name')) {
|
||||
return this.vCard.getFirstPropertyValue('x-phonetic-last-name')
|
||||
}
|
||||
return this.lastName
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all the properties as Property objects
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @returns {Property[]} http://mozilla-comm.github.io/ical.js/api/ICAL.Property.html
|
||||
*/
|
||||
get properties() {
|
||||
return this.vCard.getAllProperties()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of formatted properties for the search
|
||||
*
|
||||
* @readonly
|
||||
* @memberof Contact
|
||||
* @returns {string[]}
|
||||
*/
|
||||
get searchData() {
|
||||
return this.jCal[1].map(x => x[0] + ':' + x[3])
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the contact to the group
|
||||
*
|
||||
* @param {string} group the group to add the contact to
|
||||
* @memberof Contact
|
||||
*/
|
||||
addToGroup(group) {
|
||||
if (this.groups.indexOf(group) === -1) {
|
||||
if (this.groups.length > 0) {
|
||||
this.vCard.getFirstProperty('categories').setValues(this.groups.concat(group))
|
||||
} else {
|
||||
this.vCard.updatePropertyWithValue('categories', [group])
|
||||
}
|
||||
get isOrphan() {
|
||||
return this._circle?.constructor?.name !== 'Circle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete this member and any reference from its circle
|
||||
*/
|
||||
delete() {
|
||||
if (this.isOrphan) {
|
||||
throw new Error('Cannot delete this member as it doesn\'t belong to any circle')
|
||||
}
|
||||
this.circle.deleteMember(this)
|
||||
this._circle = undefined
|
||||
this._data = undefined
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -55,6 +55,11 @@ export default new Router({
|
|||
name: 'group',
|
||||
component: Contacts,
|
||||
},
|
||||
{
|
||||
path: 'circle/:selectedCircle',
|
||||
name: 'circle',
|
||||
component: Contacts,
|
||||
},
|
||||
{
|
||||
path: ':selectedGroup/:selectedContact',
|
||||
name: 'contact',
|
||||
|
|
|
@ -23,12 +23,111 @@
|
|||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
|
||||
const baseApi = generateOcsUrl('apps/circles')
|
||||
const baseApi = generateOcsUrl('apps/circles', 2)
|
||||
|
||||
/**
|
||||
* Get the circles list without the members
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const getCircles = async function() {
|
||||
const response = await axios.get(baseApi + 'circles')
|
||||
return response.data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new circle
|
||||
*
|
||||
* @param {string} name the circle name
|
||||
* @returns {Object}
|
||||
*/
|
||||
export const createCircle = async function(name) {
|
||||
const response = await axios.post(baseApi + 'circles', {
|
||||
name,
|
||||
})
|
||||
return response.data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing circle
|
||||
*
|
||||
* @param {string} circleId the circle name
|
||||
* @returns {Object}
|
||||
*/
|
||||
export const deleteCircle = async function(circleId) {
|
||||
const response = await axios.delete(baseApi + `circles/${circleId}`)
|
||||
return response.data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a circle
|
||||
*
|
||||
* @param {string} circleId the circle name
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const joinCircle = async function(circleId) {
|
||||
const response = await axios.put(baseApi + `circles/${circleId}/join`)
|
||||
return response.data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a circle
|
||||
*
|
||||
* @param {string} circleId the circle name
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const leaveCircle = async function(circleId) {
|
||||
const response = await axios.put(baseApi + `circles/${circleId}/leave`)
|
||||
return response.data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the circle members without the members
|
||||
*
|
||||
* @param {string} circleId the circle id
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const getCircleMembers = async function(circleId) {
|
||||
const response = await axios.get(baseApi + `circles/${circleId}/members`)
|
||||
return Object.values(response.data.ocs.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a circle member
|
||||
*
|
||||
* @param {string} circleId the circle id
|
||||
* @param {string} memberId the member id
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const addMember = async function(circleId, memberId) {
|
||||
const response = await axios.delete(baseApi + `circles/${circleId}/members/${memberId}`)
|
||||
return Object.values(response.data.ocs.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a circle member
|
||||
*
|
||||
* @param {string} circleId the circle id
|
||||
* @param {string} memberId the member id
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const deleteMember = async function(circleId, memberId) {
|
||||
const response = await axios.delete(baseApi + `circles/${circleId}/members/${memberId}`)
|
||||
return Object.values(response.data.ocs.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* change a member level
|
||||
* @see levels file src/models/constants.js
|
||||
*
|
||||
* @param {string} circleId the circle id
|
||||
* @param {string} memberId the member id
|
||||
* @param {number} level the new member level
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const changeMemberLevel = async function(circleId, memberId, level) {
|
||||
const response = await axios.put(baseApi + `circles/${circleId}/members${memberId}}/level`, {
|
||||
level,
|
||||
})
|
||||
return Object.values(response.data.ocs.data)
|
||||
}
|
||||
|
|
|
@ -21,23 +21,15 @@
|
|||
*/
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import pLimit from 'p-limit'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { createCircle, deleteCircle, deleteMember, getCircleMembers, getCircles } from '../services/circles'
|
||||
import Member from '../models/member'
|
||||
import { getCircles } from '../services/circles'
|
||||
|
||||
const circleModel = {
|
||||
id: '',
|
||||
name: '',
|
||||
owner: {},
|
||||
members: [],
|
||||
initiator: {},
|
||||
url: '',
|
||||
}
|
||||
import Circle from '../models/circle'
|
||||
|
||||
const state = {
|
||||
circles: [],
|
||||
/** @type {Object.<string>} Circle */
|
||||
circles: {},
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
|
@ -46,37 +38,32 @@ const mutations = {
|
|||
* Add a circle into state
|
||||
*
|
||||
* @param {Object} state the store data
|
||||
* @param {Object} circle the circle to add
|
||||
* @param {Circle} circle the circle to add
|
||||
*/
|
||||
addCircle(state, circle) {
|
||||
// extend the circle to the default model
|
||||
const newCircle = Object.assign({}, circleModel, circle)
|
||||
// force reinit of the members object to prevent
|
||||
// data passed as references
|
||||
newCircle.members = {}
|
||||
state.circles.push(newCircle)
|
||||
Vue.set(state.circles, circle.id, circle)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete circle
|
||||
*
|
||||
* @param {Object} state the store data
|
||||
* @param {Object} circle the circle to delete
|
||||
* @param {Circle} circle the circle to delete
|
||||
*/
|
||||
deleteCircle(state, circle) {
|
||||
state.circles.splice(state.circles.indexOf(circle), 1)
|
||||
Vue.delete(state.circles, circle.id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a circle
|
||||
*
|
||||
* @param {Object} context the store mutations
|
||||
* @param {Object} state the store mutations
|
||||
* @param {Object} data destructuring object
|
||||
* @param {Object} data.circle the circle to rename
|
||||
* @param {Circle} data.circle the circle to rename
|
||||
* @param {string} data.newName the new name of the addressbook
|
||||
*/
|
||||
renameCircle(context, { circle, newName }) {
|
||||
circle = state.circles.find(search => search.id === circle.id)
|
||||
renameCircle(state, { circle, newName }) {
|
||||
circle = state.circles[circle.id]
|
||||
circle.displayName = newName
|
||||
},
|
||||
|
||||
|
@ -85,32 +72,23 @@ const mutations = {
|
|||
* and remove duplicates
|
||||
*
|
||||
* @param {Object} state the store data
|
||||
* @param {Object} data destructuring object
|
||||
* @param {Object} data.circle the circle to add the members to
|
||||
* @param {Member[]} data.members array of contacts to append
|
||||
* @param {Members[]} members array of members to append
|
||||
*/
|
||||
appendMembersToCircle(state, { circle, members }) {
|
||||
circle = state.circles.find(search => search.id === circle.id)
|
||||
|
||||
// convert list into an array and remove duplicate
|
||||
circle.members = members.reduce((list, member) => {
|
||||
if (list[member.uid]) {
|
||||
console.info('Duplicate contact overrided', list[member.uid], member)
|
||||
}
|
||||
Vue.set(list, member.uid, member)
|
||||
return list
|
||||
}, circle.members)
|
||||
appendMembersToCircle(state, members) {
|
||||
members.forEach(member => member.circle.addMember(member))
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a member to a circle and overwrite if duplicate uid
|
||||
*
|
||||
* @param {Object} state the store data
|
||||
* @param {Member} member the member to add
|
||||
* @param {Object} data destructuring object
|
||||
* @param {string} data.circleId the circle to add the members to
|
||||
* @param {Member} data.member array of contacts to append
|
||||
*/
|
||||
addMemberToCircle(state, member) {
|
||||
const circle = state.circles.find(search => search.id === member.circle.id)
|
||||
Vue.set(circle.members, member.uid, member)
|
||||
addMemberToCircle(state, { circleId, member }) {
|
||||
const circle = state.circles[circleId]
|
||||
circle.addmember(member)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -120,13 +98,14 @@ const mutations = {
|
|||
* @param {Member} member the member to add
|
||||
*/
|
||||
deleteMemberFromCircle(state, member) {
|
||||
const circle = state.circles.find(search => search.id === member.circle.id)
|
||||
Vue.delete(circle.members, member.uid)
|
||||
// Circles dependencies are managed directly from the model
|
||||
member.delete()
|
||||
},
|
||||
}
|
||||
|
||||
const getters = {
|
||||
getCircles: state => state.circles,
|
||||
getCircles: state => Object.values(state.circles),
|
||||
getCircle: state => (id) => state.circles[id],
|
||||
}
|
||||
|
||||
const actions = {
|
||||
|
@ -139,50 +118,92 @@ const actions = {
|
|||
*/
|
||||
async getCircles(context) {
|
||||
const circles = await getCircles()
|
||||
console.debug(`Retrieved ${circles.length} circle(s)`, circles)
|
||||
|
||||
circles.forEach(circle => {
|
||||
context.commit('addCircle', circle)
|
||||
})
|
||||
circles.map(circle => new Circle(circle))
|
||||
.forEach(circle => {
|
||||
context.commit('addCircle', circle)
|
||||
})
|
||||
|
||||
return circles
|
||||
},
|
||||
|
||||
/**
|
||||
* Append a new address book to array of existing address books
|
||||
* Retrieve and commit circle members
|
||||
*
|
||||
* @param {Object} context the store mutations
|
||||
* @param {Object} addressbook The address book to append
|
||||
* @returns {Promise}
|
||||
* @param {string} circleId the circle id
|
||||
*/
|
||||
async appendAddressbook(context, addressbook) {
|
||||
return client.addressBookHomes[0]
|
||||
.createAddressBookCollection(addressbook.displayName)
|
||||
.then((response) => {
|
||||
addressbook = mapDavCollectionToAddressbook(response)
|
||||
context.commit('addAddressbook', addressbook)
|
||||
})
|
||||
.catch((error) => { throw error })
|
||||
async getCircleMembers(context, circleId) {
|
||||
const circle = context.getters.getCircle(circleId)
|
||||
const members = await getCircleMembers(circleId)
|
||||
|
||||
console.debug(`${circleId} have ${members.length} member(s)`, members)
|
||||
context.commit('appendMembersToCircle', members.map(member => new Member(member, circle)))
|
||||
},
|
||||
|
||||
/**
|
||||
* Create circle
|
||||
*
|
||||
* @param {Object} context the store mutations Current context
|
||||
* @param {string} circleName the circle name
|
||||
*/
|
||||
async createCircle(context, circleName) {
|
||||
try {
|
||||
const response = await createCircle(circleName)
|
||||
const circle = new Circle(response)
|
||||
console.debug('Created circle', circleName, circle)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('contacts', 'Unable to create circle {circleName}', { circleName }))
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete circle
|
||||
*
|
||||
* @param {Object} context the store mutations Current context
|
||||
* @param {Object} circle the circle to delete
|
||||
* @returns {Promise}
|
||||
* @param {Circle} circle the circle to delete
|
||||
*/
|
||||
async deleteCircle(context, circle) {
|
||||
return addressbook.dav
|
||||
.delete()
|
||||
.then((response) => {
|
||||
// delete all the contacts from the store that belong to this addressbook
|
||||
Object.values(addressbook.contacts)
|
||||
.forEach(contact => context.commit('deleteContact', contact))
|
||||
// then delete the addressbook
|
||||
context.commit('deleteAddressbook', addressbook)
|
||||
})
|
||||
.catch((error) => { throw error })
|
||||
try {
|
||||
await deleteCircle(circle.id)
|
||||
console.debug('Created circle', circle.displayName, circle)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('contacts', 'Unable to create circle {displayName}', circle))
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a member to a circle
|
||||
*
|
||||
* @param {Object} context the store mutations Current context
|
||||
* @param {Object} data destructuring object
|
||||
* @param {string} data.circleId the circle to manage
|
||||
* @param {string} data.memberId the member to add
|
||||
*/
|
||||
async addMemberToCircle(context, { circleId, memberId }) {
|
||||
await this.addMember(circleId, memberId)
|
||||
console.debug('Added member', circleId, memberId)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a member from a circle
|
||||
*
|
||||
* @param {Object} context the store mutations Current context
|
||||
* @param {Member} member the member to remove
|
||||
*/
|
||||
async deleteMemberFromCircle(context, member) {
|
||||
const circleId = member.circle.id
|
||||
const memberId = member.id
|
||||
await deleteMember(circleId, memberId)
|
||||
|
||||
// success, let's remove from store
|
||||
context.commit('deleteMemberFromCircle', member)
|
||||
console.debug('Deleted member', circleId, memberId)
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export default { state, mutations, getters, actions }
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2019 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/>.
|
||||
*
|
||||
*/
|
||||
import camelcase from 'camelcase'
|
||||
import { isNumber } from './numberUtils'
|
||||
|
||||
export const formatObject = function(obj) {
|
||||
const data = {}
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
const data = obj[key]
|
||||
|
||||
// flatten object if any
|
||||
if (!!data && typeof data === 'object') {
|
||||
Object.assign(data, formatObject(data))
|
||||
} else {
|
||||
// format key and add it to the data
|
||||
if (data === 'false') {
|
||||
data[camelcase(key)] = false
|
||||
} else if (data === 'true') {
|
||||
data[camelcase(key)] = true
|
||||
} else {
|
||||
data[camelcase(key)] = isNumber(data)
|
||||
? Number(data)
|
||||
: data
|
||||
}
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2019 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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
const isNumber = function(num) {
|
||||
if (!num) {
|
||||
return false
|
||||
}
|
||||
return Number(num).toString() === num.toString()
|
||||
}
|
||||
|
||||
export { isNumber }
|
|
@ -32,12 +32,11 @@
|
|||
<!-- new-contact-button + navigation + settings -->
|
||||
<RootNavigation
|
||||
:contacts-list="contactsList"
|
||||
:loading="loading"
|
||||
:loading="loadingContacts || loadingCircles"
|
||||
:selected-group="selectedGroup"
|
||||
:selected-contact="selectedContact"
|
||||
@addContactsToGroup="addContactsToGroup">
|
||||
:selected-contact="selectedContact">
|
||||
<!-- new-contact-button -->
|
||||
<AppNavigationNew v-if="!loading"
|
||||
<AppNavigationNew v-if="!loadingContacts"
|
||||
button-id="new-contact-button"
|
||||
:text="t('contacts','New contact')"
|
||||
button-class="icon-add"
|
||||
|
@ -45,50 +44,13 @@
|
|||
@click="newContact" />
|
||||
</RootNavigation>
|
||||
|
||||
<AppContent>
|
||||
<div v-if="loading">
|
||||
<EmptyContent icon="icon-loading">
|
||||
{{ t('contacts', 'Loading contacts …') }}
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isEmptyGroup && !isRealGroup">
|
||||
<EmptyContent icon="icon-contacts-dark">
|
||||
{{ t('contacts', 'There are no contacts yet') }}
|
||||
<template #desc>
|
||||
<button class="primary" @click="newContact">
|
||||
{{ t('contacts', 'Create contact') }}
|
||||
</button>
|
||||
</template>
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isEmptyGroup && isRealGroup">
|
||||
<EmptyContent icon="icon-contacts-dark">
|
||||
{{ t('contacts', 'There are no contacts in this group') }}
|
||||
<template #desc>
|
||||
<button v-if="contacts.length === 0" class="primary" @click="addContactsToGroup(selectedGroup)">
|
||||
{{ t('contacts', 'Create contacts') }}
|
||||
</button>
|
||||
<button v-else class="primary" @click="addContactsToGroup(selectedGroup)">
|
||||
{{ t('contacts', 'Add contacts') }}
|
||||
</button>
|
||||
</template>
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div v-else id="app-content-wrapper">
|
||||
<!-- contacts list -->
|
||||
<ContactsList
|
||||
v-if="!loading"
|
||||
:list="contactsList"
|
||||
:contacts="contacts"
|
||||
:search-query="searchQuery" />
|
||||
|
||||
<!-- main contacts details -->
|
||||
<ContactDetails :contact-key="selectedContact" />
|
||||
</div>
|
||||
</AppContent>
|
||||
<!-- Main content: circle or contacts -->
|
||||
<CircleContent v-if="selectedCircle"
|
||||
:loading="loadingCircles" />
|
||||
<ContactsContent v-else
|
||||
:contacts-list="contactsList"
|
||||
:loading="loadingContacts"
|
||||
@newContact="newContact" />
|
||||
|
||||
<!-- Import modal -->
|
||||
<Modal v-if="isImporting"
|
||||
|
@ -98,42 +60,25 @@
|
|||
<ImportView @close="closeImport" />
|
||||
</Modal>
|
||||
|
||||
<!-- Bulk contacts edit modal -->
|
||||
<Modal v-if="isProcessing || isProcessDone"
|
||||
:clear-view-delay="-1"
|
||||
:can-close="isProcessDone"
|
||||
@close="closeProcess">
|
||||
<AddToGroupView v-bind="processStatus" @close="closeProcess" />
|
||||
</Modal>
|
||||
|
||||
<!-- Select contacts group modal -->
|
||||
<EntityPicker v-else-if="showContactPicker"
|
||||
:confirm-label="t('contacts', 'Add to group {group}', { group: contactPickerforGroup.name})"
|
||||
:data-types="pickerTypes"
|
||||
:data-set="pickerData"
|
||||
@close="onContactPickerClose"
|
||||
@submit="onContactPickerPick" />
|
||||
<ContactsPicker />
|
||||
</Content>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS } from '../models/groups'
|
||||
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS } from '../models/constants'
|
||||
|
||||
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
|
||||
import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew'
|
||||
import Content from '@nextcloud/vue/dist/Components/Content'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
import isMobile from '@nextcloud/vue/dist/Mixins/isMobile'
|
||||
import Modal from '@nextcloud/vue/dist/Components/Modal'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { VCardTime } from 'ical.js'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
import AddToGroupView from './Processing/AddToGroupView'
|
||||
import ContactDetails from '../components/ContactDetails'
|
||||
import ContactsList from '../components/ContactsList'
|
||||
import EntityPicker from '../components/EntityPicker/EntityPicker'
|
||||
import CircleContent from '../components/AppContent/CircleContent'
|
||||
import ContactsContent from '../components/AppContent/ContactsContent'
|
||||
import ContactsPicker from '../components/EntityPicker/ContactsPicker'
|
||||
import ImportView from './Processing/ImportView'
|
||||
import RootNavigation from '../components/AppNavigation/RootNavigation'
|
||||
|
||||
|
@ -141,20 +86,16 @@ import Contact from '../models/contact'
|
|||
import rfcProps from '../models/rfcProps'
|
||||
|
||||
import client from '../services/cdav'
|
||||
import appendContactToGroup from '../services/appendContactToGroup'
|
||||
|
||||
export default {
|
||||
name: 'Contacts',
|
||||
|
||||
components: {
|
||||
AddToGroupView,
|
||||
AppContent,
|
||||
AppNavigationNew,
|
||||
ContactDetails,
|
||||
ContactsList,
|
||||
CircleContent,
|
||||
ContactsContent,
|
||||
ContactsPicker,
|
||||
Content,
|
||||
EmptyContent,
|
||||
EntityPicker,
|
||||
ImportView,
|
||||
Modal,
|
||||
RootNavigation,
|
||||
|
@ -166,6 +107,10 @@ export default {
|
|||
|
||||
// passed by the router
|
||||
props: {
|
||||
selectedCircle: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
selectedGroup: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
|
@ -178,28 +123,8 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
|
||||
// Add to group picker
|
||||
searchQuery: '',
|
||||
showContactPicker: false,
|
||||
contactPickerforGroup: null,
|
||||
pickerData: [],
|
||||
pickerTypes: [{
|
||||
id: 'contact',
|
||||
label: t('contacts', 'Contacts'),
|
||||
}],
|
||||
|
||||
// Bulk processing
|
||||
isProcessing: false,
|
||||
isProcessDone: false,
|
||||
processStatus: {
|
||||
failed: 0,
|
||||
progress: 0,
|
||||
success: 0,
|
||||
total: 0,
|
||||
name: '',
|
||||
},
|
||||
loadingCircles: true,
|
||||
loadingContacts: true,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -211,9 +136,6 @@ export default {
|
|||
sortedContacts() {
|
||||
return this.$store.getters.getSortedContacts
|
||||
},
|
||||
contacts() {
|
||||
return this.$store.getters.getContacts
|
||||
},
|
||||
groups() {
|
||||
return this.$store.getters.getGroups
|
||||
},
|
||||
|
@ -244,30 +166,6 @@ export default {
|
|||
return this.addressbooks.find(addressbook => !addressbook.readOnly && addressbook.enabled)
|
||||
},
|
||||
|
||||
/**
|
||||
* Is this a real group ?
|
||||
* Aka not a dynamically generated one like `All contacts`
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRealGroup() {
|
||||
return this.groups.findIndex(group => group.name === this.selectedGroup) > -1
|
||||
},
|
||||
/**
|
||||
* Is this a real group and is this empty
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmptyRealGroup() {
|
||||
return this.contactsList.length === 0
|
||||
&& this.isRealGroup
|
||||
},
|
||||
/**
|
||||
* Is the current group empty
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmptyGroup() {
|
||||
return this.contactsList.length === 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Contacts list based on the selected group.
|
||||
* Those filters are pretty fast, so let's only
|
||||
|
@ -306,13 +204,11 @@ export default {
|
|||
},
|
||||
|
||||
mounted() {
|
||||
/**
|
||||
* Register search
|
||||
*/
|
||||
// Register search
|
||||
this.search = new OCA.Search(this.search, this.resetSearch)
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
async beforeMount() {
|
||||
// get addressbooks then get contacts
|
||||
client.connect({ enableCardDAV: true }).then(() => {
|
||||
console.debug('Connected to dav!', client)
|
||||
|
@ -339,8 +235,8 @@ export default {
|
|||
})
|
||||
|
||||
// Get circles
|
||||
this.$store.dispatch('getCircles').then(circles => {
|
||||
console.debug(`Retrieved ${circles.length} circle(s)`, circles)
|
||||
this.$store.dispatch('getCircles').then(() => {
|
||||
this.loadingCircles = false
|
||||
})
|
||||
},
|
||||
|
||||
|
@ -416,7 +312,7 @@ export default {
|
|||
return this.$store.dispatch('getContactsFromAddressBook', { addressbook })
|
||||
})
|
||||
).then(results => {
|
||||
this.loading = false
|
||||
this.loadingContacts = false
|
||||
if (!this.isMobile) {
|
||||
this.selectFirstContactIfNone()
|
||||
}
|
||||
|
@ -469,14 +365,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
/* SEARCH */
|
||||
search(query) {
|
||||
this.searchQuery = query
|
||||
},
|
||||
resetSearch() {
|
||||
this.searchQuery = ''
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the list and deselect contact
|
||||
*/
|
||||
|
@ -497,122 +385,11 @@ export default {
|
|||
closeImport() {
|
||||
this.$store.dispatch('changeStage', 'default')
|
||||
},
|
||||
|
||||
// Bulk contacts group management handlers
|
||||
addContactsToGroup(group) {
|
||||
console.debug('Contacts picker opened for group', group)
|
||||
|
||||
// Get the full group if we provided the group name only
|
||||
if (typeof group === 'string') {
|
||||
group = this.groups.find(a => a.name === group)
|
||||
if (!group) {
|
||||
console.error('Cannot add contact to an undefined group', group)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Init data set
|
||||
this.pickerData = this.sortedContacts
|
||||
.map(({ key }) => {
|
||||
const contact = this.contacts[key]
|
||||
return {
|
||||
id: contact.key,
|
||||
label: contact.displayName,
|
||||
type: 'contact',
|
||||
readOnly: contact.addressbook.readOnly,
|
||||
groups: contact.groups,
|
||||
}
|
||||
})
|
||||
// No read only contacts
|
||||
.filter(contact => !contact.readOnly)
|
||||
// No contacts already present in group
|
||||
.filter(contact => contact.groups.indexOf(group.name) === -1)
|
||||
|
||||
this.showContactPicker = true
|
||||
this.contactPickerforGroup = group
|
||||
},
|
||||
|
||||
onContactPickerClose() {
|
||||
this.pickerData = []
|
||||
this.showContactPicker = false
|
||||
},
|
||||
|
||||
onContactPickerPick(selection) {
|
||||
console.debug('Adding', selection, 'to group', this.contactPickerforGroup)
|
||||
const groupName = this.contactPickerforGroup.name
|
||||
|
||||
this.isProcessing = true
|
||||
this.showContactPicker = false
|
||||
|
||||
this.processStatus.total = selection.length
|
||||
this.processStatus.name = this.contactPickerforGroup.name
|
||||
this.processStatus.progress = 0
|
||||
this.processStatus.failed = 0
|
||||
|
||||
// max simultaneous requests
|
||||
const limit = pLimit(3)
|
||||
const requests = []
|
||||
|
||||
// create the array of requests to send
|
||||
selection.map(async entity => {
|
||||
try {
|
||||
// Get contact
|
||||
const contact = this.contacts[entity.id]
|
||||
|
||||
// push contact to server and use limit
|
||||
requests.push(limit(() => appendContactToGroup(contact, groupName)
|
||||
.then((response) => {
|
||||
this.$store.dispatch('addContactToGroup', { contact, groupName })
|
||||
this.processStatus.progress++
|
||||
this.processStatus.success++
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processStatus.progress++
|
||||
this.processStatus.error++
|
||||
console.error(error)
|
||||
})
|
||||
))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all(requests).then(() => {
|
||||
this.isProcessDone = true
|
||||
this.showContactPicker = false
|
||||
|
||||
// Auto close after 3 seconds if no errors
|
||||
if (this.processStatus.failed === 0) {
|
||||
setTimeout(this.closeProcess, 3000)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
closeProcess() {
|
||||
this.contactPickerforGroup = null
|
||||
this.isProcessing = false
|
||||
this.isProcessDone = false
|
||||
|
||||
// Reset
|
||||
this.processStatus.failed = 0
|
||||
this.processStatus.progress = 0
|
||||
this.processStatus.success = 0
|
||||
this.processStatus.total = 0
|
||||
},
|
||||
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#newgroup a {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
#app-content-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-details-toggle {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
|
|
Loading…
Reference in New Issue