Add HelpPanel and TodoItemTouch components, extend task commands, and refactor task and view logic
- Introduced `HelpPanel.vue` for displaying keyboard shortcuts and command descriptions. - Added `TodoItemTouch.vue`, a mobile-friendly task item component with updated bindings and improved actions. - Extended task commands with support for tagging, due date parsing, and dynamic text formatting. - Implemented `useActions` utility for parsing and executing command-based task modifications. - Streamlined task editing and creation in `useTasks` for consistency and API integration. - Updated `ListScreen` to support collapsible, categorized task lists with visual enhancements. - Refactored `App.vue` for adaptive input handling on mobile versus desktop views. - Enhanced API communication in `useApi` with cleaner header generation and error handling.
This commit is contained in:
28
src/App.vue
28
src/App.vue
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { PhCheckSquareOffset, PhListChecks, PhSliders } from '@phosphor-icons/vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CreateInput from './components/CreateInput.vue'
|
||||
import CreateModal from './components/CreateModal.vue'
|
||||
|
||||
import { useTasks } from './composables/useTasks.ts'
|
||||
|
||||
const isMobile = useMediaQuery('(pointer: coarse)')
|
||||
|
||||
const { fetchTasks } = useTasks()
|
||||
const router = useRouter()
|
||||
const currentPath = computed(() => router.currentRoute.value.path)
|
||||
@@ -11,22 +16,13 @@ onMounted(fetchTasks)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<main class="pb-40 overflow-y-scroll h-screen">
|
||||
<div>
|
||||
<main class="h-screen overflow-y-auto">
|
||||
<RouterView />
|
||||
</main>
|
||||
<div class="dock dock-xl inset-shadow-sm">
|
||||
<RouterLink to="/create" :class="currentPath === '/create' ? 'dock-active' : ''">
|
||||
<PhCheckSquareOffset :size="32" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/" :class="currentPath === '/' ? 'dock-active' : ''">
|
||||
<PhListChecks :size="32" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/settings" :class="currentPath === '/settings' ? 'dock-active' : ''">
|
||||
<PhSliders :size="32" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<template v-if="currentPath === '/'">
|
||||
<CreateModal v-if="isMobile" />
|
||||
<CreateInput v-else />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
142
src/components/CreateInput.vue
Normal file
142
src/components/CreateInput.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside, onKeyStroke, onStartTyping } from '@vueuse/core'
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue'
|
||||
import useActions from '../composables/useActions.ts'
|
||||
import useHistory from '../composables/useHistory.ts'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
import HelpPanel from './HelpPanel.vue'
|
||||
|
||||
const inputComponent = useTemplateRef('inputComponent')
|
||||
const value = ref('')
|
||||
|
||||
const { run } = useActions()
|
||||
const { pushHistory, matchHistory, historyItem, moveHistory, resetHistoryIndex } = useHistory()
|
||||
const { tasks } = useTasks()
|
||||
|
||||
const inputActive = ref(false)
|
||||
const showHelp = ref(false)
|
||||
|
||||
const suggestion = ref('')
|
||||
const rememberTemp = ref('')
|
||||
|
||||
function resetInput() {
|
||||
value.value = ''
|
||||
suggestion.value = ''
|
||||
rememberTemp.value = value.value = ''
|
||||
resetHistoryIndex()
|
||||
inputActive.value = false
|
||||
inputComponent?.value?.blur()
|
||||
}
|
||||
|
||||
onKeyStroke('Escape', (e) => {
|
||||
e.preventDefault()
|
||||
resetInput()
|
||||
})
|
||||
|
||||
onClickOutside(inputComponent, resetInput)
|
||||
|
||||
onKeyStroke(' ', (e) => {
|
||||
if (!inputActive.value) {
|
||||
e.preventDefault()
|
||||
showHelp.value = true
|
||||
inputActive.value = true
|
||||
inputComponent?.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
onStartTyping(() => {
|
||||
showHelp.value = false
|
||||
inputActive.value = true
|
||||
inputComponent?.value?.focus()
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
run(value.value)
|
||||
pushHistory(value.value)
|
||||
resetInput()
|
||||
}
|
||||
|
||||
onKeyStroke('Tab', (e) => {
|
||||
if (inputActive.value && suggestion.value.length > 0) {
|
||||
e.preventDefault()
|
||||
value.value = suggestion.value
|
||||
}
|
||||
})
|
||||
|
||||
onKeyStroke('ArrowUp', (e) => {
|
||||
if (inputActive.value) {
|
||||
e.preventDefault()
|
||||
if (suggestion.value.trim().length > 0) {
|
||||
moveHistory('up')
|
||||
}
|
||||
else {
|
||||
rememberTemp.value = value.value
|
||||
}
|
||||
value.value = historyItem.value
|
||||
}
|
||||
else {
|
||||
inputActive.value = true
|
||||
inputComponent?.value?.focus()
|
||||
value.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
if (inputActive.value) {
|
||||
e.preventDefault()
|
||||
if (suggestion.value.trim().length > 0) {
|
||||
const before = historyItem.value
|
||||
moveHistory('down')
|
||||
const after = historyItem.value
|
||||
if (before !== after) {
|
||||
value.value = after
|
||||
}
|
||||
else {
|
||||
value.value = rememberTemp.value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
let match = ''
|
||||
if (value.value.length > 0) {
|
||||
const isEditCommand = value.value.match(/^e(?:dit)? (\d+)\s?$/i)
|
||||
if (isEditCommand && isEditCommand[1]) {
|
||||
const id = Number.parseInt(isEditCommand[1])
|
||||
if (id && !Number.isNaN(id)) {
|
||||
const found = (tasks.value.filter(t => t.id_ === id) || []).pop()
|
||||
if (found) {
|
||||
match = `${value.value.trim()} ${found.title}`
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
match = matchHistory(value.value.trim())
|
||||
}
|
||||
}
|
||||
suggestion.value = match
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex bottom-0 right-0 left-0 top-0 justify-center items-center flex-col gap-6 transition-opacity duration-500 bg-primary/10 " :class=" inputActive ? 'fixed' : 'opacity-0' " @submit.prevent="handleSubmit">
|
||||
<div class="font-mono w-10/12 max-w-md relative h-10">
|
||||
<div class="absolute top-0 left-0 right-0 bottom-0 bg-white px-4 py-2">
|
||||
{{ suggestion }}
|
||||
</div>
|
||||
<input
|
||||
ref="inputComponent"
|
||||
v-model="value" type="text"
|
||||
class="absolute top-0 left-0 right-0 bottom-0 bg-white/40 px-4 py-2 rounded-lg shadow-2xl focus:outline-none focus:ring-2 focus:ring-primary h-full w-full"
|
||||
>
|
||||
</div>
|
||||
<div v-if="showHelp" class="bg-white/70 backdrop-blur-xs p-6 rounded-lg shadow-xl text-center">
|
||||
<HelpPanel />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
68
src/components/CreateModal.vue
Normal file
68
src/components/CreateModal.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { PhPlus } from '@phosphor-icons/vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
import { router } from '../router.ts'
|
||||
|
||||
const { createTask, tasks } = useTasks()
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
const data = new FormData(e.target as HTMLFormElement)
|
||||
const task: Partial<Task> = Object.fromEntries(data)
|
||||
|
||||
await createTask(task)
|
||||
await router.push('/')
|
||||
}
|
||||
const createModal = useTemplateRef('createModal')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="fab">
|
||||
<button class="btn btn-xl btn-circle btn-secondary" @click="createModal?.showModal()">
|
||||
<PhPlus size="24" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog ref="createModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
What is your name?
|
||||
</legend>
|
||||
<input type="text" class="input" name="title" placeholder="Type here">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
Category
|
||||
</legend>
|
||||
<select class="select" name="tag">
|
||||
<option disabled selected value="@uncategorized">
|
||||
@uncategorized
|
||||
</option>
|
||||
<option value="@home">
|
||||
@home
|
||||
</option>
|
||||
<option value="@work">
|
||||
Amber
|
||||
</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<button class="btn btn-primary">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
92
src/components/HelpPanel.vue
Normal file
92
src/components/HelpPanel.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="font-mono text-sm text-left">
|
||||
<p>
|
||||
<b>
|
||||
<u>/</u>:
|
||||
</b>
|
||||
search for anything
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>t</u>ask:
|
||||
</b>
|
||||
create a new task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>e</u>dit:
|
||||
</b>
|
||||
edit a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>due</u>:
|
||||
</b>
|
||||
set due date or <u>clear</u>
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>b</u>egin:
|
||||
</b>
|
||||
start timer
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>st</u>op:
|
||||
</b>
|
||||
stop timer
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>fl</u>ag:
|
||||
</b>
|
||||
flag a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>move</u>:
|
||||
</b>
|
||||
move task to another tag
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>d</u>elete:
|
||||
</b>
|
||||
delete task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>a</u>rchive:
|
||||
</b>
|
||||
archive a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>re</u>store:
|
||||
</b>
|
||||
unarchive a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>sw</u>itch:
|
||||
</b>
|
||||
switch the working task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>list-archived</u>:
|
||||
</b>
|
||||
show archived tasks
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>today</u>:
|
||||
</b>
|
||||
show today overview
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { PhCheckSquare, PhDotsThree, PhFlag, PhPause, PhPlay, PhSquare, PhX } from '@phosphor-icons/vue'
|
||||
import { PhCheckSquare, PhFlag, PhPlay, PhSquare } from '@phosphor-icons/vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
@@ -37,48 +37,20 @@ async function handleClick(update: Partial<Task>) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="list-row">
|
||||
<div class="flex items-center justify-center">
|
||||
<button class="btn btn-square btn-ghost" @click="statusSelectVisible = !statusSelectVisible">
|
||||
<PhX v-if="statusSelectVisible" :size="20" />
|
||||
<template v-else>
|
||||
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="20" />
|
||||
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="20" weight="fill" class="text-success" />
|
||||
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="20" weight="fill" class="text-warning" />
|
||||
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="20" weight="fill" class="text-info" />
|
||||
</template>
|
||||
</button>
|
||||
<Transition>
|
||||
<div v-if="statusSelectVisible" class="">
|
||||
<template v-if="task.status !== TaskStatus.WIP">
|
||||
<button v-if="task.status !== TaskStatus.DONE" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.DONE })">
|
||||
<PhCheckSquare :size="24" weight="regular" class="text-success" />
|
||||
</button>
|
||||
<button v-if="task.status !== TaskStatus.WAIT" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })">
|
||||
<PhSquare :size="24" weight="regular" />
|
||||
</button>
|
||||
<button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.FLAG })">
|
||||
<PhFlag :size="24" weight="fill" class="text-warning" />
|
||||
</button>
|
||||
<button class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WIP })">
|
||||
<PhPlay :size="24" weight="fill" class="text-info" />
|
||||
</button>
|
||||
</template>
|
||||
<button v-else class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })">
|
||||
<PhPause :size="24" weight="fill" class="text-info" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div>{{ task.id_ }} {{ task.title }}</div>
|
||||
<div v-if="task.dueDate" :class="dueColor">
|
||||
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }}
|
||||
<li class="">
|
||||
<div class="font-mono text-sm flex flex-row justify-start gap-4">
|
||||
<span>{{ String(task.id_).padStart(3, ' ') }}</span>
|
||||
<div class="flex items-center justify-center">
|
||||
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="16" />
|
||||
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="16" weight="fill" class="text-success" />
|
||||
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="16" weight="fill" class="text-warning" />
|
||||
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="16" weight="fill" class="text-info" />
|
||||
</div>
|
||||
<span>{{ task.title }}</span>
|
||||
<span v-if="task.dueDate" :class="dueColor">
|
||||
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-square btn-ghost">
|
||||
<PhDotsThree :size="24" weight="regular" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
|
||||
87
src/components/TodoItemTouch.vue
Normal file
87
src/components/TodoItemTouch.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { PhCheckSquare, PhDotsThree, PhFlag, PhPause, PhPlay, PhSquare, PhX } from '@phosphor-icons/vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
import { TaskStatus } from '../types.ts'
|
||||
|
||||
const { task } = defineProps<{ task: Task }>()
|
||||
|
||||
const { updateTask } = useTasks()
|
||||
|
||||
const dueColor = computed(() => {
|
||||
const dueDiff = task.dueDate ? DateTime.fromMillis(task.dueDate).diffNow('days').days : undefined
|
||||
if (!dueDiff)
|
||||
return ''
|
||||
if (dueDiff < 0) {
|
||||
return 'text-error'
|
||||
}
|
||||
else if (dueDiff < 2) {
|
||||
return 'text-warning'
|
||||
}
|
||||
else if (dueDiff < 7) {
|
||||
return 'text-success'
|
||||
}
|
||||
else {
|
||||
return 'text-neutral'
|
||||
}
|
||||
})
|
||||
|
||||
const statusSelectVisible = ref(false)
|
||||
|
||||
async function handleClick(update: Partial<Task>) {
|
||||
updateTask({ ...task, ...update })
|
||||
statusSelectVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="list-row">
|
||||
<div class="flex items-center justify-center">
|
||||
<button class="btn btn-square btn-ghost" @click="statusSelectVisible = !statusSelectVisible">
|
||||
<PhX v-if="statusSelectVisible" :size="20" />
|
||||
<template v-else>
|
||||
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="20" />
|
||||
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="20" weight="fill" class="text-success" />
|
||||
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="20" weight="fill" class="text-warning" />
|
||||
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="20" weight="fill" class="text-info" />
|
||||
</template>
|
||||
</button>
|
||||
<Transition>
|
||||
<div v-if="statusSelectVisible" class="">
|
||||
<template v-if="task.status !== TaskStatus.WIP">
|
||||
<button v-if="task.status !== TaskStatus.DONE" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.DONE })">
|
||||
<PhCheckSquare :size="24" weight="regular" class="text-success" />
|
||||
</button>
|
||||
<button v-if="task.status !== TaskStatus.WAIT" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })">
|
||||
<PhSquare :size="24" weight="regular" />
|
||||
</button>
|
||||
<button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.FLAG })">
|
||||
<PhFlag :size="24" weight="fill" class="text-warning" />
|
||||
</button>
|
||||
<button class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WIP })">
|
||||
<PhPlay :size="24" weight="fill" class="text-info" />
|
||||
</button>
|
||||
</template>
|
||||
<button v-else class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })">
|
||||
<PhPause :size="24" weight="fill" class="text-info" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div>{{ task.id }} {{ task.id_ }} {{ task.title }}</div>
|
||||
<div v-if="task.dueDate" :class="dueColor">
|
||||
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-square btn-ghost">
|
||||
<PhDotsThree :size="24" weight="regular" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
109
src/composables/useActions.ts
Normal file
109
src/composables/useActions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Task } from '../types.ts'
|
||||
import {
|
||||
archiveCommand,
|
||||
beginCommand,
|
||||
checkCommand,
|
||||
deleteCommand,
|
||||
dueCommand,
|
||||
editTaskCommand,
|
||||
flagCommand,
|
||||
insertTaskCommand,
|
||||
moveCommand,
|
||||
restoreCommand,
|
||||
stopCommand,
|
||||
switchCommand,
|
||||
tagRenameCommand,
|
||||
} from '../utils/actions.ts'
|
||||
import { parseCommand } from '../utils/parser.ts'
|
||||
import { useTasks } from './useTasks.ts'
|
||||
|
||||
export default function useActions() {
|
||||
const { tasks: tasksOriginal, createTask, updateTask } = useTasks()
|
||||
const run = (value: string) => {
|
||||
const cmd = parseCommand(value)
|
||||
let tasksToUpdate: Task[] = []
|
||||
let taskToCreate: Pick<Task, 'tag' | 'title' | 'dueDate'> | null = null
|
||||
const tasks = JSON.parse(JSON.stringify(tasksOriginal.value)) as Task[]
|
||||
if (cmd) {
|
||||
const ids = cmd.id
|
||||
? (cmd.id.match(/\d+/g) || []).map(s => Number.parseInt(s))
|
||||
: []
|
||||
switch (cmd.command.toLowerCase()) {
|
||||
case 'mv':
|
||||
case 'move':
|
||||
tasksToUpdate = moveCommand(tasks, ids, cmd)
|
||||
break
|
||||
case 'b':
|
||||
case 'begin':
|
||||
tasksToUpdate = beginCommand(tasks, ids)
|
||||
break
|
||||
case 'c':
|
||||
case 'check':
|
||||
tasksToUpdate = checkCommand(tasks, ids)
|
||||
break
|
||||
case 'd':
|
||||
case 'delete':
|
||||
tasksToUpdate = deleteCommand(ids, cmd, tasks)
|
||||
break
|
||||
case 'fl':
|
||||
case 'flag':
|
||||
tasksToUpdate = flagCommand(tasks, ids)
|
||||
break
|
||||
case 'st':
|
||||
case 'stop':
|
||||
tasksToUpdate = stopCommand(tasks, ids)
|
||||
break
|
||||
case 'sw':
|
||||
case 'switch':
|
||||
tasksToUpdate = switchCommand(tasks, ids)
|
||||
break
|
||||
case 'a':
|
||||
case 'archive':
|
||||
tasksToUpdate = archiveCommand(ids, cmd, tasks)
|
||||
break
|
||||
case 're':
|
||||
case 'restore':
|
||||
tasksToUpdate = restoreCommand(ids, cmd, tasks)
|
||||
break
|
||||
case 't':
|
||||
case 'task':
|
||||
taskToCreate = insertTaskCommand(cmd)
|
||||
break
|
||||
case 'e':
|
||||
case 'edit':
|
||||
tasksToUpdate = editTaskCommand(ids, cmd, tasks)
|
||||
break
|
||||
case 'due':
|
||||
tasksToUpdate = dueCommand(ids, cmd, tasks)
|
||||
break
|
||||
case 'tr':
|
||||
case 'tagre':
|
||||
case 'tagrename':
|
||||
tasksToUpdate = tagRenameCommand(cmd, tasks)
|
||||
break
|
||||
/* Visibility */
|
||||
// case 'hide':
|
||||
// updateCandidate = hideCommand(updateCandidate, cmd)
|
||||
// break
|
||||
// case 'show':
|
||||
// updateCandidate = showCommand(updateCandidate, cmd)
|
||||
// break
|
||||
// /* Single command */
|
||||
// case 'search':
|
||||
// updateCandidate = searchCommand(updateCandidate, cmd)
|
||||
// break
|
||||
// default:
|
||||
// updateCandidate = otherCommand(updateCandidate, cmd, state)
|
||||
// break
|
||||
}
|
||||
}
|
||||
// console.log(tasksToUpdate, taskToCreate)
|
||||
if (tasksToUpdate) {
|
||||
updateTask(tasksToUpdate)
|
||||
}
|
||||
if (taskToCreate) {
|
||||
createTask(taskToCreate)
|
||||
}
|
||||
}
|
||||
return { run }
|
||||
}
|
||||
@@ -1,42 +1,43 @@
|
||||
import { useStore } from './useStore.ts';
|
||||
import { useCrypto } from './useCrypto.ts';
|
||||
import { useCrypto } from './useCrypto.ts'
|
||||
import { useStore } from './useStore.ts'
|
||||
|
||||
const BASE_URL = 'https://automation.deep-node.de/webhook';
|
||||
const BASE_URL = 'https://automation.deep-node.de/webhook'
|
||||
|
||||
type Settings = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
interface Settings {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const isTauri = () =>
|
||||
typeof window !== 'undefined' && Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__);
|
||||
function isTauri() {
|
||||
return typeof window !== 'undefined' && Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__)
|
||||
}
|
||||
|
||||
async function buildAuthHeader(): Promise<string | undefined> {
|
||||
const {decrypt} = useCrypto();
|
||||
const { getValue } = useStore();
|
||||
const settings = await getValue<Settings>('settings');
|
||||
if (!settings) return undefined;
|
||||
let {username, password} = settings;
|
||||
password = decrypt(password) as string;
|
||||
const { decrypt } = useCrypto()
|
||||
const { getValue } = useStore()
|
||||
const settings = await getValue<Settings>('settings')
|
||||
if (!settings)
|
||||
return undefined
|
||||
let { username, password } = settings
|
||||
password = decrypt(password) as string
|
||||
|
||||
if (username && password) {
|
||||
const token = btoa(`${username}:${password}`);
|
||||
return `Basic ${token}`;
|
||||
const token = btoa(`${username}:${password}`)
|
||||
return `Basic ${token}`
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
const apiFetch = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}/${endpoint}`;
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}/${endpoint}`
|
||||
|
||||
const authHeader = await buildAuthHeader();
|
||||
console.log('authHeader',authHeader);
|
||||
const authHeader = await buildAuthHeader()
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeader ? { Authorization: authHeader } : {}),
|
||||
...options.headers,
|
||||
} as Record<string, string>;
|
||||
} as Record<string, string>
|
||||
|
||||
const response = isTauri()
|
||||
? await (await import('@tauri-apps/plugin-http')).fetch(url, {
|
||||
@@ -46,28 +47,28 @@ export function useApi() {
|
||||
: await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API call failed: ${response.statusText}`);
|
||||
throw new Error(`API call failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
return response
|
||||
}
|
||||
|
||||
const get = (endpoint: string, options: RequestInit = {}) =>
|
||||
apiFetch(endpoint, { ...options, method: 'GET' });
|
||||
apiFetch(endpoint, { ...options, method: 'GET' })
|
||||
|
||||
const post = (endpoint: string, body: unknown, options: RequestInit = {}) =>
|
||||
apiFetch(endpoint, { ...options, method: 'POST', body: JSON.stringify(body) });
|
||||
apiFetch(endpoint, { ...options, method: 'POST', body: JSON.stringify(body) })
|
||||
|
||||
const put = (endpoint: string, body: unknown, options: RequestInit = {}) =>
|
||||
apiFetch(endpoint, { ...options, method: 'PUT', body: JSON.stringify(body) });
|
||||
apiFetch(endpoint, { ...options, method: 'PUT', body: JSON.stringify(body) })
|
||||
|
||||
return {
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
fetch: apiFetch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
49
src/composables/useHistory.ts
Normal file
49
src/composables/useHistory.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useStore } from './useStore.ts'
|
||||
|
||||
export default function useHistory() {
|
||||
const { getValue, setValue } = useStore()
|
||||
|
||||
const store = ref<string[]>([])
|
||||
const history = computed<string[]>(() => store.value)
|
||||
|
||||
const historyIndex = ref(0)
|
||||
|
||||
const resetHistoryIndex = () => {
|
||||
historyIndex.value = store.value.length - 1
|
||||
}
|
||||
onMounted(async () => {
|
||||
store.value = await getValue('history') || []
|
||||
resetHistoryIndex()
|
||||
console.log({ s: store.value })
|
||||
})
|
||||
|
||||
const pushHistory = (item: string) => {
|
||||
if (store.value.length > 20)
|
||||
store.value.shift()
|
||||
store.value.push(item.trim())
|
||||
setValue('history', store.value)
|
||||
}
|
||||
|
||||
const matchHistory = (item: string) => {
|
||||
const match = store.value.filter(i => i.startsWith(item.trim())).pop()
|
||||
if (match) {
|
||||
historyIndex.value = store.value.indexOf(match)
|
||||
return match
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const moveHistory = (direction: 'up' | 'down') => {
|
||||
if (direction === 'up') {
|
||||
historyIndex.value = Math.max(0, historyIndex.value - 1)
|
||||
}
|
||||
else {
|
||||
historyIndex.value = Math.min(store.value.length - 1, historyIndex.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const historyItem = computed(() => store.value[historyIndex.value])
|
||||
|
||||
return { history, pushHistory, matchHistory, moveHistory, historyItem, resetHistoryIndex }
|
||||
}
|
||||
@@ -1,52 +1,51 @@
|
||||
import type { Store } from '@tauri-apps/plugin-store';
|
||||
import type { Store } from '@tauri-apps/plugin-store'
|
||||
|
||||
let storePromise: Promise<Store> | null = null;
|
||||
let storePromise: Promise<Store> | null = null
|
||||
|
||||
const isTauri = () =>
|
||||
typeof window !== 'undefined' && Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__);
|
||||
function isTauri() {
|
||||
return typeof window !== 'undefined' && Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__)
|
||||
}
|
||||
|
||||
async function getStore(): Promise<Store> {
|
||||
if (!storePromise) {
|
||||
const { load } = await import('@tauri-apps/plugin-store');
|
||||
const { load } = await import('@tauri-apps/plugin-store')
|
||||
storePromise = load('store.json', {
|
||||
autoSave: false,
|
||||
defaults: {},
|
||||
});
|
||||
})
|
||||
}
|
||||
return storePromise;
|
||||
return storePromise
|
||||
}
|
||||
|
||||
export function useStore() {
|
||||
const setValue = async <T>(key: string, value: T) => {
|
||||
console.log('setValue',key,value);
|
||||
if (isTauri()) {
|
||||
const store = await getStore();
|
||||
await store.set(key, value);
|
||||
await store.save();
|
||||
return;
|
||||
const store = await getStore()
|
||||
await store.set(key, value)
|
||||
await store.save()
|
||||
return
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
const getValue = async <T>(key: string) => {
|
||||
console.log('getValue',key);
|
||||
if (isTauri()) {
|
||||
const store = await getStore();
|
||||
return store.get<T>(key);
|
||||
const store = await getStore()
|
||||
return store.get<T>(key)
|
||||
}
|
||||
const rawValue = localStorage.getItem(key);
|
||||
if (rawValue === null) return null;
|
||||
const rawValue = localStorage.getItem(key)
|
||||
if (rawValue === null)
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(rawValue) as T;
|
||||
} catch {
|
||||
return null;
|
||||
return JSON.parse(rawValue) as T
|
||||
}
|
||||
};
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setValue,
|
||||
getValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Task } from '../types.ts'
|
||||
import { useArrayUnique } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { TaskStatus } from '../types.ts'
|
||||
import { useApi } from './useApi.ts'
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
@@ -29,22 +30,23 @@ export function useTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const createTask = async (taskData: Partial<Task>) => {
|
||||
const createTask = async (taskData: Pick<Task, 'title' | 'dueDate' | 'tag'>) => {
|
||||
// Get next ID as per current logic in CreateScreen.vue
|
||||
const nextId = () => tasks.value.sort((a, b) => a.id_ - b.id_).reduce((acc, task) => {
|
||||
if (task.id_ === acc + 1)
|
||||
return acc + 1
|
||||
return acc
|
||||
}, 0) + 1
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// Get next ID as per current logic in CreateScreen.vue
|
||||
const nextId = tasks.value.sort((a, b) => b.id_ - a.id_).reduce((acc, task) => {
|
||||
if (task.id_ === acc + 1)
|
||||
return acc + 1
|
||||
return task.id_ + 1
|
||||
}, 0)
|
||||
|
||||
const newTask: Partial<Task> = {
|
||||
...taskData,
|
||||
id_: nextId,
|
||||
const newTask: Omit<Task, 'id'> = {
|
||||
id_: nextId(),
|
||||
status: TaskStatus.WAIT,
|
||||
logs: [],
|
||||
lastaction: Date.now(),
|
||||
archived: false,
|
||||
...taskData,
|
||||
}
|
||||
|
||||
const data = await api.put('e5880167-9322-4d7b-8a38-e06bae8a7734/list', { tasks: [newTask] }).then(res => res.json())
|
||||
@@ -62,12 +64,12 @@ export function useTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateTask = async (task: Task) => {
|
||||
console.log('updateTask', task)
|
||||
const updateTask = async (task: Task | Task[]) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
const tasksToUpdate = (Array.isArray(task) ? task : [task]).map(t => ({ ...t, lastaction: Date.now() } as Task))
|
||||
try {
|
||||
const data = await api.put('e5880167-9322-4d7b-8a38-e06bae8a7734/list', { tasks: [task] }).then(res => res.json())
|
||||
const data = await api.put('e5880167-9322-4d7b-8a38-e06bae8a7734/list', { tasks: tasksToUpdate }).then(res => res.json())
|
||||
if (data.tasks) {
|
||||
tasks.value = data.tasks
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { createWebHistory, createRouter } from 'vue-router'
|
||||
import ListScreen from './screens/ListScreen.vue';
|
||||
import SettingsScreen from './screens/SettingsScreen.vue';
|
||||
import CreateScreen from './screens/CreateScreen.vue';
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ListScreen from './screens/ListScreen.vue'
|
||||
import SettingsScreen from './screens/SettingsScreen.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: ListScreen },
|
||||
{ path: '/settings', component: SettingsScreen },
|
||||
{ path: '/create', component: CreateScreen }
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
import { router } from '../router.ts'
|
||||
|
||||
const { createTask, tasks } = useTasks()
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
const data = new FormData(e.target as HTMLFormElement)
|
||||
const task: Partial<Task> = Object.fromEntries(data)
|
||||
|
||||
await createTask(task)
|
||||
await router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
What is your name?
|
||||
</legend>
|
||||
<input type="text" class="input" name="title" placeholder="Type here">
|
||||
</fieldset>
|
||||
<button class="btn btn-primary">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { PhCaretDown, PhCaretUp } from '@phosphor-icons/vue'
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import TodoItem from '../components/TodoItem.vue'
|
||||
@@ -14,12 +13,21 @@ onMounted(async () => {
|
||||
|
||||
const visibleTasks = computed<Task[]>(() => tasks.value.filter(task => !task.archived).sort((a, b) => a.id_ - b.id_))
|
||||
|
||||
const categorizedTasks = computed(() => visibleTasks.value.reduce((acc, task) => {
|
||||
const tag = task.tag ?? '@uncategorized'
|
||||
acc[tag] = acc[tag] ?? []
|
||||
acc[tag].push(task)
|
||||
return acc
|
||||
}, {} as Record<string, Task[]>))
|
||||
const categorizedTasks = computed(() => {
|
||||
const unordered: Record<string, Task[]> = visibleTasks.value.reduce((acc, task) => {
|
||||
const tag = task.tag ?? '@uncategorized'
|
||||
acc[tag] = acc[tag] ?? []
|
||||
acc[tag].push(task)
|
||||
return acc
|
||||
}, {} as Record<string, Task[]>)
|
||||
return Object.keys(unordered).sort().reduce(
|
||||
(obj, key) => {
|
||||
obj[key] = unordered[key]
|
||||
return obj
|
||||
},
|
||||
{} as Record<string, Task[]>,
|
||||
)
|
||||
})
|
||||
|
||||
const collapsed = ref<string[]>([])
|
||||
</script>
|
||||
@@ -27,22 +35,15 @@ const collapsed = ref<string[]>([])
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="(tasks, category) in categorizedTasks" :key="category" class="m-4 rounded-box border border-neutral-100 shadow-md">
|
||||
<div class="m-4 flex justify-between items-center">
|
||||
<div class="badge badge-xl badge-primary">
|
||||
{{ category }}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-square btn-sm"
|
||||
@click="collapsed.includes(category) ? collapsed.splice(collapsed.indexOf(category), 1) : collapsed.push(category)"
|
||||
>
|
||||
<PhCaretDown v-if="collapsed.includes(category)" :size="20" />
|
||||
<PhCaretUp v-else :size="20" />
|
||||
<div v-for="(catTasks, category) in categorizedTasks" :key="category" class="m-4 ">
|
||||
<div class="mb-4">
|
||||
<button class="px-4 py-1 rounded bg-neutral-200 font-mono text-sm font-bold" @click="collapsed.includes(category) ? collapsed.splice(collapsed.indexOf(category), 1) : collapsed.push(category)">
|
||||
{{ category }} [{{ catTasks.length }}]
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="fade">
|
||||
<ul v-if="!collapsed.includes(category)" class="list bg-base-100 rounded-box">
|
||||
<TodoItem v-for="task in tasks.sort((a, b) => a.id_ - b.id_)" :key="task.id" :task />
|
||||
<ul v-if="!collapsed.includes(category)" class="">
|
||||
<TodoItem v-for="task in catTasks" :key="task.id" :task />
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
35
src/types.ts
35
src/types.ts
@@ -1,4 +1,3 @@
|
||||
|
||||
export enum TaskStatus {
|
||||
NONE,
|
||||
DONE,
|
||||
@@ -7,21 +6,21 @@ export enum TaskStatus {
|
||||
FLAG,
|
||||
}
|
||||
|
||||
|
||||
export type Worklog = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type Task = {
|
||||
"uuid": string,
|
||||
"archived": boolean,
|
||||
"tag": string,
|
||||
"title": string,
|
||||
"status": TaskStatus,
|
||||
"lastaction": number | null,
|
||||
"logs": Worklog[],
|
||||
"dueDate": number | null,
|
||||
"id_": number,
|
||||
"id": number
|
||||
export interface Worklog {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
archived: boolean
|
||||
tag: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
lastaction: number | null
|
||||
logs: Worklog[]
|
||||
dueDate: number | null
|
||||
id_: number
|
||||
id: number
|
||||
}
|
||||
|
||||
export type Actions = 't' | 'c' | 'f'
|
||||
|
||||
403
src/utils/actions.ts
Normal file
403
src/utils/actions.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import type { Task } from '../types.ts'
|
||||
import type { Command } from './parser.ts'
|
||||
import { TaskStatus } from '../types.ts'
|
||||
import { parseDueDate, stopWorkLogging } from './helpers.ts'
|
||||
|
||||
export function moveCommand(tasks: Task[], ids: Task['id_'][], cmd: Command) {
|
||||
return tasks.filter(t => ids.includes(t.id_) && t.tag && cmd?.tag).map(t => ({
|
||||
...t,
|
||||
tag: cmd?.tag,
|
||||
} as Task))
|
||||
}
|
||||
|
||||
export function beginCommand(tasks: Task[], ids: Task['id_'][]) {
|
||||
return tasks.filter(t => ids.includes(t.id_) && t.status !== TaskStatus.WIP).map((t) => {
|
||||
t.status = TaskStatus.WIP
|
||||
t.logs = (t.logs || []).concat({
|
||||
start: Date.now(),
|
||||
end: 0,
|
||||
})
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
export function checkCommand(tasks: Task[], ids: Task['id_'][]) {
|
||||
return tasks.filter(t => ids.includes(t.id_)).map((t) => {
|
||||
t.status
|
||||
= t.status === TaskStatus.DONE ? TaskStatus.WAIT : TaskStatus.DONE
|
||||
if (t.status === TaskStatus.DONE) {
|
||||
t = stopWorkLogging(t)
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
|
||||
if (ids.length === 0) {
|
||||
// Delete by tag
|
||||
const tag = (cmd?.id?.match(/^(@.*)/) || []).pop()
|
||||
if (tag) {
|
||||
return tasks.filter(t => t.tag === tag).map(t => ({
|
||||
...t,
|
||||
status: TaskStatus.NONE,
|
||||
} as Task))
|
||||
}
|
||||
// Delete by status
|
||||
const status = (
|
||||
cmd?.id?.match(/^(finished|done|flag|ongoing|wip|wait|pending)/)
|
||||
|| []
|
||||
).pop()
|
||||
if (status) {
|
||||
let taskStatus = null
|
||||
switch (status) {
|
||||
case 'finished':
|
||||
case 'done':
|
||||
taskStatus = TaskStatus.DONE
|
||||
break
|
||||
case 'flag':
|
||||
case 'flagged':
|
||||
taskStatus = TaskStatus.FLAG
|
||||
break
|
||||
case 'ongoing':
|
||||
case 'wip':
|
||||
taskStatus = TaskStatus.WIP
|
||||
break
|
||||
case 'wait':
|
||||
case 'pending':
|
||||
taskStatus = TaskStatus.WAIT
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (taskStatus) {
|
||||
return tasks.filter(t => t.status === taskStatus && !t.archived).map(t => ({
|
||||
...t,
|
||||
status: TaskStatus.NONE,
|
||||
} as Task))
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
else {
|
||||
// Delete by id
|
||||
return tasks.filter(t => ids.includes(t.id_)).map(t => ({
|
||||
...t,
|
||||
status: TaskStatus.NONE,
|
||||
} as Task))
|
||||
}
|
||||
}
|
||||
|
||||
export function flagCommand(tasks: Task[], ids: Task['id_'][]) {
|
||||
return tasks.filter(t => ids.includes(t.id_)).map((t) => {
|
||||
t.status
|
||||
= t.status === TaskStatus.FLAG ? TaskStatus.WAIT : TaskStatus.FLAG
|
||||
t = stopWorkLogging(t)
|
||||
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
export function stopCommand(tasks: Task[], ids: Task['id_'][]) {
|
||||
return tasks.filter(t => ids.includes(t.id_) && t.status === TaskStatus.WIP).map((t) => {
|
||||
t = stopWorkLogging(t)
|
||||
return {
|
||||
...t,
|
||||
status: TaskStatus.WAIT,
|
||||
} as Task
|
||||
})
|
||||
}
|
||||
|
||||
export function switchCommand(tasks: Task[], ids: Task['id_'][]) {
|
||||
if (ids.length === 2) {
|
||||
const stopId = ids[0]
|
||||
const startId = ids[1]
|
||||
return tasks.filter(t => ids.includes(t.id_)).map((t) => {
|
||||
if (t.id_ === stopId && t.status === TaskStatus.WIP) {
|
||||
return stopCommand([t], [stopId])[0]
|
||||
}
|
||||
if (t.id_ === startId && t.status !== TaskStatus.WIP) {
|
||||
return beginCommand([t], [startId])[0]
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function archiveCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
|
||||
if (ids.length === 0) {
|
||||
// Archive by tag
|
||||
const tag = (cmd?.id?.match(/^(@.*)/) || []).pop()
|
||||
if (tag) {
|
||||
return tasks.filter(t => t.tag === tag).map(t => ({
|
||||
...t,
|
||||
archived: true,
|
||||
} as Task))
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Archive by Ids
|
||||
return tasks.filter(t => ids.includes(t.id_)).map(t => ({
|
||||
...t,
|
||||
archived: true,
|
||||
} as Task))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function restoreCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
|
||||
if (ids.length === 0) {
|
||||
// Archive by tag
|
||||
const tag = (cmd?.id?.match(/^(@.*)/) || []).pop()
|
||||
if (tag) {
|
||||
return tasks.filter(t => t.tag === tag).map(t => ({
|
||||
...t,
|
||||
archived: false,
|
||||
} as Task))
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Archive by Ids
|
||||
return tasks.filter(t => ids.includes(t.id)).map(t => ({
|
||||
...t,
|
||||
archived: false,
|
||||
} as Task))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function insertTaskCommand(cmd: Command) {
|
||||
const tag = cmd?.tag || '@uncategorized'
|
||||
const task = cmd?.text
|
||||
if (task && task.length) {
|
||||
return {
|
||||
tag,
|
||||
title: task,
|
||||
dueDate: null,
|
||||
} as Pick<Task, 'title' | 'dueDate' | 'tag'>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function editTaskCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
|
||||
const id = ids[0]
|
||||
const task = cmd?.text
|
||||
if (task && task.length) {
|
||||
return tasks.filter(t => t.id === id).map(t => ({
|
||||
...t,
|
||||
title: task,
|
||||
} as Task))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function dueCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
|
||||
const id = ids && ids.length ? ids[0] : null
|
||||
const text = (cmd?.text || '').trim()
|
||||
if (id) {
|
||||
return tasks.filter(t => t.id_ === id).map((t: Task) => {
|
||||
if (/^(?:clear|none|remove)$/i.test(text)) {
|
||||
t.dueDate = null
|
||||
}
|
||||
else if (text && text.length) {
|
||||
t.dueDate = parseDueDate(text)
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function tagRenameCommand(cmd: Command, tasks: Task[]) {
|
||||
const [from, to] = cmd?.tag?.split(' ') || []
|
||||
if (!from || !to)
|
||||
return []
|
||||
return tasks.filter(t => t.tag.match(from)).map(t => ({
|
||||
...t,
|
||||
tag: to,
|
||||
} as Task))
|
||||
}
|
||||
|
||||
// export function hideCommand(updateCandidate, cmd) {
|
||||
// updateCandidate = (() => {
|
||||
// switch (cmd.text) {
|
||||
// case 'finished':
|
||||
// case 'done':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// done: false,
|
||||
// },
|
||||
// }
|
||||
// case 'flag':
|
||||
// case 'flagged':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// flagged: false,
|
||||
// },
|
||||
// }
|
||||
// case 'ongoing':
|
||||
// case 'wip':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// wip: false,
|
||||
// },
|
||||
// }
|
||||
// case 'pending':
|
||||
// case 'wait':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// wait: false,
|
||||
// },
|
||||
// }
|
||||
// default:
|
||||
// return updateCandidate
|
||||
// }
|
||||
// })()
|
||||
// return updateCandidate
|
||||
// }
|
||||
//
|
||||
// export function showCommand(updateCandidate, cmd) {
|
||||
// updateCandidate = (() => {
|
||||
// switch (cmd.text) {
|
||||
// case 'finished':
|
||||
// case 'done':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// done: true,
|
||||
// },
|
||||
// }
|
||||
// case 'flag':
|
||||
// case 'flagged':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// flagged: true,
|
||||
// },
|
||||
// }
|
||||
// case 'wip':
|
||||
// case 'ongoing':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// wip: true,
|
||||
// },
|
||||
// }
|
||||
// case 'pending':
|
||||
// case 'wait':
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// taskVisibility: {
|
||||
// ...updateCandidate.taskVisibility,
|
||||
// wait: true,
|
||||
// },
|
||||
// }
|
||||
// default:
|
||||
// return updateCandidate
|
||||
// }
|
||||
// })()
|
||||
// return updateCandidate
|
||||
// }
|
||||
//
|
||||
// export function searchCommand(updateCandidate: any, cmd) {
|
||||
// if (cmd.command.match(/search/i)) {
|
||||
// updateCandidate = {
|
||||
// ...updateCandidate,
|
||||
// filterBy: cmd.text,
|
||||
// }
|
||||
// }
|
||||
// return updateCandidate
|
||||
// }
|
||||
//
|
||||
// export function otherCommand(updateCandidate, cmd, tasks: Task[]) {
|
||||
// updateCandidate = (() => {
|
||||
// const commandText = cmd.command.toLowerCase()
|
||||
// if (commandText === 'help') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// showHelp: true,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'quickhelp') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// showQuickHelp: true,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'today') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// showToday: !state.showToday,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'dark') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// darkMode: true,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'light') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// darkMode: false,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'setting') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// showSettings: true,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'customize') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// showCustomCSS: !updateCandidate.showCustomCSS,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'list-archived') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// showArchived: !updateCandidate.showArchived,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'login') {
|
||||
// // OK, Let me explain the weird @demo stuff here:
|
||||
// // If the user is already has their data on another machine, and
|
||||
// // they opened this app on a new machine, then login right away,
|
||||
// // the tasks in the range of 1..12 will be conflict with the demo
|
||||
// // tasks. So, we will explicitly remove these demo tasks if they're
|
||||
// // actually a demo, when login.
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// tasks: updateCandidate.tasks.filter(t =>
|
||||
// (t.id - 1) * (t.id - 12) <= 0 ? t.tag !== '@demo' : true,
|
||||
// ),
|
||||
// userWantToLogin: true,
|
||||
// }
|
||||
// }
|
||||
// else if (commandText === 'logout') {
|
||||
// return {
|
||||
// ...updateCandidate,
|
||||
// authToken: '',
|
||||
// userName: '',
|
||||
// userWantToLogin: true,
|
||||
// }
|
||||
// }
|
||||
// else {
|
||||
// return updateCandidate
|
||||
// }
|
||||
// })()
|
||||
// return updateCandidate
|
||||
// }
|
||||
27
src/utils/helpers.ts
Normal file
27
src/utils/helpers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Task } from '../types.ts'
|
||||
import Sherlock from 'sherlockjs'
|
||||
|
||||
export function parseDueDate(text: string): number | null {
|
||||
try {
|
||||
const parsed = Sherlock.parse(text)
|
||||
if (parsed && parsed.startDate) {
|
||||
return parsed.startDate.getTime()
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
const ts = Date.parse(text)
|
||||
return Number.isNaN(ts) ? null : ts
|
||||
}
|
||||
|
||||
export function stopWorkLogging(t: Task) {
|
||||
if (t.logs && t.logs.length) {
|
||||
const lastLog = t.logs[t.logs.length - 1]
|
||||
if (lastLog.start && !lastLog.end) {
|
||||
lastLog.end = Date.now()
|
||||
}
|
||||
}
|
||||
else {
|
||||
t.logs = []
|
||||
}
|
||||
return t
|
||||
}
|
||||
212
src/utils/parser.ts
Normal file
212
src/utils/parser.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
export type Command = {
|
||||
command: string
|
||||
tag?: string
|
||||
text?: string
|
||||
id?: string
|
||||
} | null
|
||||
function parseTaskCommand(str: string) {
|
||||
return str.match(/^(t(?:ask)?)\s(@\S*[0-9a-z'-])?([\s\S]*)/i)
|
||||
}
|
||||
function parseEditCommand(str: string) {
|
||||
return str.match(/^(e(?:dit)?)\s(\d+)([\s\S]*)/i)
|
||||
}
|
||||
const parseDueCommand = (str: string) => str.match(/^(due)\s(\d+)([\s\S]*)/i)
|
||||
function parseMoveCommand(str: string) {
|
||||
return str.match(/^(mv|move)\s(?:(\d+)\s)+(@\S*[0-9a-z'-])/i)
|
||||
}
|
||||
function parseCheckCommand(str: string) {
|
||||
return str.match(/^(c(?:heck)?)\s([\s\S]*)/i)
|
||||
}
|
||||
function parseBeginCommand(str: string) {
|
||||
return str.match(/^(b(?:egin)?)\s([\s\S]*)/i)
|
||||
}
|
||||
function parseDeleteCommand(str: string) {
|
||||
return str.match(/^(d(?:elete)?)\s([\s\S]*)/i)
|
||||
}
|
||||
const parseFlagCommand = (str: string) => str.match(/^(fl(?:ag)?)\s([\s\S]*)/i)
|
||||
const parseStopCommand = (str: string) => str.match(/^(st(?:op)?)\s([\s\S]*)/i)
|
||||
function parseSwitchCommand(str: string) {
|
||||
return str.match(/^(sw(?:itch)?)\s([\s\S]*)/i)
|
||||
}
|
||||
function parseArchiveCommand(str: string) {
|
||||
return str.match(/^(a(?:rchive)?)\s([\s\S]*)/i)
|
||||
}
|
||||
function parseRestoreCommand(str: string) {
|
||||
return str.match(/^(re(?:store)?)\s([\s\S]*)/i)
|
||||
}
|
||||
function parseTagRenameCommand(str: string) {
|
||||
return str.match(
|
||||
/^(tr|tagre|tagrename)\s(@\S*[0-9a-z'-])\s(@\S*[0-9a-z'-])/i,
|
||||
)
|
||||
}
|
||||
function parseVisibilityCommand(str: string) {
|
||||
return str.match(
|
||||
/^(hide|show)\s(done|finished|wait|pending|ongoing|wip|flag|flagged)\b/i,
|
||||
)
|
||||
}
|
||||
function parseOtherCommand(str: string) {
|
||||
return str.match(
|
||||
/^(help|quickhelp|today|dark|light|setting|customize|list-archived|login|logout)/i,
|
||||
)
|
||||
}
|
||||
const parseTextFallback = (str: string) => str.match(/(\b\w*\b\S*\s)/)
|
||||
const parseSearch = (str: string) => str.match(/^\/.*/)
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
function compileTaskCommand(input: string) {
|
||||
const matchTask = parseTaskCommand(input)
|
||||
if (matchTask) {
|
||||
return {
|
||||
command: matchTask[1],
|
||||
tag: matchTask[2],
|
||||
text: matchTask[3].trim(),
|
||||
} as Command
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileEditCommand(input: string) {
|
||||
const matchEdit = parseEditCommand(input)
|
||||
if (matchEdit) {
|
||||
return {
|
||||
command: matchEdit[1],
|
||||
id: matchEdit[2],
|
||||
text: matchEdit[3].trim(),
|
||||
} as Command
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileDueCommand(input: string) {
|
||||
const matchDue = parseDueCommand(input)
|
||||
if (matchDue) {
|
||||
return {
|
||||
command: matchDue[1],
|
||||
id: matchDue[2],
|
||||
text: matchDue[3].trim(),
|
||||
} as Command
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileMoveCommand(input: string) {
|
||||
const matchMove = parseMoveCommand(input)
|
||||
if (matchMove) {
|
||||
return {
|
||||
command: matchMove[1],
|
||||
id: matchMove[2],
|
||||
tag: matchMove[3],
|
||||
} as Command
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileTagReCommand(input: string) {
|
||||
const matchTagRe = parseTagRenameCommand(input)
|
||||
if (matchTagRe) {
|
||||
return {
|
||||
command: matchTagRe[1],
|
||||
tag: `${matchTagRe[2]} ${matchTagRe[3]}`,
|
||||
} as Command
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileMathOtherCommand(input: string) {
|
||||
const matchOther
|
||||
= parseCheckCommand(input)
|
||||
|| parseBeginCommand(input)
|
||||
|| parseDeleteCommand(input)
|
||||
|| parseFlagCommand(input)
|
||||
|| parseStopCommand(input)
|
||||
|| parseSwitchCommand(input)
|
||||
|| parseArchiveCommand(input)
|
||||
|| parseRestoreCommand(input)
|
||||
if (matchOther) {
|
||||
return {
|
||||
command: matchOther[1],
|
||||
id: matchOther[2],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileVisibilityCommand(input: string) {
|
||||
const matchVisibility = parseVisibilityCommand(input)
|
||||
if (matchVisibility) {
|
||||
return {
|
||||
command: matchVisibility[1],
|
||||
text: matchVisibility[2],
|
||||
} as Command
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileHelpCommand(input: string) {
|
||||
const matchHelp = parseOtherCommand(input)
|
||||
if (matchHelp) {
|
||||
return {
|
||||
command: matchHelp[1],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compileSearchCommand(input: string) {
|
||||
if (parseSearch(input)) {
|
||||
return {
|
||||
command: 'search',
|
||||
text: input.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
export function parseCommand(input: string): Command {
|
||||
let ret
|
||||
|
||||
ret = compileSearchCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileTaskCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileEditCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileDueCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileMoveCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileTagReCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileMathOtherCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileVisibilityCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
ret = compileHelpCommand(input)
|
||||
if (ret)
|
||||
return ret
|
||||
|
||||
if (parseTextFallback(input)) {
|
||||
return compileTaskCommand(`task ${input}`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
11
src/vite-env.d.ts
vendored
11
src/vite-env.d.ts
vendored
@@ -1,7 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module 'sherlockjs';
|
||||
|
||||
Reference in New Issue
Block a user