Browse Source

wip(gui-v2): view create

Signed-off-by: Pranav C <pranavxc@gmail.com>
pull/2716/head
Pranav C 2 years ago
parent
commit
8b9d1cd37c
  1. 10
      packages/nc-gui-v2/components/cell/Duration.vue
  2. 97
      packages/nc-gui-v2/components/dlg/VueCreate.vue
  3. 1
      packages/nc-gui-v2/components/index.ts
  4. 119
      packages/nc-gui-v2/components/smartsheet/Sidebar.vue
  5. 41
      packages/nc-gui-v2/composables/useViewCreate.ts
  6. 366
      packages/nc-gui-v2/utils/durationHelper.ts

10
packages/nc-gui-v2/components/cell/Duration.vue

@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, inject } from '#imports' import { computed, inject, ref } from '#imports'
import { ColumnInj } from '~/components' import { ColumnInj } from '~/components'
import { convertDurationToSeconds, convertMS2Duration, durationOptions } from '~/utils/durationHelper' import { convertDurationToSeconds, convertMS2Duration, durationOptions } from '~/utils/durationHelper'
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj) const column = inject(ColumnInj)
interface Props { interface Props {
modelValue: number | string modelValue: number | string
} }
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const showWarningMessage = ref(false) const showWarningMessage = ref(false)
const durationInMS = ref(0) const durationInMS = ref(0)
const isEdited = ref(false) const isEdited = ref(false)

97
packages/nc-gui-v2/components/dlg/VueCreate.vue

@ -0,0 +1,97 @@
<script setup lang="ts">
import { ViewTypes } from "nocodb-sdk";
import useViewCreate from "~/composables/useViewCreate";
const { modelValue, type } = defineProps<{ type: ViewTypes, modelValue: boolean }>();
const emit = defineEmits(["update:modelValue"]);
const dialogShow = computed({
get() {
return modelValue;
},
set(v) {
emit("update:modelValue", v);
}
});
const { view, createView, generateUniqueTitle } = useViewCreate(async () => {
});
/*import inflection from 'inflection'
export default {
name: 'DlgViewCreate',
props: ['value'],
data() {
return {
view: {
name: '',
},
}
},
computed: {
dialogShow: {
get() {
return this.value
},
set(v) {
this.$emit('input', v)
},
},
projectPrefix() {
return this.$store.getters['project/GtrProjectPrefix']
},
},
watch: {
'view.alias': function (v) {
this.$set(this.view, 'name', `${this.projectPrefix || ''}${inflection.underscore(v)}`)
},
},
mounted() {
setTimeout(() => {
this.$refs.input.$el.querySelector('input').focus()
}, 100)
},
}*/
</script>
<template>
<v-dialog v-model="dialogShow" max-width="500">
<v-card class="elevation-20">
<v-card-title class="grey darken-2 subheading" style="height: 30px" />
<v-card-text class="pt-4 pl-4">
<p class="headline">
{{ $t('general.create') }} <span class="text-capitalize">{{ typeAlias }}</span> {{ $t('objects.view') }}
</p>
<v-form ref="form" v-model="valid" @submit.prevent="createView">
<!-- label="View Name" -->
<v-text-field
ref="name"
v-model="view.title"
:label="$t('labels.viewName')"
:rules="[
v => !!v || 'View name required',
v => viewsList.every(v1 => (v1.alias || v1.title) !== v) || 'View name should be unique',
]"
autofocus
/>
</v-form>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn class="" small @click="$emit('input', false)">
{{ $t('general.cancel') }}
</v-btn>
<v-btn small :loading="loading" class="primary" :disabled="!valid" @click="createView">
{{ $t('general.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped></style>

1
packages/nc-gui-v2/components/index.ts

@ -18,3 +18,4 @@ export const ActiveViewInj: InjectionKey<Ref<(GridType | GalleryType | FormType
export const ReadonlyInj: InjectionKey<any> = Symbol('readonly-injection') export const ReadonlyInj: InjectionKey<any> = Symbol('readonly-injection')
export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-view-data-injection') export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-view-data-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection') export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const ViewListInj: InjectionKey<Ref<any[]>> = Symbol('view-list-injection')

119
packages/nc-gui-v2/components/smartsheet/Sidebar.vue

@ -1,15 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ViewTypes } from 'nocodb-sdk'
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { inject, onMounted, ref } from '#imports' import { inject, ref } from '#imports'
import { ActiveViewInj, MetaInj } from '~/components' import { ActiveViewInj, MetaInj } from '~/components'
import useViews from '~/composables/useViews' import useViews from '~/composables/useViews'
import { viewIcons } from '~/utils/viewUtils' import { viewIcons } from '~/utils/viewUtils'
import MdiPlusIcon from '~icons/mdi/plus'
const meta = inject(MetaInj) const meta = inject(MetaInj)
const activeView = inject(ActiveViewInj) const activeView = inject(ActiveViewInj)
const { views, loadViews } = useViews(meta as Ref<TableType>) const { views, loadViews } = useViews(meta as Ref<TableType>)
provide(ViewListInj, views)
const _isUIAllowed = (view: string) => {} const _isUIAllowed = (view: string) => {}
loadViews().then(() => { loadViews().then(() => {
@ -17,6 +22,15 @@ loadViews().then(() => {
}) })
const toggleDrawer = ref(false) const toggleDrawer = ref(false)
// todo: identify based on meta
const isView = ref(false)
const viewCreateType = ref<ViewTypes>()
const viewCreateDlg = ref<boolean>()
const openCreateViewDlg = (type: ViewTypes) => {
viewCreateDlg.value = true
viewCreateType.value = type
}
</script> </script>
<template> <template>
@ -144,8 +158,111 @@ const toggleDrawer = ref(false)
<!-- </draggable> --> <!-- </draggable> -->
<!-- </v-list-group> --> <!-- </v-list-group> -->
</v-list> </v-list>
<v-divider class="advance-menu-divider" />
<v-list dense>
<v-list-item dense>
<!-- Create a View -->
<span class="body-2 font-weight-medium" @dblclick="enableDummyFeat = true">
{{ $t('activity.createView') }}
</span>
<v-tooltip top>
<template #activator="{ on }">
<!-- <x-icon -->
<!-- color="pink textColor" -->
<!-- icon-class="ml-2" -->
<!-- small -->
<!-- v-on="on" -->
<!-- @mouseenter="overShieldIcon = true" -->
<!-- @mouseleave="overShieldIcon = false" -->
<!-- > -->
<!-- mdi-shield-lock-outline -->
<!-- </x-icon> -->
</template>
<!-- Only visible to Creator -->
<span class="caption">
{{ $t('msg.info.onlyCreator') }}
</span>
</v-tooltip>
</v-list-item>
<v-tooltip bottom>
<template #activator="{ on }">
<v-list-item dense class="body-2 nc-create-grid-view" v-on="on" @click="openCreateViewDlg(viewTypes.GRID)">
<!-- <v-list-item-icon class="mr-n1"> -->
<component :is="viewIcons[ViewTypes.GRID].icon" :class="`text-${viewIcons[ViewTypes.GRID].color} mr-1`" />
<!-- </v-list-item-icon> -->
<v-list-item-title>
<span class="font-weight-regular">
<!-- Grid -->
{{ $t('objects.viewType.grid') }}
</span>
</v-list-item-title>
<v-spacer />
<MdiPlusIcon class="mr-1" />
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> -->
</v-list-item>
</template>
<!-- Add Grid View -->
{{ $t('msg.info.addView.grid') }}
</v-tooltip>
<v-tooltip bottom>
<template #activator="{ on }">
<v-list-item dense class="body-2 nc-create-gallery-view" v-on="on" @click="openCreateViewDlg(viewTypes.GALLERY)">
<!-- <v-list-item-icon class="mr-n1"> -->
<component :is="viewIcons[ViewTypes.GALLERY].icon" :class="`text-${viewIcons[ViewTypes.GALLERY].color} mr-1`" />
<!-- </v-list-item-icon> -->
<v-list-item-title>
<span class="font-weight-regular">
<!-- Gallery -->
{{ $t('objects.viewType.gallery') }}
</span>
</v-list-item-title>
<v-spacer />
<MdiPlusIcon class="mr-1" />
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> -->
</v-list-item>
</template>
<!-- Add Gallery View -->
{{ $t('msg.info.addView.gallery') }}
</v-tooltip>
<v-tooltip bottom>
<template #activator="{ on }">
<v-list-item
v-if="!isView"
dense
class="body-2 nc-create-form-view"
v-on="on"
@click="openCreateViewDlg(viewTypes.FORM)"
>
<!-- <v-list-item-icon class="mr-n1"> -->
<component :is="viewIcons[ViewTypes.FORM].icon" :class="`text-${viewIcons[ViewTypes.FORM].color} mr-1`" />
<!-- </v-list-item-icon> -->
<v-list-item-title>
<span class="font-weight-regular">
<!-- Form -->
{{ $t('objects.viewType.form') }}
</span>
</v-list-item-title>
<v-spacer />
<MdiPlusIcon class="mr-1" />
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> -->
</v-list-item>
</template>
<!-- Add Form View -->
{{ $t('msg.info.addView.form') }}
</v-tooltip>
</v-list>
</div> </div>
</div> </div>
<DlgViewCreate v-model="viewCreateDlg" :type="viewCreateType" />
</div> </div>
</template> </template>

41
packages/nc-gui-v2/composables/useViewCreate.ts

@ -0,0 +1,41 @@
import type { ViewTypes } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { useNuxtApp } from '#app'
export default (onViewCreate?: (viewMeta: any) => void) => {
const view = reactive<{ title: string; type?: ViewTypes }>({
title: '',
})
const { $api } = useNuxtApp()
const createView = async () => {
if (!sqlUi?.value) return
const columns = sqlUi?.value?.getNewTableColumns().filter((col) => {
if (col.column_name === 'id' && table.columns.includes('id_ag')) {
Object.assign(col, sqlUi?.value?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG'))
col.dtxp = sqlUi?.value?.getDefaultLengthForDatatype(col.dt)
col.dtxs = sqlUi?.value?.getDefaultScaleForDatatype(col.dt)
return true
}
return table.columns.includes(col.column_name)
})
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
...table,
columns,
})
onViewCreate?.(tableMeta)
}
const generateUniqueTitle = () => {
// let c = 1
// while (tables?.value?.some((t) => t.title === `Sheet${c}`)) {
// c++
// }
// table.title = `Sheet${c}`
}
return { view, createView, generateUniqueTitle }
}

366
packages/nc-gui-v2/utils/durationHelper.ts

@ -1,193 +1,195 @@
export const durationOptions = [ export const durationOptions = [
{ {
id: 0, id: 0,
title: 'h:mm', title: 'h:mm',
example: '(e.g. 1:23)', example: '(e.g. 1:23)',
regex: /(\d+)(?::(\d+))?/ regex: /(\d+)(?::(\d+))?/,
}, { },
id: 1, {
title: 'h:mm:ss', id: 1,
example: '(e.g. 3:45, 1:23:40)', title: 'h:mm:ss',
regex: /(\d+)?(?::(\d+))?(?::(\d+))?/ example: '(e.g. 3:45, 1:23:40)',
}, { regex: /(\d+)?(?::(\d+))?(?::(\d+))?/,
id: 2, },
title: 'h:mm:ss.s', {
example: '(e.g. 3:34.6, 1:23:40.0)', id: 2,
regex: /(\d+)?(?::(\d+))?(?::(\d+))?(?:.(\d{0,4})?)?/ title: 'h:mm:ss.s',
}, { example: '(e.g. 3:34.6, 1:23:40.0)',
id: 3, regex: /(\d+)?(?::(\d+))?(?::(\d+))?(?:.(\d{0,4})?)?/,
title: 'h:mm:ss.ss', },
example: '(e.g. 3.45.67, 1:23:40.00)', {
regex: /(\d+)?(?::(\d+))?(?::(\d+))?(?:.(\d{0,4})?)?/ id: 3,
}, { title: 'h:mm:ss.ss',
id: 4, example: '(e.g. 3.45.67, 1:23:40.00)',
title: 'h:mm:ss.sss', regex: /(\d+)?(?::(\d+))?(?::(\d+))?(?:.(\d{0,4})?)?/,
example: '(e.g. 3.45.678, 1:23:40.000)', },
regex: /(\d+)?(?::(\d+))?(?::(\d+))?(?:.(\d{0,4})?)?/ {
} id: 4,
] title: 'h:mm:ss.sss',
example: '(e.g. 3.45.678, 1:23:40.000)',
// pad zero regex: /(\d+)?(?::(\d+))?(?::(\d+))?(?:.(\d{0,4})?)?/,
// mm && ss },
// e.g. 3 -> 03 ]
// e.g. 12 -> 12
// sss // pad zero
// e.g. 1 -> 001 // mm && ss
// e.g. 10 -> 010 // e.g. 3 -> 03
const padZero = (val: number, isSSS = false) => { // e.g. 12 -> 12
return (val + '').padStart(isSSS ? 3 : 2, '0') // sss
// e.g. 1 -> 001
// e.g. 10 -> 010
const padZero = (val: number, isSSS = false) => {
return `${val}`.padStart(isSSS ? 3 : 2, '0')
}
export const convertMS2Duration = (val: any, durationType: number) => {
if (val === '' || val === null || val === undefined) {
return val
}
// 600.000 s --> 10:00 (10 mins)
const milliseconds = Math.round((val % 1) * 1000)
const centiseconds = Math.round(milliseconds / 10)
const deciseconds = Math.round(centiseconds / 10)
const hours = Math.floor(parseInt(val, 10) / (60 * 60))
const minutes = Math.floor((parseInt(val, 10) - hours * 60 * 60) / 60)
const seconds = parseInt(val, 10) - hours * 60 * 60 - minutes * 60
if (durationType === 0) {
// h:mm
return `${padZero(hours)}:${padZero(minutes + (seconds >= 30 ? 1 : 0))}`
} else if (durationType === 1) {
// h:mm:ss
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}`
} else if (durationType === 2) {
// h:mm:ss.s
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}.${deciseconds}`
} else if (durationType === 3) {
// h:mm:ss.ss
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}.${padZero(centiseconds)}`
} else if (durationType === 4) {
// h:mm:ss.sss
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}.${padZero(milliseconds, true)}`
}
return val
}
export const convertDurationToSeconds = (val: any, durationType: number) => {
// 10:00 (10 mins) -> 600.000 s
const res = {
_sec: 0,
_isValid: true,
} }
const durationRegex = durationOptions[durationType].regex
export const convertMS2Duration = (val: any, durationType: number) => { if (durationRegex.test(val)) {
if (val === "" || val === null || val === undefined) { return val } let h, mm, ss
// 600.000 s --> 10:00 (10 mins) const groups = val.match(durationRegex)
const milliseconds = Math.round((val % 1) * 1000) if (groups[0] && groups[1] && !groups[2] && !groups[3] && !groups[4]) {
const centiseconds = Math.round(milliseconds / 10) const val = parseInt(groups[1], 10)
const deciseconds = Math.round(centiseconds / 10) if (groups.input.slice(-1) === ':') {
const hours = Math.floor(parseInt(val, 10) / (60 * 60)) // e.g. 30:
const minutes = Math.floor((parseInt(val, 10) - (hours * 60 * 60)) / 60) h = groups[1]
const seconds = parseInt(val, 10) - (hours * 60 * 60) - (minutes * 60) mm = 0
ss = 0
} else if (durationType === 0) {
// consider it as minutes
// e.g. 360 -> 06:00
h = Math.floor(val / 60)
mm = Math.floor(val - (h * 3600) / 60)
ss = 0
} else {
// consider it as seconds
// e.g. 3600 -> 01:00:00
h = Math.floor(groups[1] / 3600)
mm = Math.floor(groups[1] / 60) % 60
ss = val % 60
}
} else if (durationType !== 0 && groups[1] && groups[2] && !groups[3]) {
// 10:10 means mm:ss instead of h:mm
// 10:10:10 means h:mm:ss
h = 0
mm = groups[1]
ss = groups[2]
} else {
h = groups[1] || 0
mm = groups[2] || 0
ss = groups[3] || 0
}
if (durationType === 0) { if (durationType === 0) {
// h:mm // h:mm
return `${padZero(hours)}:${padZero(minutes + (seconds >= 30 ? 1 : 0))}` res._sec = h * 3600 + mm * 60
} else if (durationType === 1) { } else if (durationType === 1) {
// h:mm:ss // h:mm:ss
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}` res._sec = h * 3600 + mm * 60 + ss * 1
} else if (durationType === 2) { } else if (durationType === 2) {
// h:mm:ss.s // h:mm:ss.s (deciseconds)
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}.${deciseconds}` const ds = groups[4] || 0
const len = (Math.log(ds) * Math.LOG10E + 1) | 0
const ms =
// e.g. len = 4: 1234 -> 1, 1456 -> 1
// e.g. len = 3: 123 -> 1, 191 -> 2
// e.g. len = 2: 12 -> 1 , 16 -> 2
(len === 4
? Math.round(ds / 1000)
: len === 3
? Math.round(ds / 100)
: len === 2
? Math.round(ds / 10)
: // take whatever it is
ds) * 100
res._sec = h * 3600 + mm * 60 + ss * 1 + ms / 1000
} else if (durationType === 3) { } else if (durationType === 3) {
// h:mm:ss.ss // h:mm:ss.ss (centiseconds)
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}.${padZero(centiseconds)}` const cs = groups[4] || 0
const len = (Math.log(cs) * Math.LOG10E + 1) | 0
const ms =
// e.g. len = 4: 1234 -> 12, 1285 -> 13
// e.g. len = 3: 123 -> 12, 128 -> 13
// check the third digit
(len === 4
? Math.round(cs / 100)
: len === 3
? Math.round(cs / 10)
: // take whatever it is
cs) * 10
res._sec = h * 3600 + mm * 60 + ss * 1 + ms / 1000
} else if (durationType === 4) { } else if (durationType === 4) {
// h:mm:ss.sss // h:mm:ss.sss (milliseconds)
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}.${padZero(milliseconds, true)}` let ms = groups[4] || 0
} const len = (Math.log(ms) * Math.LOG10E + 1) | 0
return val ms =
} // e.g. 1235 -> 124
// e.g. 1234 -> 123
export const convertDurationToSeconds = (val: any, durationType: number) => { (len === 4
// 10:00 (10 mins) -> 600.000 s ? Math.round(ms / 10)
const res = { : // take whatever it is
_sec: 0, ms) * 1
_isValid: true res._sec = h * 3600 + mm * 60 + ss * 1 + ms / 1000
}
const durationRegex = durationOptions[durationType].regex
if (durationRegex.test(val)) {
let h, mm, ss
const groups = val.match(durationRegex)
if (groups[0] && groups[1] && !groups[2] && !groups[3] && !groups[4]) {
const val = parseInt(groups[1], 10)
if (groups.input.slice(-1) === ':') {
// e.g. 30:
h = groups[1]
mm = 0
ss = 0
} else if (durationType === 0) {
// consider it as minutes
// e.g. 360 -> 06:00
h = Math.floor(val / 60)
mm = Math.floor((val - ((h * 3600)) / 60))
ss = 0
} else {
// consider it as seconds
// e.g. 3600 -> 01:00:00
h = Math.floor(groups[1] / 3600)
mm = Math.floor(groups[1] / 60) % 60
ss = val % 60
}
} else if (durationType !== 0 && groups[1] && groups[2] && !groups[3]) {
// 10:10 means mm:ss instead of h:mm
// 10:10:10 means h:mm:ss
h = 0
mm = groups[1]
ss = groups[2]
} else {
h = groups[1] || 0
mm = groups[2] || 0
ss = groups[3] || 0
}
if (durationType === 0) {
// h:mm
res._sec = h * 3600 + mm * 60
} else if (durationType === 1) {
// h:mm:ss
res._sec = h * 3600 + mm * 60 + ss * 1
} else if (durationType === 2) {
// h:mm:ss.s (deciseconds)
const ds = groups[4] || 0
const len = Math.log(ds) * Math.LOG10E + 1 | 0
const ms = (
// e.g. len = 4: 1234 -> 1, 1456 -> 1
// e.g. len = 3: 123 -> 1, 191 -> 2
// e.g. len = 2: 12 -> 1 , 16 -> 2
len === 4
? Math.round(ds / 1000)
: len === 3
? Math.round(ds / 100)
: len === 2
? Math.round(ds / 10)
// take whatever it is
: ds
) * 100
res._sec = h * 3600 + mm * 60 + ss * 1 + ms / 1000
} else if (durationType === 3) {
// h:mm:ss.ss (centiseconds)
const cs = groups[4] || 0
const len = Math.log(cs) * Math.LOG10E + 1 | 0
const ms = (
// e.g. len = 4: 1234 -> 12, 1285 -> 13
// e.g. len = 3: 123 -> 12, 128 -> 13
// check the third digit
len === 4
? Math.round(cs / 100)
: len === 3
? Math.round(cs / 10)
// take whatever it is
: cs
) * 10
res._sec = h * 3600 + mm * 60 + ss * 1 + ms / 1000
} else if (durationType === 4) {
// h:mm:ss.sss (milliseconds)
let ms = groups[4] || 0
const len = Math.log(ms) * Math.LOG10E + 1 | 0
ms = (
// e.g. 1235 -> 124
// e.g. 1234 -> 123
len === 4
? Math.round(ms / 10)
// take whatever it is
: ms
) * 1
res._sec = h * 3600 + mm * 60 + ss * 1 + ms / 1000
}
} else {
res._isValid = false
} }
return res } else {
res._isValid = false
} }
return res
/** }
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
* /**
* @author Wing-Kam Wong <wingkwong.code@gmail.com> * @copyright Copyright (c) 2021, Xgene Cloud Ltd
* *
* @license GNU AGPL version 3 or any later version * @author Wing-Kam Wong <wingkwong.code@gmail.com>
* *
* This program is free software: you can redistribute it and/or modify * @license GNU AGPL version 3 or any later version
* it under the terms of the GNU Affero General Public License as *
* published by the Free Software Foundation, either version 3 of the * This program is free software: you can redistribute it and/or modify
* License, or (at your option) any later version. * it under the terms of the GNU Affero General Public License as
* * published by the Free Software Foundation, either version 3 of the
* This program is distributed in the hope that it will be useful, * License, or (at your option) any later version.
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * This program is distributed in the hope that it will be useful,
* GNU Affero General Public License for more details. * but WITHOUT ANY WARRANTY; without even the implied warranty of
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* You should have received a copy of the GNU Affero General Public License * GNU Affero General Public License for more details.
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* * 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/>.
*
*/

Loading…
Cancel
Save