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:
kolaente
2026-08-02 21:11:28 +02:00
parent aa0e9d0c01
commit e106150ed4
5 changed files with 128 additions and 74 deletions
@@ -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) {
+39
View File
@@ -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)
})
})
+15
View File
@@ -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)
}
}
+1 -37
View File
@@ -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())
+71 -35
View File
@@ -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,