feat: make magic todo status editable
This commit is contained in:
@@ -1932,6 +1932,50 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
database.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updates a derived todo and its source checklist together', async () => {
|
||||||
|
const database = await createDatabase()
|
||||||
|
const note = database.createMagicNote({ title: '发布笔记' })
|
||||||
|
database.createMagicNoteEntry({
|
||||||
|
noteId: note.id,
|
||||||
|
content: {
|
||||||
|
version: 1,
|
||||||
|
ops: [
|
||||||
|
{ insert: '核对发布材料' },
|
||||||
|
{ insert: '\n', attributes: { list: 'unchecked' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plainText: '核对发布材料'
|
||||||
|
})
|
||||||
|
const todo = database.listMagicTodos()[0]!
|
||||||
|
|
||||||
|
const updated = database.updateMagicTodo({
|
||||||
|
todoId: todo.id,
|
||||||
|
completed: true,
|
||||||
|
expectedRevision: todo.revision
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updated).toMatchObject({
|
||||||
|
id: todo.id,
|
||||||
|
completed: true,
|
||||||
|
revision: todo.revision + 1
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
database.getMagicNote(note.id).entries[0]!.content.ops
|
||||||
|
).toEqual([
|
||||||
|
{ insert: '核对发布材料' },
|
||||||
|
{ insert: '\n', attributes: { list: 'checked' } }
|
||||||
|
])
|
||||||
|
expect(() =>
|
||||||
|
database.updateMagicTodo({
|
||||||
|
todoId: todo.id,
|
||||||
|
completed: false,
|
||||||
|
expectedRevision: todo.revision
|
||||||
|
})
|
||||||
|
).toThrow('待办已被更新,请刷新后重试')
|
||||||
|
|
||||||
|
database.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('protects magic note records from stale revisions', async () => {
|
it('protects magic note records from stale revisions', async () => {
|
||||||
const database = await createDatabase()
|
const database = await createDatabase()
|
||||||
const note = database.createMagicNote({ title: '并发笔记' })
|
const note = database.createMagicNote({ title: '并发笔记' })
|
||||||
|
|||||||
@@ -51,13 +51,16 @@ import {
|
|||||||
type MagicNoteRichContent,
|
type MagicNoteRichContent,
|
||||||
type MagicNoteSearchResult,
|
type MagicNoteSearchResult,
|
||||||
type MagicNoteSummary,
|
type MagicNoteSummary,
|
||||||
type MagicTodoItem
|
type MagicTodoItem,
|
||||||
|
type MagicTodoUpdateInput
|
||||||
} from '../../shared/magic-notes-contracts'
|
} from '../../shared/magic-notes-contracts'
|
||||||
import type { ComputerControlAuditEvent } from '../computer-control/audit'
|
import type { ComputerControlAuditEvent } from '../computer-control/audit'
|
||||||
import {
|
import {
|
||||||
magicNoteChecklistItems,
|
magicNoteChecklistItems,
|
||||||
magicNoteImageBytes,
|
magicNoteImageBytes,
|
||||||
magicNotePreview
|
magicNotePlainText,
|
||||||
|
magicNotePreview,
|
||||||
|
setMagicNoteChecklistCompletion
|
||||||
} from '../magic-notes/rich-content'
|
} from '../magic-notes/rich-content'
|
||||||
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
||||||
|
|
||||||
@@ -2164,6 +2167,86 @@ export class AssistantDatabase {
|
|||||||
return toMagicTodo(row)
|
return toMagicTodo(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateMagicTodo(input: MagicTodoUpdateInput): MagicTodoItem {
|
||||||
|
const database = this.requireDatabase()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
database.exec('BEGIN IMMEDIATE')
|
||||||
|
try {
|
||||||
|
const existing = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.note_id, t.entry_id, t.source_index, t.completed,
|
||||||
|
t.revision AS todo_revision,
|
||||||
|
e.content_json, e.revision AS entry_revision
|
||||||
|
FROM magic_todos t
|
||||||
|
INNER JOIN magic_note_entries e ON e.id = t.entry_id
|
||||||
|
WHERE t.id = ? AND t.source = 'note'`
|
||||||
|
)
|
||||||
|
.get(input.todoId) as
|
||||||
|
| {
|
||||||
|
note_id: string
|
||||||
|
entry_id: string
|
||||||
|
source_index: number
|
||||||
|
completed: number
|
||||||
|
todo_revision: number
|
||||||
|
content_json: string
|
||||||
|
entry_revision: number
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error('待办不存在')
|
||||||
|
}
|
||||||
|
if (existing.todo_revision !== input.expectedRevision) {
|
||||||
|
throw new Error('待办已被更新,请刷新后重试')
|
||||||
|
}
|
||||||
|
if (Boolean(existing.completed) === input.completed) {
|
||||||
|
database.exec('COMMIT')
|
||||||
|
return this.getMagicTodo(input.todoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = setMagicNoteChecklistCompletion(
|
||||||
|
JSON.parse(existing.content_json) as MagicNoteRichContent,
|
||||||
|
existing.source_index,
|
||||||
|
input.completed
|
||||||
|
)
|
||||||
|
const result = database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE magic_note_entries
|
||||||
|
SET content_json = ?, plain_text = ?, comments_json = '[]',
|
||||||
|
analyzed_at = NULL, revision = revision + 1, updated_at = ?
|
||||||
|
WHERE id = ? AND revision = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
JSON.stringify(content),
|
||||||
|
magicNotePlainText(content),
|
||||||
|
now,
|
||||||
|
existing.entry_id,
|
||||||
|
existing.entry_revision
|
||||||
|
)
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error('记录已被更新,请刷新后重试')
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE magic_notes
|
||||||
|
SET revision = revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(now, existing.note_id)
|
||||||
|
this.syncMagicNoteTodos(
|
||||||
|
database,
|
||||||
|
existing.note_id,
|
||||||
|
existing.entry_id,
|
||||||
|
content,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
database.exec('COMMIT')
|
||||||
|
} catch (error) {
|
||||||
|
database.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.getMagicTodo(input.todoId)
|
||||||
|
}
|
||||||
|
|
||||||
saveMagicTodoAnalysis(input: {
|
saveMagicTodoAnalysis(input: {
|
||||||
todoId: string
|
todoId: string
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
|
|||||||
+12
-1
@@ -86,7 +86,8 @@ import {
|
|||||||
magicNoteEntryDeleteSchema,
|
magicNoteEntryDeleteSchema,
|
||||||
magicNoteEntryUpdateSchema,
|
magicNoteEntryUpdateSchema,
|
||||||
magicNoteUpdateSchema,
|
magicNoteUpdateSchema,
|
||||||
magicTodoIdSchema
|
magicTodoIdSchema,
|
||||||
|
magicTodoUpdateSchema
|
||||||
} from '../shared/magic-notes-contracts'
|
} from '../shared/magic-notes-contracts'
|
||||||
import {
|
import {
|
||||||
assistantIdSchema,
|
assistantIdSchema,
|
||||||
@@ -3495,6 +3496,16 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
ipcChannels.magicTodosUpdate,
|
||||||
|
(event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
return assistantDatabase.updateMagicTodo(
|
||||||
|
magicTodoUpdateSchema.parse(input)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
ipcChannels.magicTodosAnalyze,
|
ipcChannels.magicTodosAnalyze,
|
||||||
async (event, input: unknown) => {
|
async (event, input: unknown) => {
|
||||||
|
|||||||
@@ -785,6 +785,11 @@ const desktopApi: DesktopApi = {
|
|||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.magicTodosList
|
ipcChannels.magicTodosList
|
||||||
) as Promise<MagicTodosSnapshot>,
|
) as Promise<MagicTodosSnapshot>,
|
||||||
|
updateTodo: (input) =>
|
||||||
|
ipcRenderer.invoke(
|
||||||
|
ipcChannels.magicTodosUpdate,
|
||||||
|
input
|
||||||
|
) as Promise<MagicTodoItem>,
|
||||||
analyzeTodo: (todoId, options) =>
|
analyzeTodo: (todoId, options) =>
|
||||||
ipcRenderer.invoke(ipcChannels.magicTodosAnalyze, {
|
ipcRenderer.invoke(ipcChannels.magicTodosAnalyze, {
|
||||||
todoId,
|
todoId,
|
||||||
|
|||||||
@@ -479,6 +479,9 @@ const api: DesktopApi = {
|
|||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
}),
|
}),
|
||||||
listTodos: vi.fn(async () => ({ todos: [] })),
|
listTodos: vi.fn(async () => ({ todos: [] })),
|
||||||
|
updateTodo: vi.fn(async () => {
|
||||||
|
throw new Error('not used')
|
||||||
|
}),
|
||||||
analyzeTodo: vi.fn(async () => {
|
analyzeTodo: vi.fn(async () => {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
|||||||
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
||||||
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
|
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
|
||||||
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
|
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
|
||||||
|
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
||||||
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
|
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
|
||||||
const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>()
|
const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>()
|
||||||
let analysisEventListener:
|
let analysisEventListener:
|
||||||
@@ -206,6 +207,11 @@ beforeEach(() => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
createEntry.mockResolvedValue(createdDetail)
|
createEntry.mockResolvedValue(createdDetail)
|
||||||
|
updateTodo.mockImplementation(async (input) => ({
|
||||||
|
...noteTodo,
|
||||||
|
completed: input.completed,
|
||||||
|
revision: noteTodo.revision + 1
|
||||||
|
}))
|
||||||
analyze.mockResolvedValue({
|
analyze.mockResolvedValue({
|
||||||
...createdDetail,
|
...createdDetail,
|
||||||
entries: createdDetail.entries.map((entry) =>
|
entries: createdDetail.entries.map((entry) =>
|
||||||
@@ -258,6 +264,7 @@ beforeEach(() => {
|
|||||||
remove,
|
remove,
|
||||||
createEntry,
|
createEntry,
|
||||||
analyze,
|
analyze,
|
||||||
|
updateTodo,
|
||||||
analyzeTodo,
|
analyzeTodo,
|
||||||
analyzeDraft,
|
analyzeDraft,
|
||||||
onAnalysisEvent
|
onAnalysisEvent
|
||||||
@@ -319,8 +326,12 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
).toHaveAttribute('aria-pressed', 'true')
|
).toHaveAttribute('aria-pressed', 'true')
|
||||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||||
|
const selectedTodoButton = screen
|
||||||
|
.getAllByText('核对发布材料')
|
||||||
|
.find((element) => element.tagName === 'STRONG')
|
||||||
|
?.closest('button')
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: /核对发布材料/ })
|
selectedTodoButton
|
||||||
).toHaveAttribute('aria-pressed', 'true')
|
).toHaveAttribute('aria-pressed', 'true')
|
||||||
expect(onNotify).not.toHaveBeenCalledWith(
|
expect(onNotify).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -355,7 +366,11 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
Node.DOCUMENT_POSITION_FOLLOWING
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
).toBeTruthy()
|
).toBeTruthy()
|
||||||
|
|
||||||
expect(screen.getByLabelText('未完成')).toBeInTheDocument()
|
expect(
|
||||||
|
screen.getAllByRole('button', {
|
||||||
|
name: `标记为已完成:${noteTodo.title}`
|
||||||
|
})
|
||||||
|
).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: '打开原笔记修改' })
|
screen.getByRole('button', { name: '打开原笔记修改' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -571,7 +586,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('groups note-backed todos in a directory view', async () => {
|
it('only shows note-backed todos in a directory view', async () => {
|
||||||
render(
|
render(
|
||||||
<MagicNotesWorkspace onNotify={onNotify} />
|
<MagicNotesWorkspace onNotify={onNotify} />
|
||||||
)
|
)
|
||||||
@@ -581,12 +596,45 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByRole('button', { name: '新建待办' })
|
screen.queryByRole('button', { name: '新建待办' })
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '目录视图' }))
|
expect(
|
||||||
|
screen.queryByRole('group', { name: '待办列表方式' })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
expect(screen.getByText('发布笔记')).toBeInTheDocument()
|
expect(screen.getByText('发布笔记')).toBeInTheDocument()
|
||||||
expect(screen.getByText('演示笔记')).toBeInTheDocument()
|
expect(screen.getByText('演示笔记')).toBeInTheDocument()
|
||||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks a todo completed from the standalone todo tab', async () => {
|
||||||
|
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||||
|
|
||||||
|
await screen.findByText('记录正文')
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole('button', {
|
||||||
|
name: `标记为已完成:${noteTodo.title}`
|
||||||
|
})[0]!
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateTodo).toHaveBeenCalledWith({
|
||||||
|
todoId: noteTodo.id,
|
||||||
|
completed: true,
|
||||||
|
expectedRevision: noteTodo.revision
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: `标记为未完成:${noteTodo.title}`
|
||||||
|
})
|
||||||
|
).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(onNotify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tone: 'success',
|
||||||
|
message: '待办已完成'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('reuses the AI comments pane for selected todos', async () => {
|
it('reuses the AI comments pane for selected todos', async () => {
|
||||||
getApplicationSettings.mockResolvedValue({
|
getApplicationSettings.mockResolvedValue({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export type MagicNotesWorkspaceProps = {
|
|||||||
|
|
||||||
type LibraryView = 'notes' | 'todos'
|
type LibraryView = 'notes' | 'todos'
|
||||||
type TodoFilter = 'active' | 'completed' | 'all'
|
type TodoFilter = 'active' | 'completed' | 'all'
|
||||||
type TodoListMode = 'list' | 'directory'
|
|
||||||
type LoadStatus = 'loading' | 'ready' | 'error'
|
type LoadStatus = 'loading' | 'ready' | 'error'
|
||||||
type ValidationTarget =
|
type ValidationTarget =
|
||||||
| 'create-note'
|
| 'create-note'
|
||||||
@@ -73,11 +72,6 @@ const todoFilters = [
|
|||||||
{ value: 'all', label: '全部' }
|
{ value: 'all', label: '全部' }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const todoListModes = [
|
|
||||||
{ value: 'list', label: '待办视图' },
|
|
||||||
{ value: 'directory', label: '目录视图' }
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const commentDirections: ReadonlyArray<{
|
const commentDirections: ReadonlyArray<{
|
||||||
value: MagicNoteCommentDirection
|
value: MagicNoteCommentDirection
|
||||||
label: string
|
label: string
|
||||||
@@ -211,35 +205,50 @@ function AiComment({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TodoListItem({
|
function TodoListItem({
|
||||||
|
disabled,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggle,
|
||||||
selected,
|
selected,
|
||||||
todo
|
todo
|
||||||
}: {
|
}: {
|
||||||
|
disabled: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
|
onToggle: () => void
|
||||||
selected: boolean
|
selected: boolean
|
||||||
todo: MagicTodoItem
|
todo: MagicTodoItem
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
aria-pressed={selected}
|
|
||||||
className={`magic-todo-list-item ${
|
className={`magic-todo-list-item ${
|
||||||
selected ? 'magic-todo-list-item--active' : ''
|
selected ? 'magic-todo-list-item--active' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={onSelect}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="magic-todo-list-item__check">
|
<button
|
||||||
|
aria-label={`${
|
||||||
|
todo.completed ? '标记为未完成' : '标记为已完成'
|
||||||
|
}:${todo.title}`}
|
||||||
|
aria-pressed={todo.completed}
|
||||||
|
className="magic-todo-list-item__check"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{todo.completed ? (
|
{todo.completed ? (
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
) : (
|
) : (
|
||||||
<Circle size={16} />
|
<Circle size={16} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<button
|
||||||
|
aria-pressed={selected}
|
||||||
|
className="magic-todo-list-item__content"
|
||||||
|
onClick={onSelect}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<strong>{todo.title}</strong>
|
<strong>{todo.title}</strong>
|
||||||
<small>来自笔记:{todo.noteTitle}</small>
|
<small>来自笔记:{todo.noteTitle}</small>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,8 +259,6 @@ export function MagicNotesWorkspace({
|
|||||||
const [todos, setTodos] = useState<MagicTodoItem[]>([])
|
const [todos, setTodos] = useState<MagicTodoItem[]>([])
|
||||||
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
||||||
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
||||||
const [todoListMode, setTodoListMode] =
|
|
||||||
useState<TodoListMode>('list')
|
|
||||||
const [commentMode, setCommentMode] =
|
const [commentMode, setCommentMode] =
|
||||||
useState<MagicNoteCommentMode>('immediate')
|
useState<MagicNoteCommentMode>('immediate')
|
||||||
const [commentDirection, setCommentDirection] =
|
const [commentDirection, setCommentDirection] =
|
||||||
@@ -873,6 +880,30 @@ export function MagicNotesWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateTodoCompletion = async (
|
||||||
|
todo: MagicTodoItem
|
||||||
|
): Promise<void> => {
|
||||||
|
const operation = `update-todo-${todo.id}`
|
||||||
|
if (!beginBusy(operation)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const completed = !todo.completed
|
||||||
|
applyTodo(
|
||||||
|
await window.goodbuddy.magicNotes.updateTodo({
|
||||||
|
todoId: todo.id,
|
||||||
|
completed,
|
||||||
|
expectedRevision: todo.revision
|
||||||
|
})
|
||||||
|
)
|
||||||
|
notifySuccess(completed ? '待办已完成' : '待办已恢复为未完成')
|
||||||
|
} catch (updateError) {
|
||||||
|
notifyError(updateError)
|
||||||
|
} finally {
|
||||||
|
endBusy(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateTitle = async (): Promise<void> => {
|
const updateTitle = async (): Promise<void> => {
|
||||||
if (!detail || titleDraft.trim() === detail.title) {
|
if (!detail || titleDraft.trim() === detail.title) {
|
||||||
return
|
return
|
||||||
@@ -1308,12 +1339,6 @@ export function MagicNotesWorkspace({
|
|||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<SegmentedControl
|
|
||||||
ariaLabel="待办列表方式"
|
|
||||||
onChange={setTodoListMode}
|
|
||||||
options={todoListModes}
|
|
||||||
value={todoListMode}
|
|
||||||
/>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
ariaLabel="筛选待办"
|
ariaLabel="筛选待办"
|
||||||
onChange={setTodoFilter}
|
onChange={setTodoFilter}
|
||||||
@@ -1347,18 +1372,6 @@ export function MagicNotesWorkspace({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : todoListMode === 'list' ? (
|
|
||||||
visibleTodos.map((todo) => (
|
|
||||||
<TodoListItem
|
|
||||||
key={todo.id}
|
|
||||||
onSelect={() => {
|
|
||||||
setValidation(undefined)
|
|
||||||
setSelectedTodoId(todo.id)
|
|
||||||
}}
|
|
||||||
selected={selectedTodoId === todo.id}
|
|
||||||
todo={todo}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
todoDirectories.map((directory) => (
|
todoDirectories.map((directory) => (
|
||||||
<section
|
<section
|
||||||
@@ -1373,11 +1386,15 @@ export function MagicNotesWorkspace({
|
|||||||
<div className="magic-todo-directory__items">
|
<div className="magic-todo-directory__items">
|
||||||
{directory.todos.map((todo) => (
|
{directory.todos.map((todo) => (
|
||||||
<TodoListItem
|
<TodoListItem
|
||||||
|
disabled={busy === `update-todo-${todo.id}`}
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setValidation(undefined)
|
setValidation(undefined)
|
||||||
setSelectedTodoId(todo.id)
|
setSelectedTodoId(todo.id)
|
||||||
}}
|
}}
|
||||||
|
onToggle={() =>
|
||||||
|
void updateTodoCompletion(todo)
|
||||||
|
}
|
||||||
selected={selectedTodoId === todo.id}
|
selected={selectedTodoId === todo.id}
|
||||||
todo={todo}
|
todo={todo}
|
||||||
/>
|
/>
|
||||||
@@ -1780,18 +1797,28 @@ export function MagicNotesWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
<section className="magic-todo-detail">
|
<section className="magic-todo-detail">
|
||||||
<header>
|
<header>
|
||||||
<span
|
<button
|
||||||
aria-label={
|
aria-label={`${
|
||||||
selectedTodo.completed ? '已完成' : '未完成'
|
selectedTodo.completed
|
||||||
}
|
? '标记为未完成'
|
||||||
|
: '标记为已完成'
|
||||||
|
}:${selectedTodo.title}`}
|
||||||
|
aria-pressed={selectedTodo.completed}
|
||||||
className="magic-todo-detail__check"
|
className="magic-todo-detail__check"
|
||||||
|
disabled={
|
||||||
|
busy === `update-todo-${selectedTodo.id}`
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void updateTodoCompletion(selectedTodo)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{selectedTodo.completed ? (
|
{selectedTodo.completed ? (
|
||||||
<CheckCircle2 size={24} />
|
<CheckCircle2 size={24} />
|
||||||
) : (
|
) : (
|
||||||
<Circle size={24} />
|
<Circle size={24} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h2>{selectedTodo.title}</h2>
|
<h2>{selectedTodo.title}</h2>
|
||||||
<span>来自笔记:{selectedTodo.noteTitle}</span>
|
<span>来自笔记:{selectedTodo.noteTitle}</span>
|
||||||
|
|||||||
@@ -316,7 +316,6 @@
|
|||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -333,18 +332,34 @@
|
|||||||
|
|
||||||
.magic-todo-list-item__check {
|
.magic-todo-list-item__check {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.magic-todo-list-item > span:last-child {
|
.magic-todo-list-item__check:hover {
|
||||||
display: grid;
|
background: var(--accent-subtle);
|
||||||
min-width: 0;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.magic-todo-list-item strong {
|
.magic-todo-list-item__content {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: var(--space-1);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magic-todo-list-item__content strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--font-body);
|
font-size: var(--font-body);
|
||||||
@@ -352,7 +367,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.magic-todo-list-item small {
|
.magic-todo-list-item__content small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-caption);
|
font-size: var(--font-caption);
|
||||||
@@ -476,12 +491,19 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--accent-subtle);
|
background: var(--accent-subtle);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.magic-todo-detail__check:hover {
|
||||||
|
background: var(--accent-selected);
|
||||||
|
}
|
||||||
|
|
||||||
.magic-todo-detail > header > div:nth-child(2) {
|
.magic-todo-detail > header > div:nth-child(2) {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import type {
|
|||||||
MagicNotesSnapshot,
|
MagicNotesSnapshot,
|
||||||
MagicNoteUpdateInput,
|
MagicNoteUpdateInput,
|
||||||
MagicTodoItem,
|
MagicTodoItem,
|
||||||
|
MagicTodoUpdateInput,
|
||||||
MagicTodosSnapshot
|
MagicTodosSnapshot
|
||||||
} from './magic-notes-contracts'
|
} from './magic-notes-contracts'
|
||||||
import type {
|
import type {
|
||||||
@@ -1223,6 +1224,9 @@ export type DesktopApi = {
|
|||||||
options: MagicNoteAnalysisOptions
|
options: MagicNoteAnalysisOptions
|
||||||
) => Promise<MagicNoteDraftAnalysis>
|
) => Promise<MagicNoteDraftAnalysis>
|
||||||
listTodos: () => Promise<MagicTodosSnapshot>
|
listTodos: () => Promise<MagicTodosSnapshot>
|
||||||
|
updateTodo: (
|
||||||
|
input: MagicTodoUpdateInput
|
||||||
|
) => Promise<MagicTodoItem>
|
||||||
analyzeTodo: (
|
analyzeTodo: (
|
||||||
todoId: string,
|
todoId: string,
|
||||||
options: MagicNoteAnalysisOptions
|
options: MagicNoteAnalysisOptions
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export const ipcChannels = {
|
|||||||
magicNotesAnalyzeDraft: 'magic-notes:analyze-draft',
|
magicNotesAnalyzeDraft: 'magic-notes:analyze-draft',
|
||||||
magicNotesAnalysisEvent: 'magic-notes:analysis-event',
|
magicNotesAnalysisEvent: 'magic-notes:analysis-event',
|
||||||
magicTodosList: 'magic-todos:list',
|
magicTodosList: 'magic-todos:list',
|
||||||
|
magicTodosUpdate: 'magic-todos:update',
|
||||||
magicTodosAnalyze: 'magic-todos:analyze',
|
magicTodosAnalyze: 'magic-todos:analyze',
|
||||||
knowledgeSnapshot: 'knowledge:snapshot',
|
knowledgeSnapshot: 'knowledge:snapshot',
|
||||||
knowledgeCreateLibrary: 'knowledge:library:create',
|
knowledgeCreateLibrary: 'knowledge:library:create',
|
||||||
|
|||||||
@@ -205,6 +205,17 @@ export const magicTodoIdSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
export const magicTodoUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
todoId: magicNoteIdSchema,
|
||||||
|
completed: z.boolean(),
|
||||||
|
expectedRevision: z.number().int().nonnegative()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
export type MagicTodoUpdateInput = z.infer<
|
||||||
|
typeof magicTodoUpdateSchema
|
||||||
|
>
|
||||||
|
|
||||||
export type MagicNoteCommentKind =
|
export type MagicNoteCommentKind =
|
||||||
| 'narrative'
|
| 'narrative'
|
||||||
| 'summary'
|
| 'summary'
|
||||||
|
|||||||
Reference in New Issue
Block a user