diff --git a/frontend/src/helpers/parseSubtasksViaIndention.ts b/frontend/src/helpers/parseSubtasksViaIndention.ts index 0b6b4877b..5e74b1c33 100644 --- a/frontend/src/helpers/parseSubtasksViaIndention.ts +++ b/frontend/src/helpers/parseSubtasksViaIndention.ts @@ -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) { diff --git a/frontend/src/helpers/runWrites.test.ts b/frontend/src/helpers/runWrites.test.ts new file mode 100644 index 000000000..78077eb93 --- /dev/null +++ b/frontend/src/helpers/runWrites.test.ts @@ -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) + }) +}) diff --git a/frontend/src/helpers/runWrites.ts b/frontend/src/helpers/runWrites.ts new file mode 100644 index 000000000..6876dd7a2 --- /dev/null +++ b/frontend/src/helpers/runWrites.ts @@ -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( + items: readonly T[], + write: (item: T) => Promise, + concurrent: boolean, +): Promise { + if (concurrent) { + await Promise.all(items.map(item => write(item))) + return + } + for (const item of items) { + await write(item) + } +} diff --git a/frontend/src/stores/tasks.test.ts b/frontend/src/stores/tasks.test.ts index 9bb59ded4..535de2685 100644 --- a/frontend/src/stores/tasks.test.ts +++ b/frontend/src/stores/tasks.test.ts @@ -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()) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 8ad61fcd2..f91a00ab8 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -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( - items: readonly T[], - write: (item: T) => Promise, - concurrent: boolean, -): Promise { - 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, - ) { - 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, + ) { + 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,