From e106150ed4edd31bc9a4f67cdee2dcef344306d4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 2 Aug 2026 20:28:58 +0200 Subject: [PATCH] feat(tasks): add bulk quick-add creation to the task store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/helpers/parseSubtasksViaIndention.ts | 4 +- frontend/src/helpers/runWrites.test.ts | 39 +++++++ frontend/src/helpers/runWrites.ts | 15 +++ frontend/src/stores/tasks.test.ts | 38 +------ frontend/src/stores/tasks.ts | 106 ++++++++++++------ 5 files changed, 128 insertions(+), 74 deletions(-) create mode 100644 frontend/src/helpers/runWrites.test.ts create mode 100644 frontend/src/helpers/runWrites.ts 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,