mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-24 19:50:09 -05:00
feat(tasks): add bulk quick-add creation to the task store
createNewTasksBulk builds full task payloads from quick add magic titles (extracted from createNewTask so both paths share it) and hands them to the service in one call. Label application failures toast and continue — the tasks already exist server-side, failing the whole action would invite duplicate resubmits. runWrites moves to helpers/ so components can use the write-serializing util without importing the store module.
This commit is contained in:
@@ -20,11 +20,11 @@ export function parseSubtasksViaIndention(taskTitles: string, prefixMode: Prefix
|
||||
let titles = taskTitles
|
||||
.split(/[\r\n]+/)
|
||||
.filter(t => t.replace(/\s/g, '').length > 0) // Remove titles which are empty or only contain spaces / tabs
|
||||
|
||||
|
||||
if (titles.length == 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
const spaceOnFirstLine = /^(\t| )+/
|
||||
const spaces = spaceOnFirstLine.exec(titles[0])
|
||||
if (spaces !== null) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
|
||||
import {runWrites} from './runWrites'
|
||||
|
||||
describe('runWrites', () => {
|
||||
function deferredWrite() {
|
||||
const inFlight: string[] = []
|
||||
let maxConcurrent = 0
|
||||
const completed: string[] = []
|
||||
const write = async (item: string) => {
|
||||
inFlight.push(item)
|
||||
maxConcurrent = Math.max(maxConcurrent, inFlight.length)
|
||||
await Promise.resolve()
|
||||
inFlight.splice(inFlight.indexOf(item), 1)
|
||||
completed.push(item)
|
||||
}
|
||||
return {write, completed, getMaxConcurrent: () => maxConcurrent}
|
||||
}
|
||||
|
||||
it('runs all writes in parallel when concurrent', async () => {
|
||||
const {write, completed, getMaxConcurrent} = deferredWrite()
|
||||
await runWrites(['a', 'b', 'c'], write, true)
|
||||
expect(completed).toHaveLength(3)
|
||||
expect(getMaxConcurrent()).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('runs writes one at a time when not concurrent', async () => {
|
||||
const {write, completed, getMaxConcurrent} = deferredWrite()
|
||||
await runWrites(['a', 'b', 'c'], write, false)
|
||||
expect(completed).toEqual(['a', 'b', 'c'])
|
||||
expect(getMaxConcurrent()).toBe(1)
|
||||
})
|
||||
|
||||
it('does nothing for an empty list', async () => {
|
||||
const {write, completed} = deferredWrite()
|
||||
await runWrites([], write, false)
|
||||
expect(completed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// runWrites applies a write to each item. SQLite deadlocks on concurrent writes
|
||||
// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize.
|
||||
export async function runWrites<T>(
|
||||
items: readonly T[],
|
||||
write: (item: T) => Promise<unknown>,
|
||||
concurrent: boolean,
|
||||
): Promise<void> {
|
||||
if (concurrent) {
|
||||
await Promise.all(items.map(item => write(item)))
|
||||
return
|
||||
}
|
||||
for (const item of items) {
|
||||
await write(item)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/stores/base', () => ({
|
||||
useBaseStore: () => ({setHasTasks: vi.fn()}),
|
||||
}))
|
||||
|
||||
import {buildDefaultRemindersForQuickAdd, runWrites, useTaskStore} from './tasks'
|
||||
import {buildDefaultRemindersForQuickAdd, useTaskStore} from './tasks'
|
||||
import {useLabelStore} from './labels'
|
||||
import LabelModel from '@/models/label'
|
||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||
@@ -64,42 +64,6 @@ describe('buildDefaultRemindersForQuickAdd', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWrites', () => {
|
||||
function deferredWrite() {
|
||||
const inFlight: string[] = []
|
||||
let maxConcurrent = 0
|
||||
const completed: string[] = []
|
||||
const write = async (item: string) => {
|
||||
inFlight.push(item)
|
||||
maxConcurrent = Math.max(maxConcurrent, inFlight.length)
|
||||
await Promise.resolve()
|
||||
inFlight.splice(inFlight.indexOf(item), 1)
|
||||
completed.push(item)
|
||||
}
|
||||
return {write, completed, getMaxConcurrent: () => maxConcurrent}
|
||||
}
|
||||
|
||||
it('runs all writes in parallel when concurrent', async () => {
|
||||
const {write, completed, getMaxConcurrent} = deferredWrite()
|
||||
await runWrites(['a', 'b', 'c'], write, true)
|
||||
expect(completed).toHaveLength(3)
|
||||
expect(getMaxConcurrent()).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('runs writes one at a time when not concurrent', async () => {
|
||||
const {write, completed, getMaxConcurrent} = deferredWrite()
|
||||
await runWrites(['a', 'b', 'c'], write, false)
|
||||
expect(completed).toEqual(['a', 'b', 'c'])
|
||||
expect(getMaxConcurrent()).toBe(1)
|
||||
})
|
||||
|
||||
it('does nothing for an empty list', async () => {
|
||||
const {write, completed} = deferredWrite()
|
||||
await runWrites([], write, false)
|
||||
expect(completed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureLabelsExist', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
@@ -36,6 +36,8 @@ import ProjectUserService from '@/services/projectUsers'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection'
|
||||
import {getRandomColorHex} from '@/helpers/color/randomColor'
|
||||
import {runWrites} from '@/helpers/runWrites'
|
||||
import {error} from '@/message'
|
||||
import {REPEAT_TYPES} from '@/types/IRepeatAfter'
|
||||
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
||||
|
||||
@@ -60,22 +62,6 @@ export function buildDefaultRemindersForQuickAdd(
|
||||
}))
|
||||
}
|
||||
|
||||
// runWrites applies a write to each item. SQLite deadlocks on concurrent writes
|
||||
// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize.
|
||||
export async function runWrites<T>(
|
||||
items: readonly T[],
|
||||
write: (item: T) => Promise<unknown>,
|
||||
concurrent: boolean,
|
||||
): Promise<void> {
|
||||
if (concurrent) {
|
||||
await Promise.all(items.map(item => write(item)))
|
||||
return
|
||||
}
|
||||
for (const item of items) {
|
||||
await write(item)
|
||||
}
|
||||
}
|
||||
|
||||
// IDEA: maybe use a small fuzzy search here to prevent errors
|
||||
function findPropertyByValue(object, key, value, fuzzy = false) {
|
||||
return Object.values(object).find(l => {
|
||||
@@ -462,41 +448,35 @@ export const useTaskStore = defineStore('task', () => {
|
||||
return foundProjectId
|
||||
}
|
||||
|
||||
async function createNewTask({
|
||||
async function buildTaskFromQuickAddTitle({
|
||||
title,
|
||||
bucketId,
|
||||
projectId,
|
||||
position,
|
||||
index,
|
||||
} :
|
||||
} :
|
||||
Partial<ITask>,
|
||||
) {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
): Promise<{task: TaskModel, parsedLabels: string[]}> {
|
||||
const quickAddMagicMode = authStore.settings.frontendSettings.quickAddMagicMode
|
||||
const parsedTask = parseTaskText(title, quickAddMagicMode)
|
||||
|
||||
if(parsedTask.text === '') {
|
||||
const taskService = new TaskService()
|
||||
try {
|
||||
return taskService.create(new TaskModel({
|
||||
return {
|
||||
task: new TaskModel({
|
||||
title,
|
||||
projectId,
|
||||
bucketId,
|
||||
position,
|
||||
index,
|
||||
}))
|
||||
} finally {
|
||||
cancel()
|
||||
}),
|
||||
parsedLabels: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const foundProjectId = await findProjectId({
|
||||
project: parsedTask.project,
|
||||
projectId: projectId || 0,
|
||||
})
|
||||
|
||||
|
||||
if(foundProjectId === null || foundProjectId === 0) {
|
||||
cancel()
|
||||
throw new Error('NO_PROJECT')
|
||||
}
|
||||
|
||||
@@ -513,7 +493,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
|
||||
// I don't know why, but it all goes up in flames when I just pass in the date normally.
|
||||
const dueDate = parsedTask.date !== null ? new Date(parsedTask.date).toISOString() : null
|
||||
|
||||
|
||||
const task = new TaskModel({
|
||||
title: cleanedTitle,
|
||||
projectId: foundProjectId,
|
||||
@@ -522,7 +502,6 @@ export const useTaskStore = defineStore('task', () => {
|
||||
assignees,
|
||||
bucketId: bucketId || 0,
|
||||
position,
|
||||
index,
|
||||
})
|
||||
task.repeatAfter = parsedTask.repeats
|
||||
task.reminders = buildDefaultRemindersForQuickAdd(
|
||||
@@ -534,17 +513,73 @@ export const useTaskStore = defineStore('task', () => {
|
||||
task.repeatMode = TASK_REPEAT_MODES.REPEAT_MODE_MONTH
|
||||
}
|
||||
|
||||
const taskService = new TaskService()
|
||||
return {task, parsedLabels: parsedTask.labels}
|
||||
}
|
||||
|
||||
async function createNewTask({
|
||||
title,
|
||||
bucketId,
|
||||
projectId,
|
||||
position,
|
||||
} :
|
||||
Partial<ITask>,
|
||||
) {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
try {
|
||||
const {task, parsedLabels} = await buildTaskFromQuickAddTitle({
|
||||
title,
|
||||
bucketId,
|
||||
projectId,
|
||||
position,
|
||||
})
|
||||
|
||||
const taskService = new TaskService()
|
||||
const createdTask = await taskService.create(task)
|
||||
return await addLabelsToTask({
|
||||
task: createdTask,
|
||||
parsedLabels: parsedTask.labels,
|
||||
parsedLabels,
|
||||
})
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the created tasks aligned 1:1 with entries (null = not created),
|
||||
// error is null when nothing failed.
|
||||
async function createNewTasksBulk(
|
||||
entries: {title: string, projectId: number}[],
|
||||
): Promise<{tasks: (ITask | null)[], error: unknown}> {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
try {
|
||||
const built = await Promise.all(entries.map(async ({title, projectId}) => {
|
||||
const {task, parsedLabels} = await buildTaskFromQuickAddTitle({title, projectId})
|
||||
return {task, parsedLabels}
|
||||
}))
|
||||
|
||||
const taskService = new TaskService()
|
||||
const {tasks, error: bulkError} = await taskService.bulkCreate(built.map(b => b.task))
|
||||
|
||||
const withLabels = built
|
||||
.map(({parsedLabels}, index) => ({task: tasks[index], parsedLabels}))
|
||||
.filter(c => c.task !== null && c.parsedLabels.length > 0)
|
||||
|
||||
try {
|
||||
await runWrites(
|
||||
withLabels,
|
||||
c => addLabelsToTask({task: c.task as ITask, parsedLabels: c.parsedLabels}),
|
||||
configStore.concurrentWrites,
|
||||
)
|
||||
} catch (e) {
|
||||
// The tasks exist by now, so failing here must not look like the
|
||||
// whole creation failed — the caller would let the user resubmit.
|
||||
error(e)
|
||||
}
|
||||
|
||||
return {tasks, error: bulkError}
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async function setCoverImage(task: ITask, attachment: IAttachment | null) {
|
||||
return update({
|
||||
@@ -615,6 +650,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
removeLabel,
|
||||
addLabelsToTask,
|
||||
createNewTask,
|
||||
createNewTasksBulk,
|
||||
setCoverImage,
|
||||
findProjectId,
|
||||
ensureLabelsExist,
|
||||
|
||||
Reference in New Issue
Block a user