Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Adrian Simmons 2021-12-09 10:32:46 +00:00
commit 7187cde253
46 changed files with 1316 additions and 1163 deletions

View File

@ -19,16 +19,16 @@
"dependencies": { "dependencies": {
"@github/hotkey": "1.6.0", "@github/hotkey": "1.6.0",
"@kyvg/vue3-notification": "2.3.4", "@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.15.0", "@sentry/tracing": "6.16.0",
"@sentry/vue": "6.15.0", "@sentry/vue": "6.16.0",
"@vue/compat": "3.2.23", "@vue/compat": "3.2.24",
"@vueuse/core": "7.1.2", "@vueuse/core": "7.2.2",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"camel-case": "4.1.2", "camel-case": "4.1.2",
"codemirror": "5.64.0", "codemirror": "5.64.0",
"copy-to-clipboard": "3.3.1", "copy-to-clipboard": "3.3.1",
"date-fns": "2.27.0", "date-fns": "2.27.0",
"dompurify": "2.3.3", "dompurify": "2.3.4",
"easymde": "2.15.0", "easymde": "2.15.0",
"flatpickr": "4.6.9", "flatpickr": "4.6.9",
"flexsearch": "0.7.21", "flexsearch": "0.7.21",
@ -36,20 +36,20 @@
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"marked": "4.0.5", "marked": "4.0.6",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"snake-case": "3.0.4", "snake-case": "3.0.4",
"ufo": "0.7.9", "ufo": "0.7.9",
"v-tooltip": "4.0.0-beta.2", "v-tooltip": "4.0.0-beta.2",
"vue": "3.2.23", "vue": "3.2.24",
"vue-advanced-cropper": "2.7.0", "vue-advanced-cropper": "2.7.0",
"vue-drag-resize": "2.0.3", "vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5", "vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.22", "vue-i18n": "9.2.0-beta.23",
"vue-router": "4.0.12", "vue-router": "4.0.12",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
"vuex": "4.0.2", "vuex": "4.0.2",
"workbox-precaching": "6.4.1" "workbox-precaching": "6.4.2"
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0", "@4tw/cypress-drag-drop": "2.1.0",
@ -59,37 +59,37 @@
"@fortawesome/vue-fontawesome": "3.0.0-5", "@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "0.7.2", "@types/flexsearch": "0.7.2",
"@types/jest": "27.0.3", "@types/jest": "27.0.3",
"@typescript-eslint/eslint-plugin": "5.5.0", "@typescript-eslint/eslint-plugin": "5.6.0",
"@typescript-eslint/parser": "5.5.0", "@typescript-eslint/parser": "5.6.0",
"@vitejs/plugin-legacy": "1.6.3", "@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "1.10.1", "@vitejs/plugin-vue": "1.10.2",
"@vue/eslint-config-typescript": "9.1.0", "@vue/eslint-config-typescript": "9.1.0",
"autoprefixer": "10.4.0", "autoprefixer": "10.4.0",
"axios": "0.24.0", "axios": "0.24.0",
"browserslist": "4.18.1", "browserslist": "4.18.1",
"cypress": "8.7.0", "cypress": "8.7.0",
"cypress-file-upload": "5.0.8", "cypress-file-upload": "5.0.8",
"esbuild": "0.14.1", "esbuild": "0.14.2",
"eslint": "8.3.0", "eslint": "8.4.1",
"eslint-plugin-vue": "8.1.1", "eslint-plugin-vue": "8.2.0",
"express": "4.17.1", "express": "4.17.1",
"faker": "5.5.3", "faker": "5.5.3",
"jest": "27.4.2", "jest": "27.4.3",
"netlify-cli": "8.0.6", "netlify-cli": "8.0.16",
"postcss": "8.4.4", "postcss": "8.4.4",
"postcss-preset-env": "7.0.1", "postcss-preset-env": "7.0.1",
"rollup": "2.60.2", "rollup": "2.60.2",
"rollup-plugin-visualizer": "5.5.2", "rollup-plugin-visualizer": "5.5.2",
"sass": "1.44.0", "sass": "1.44.0",
"slugify": "1.6.3", "slugify": "1.6.3",
"ts-jest": "27.0.7", "ts-jest": "27.1.1",
"typescript": "4.5.2", "typescript": "4.5.2",
"vite": "2.6.14", "vite": "2.7.1",
"vite-plugin-pwa": "0.11.9", "vite-plugin-pwa": "0.11.10",
"vite-svg-loader": "3.1.0", "vite-svg-loader": "3.1.0",
"vue-tsc": "0.29.8", "vue-tsc": "0.29.8",
"wait-on": "6.0.0", "wait-on": "6.0.0",
"workbox-cli": "6.4.1" "workbox-cli": "6.4.2"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

2
run.sh
View File

@ -4,7 +4,7 @@
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}" VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}" VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://7e684483a06a4225b3e05cc47cae7a11@sentry.kolaente.de/2"}" VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}" VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}" VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"

View File

@ -20,64 +20,59 @@
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}" :class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)" @click.stop="toggleFavoriteList(list)"
class="favorite"> class="favorite">
<icon icon="star" v-if="list.isFavorite"/> <icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
<icon :icon="['far', 'star']" v-else/>
</span> </span>
</div> </div>
<div class="title">{{ list.title }}</div> <div class="title">{{ list.title }}</div>
</router-link> </router-link>
</template> </template>
<script> <script lang="ts" setup>
import {ref, watch} from 'vue'
import {useStore} from 'vuex'
import ListService from '@/services/list' import ListService from '@/services/list'
export default { const background = ref(null)
name: 'list-card', const backgroundLoading = ref(false)
data() {
return {
background: null,
backgroundLoading: false,
}
},
props: {
list: {
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
},
watch: {
list: {
handler: 'loadBackground',
immediate: true,
},
},
methods: {
async loadBackground() {
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
return
}
this.backgroundLoading = true const props = defineProps({
list: {
const listService = new ListService() type: Object,
try { required: true,
this.background = await listService.background(this.list)
} finally {
this.backgroundLoading = false
}
},
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
},
}, },
showArchived: {
default: false,
type: Boolean,
},
})
watch(props.list, loadBackground, { immediate: true })
async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
return
}
backgroundLoading.value = true
const listService = new ListService()
try {
background.value = await listService.background(props.list)
} finally {
backgroundLoading.value = false
}
}
const store = useStore()
function toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
store.dispatch('lists/toggleListFavorite', list)
} }
</script> </script>

View File

@ -1,40 +1,27 @@
<template> <template>
<div <div
v-if="isDone" v-if="isDone"
class="is-done" class="is-done"
:class="{ 'is-done--small': variant === variants.SMALL }" :class="{ 'is-done--small': variant === 'small' }"
> >
{{ $t('task.attributes.done') }} {{ $t('task.attributes.done') }}
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
const VARIANTS = { import {PropType} from 'vue'
DEFAULT: 'default', type Variants = 'default' | 'small'
SMALL: 'small',
}
export default { defineProps({
name: 'Done', isDone: {
type: Boolean,
data() { default: false,
return {
variants: VARIANTS,
}
}, },
variant: {
props: { type: String as PropType<Variants>,
isDone: { default: 'default',
type: Boolean,
default: false,
},
variant: {
type: String,
default: VARIANTS.DEFAULT,
validator: (variant) => Object.values(VARIANTS).includes(variant),
},
}, },
} })
</script> </script>

View File

@ -24,41 +24,39 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
export default { defineProps({
name: 'card', title: {
props: { type: String,
title: { default: '',
type: String,
default: '',
},
padding: {
type: Boolean,
default: true,
},
hasClose: {
type: Boolean,
default: false,
},
closeIcon: {
type: String,
default: 'times',
},
shadow: {
type: Boolean,
default: true,
},
hasContent: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
}, },
emits: ['close'], padding: {
} type: Boolean,
default: true,
},
hasClose: {
type: Boolean,
default: false,
},
closeIcon: {
type: String,
default: 'times',
},
shadow: {
type: Boolean,
default: true,
},
hasContent: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
})
defineEmits(['close'])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -11,18 +11,15 @@
</router-link> </router-link>
</template> </template>
<script> <script lang="ts" setup>
export default { defineProps({
name: 'dropdown-item', to: {
props: { required: true,
to: {
required: true,
},
icon: {
type: String,
required: false,
default: '',
},
}, },
} icon: {
type: String,
required: false,
default: '',
},
})
</script> </script>

View File

@ -7,16 +7,10 @@
</message> </message>
</template> </template>
<script> <script lang="ts" setup>
import Message from '@/components/misc/message' import Message from '@/components/misc/message.vue'
export default { function reload() {
name: 'error', window.location.reload()
components: {Message},
methods: {
reload() {
window.location.reload()
},
},
} }
</script> </script>

View File

@ -6,16 +6,14 @@
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
import {mapState} from 'vuex' import {computed} from 'vue'
import {useStore} from 'vuex'
export default { const store = useStore()
name: 'legal',
computed: mapState({ const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
imprintUrl: state => state.config.legal.imprintUrl, const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
}),
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -2,12 +2,6 @@
<div class="loader-container is-loading"></div> <div class="loader-container is-loading"></div>
</template> </template>
<script>
export default {
name: 'loading',
}
</script>
<style scoped> <style scoped>
.loader-container { .loader-container {
height: 100%; height: 100%;

View File

@ -4,7 +4,7 @@
</div> </div>
</template> </template>
<script setup> <script lang="ts" setup>
defineProps({ defineProps({
variant: { variant: {
type: String, type: String,

View File

@ -2,17 +2,17 @@
<div class="no-auth-wrapper"> <div class="no-auth-wrapper">
<div class="noauth-container"> <div class="noauth-container">
<Logo class="logo" width="400" height="117" /> <Logo class="logo" width="400" height="117" />
<message v-if="motd !== ''" class="my-2"> <Message v-if="motd !== ''" class="my-2">
{{ motd }} {{ motd }}
</message> </Message>
<slot/> <slot/>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script lang="ts" setup>
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import message from '@/components/misc/message' import Message from '@/components/misc/message.vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import {computed} from 'vue' import {computed} from 'vue'

View File

@ -34,8 +34,10 @@
</nav> </nav>
</template> </template>
<script> <script lang="ts" setup>
function createPagination(totalPages, currentPage) { import {computed} from 'vue'
function createPagination(totalPages: number, currentPage: number) {
const pages = [] const pages = []
for (let i = 0; i < totalPages; i++) { for (let i = 0; i < totalPages; i++) {
@ -79,30 +81,18 @@ function getRouteForPagination(page = 1, type = 'list') {
} }
} }
export default { const props = defineProps({
name: 'Pagination', totalPages: {
type: Number,
props: { required: true,
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
default: 0,
},
}, },
currentPage: {
computed: { type: Number,
pages() { default: 0,
return createPagination(this.totalPages, this.currentPage)
},
}, },
})
methods: { const pages = computed(() => createPagination(props.totalPages, props.currentPage))
getRouteForPagination,
},
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -13,7 +13,7 @@
<section v-else-if="error !== ''"> <section v-else-if="error !== ''">
<no-auth-wrapper> <no-auth-wrapper>
<card> <card>
<p v-if="error === errorNoApiUrl"> <p v-if="error === ERROR_NO_API_URL">
{{ $t('ready.noApiUrlConfigured') }} {{ $t('ready.noApiUrlConfigured') }}
</p> </p>
<message variant="danger" v-else> <message variant="danger" v-else>
@ -40,51 +40,34 @@
</transition> </transition>
</template> </template>
<script> <script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import Logo from '@/assets/logo.svg?component' import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config' import ApiConfig from '@/components/misc/api-config.vue'
import Message from '@/components/misc/message' import Message from '@/components/misc/message.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper' import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {mapState} from 'vuex'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl' import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
export default { const store = useStore()
name: 'ready',
components: { const ready = computed(() => store.state.vikunjaReady)
Message, const online = computed(() => store.state.online)
Logo,
NoAuthWrapper, const error = ref('')
ApiConfig, const showLoading = computed(() => !ready.value && error.value === '')
},
data() { async function load() {
return { try {
error: '', await store.dispatch('loadApp')
errorNoApiUrl: ERROR_NO_API_URL, } catch(e: any) {
} error.value = e
}, }
created() {
this.load()
},
computed: {
ready() {
return this.$store.state.vikunjaReady
},
showLoading() {
return !this.ready && this.error === ''
},
...mapState([
'online',
]),
},
methods: {
load() {
this.$store.dispatch('loadApp')
.catch(e => {
this.error = e
})
},
},
} }
load()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -7,24 +7,21 @@
</component> </component>
</template> </template>
<script> <script lang="ts" setup>
export default { defineProps({
name: 'shortcut', keys: {
props: { type: Array,
keys: { required: true,
type: Array,
required: true,
},
combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
}, },
} combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -22,91 +22,89 @@
</a> </a>
</template> </template>
<script> <script lang="ts" setup>
import {computed, shallowRef} from 'vue'
import {useI18n} from 'vue-i18n'
import SubscriptionService from '@/services/subscription' import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription' import SubscriptionModel from '@/models/subscription'
export default { import {success} from '@/message'
name: 'task-subscription',
data() {
return {
subscriptionService: new SubscriptionService(),
}
},
props: {
entity: {
required: true,
type: String,
},
subscription: {
required: true,
},
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
},
emits: ['change'],
computed: {
tooltipText() {
if (this.disabled) {
return this.$t('task.subscription.subscribedThroughParent', {
entity: this.entity,
parent: this.subscription.entity,
})
}
return this.subscription !== null ? const props = defineProps({
this.$t('task.subscription.subscribed', {entity: this.entity}) : entity: {
this.$t('task.subscription.notSubscribed', {entity: this.entity}) required: true,
}, type: String,
buttonText() {
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
},
icon() {
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
},
disabled() {
if (this.subscription === null) {
return false
}
return this.subscription.entity !== this.entity
},
}, },
methods: { subscription: {
changeSubscription() { required: true,
if (this.disabled) {
return
}
if (this.subscription === null) {
this.subscribe()
} else {
this.unsubscribe()
}
},
async subscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
await this.subscriptionService.create(subscription)
this.$emit('change', subscription)
this.$message.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
},
async unsubscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
await this.subscriptionService.delete(subscription)
this.$emit('change', null)
this.$message.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
},
}, },
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n()
const tooltipText = computed(() => {
if (disabled.value) {
return t('task.subscription.subscribedThroughParent', {
entity: props.entity,
parent: props.subscription.entity,
})
}
return props.subscription !== null ?
t('task.subscription.subscribed', {entity: props.entity}) :
t('task.subscription.notSubscribed', {entity: props.entity})
})
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => {
if (props.subscription === null) {
return false
}
return props.subscription.entity !== props.entity
})
function changeSubscription() {
if (disabled.value) {
return
}
if (props.subscription === null) {
subscribe()
} else {
unsubscribe()
}
}
async function subscribe() {
const subscription = new SubscriptionModel({
entity: props.entity,
entityId: props.entityId,
})
await subscriptionService.value.create(subscription)
emit('change', subscription)
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
}
async function unsubscribe() {
const subscription = new SubscriptionModel({
entity: props.entity,
entityId: props.entityId,
})
await subscriptionService.value.delete(subscription)
emit('change', null)
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
} }
</script> </script>

View File

@ -11,31 +11,28 @@
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
export default { defineProps({
name: 'user', user: {
props: { required: true,
user: { type: Object,
required: true,
type: Object,
},
showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
}, },
} showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -9,32 +9,23 @@
/> />
</template> </template>
<script> <script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
export default { const emit = defineEmits(['selected'])
name: 'namespace-search',
emits: ['selected'], const query = ref('')
data() {
return { const store = useStore()
query: '', const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
}
}, function findNamespaces(newQuery: string) {
components: { query.value = newQuery
Multiselect, }
},
computed: { function select(namespace) {
namespaces() { emit('selected', namespace)
return this.$store.getters['namespaces/searchNamespace'](this.query)
},
},
methods: {
findNamespaces(query) {
this.query = query
},
select(namespace) {
this.$emit('selected', namespace)
},
},
} }
</script> </script>

View File

@ -52,30 +52,22 @@
</dropdown> </dropdown>
</template> </template>
<script> <script setup lang="ts">
import {ref, onMounted} from 'vue'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue' import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue' import TaskSubscription from '@/components/misc/subscription.vue'
export default { const props = defineProps({
name: 'namespace-settings-dropdown', namespace: {
data() { type: Object, // NamespaceModel
return { required: true,
subscription: null,
}
}, },
components: { })
DropdownItem,
Dropdown, const subscription = ref(null)
TaskSubscription, onMounted(() => {
}, subscription.value = props.namespace.subscription
props: { })
namespace: {
required: true,
},
},
mounted() {
this.subscription = this.namespace.subscription
},
}
</script> </script>

View File

@ -3,7 +3,6 @@
<div class="filter-container"> <div class="filter-container">
<div class="items"> <div class="items">
<filter-popup <filter-popup
:visible="showTaskFilter"
v-model="params" v-model="params"
@update:modelValue="loadTasks()" @update:modelValue="loadTasks()"
/> />

View File

@ -65,6 +65,21 @@
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li> <li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul> </ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p> <p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
<li>Every 6 months</li>
<li>Every year</li>
<li>Every 2 years</li>
</ul>
</card> </card>
</modal> </modal>
</div> </div>

View File

@ -21,6 +21,7 @@ Here's some text in between
expect(checkboxes[0]).toBe(0) expect(checkboxes[0]).toBe(0)
expect(checkboxes[1]).toBe(18) expect(checkboxes[1]).toBe(18)
expect(checkboxes[2]).toBe(69) expect(checkboxes[2]).toBe(69)
expect(checkboxes[3]).toBe(90)
}) })
it('should find one checkbox with *', () => { it('should find one checkbox with *', () => {
const text: string = '* [ ] Lorem Ipsum' const text: string = '* [ ] Lorem Ipsum'

View File

@ -40,7 +40,7 @@ export const findCheckboxesInText = (text: string): number[] => {
return [ return [
...checkboxes.checked, ...checkboxes.checked,
...checkboxes.unchecked, ...checkboxes.unchecked,
].sort() ].sort((a, b) => a < b ? -1 : 1)
} }
export const getChecklistStatistics = (text: string): CheckboxStatistics => { export const getChecklistStatistics = (text: string): CheckboxStatistics => {

View File

@ -135,18 +135,18 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
if (result === null) { if (result === null) {
// 3. Try parsing the date as "27/01" or "01/27" // 3. Try parsing the date as "27/01" or "01/27"
const monthNumericRegex:RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig const monthNumericRegex: RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig
results = monthNumericRegex.exec(text) results = monthNumericRegex.exec(text)
// Put the year before or after the date, depending on what works // Put the year before or after the date, depending on what works
result = results === null ? null : `${now.getFullYear()}/${results[0]}` result = results === null ? null : `${now.getFullYear()}/${results[0]}`
if(result === null) { if (result === null) {
return { return {
foundText, foundText,
date: null, date: null,
} }
} }
foundText = results === null ? '' : results[0] foundText = results === null ? '' : results[0]
if (result === null || isNaN(new Date(result).getTime())) { if (result === null || isNaN(new Date(result).getTime())) {
result = results === null ? null : `${results[0]}/${now.getFullYear()}` result = results === null ? null : `${results[0]}/${now.getFullYear()}`
@ -280,7 +280,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
if (foundText.endsWith(' ')) { if (foundText.endsWith(' ')) {
foundText = foundText.substr(0, foundText.length - 1) foundText = foundText.substr(0, foundText.length - 1)
} }
return { return {
foundText: foundText, foundText: foundText,
date: date, date: date,
@ -297,19 +297,20 @@ const getDayFromText = (text: string) => {
} }
} }
const date = new Date() const now = new Date()
const date = new Date(now)
const day = parseInt(results[0]) const day = parseInt(results[0])
date.setDate(day) date.setDate(day)
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the // If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the
// date to the next month, but the first. // date to the next month, but the first.
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after // This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
// setting it for the first time and set it again if it isn't - that would mean the month overflowed. // setting it for the first time and set it again if it isn't - that would mean the month overflowed.
if(day === 31 && date.getDate() !== day) { if (day === 31 && date.getDate() !== day) {
date.setDate(day) date.setDate(day)
} }
if (date < new Date()) { if (date < now) {
date.setMonth(date.getMonth() + 1) date.setMonth(date.getMonth() + 1)
} }

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Iniciály", "initials": "Iniciály",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Mramor",
"upload": "Nahrát", "upload": "Nahrát",
"uploadAvatar": "Nahrát avatara", "uploadAvatar": "Nahrát avatara",
"statusUpdateSuccess": "Stav avatara byl úspěšně aktualizován!", "statusUpdateSuccess": "Stav avatara byl úspěšně aktualizován!",
@ -114,12 +115,12 @@
"vikunja": "Vikunja" "vikunja": "Vikunja"
}, },
"appearance": { "appearance": {
"title": "Color Scheme", "title": "Barevné schéma",
"setSuccess": "Saved change of color scheme to {colorScheme}", "setSuccess": "Změna barevného schématu na {colorScheme} byla uložena",
"colorScheme": { "colorScheme": {
"light": "Light", "light": "Světlý",
"system": "System", "system": "Systém",
"dark": "Dark" "dark": "Tmavý"
} }
} }
}, },
@ -471,7 +472,8 @@
"close": "Zavřít", "close": "Zavřít",
"download": "Stáhnout", "download": "Stáhnout",
"showMenu": "Zobrazit nabídku", "showMenu": "Zobrazit nabídku",
"hideMenu": "Skrýt nabídku" "hideMenu": "Skrýt nabídku",
"forExample": "Například:"
}, },
"input": { "input": {
"resetColor": "Obnovit barvu", "resetColor": "Obnovit barvu",
@ -556,7 +558,7 @@
"text2": "Tímto také odstraníte všechny přílohy, připomenutí a vztahy spojené s tímto úkolem a nelze je vrátit zpět!" "text2": "Tímto také odstraníte všechny přílohy, připomenutí a vztahy spojené s tímto úkolem a nelze je vrátit zpět!"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Přiřadit uživateli",
"label": "Přidat štítky", "label": "Přidat štítky",
"priority": "Nastavit prioritu", "priority": "Nastavit prioritu",
"dueDate": "Nastavit termín", "dueDate": "Nastavit termín",
@ -720,7 +722,9 @@
"dateWeekday": "každý pracovní den použije další datum s tímto datem", "dateWeekday": "každý pracovní den použije další datum s tímto datem",
"dateCurrentYear": "použije aktuální rok", "dateCurrentYear": "použije aktuální rok",
"dateNth": "použije {day}. den aktuálního měsíce", "dateNth": "použije {day}. den aktuálního měsíce",
"dateTime": "Pro nastavení času zkombinujte libovolný formát data s \"{time}\" (nebo {timePM})." "dateTime": "Pro nastavení času zkombinujte libovolný formát data s \"{time}\" (nebo {timePM}).",
"repeats": "Opakující se úkoly",
"repeatsDescription": "Chcete-li nastavit úkol jako opakující se v intervalu, stačí přidat '{suffix}' do textu úkolu. Množství musí být číslo a lze ji vynechat, aby byl použit pouze typ (viz příklady)."
} }
}, },
"team": { "team": {
@ -775,7 +779,7 @@
"task": { "task": {
"title": "Stránka úkolů", "title": "Stránka úkolů",
"done": "Označit úkol jako hotový", "done": "Označit úkol jako hotový",
"assign": "Assign to a user", "assign": "Přiřadit uživateli",
"labels": "Přidat štítky k tomuto úkolu", "labels": "Přidat štítky k tomuto úkolu",
"dueDate": "Změnit termín tohoto úkolu", "dueDate": "Změnit termín tohoto úkolu",
"attachment": "Přidat přílohu k tomuto úkolu", "attachment": "Přidat přílohu k tomuto úkolu",
@ -901,7 +905,7 @@
"5010": "Tento tým nemá k tomuto prostoru přístup.", "5010": "Tento tým nemá k tomuto prostoru přístup.",
"5011": "Tento uživatel již má přístup k tomuto prostoru.", "5011": "Tento uživatel již má přístup k tomuto prostoru.",
"5012": "Prostor je archivován, a proto je přístupný pouze pro čtení.", "5012": "Prostor je archivován, a proto je přístupný pouze pro čtení.",
"6001": "The team name cannot be empty.", "6001": "Název týmu nemůže být prázdný.",
"6002": "Tým neexistuje.", "6002": "Tým neexistuje.",
"6004": "Tým již má přístup k tomuto prostoru nebo seznamu.", "6004": "Tým již má přístup k tomuto prostoru nebo seznamu.",
"6005": "Uživatel je již členem tohoto týmu.", "6005": "Uživatel je již členem tohoto týmu.",

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initialen", "initials": "Initialen",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Hochladen", "upload": "Hochladen",
"uploadAvatar": "Avatar hochladen", "uploadAvatar": "Avatar hochladen",
"statusUpdateSuccess": "Avatar-Status wurde erfolgreich aktualisiert.", "statusUpdateSuccess": "Avatar-Status wurde erfolgreich aktualisiert.",
@ -471,7 +472,8 @@
"close": "Schließen", "close": "Schließen",
"download": "Herunterladen", "download": "Herunterladen",
"showMenu": "Menü anzeigen", "showMenu": "Menü anzeigen",
"hideMenu": "Menü ausblenden" "hideMenu": "Menü ausblenden",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Farbe zurücksetzen", "resetColor": "Farbe zurücksetzen",
@ -720,7 +722,9 @@
"dateWeekday": "jeder Wochentag, wird das nächste Datum mit diesem Tag verwenden", "dateWeekday": "jeder Wochentag, wird das nächste Datum mit diesem Tag verwenden",
"dateCurrentYear": "wird das laufende Jahr nutzen", "dateCurrentYear": "wird das laufende Jahr nutzen",
"dateNth": "wird den {day}. des aktuellen Monats verwenden", "dateNth": "wird den {day}. des aktuellen Monats verwenden",
"dateTime": "Kombiniere eines der Datumsformate mit \"{time}\" (oder {timePM}), um eine Zeit festzulegen." "dateTime": "Kombiniere eines der Datumsformate mit \"{time}\" (oder {timePM}), um eine Zeit festzulegen.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Herr Der Elemente", "title": "Herr Der Elemente",
"initials": "Initialä", "initials": "Initialä",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Ufeladä", "upload": "Ufeladä",
"uploadAvatar": "Profiilbild ufeladä", "uploadAvatar": "Profiilbild ufeladä",
"statusUpdateSuccess": "Avatar Zuestand erfolgriich aktualisiert!", "statusUpdateSuccess": "Avatar Zuestand erfolgriich aktualisiert!",
@ -471,7 +472,8 @@
"close": "Schlüüse", "close": "Schlüüse",
"download": "Herunterladen", "download": "Herunterladen",
"showMenu": "Menü anzeigen", "showMenu": "Menü anzeigen",
"hideMenu": "Menü ausblenden" "hideMenu": "Menü ausblenden",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Farb zruggsetze", "resetColor": "Farb zruggsetze",
@ -720,7 +722,9 @@
"dateWeekday": "jede Wuchetaag wird nimmt s'negste Datum mit dem Datum", "dateWeekday": "jede Wuchetaag wird nimmt s'negste Datum mit dem Datum",
"dateCurrentYear": "nimmt das laufende Jahr", "dateCurrentYear": "nimmt das laufende Jahr",
"dateNth": "nimmt de {day}ti vom jetzige Monet", "dateNth": "nimmt de {day}ti vom jetzige Monet",
"dateTime": "Kombiniere irgendeis vo dene Datumsformat mit \"{time}\" (oder {timePM}) um e Ziit z'setze." "dateTime": "Kombiniere irgendeis vo dene Datumsformat mit \"{time}\" (oder {timePM}) um e Ziit z'setze.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initiales", "initials": "Initiales",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Téléverser", "upload": "Téléverser",
"uploadAvatar": "Téléverser lavatar", "uploadAvatar": "Téléverser lavatar",
"statusUpdateSuccess": "Statut de lavatar mis à jour.", "statusUpdateSuccess": "Statut de lavatar mis à jour.",
@ -471,7 +472,8 @@
"close": "Fermer", "close": "Fermer",
"download": "Télécharger", "download": "Télécharger",
"showMenu": "Afficher le menu", "showMenu": "Afficher le menu",
"hideMenu": "Masquer le menu" "hideMenu": "Masquer le menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Réinitialiser la couleur", "resetColor": "Réinitialiser la couleur",
@ -720,7 +722,9 @@
"dateWeekday": "nimporte quel jour de la semaine, utilisera la date suivante avec cette date", "dateWeekday": "nimporte quel jour de la semaine, utilisera la date suivante avec cette date",
"dateCurrentYear": "utilisera lannée en cours", "dateCurrentYear": "utilisera lannée en cours",
"dateNth": "utilisera le {day}e du mois en cours", "dateNth": "utilisera le {day}e du mois en cours",
"dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure." "dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Iniziali", "initials": "Iniziali",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Carica", "upload": "Carica",
"uploadAvatar": "Carica Avatar", "uploadAvatar": "Carica Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Chiudi", "close": "Chiudi",
"download": "Scarica", "download": "Scarica",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Ripristina Colore", "resetColor": "Ripristina Colore",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Аватар", "title": "Аватар",
"initials": "Инициалы", "initials": "Инициалы",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Загрузить файл", "upload": "Загрузить файл",
"uploadAvatar": "Загрузить аватар", "uploadAvatar": "Загрузить аватар",
"statusUpdateSuccess": "Статус аватара обновлён.", "statusUpdateSuccess": "Статус аватара обновлён.",
@ -471,7 +472,8 @@
"close": "Закрыть", "close": "Закрыть",
"download": "Скачать", "download": "Скачать",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Сбросить цвет", "resetColor": "Сбросить цвет",
@ -720,7 +722,9 @@
"dateWeekday": "любой день недели, будет использовать следующую дату", "dateWeekday": "любой день недели, будет использовать следующую дату",
"dateCurrentYear": "текущий год", "dateCurrentYear": "текущий год",
"dateNth": "будет использовать {day}е текущего месяца", "dateNth": "будет использовать {day}е текущего месяца",
"dateTime": "Комбинируй любой из этих форматов даты с «{time}» (или {timePM}), чтобы установить время." "dateTime": "Комбинируй любой из этих форматов даты с «{time}» (или {timePM}), чтобы установить время.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initials", "initials": "Initials",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload", "upload": "Upload",
"uploadAvatar": "Upload Avatar", "uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close", "close": "Close",
"download": "Download", "download": "Download",
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu" "hideMenu": "Hide the menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -720,7 +722,9 @@
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month", "dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Chữ cái viết tắt", "initials": "Chữ cái viết tắt",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble",
"upload": "Tải lên", "upload": "Tải lên",
"uploadAvatar": "Tải lên Avatar", "uploadAvatar": "Tải lên Avatar",
"statusUpdateSuccess": "Avatar đã được cập nhật!", "statusUpdateSuccess": "Avatar đã được cập nhật!",
@ -471,7 +472,8 @@
"close": "Đóng", "close": "Đóng",
"download": "Tải về", "download": "Tải về",
"showMenu": "Hiển thị menu", "showMenu": "Hiển thị menu",
"hideMenu": "Ẩn menu" "hideMenu": "Ẩn menu",
"forExample": "For example:"
}, },
"input": { "input": {
"resetColor": "Đặt lại màu", "resetColor": "Đặt lại màu",
@ -720,7 +722,9 @@
"dateWeekday": "bất kỳ ngày nào trong tuần, sẽ sử dụng ngày tiếp theo với ngày đó", "dateWeekday": "bất kỳ ngày nào trong tuần, sẽ sử dụng ngày tiếp theo với ngày đó",
"dateCurrentYear": "sẽ sử dụng năm hiện tại", "dateCurrentYear": "sẽ sử dụng năm hiện tại",
"dateNth": "sẽ sử dụng ngày {day} của tháng hiện tại", "dateNth": "sẽ sử dụng ngày {day} của tháng hiện tại",
"dateTime": "Kết hợp bất kì đinh dạng ngày với \"{time}\" (hoặc {timePM}) để thiết lập thời gian." "dateTime": "Kết hợp bất kì đinh dạng ngày với \"{time}\" (hoặc {timePM}) để thiết lập thời gian.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
} }
}, },
"team": { "team": {

View File

@ -7,14 +7,14 @@ describe('Parse Task Text', () => {
it('should return text with no intents as is', () => { it('should return text with no intents as is', () => {
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum') expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
}) })
it('should not parse text when disabled', () => { it('should not parse text when disabled', () => {
const text = 'Lorem Ipsum today *label +list !2 @user' const text = 'Lorem Ipsum today *label +list !2 @user'
const result = parseTaskText(text, 'disabled') const result = parseTaskText(text, 'disabled')
expect(result.text).toBe(text) expect(result.text).toBe(text)
}) })
it('should parse text in todoist mode when configured', () => { it('should parse text in todoist mode when configured', () => {
const result = parseTaskText('Lorem Ipsum today @label #list !2 +user', 'todoist') const result = parseTaskText('Lorem Ipsum today @label #list !2 +user', 'todoist')
@ -208,7 +208,11 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum') expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(date.getDate()) expect(result.date.getDate()).toBe(date.getDate())
expect(result.date.getMonth()).toBe(result.date.getDate() === 31 ? date.getMonth() + 2 : date.getMonth() + 1)
const nextMonthWithDate = result.date.getDate() === 31
? (date.getMonth() + 2) % 12
: (date.getMonth() + 1) % 12
expect(result.date.getMonth()).toBe(nextMonthWithDate)
}) })
it('should recognize dates of the month in the future', () => { it('should recognize dates of the month in the future', () => {
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000) const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
@ -506,4 +510,54 @@ describe('Parse Task Text', () => {
expect(result.assignees[0]).toBe('user with long name') expect(result.assignees[0]).toBe('user with long name')
}) })
}) })
describe('Recurring Dates', () => {
const cases = {
'every 1 hour': {type: 'hours', amount: 1},
'every hour': {type: 'hours', amount: 1},
'every 5 hours': {type: 'hours', amount: 5},
'every 12 hours': {type: 'hours', amount: 12},
'every day': {type: 'days', amount: 1},
'every 1 day': {type: 'days', amount: 1},
'every 2 days': {type: 'days', amount: 2},
'every week': {type: 'weeks', amount: 1},
'every 1 week': {type: 'weeks', amount: 1},
'every 3 weeks': {type: 'weeks', amount: 3},
'every month': {type: 'months', amount: 1},
'every 1 month': {type: 'months', amount: 1},
'every 2 months': {type: 'months', amount: 2},
'every year': {type: 'years', amount: 1},
'every 1 year': {type: 'years', amount: 1},
'every 4 years': {type: 'years', amount: 4},
'anually': {type: 'years', amount: 1},
'bianually': {type: 'months', amount: 6},
'semiannually': {type: 'months', amount: 6},
'biennially': {type: 'years', amount: 2},
'daily': {type: 'days', amount: 1},
'hourly': {type: 'hours', amount: 1},
'monthly': {type: 'months', amount: 1},
'weekly': {type: 'weeks', amount: 1},
'yearly': {type: 'years', amount: 1},
'every one hour': {type: 'hours', amount: 1}, // maybe unnesecary but better to include it for completeness sake
'every two hours': {type: 'hours', amount: 2},
'every three hours': {type: 'hours', amount: 3},
'every four hours': {type: 'hours', amount: 4},
'every five hours': {type: 'hours', amount: 5},
'every six hours': {type: 'hours', amount: 6},
'every seven hours': {type: 'hours', amount: 7},
'every eight hours': {type: 'hours', amount: 8},
'every nine hours': {type: 'hours', amount: 9},
'every ten hours': {type: 'hours', amount: 10},
}
for (const c in cases) {
it(`should parse ${c} as recurring date every ${cases[c].amount} ${cases[c].type}`, () => {
const result = parseTaskText(`Lorem Ipsum ${c}`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.repeats.type).toBe(cases[c].type)
expect(result.repeats.amount).toBe(cases[c].amount)
})
}
})
}) })

View File

@ -38,6 +38,24 @@ interface Priorites {
DO_NOW: number, DO_NOW: number,
} }
enum RepeatType {
Hours = 'hours',
Days = 'days',
Weeks = 'weeks',
Months = 'months',
Years = 'years',
}
interface Repeats {
type: RepeatType,
amount: number,
}
interface repeatParsedResult {
textWithoutMatched: string,
repeats: Repeats | null,
}
interface ParsedTaskText { interface ParsedTaskText {
text: string, text: string,
date: Date | null, date: Date | null,
@ -45,6 +63,7 @@ interface ParsedTaskText {
list: string | null, list: string | null,
priority: number | null, priority: number | null,
assignees: string[], assignees: string[],
repeats: Repeats | null,
} }
interface Prefixes { interface Prefixes {
@ -67,6 +86,7 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
list: null, list: null,
priority: null, priority: null,
assignees: [], assignees: [],
repeats: null,
} }
const prefixes = PREFIXES[prefixesMode] const prefixes = PREFIXES[prefixesMode]
@ -83,7 +103,11 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
result.assignees = getItemsFromPrefix(text, prefixes.assignee) result.assignees = getItemsFromPrefix(text, prefixes.assignee)
const {newText, date} = parseDate(text) const {textWithoutMatched, repeats} = getRepeats(text)
result.text = textWithoutMatched
result.repeats = repeats
const {newText, date} = parseDate(result.text)
result.text = newText result.text = newText
result.date = date result.date = date
@ -132,6 +156,113 @@ const getPriority = (text: string, prefix: string): number | null => {
return null return null
} }
const getRepeats = (text: string): repeatParsedResult => {
const regex = /((every|each) (([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten) )?(hours?|days?|weeks?|months?|years?))|anually|bianually|semiannually|biennially|daily|hourly|monthly|weekly|yearly/ig
const results = regex.exec(text)
if (results === null) {
return {
textWithoutMatched: text,
repeats: null,
}
}
let amount = 1
switch (results[3] ? results[3].trim() : undefined) {
case 'one':
amount = 1
break
case 'two':
amount = 2
break
case 'three':
amount = 3
break
case 'four':
amount = 4
break
case 'five':
amount = 5
break
case 'six':
amount = 6
break
case 'seven':
amount = 7
break
case 'eight':
amount = 8
break
case 'nine':
amount = 9
break
case 'ten':
amount = 10
break
default:
amount = results[3] ? parseInt(results[3]) : 1
}
let type: RepeatType = RepeatType.Hours
switch (results[0]) {
case 'biennially':
type = RepeatType.Years
amount = 2
break
case 'bianually':
case 'semiannually':
type = RepeatType.Months
amount = 6
break
case 'yearly':
case 'anually':
type = RepeatType.Years
break
case 'daily':
type = RepeatType.Days
break
case 'hourly':
type = RepeatType.Hours
break
case 'monthly':
type = RepeatType.Months
break
case 'weekly':
type = RepeatType.Weeks
break
default:
switch (results[5]) {
case 'hour':
case 'hours':
type = RepeatType.Hours
break
case 'day':
case 'days':
type = RepeatType.Days
break
case 'week':
case 'weeks':
type = RepeatType.Weeks
break
case 'month':
case 'months':
type = RepeatType.Months
break
case 'year':
case 'years':
type = RepeatType.Years
break
}
}
return {
textWithoutMatched: text.replace(results[0], ''),
repeats: {
amount,
type,
},
}
}
const cleanupItemText = (text: string, items: string[], prefix: string): string => { const cleanupItemText = (text: string, items: string[], prefix: string): string => {
items.forEach(l => { items.forEach(l => {
text = text text = text

View File

@ -69,7 +69,7 @@ export default class TaskService extends AbstractService {
// Make the repeating amount to seconds // Make the repeating amount to seconds
let repeatAfterSeconds = 0 let repeatAfterSeconds = 0
if (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0) { if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) {
switch (model.repeatAfter.type) { switch (model.repeatAfter.type) {
case 'hours': case 'hours':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 repeatAfterSeconds = model.repeatAfter.amount * 60 * 60

View File

@ -292,6 +292,7 @@ export default {
bucketId: bucketId || 0, bucketId: bucketId || 0,
position, position,
}) })
task.repeatAfter = parsedTask.repeats
const taskService = new TaskService() const taskService = new TaskService()
const createdTask = await taskService.create(task) const createdTask = await taskService.create(task)

View File

@ -1,7 +1,7 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
/* eslint-disable no-undef */ /* eslint-disable no-undef */
const workboxVersion = 'v6.4.1' const workboxVersion = 'v6.4.2'
importScripts( `/workbox-${workboxVersion}/workbox-sw.js`) importScripts( `/workbox-${workboxVersion}/workbox-sw.js`)
workbox.setConfig({ workbox.setConfig({
modulePathPrefix: `/workbox-${workboxVersion}`, modulePathPrefix: `/workbox-${workboxVersion}`,

View File

@ -47,7 +47,6 @@
v-for="(l, k) in listHistory" v-for="(l, k) in listHistory"
:key="`l${k}`" :key="`l${k}`"
:list="l" :list="l"
:background-resolver="() => null"
/> />
</div> </div>
</div> </div>
@ -105,12 +104,8 @@ const migratorsEnabled = computed(() => store.state.config.availableMigrators?.l
const userInfo = computed(() => store.state.auth.info) const userInfo = computed(() => store.state.auth.info)
const hasTasks = computed(() => store.state.hasTasks) const hasTasks = computed(() => store.state.hasTasks)
const defaultListId = computed(() => store.state.auth.defaultListId) const defaultListId = computed(() => store.state.auth.defaultListId)
const defaultNamespaceId = computed(() => store.state.namespaces.namespaces?.[0].id || 0) const defaultNamespaceId = computed(() => store.state.namespaces.namespaces?.[0]?.id || 0)
const hasLists = computed (() => { const hasLists = computed (() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
return store.state.namespaces.namespaces.length === 0
? false
: store.state.namespaces.namespaces[0].lists.length > 0
})
const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks') const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
const deletionScheduledAt = computed(() => parseDateOrNull(store.state.auth.info?.deletionScheduledAt)) const deletionScheduledAt = computed(() => parseDateOrNull(store.state.auth.info?.deletionScheduledAt))

View File

@ -13,6 +13,10 @@
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="gravatar"/> <input name="avatarProvider" type="radio" v-model="avatarProvider" value="gravatar"/>
{{ $t('user.settings.avatar.gravatar') }} {{ $t('user.settings.avatar.gravatar') }}
</label> </label>
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="marble"/>
{{ $t('user.settings.avatar.marble') }}
</label>
<label class="radio"> <label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="upload"/> <input name="avatarProvider" type="radio" v-model="avatarProvider" value="upload"/>
{{ $t('user.settings.avatar.upload') }} {{ $t('user.settings.avatar.upload') }}

1363
yarn.lock

File diff suppressed because it is too large Load Diff