feat(magic-notes): improve tag management UI
This commit is contained in:
@@ -7805,8 +7805,8 @@ describe('AssistantDatabase', () => {
|
||||
const retagged = database.updateMagicNote({ noteId: work.id, tags: ['Work'], expectedRevision: work.revision })
|
||||
expect(retagged.tags).toEqual(['Work'])
|
||||
expect(retagged.revision).toBe(work.revision + 1)
|
||||
// "Project A" lost its last note and is removed.
|
||||
expect(database.listMagicNoteTags().map((tag) => tag.name)).not.toContain('Project A')
|
||||
// Tags remain available after their last note link is removed.
|
||||
expect(database.listMagicNoteTags().map((tag) => tag.name)).toContain('Project A')
|
||||
expect(() => database.updateMagicNote({ noteId: work.id, tags: [], expectedRevision: work.revision })).toThrow()
|
||||
expect(database.getMagicNote(work.id).tags).toEqual(['Work'])
|
||||
|
||||
@@ -7820,19 +7820,23 @@ describe('AssistantDatabase', () => {
|
||||
const merged = database.renameMagicNoteTag({ tagId: workTag.id, name: 'Personal' })
|
||||
expect(merged).toMatchObject({ merged: true, tag: { name: 'personal', noteCount: 3 } })
|
||||
expect(database.getMagicNote(both.id).tags).toEqual(['personal'])
|
||||
expect(database.listMagicNoteTags()).toHaveLength(1)
|
||||
expect(database.listMagicNoteTags().map((tag) => tag.name)).toEqual(['personal', 'Project A'])
|
||||
|
||||
const calls = onMagicNotesChanged.mock.calls.length
|
||||
database.deleteMagicNoteTag(merged.tag.id)
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(calls + 1)
|
||||
expect(database.listMagicNoteTags()).toEqual([])
|
||||
expect(database.getMagicNote(personal.id)).toMatchObject({ title: 'Personal', tags: [] })
|
||||
expect(() => database.deleteMagicNoteTag(merged.tag.id)).toThrow('标签不存在')
|
||||
expect(() => database.renameMagicNoteTag({ tagId: merged.tag.id, name: 'x' })).toThrow('标签不存在')
|
||||
expect(database.listMagicNoteTags().map((tag) => tag.name)).toEqual(['Project A'])
|
||||
expect(database.getMagicNote(personal.id)).toMatchObject({ title: 'Personal', tags: [] })
|
||||
expect(() => database.deleteMagicNoteTag(merged.tag.id)).toThrow('标签不存在')
|
||||
expect(() => database.renameMagicNoteTag({ tagId: merged.tag.id, name: 'x' })).toThrow('标签不存在')
|
||||
|
||||
database.updateMagicNote({ noteId: personal.id, tags: ['Solo'], expectedRevision: database.getMagicNote(personal.id).revision })
|
||||
const empty = database.createMagicNoteTag({ name: 'Ideas' })
|
||||
expect(empty).toEqual(expect.objectContaining({ name: 'Ideas', noteCount: 0 }))
|
||||
expect(database.listMagicNoteTags().map((tag) => tag.name)).toContain('Ideas')
|
||||
|
||||
database.updateMagicNote({ noteId: personal.id, tags: ['Solo'], expectedRevision: database.getMagicNote(personal.id).revision })
|
||||
database.deleteMagicNote(personal.id)
|
||||
expect(database.listMagicNoteTags()).toEqual([])
|
||||
expect(database.listMagicNoteTags().map((tag) => tag.name)).toEqual(['Ideas', 'Project A', 'Solo'])
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@@ -4169,13 +4169,30 @@ export class AssistantDatabase {
|
||||
FROM magic_note_tags t
|
||||
LEFT JOIN magic_note_tag_links l ON l.tag_id = t.id
|
||||
GROUP BY t.id
|
||||
HAVING note_count > 0
|
||||
ORDER BY note_count DESC, t.name_key ASC`
|
||||
)
|
||||
.all() as MagicNoteTagRow[]
|
||||
return rows.map(toMagicNoteTag)
|
||||
}
|
||||
|
||||
createMagicNoteTag(input: { name: string }): MagicNoteTag {
|
||||
const database = this.requireDatabase()
|
||||
const now = new Date().toISOString()
|
||||
const id = randomUUID()
|
||||
const key = magicNoteTagKey(input.name)
|
||||
try {
|
||||
database.prepare(
|
||||
`INSERT INTO magic_note_tags (id, name, name_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, input.name, key, now, now)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) throw new Error('标签已存在')
|
||||
throw error
|
||||
}
|
||||
this.options.onMagicNotesChanged?.()
|
||||
return { id, name: input.name, noteCount: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a tag across every note. When the new name matches another tag,
|
||||
* the two merge: links move to the existing tag and the old tag is removed.
|
||||
@@ -4286,14 +4303,6 @@ export class AssistantDatabase {
|
||||
}
|
||||
insertLink.run(noteId, tag.id, position, now)
|
||||
})
|
||||
this.deleteOrphanMagicNoteTags(database)
|
||||
}
|
||||
|
||||
private deleteOrphanMagicNoteTags(database: DatabaseSync): void {
|
||||
database.exec(
|
||||
`DELETE FROM magic_note_tags
|
||||
WHERE NOT EXISTS (SELECT 1 FROM magic_note_tag_links l WHERE l.tag_id = magic_note_tags.id)`
|
||||
)
|
||||
}
|
||||
|
||||
getMagicNote(noteId: string): MagicNoteDetail {
|
||||
@@ -4474,7 +4483,6 @@ export class AssistantDatabase {
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('笔记不存在')
|
||||
}
|
||||
this.deleteOrphanMagicNoteTags(database)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
|
||||
@@ -20,7 +20,7 @@ export const assistantStorageMethods = [
|
||||
'deleteLocalConversation', 'getOrCreateRemoteConversation', 'appendConversationMessage',
|
||||
'appendRemoteConversationMessage', 'claimChannelEvent', 'releaseChannelEvent',
|
||||
'enqueueChannelResult', 'markChannelResult', 'listUndeliveredChannelResults',
|
||||
'listMagicNotes', 'listMagicNoteTags', 'renameMagicNoteTag', 'deleteMagicNoteTag',
|
||||
'listMagicNotes', 'listMagicNoteTags', 'createMagicNoteTag', 'renameMagicNoteTag', 'deleteMagicNoteTag',
|
||||
'getMagicNote', 'getMagicNoteContext', 'createMagicNote', 'updateMagicNote',
|
||||
'deleteMagicNote', 'createMagicNoteEntry', 'updateMagicNoteEntry', 'deleteMagicNoteEntry',
|
||||
'getMagicNoteEntry', 'saveMagicNoteAnalysis', 'listMagicTodos', 'getMagicTodoStatus',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
magicNoteEntryCreateSchema,
|
||||
magicNoteEntryDeleteSchema,
|
||||
magicNoteEntryUpdateSchema,
|
||||
magicNoteTagCreateSchema,
|
||||
magicNoteTagDeleteSchema,
|
||||
magicNoteTagRenameSchema,
|
||||
magicNoteUpdateSchema,
|
||||
@@ -62,6 +63,11 @@ export function registerMagicNotesIpcHandlers(
|
||||
return assistantDatabase.renameMagicNoteTag(magicNoteTagRenameSchema.parse(input))
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesCreateTag, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.createMagicNoteTag(magicNoteTagCreateSchema.parse(input))
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesDeleteTag, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { tagId } = magicNoteTagDeleteSchema.parse(input)
|
||||
|
||||
@@ -167,6 +167,7 @@ import type {
|
||||
MagicNoteCreateResult,
|
||||
MagicNoteSummary,
|
||||
MagicNoteEntryCreateResult,
|
||||
MagicNoteTag,
|
||||
MagicNoteTagRenameResult,
|
||||
MagicNotesSnapshot,
|
||||
MagicTodoItem,
|
||||
@@ -1707,6 +1708,11 @@ const desktopApi: DesktopApi = {
|
||||
remove: async (noteId: string) => {
|
||||
await ipcRenderer.invoke(ipcChannels.magicNotesDelete, { noteId })
|
||||
},
|
||||
createTag: (input) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.magicNotesCreateTag,
|
||||
input
|
||||
) as Promise<MagicNoteTag>,
|
||||
renameTag: (input) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.magicNotesRenameTag,
|
||||
|
||||
@@ -963,6 +963,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
magicNotes: {
|
||||
search: vi.fn(async () => []),
|
||||
list: vi.fn(async () => ({ notes: [], tags: [] })),
|
||||
createTag: vi.fn(async () => ({ id: 'unused-tag', name: 'unused', noteCount: 0 })),
|
||||
renameTag: vi.fn(async () => {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
|
||||
@@ -317,11 +317,13 @@ export function MagicNoteTagManagerButton({
|
||||
export function MagicNoteTagManager({
|
||||
tags,
|
||||
onClose,
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete
|
||||
}: {
|
||||
tags: MagicNoteTag[]
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => Promise<MagicNoteTag | undefined>
|
||||
onRename: (tag: MagicNoteTag, name: string) => Promise<MagicNoteTagRenameResult | undefined>
|
||||
onDelete: (tag: MagicNoteTag) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
@@ -332,6 +334,8 @@ export function MagicNoteTagManager({
|
||||
const descriptionId = useId()
|
||||
const [editingId, setEditingId] = useState('')
|
||||
const [draft, setDraft] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createDraft, setCreateDraft] = useState('')
|
||||
const [deletingId, setDeletingId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -383,6 +387,21 @@ export function MagicNoteTagManager({
|
||||
}
|
||||
}
|
||||
|
||||
const submitCreate = async (): Promise<void> => {
|
||||
if (busy) return
|
||||
const problem = tagNameError(createDraft)
|
||||
if (problem) { setError(problem); return }
|
||||
setBusy(true)
|
||||
const created = await onCreate(createDraft.trim())
|
||||
setBusy(false)
|
||||
if (created) {
|
||||
setCreateDraft('')
|
||||
setCreating(false)
|
||||
setError('')
|
||||
requestAnimationFrame(() => document.getElementById(`magic-note-tag-rename-${created.id}`)?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="custom-task-dialog">
|
||||
<section
|
||||
@@ -411,6 +430,27 @@ export function MagicNoteTagManager({
|
||||
</button>
|
||||
</header>
|
||||
<div className="magic-note-tag-manager__body" aria-busy={busy}>
|
||||
{creating ? (
|
||||
<form className="magic-note-tag-manager__create" onSubmit={(event) => { event.preventDefault(); void submitCreate() }}>
|
||||
<input
|
||||
autoFocus
|
||||
aria-label={t('tags.inputLabel')}
|
||||
aria-invalid={Boolean(error)}
|
||||
maxLength={MAGIC_NOTE_TAG_MAX_LENGTH}
|
||||
placeholder={t('tags.inputPlaceholder')}
|
||||
value={createDraft}
|
||||
disabled={busy}
|
||||
onChange={(event) => { setCreateDraft(event.target.value); setError('') }}
|
||||
/>
|
||||
<button type="submit" className="primary-button" disabled={busy}>{t('tags.createButton')}</button>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => { setCreating(false); setCreateDraft(''); setError('') }}>{t('tags.cancel')}</button>
|
||||
{error && <small className="magic-notes-field-error" role="alert">{error}</small>}
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" className="secondary-button magic-note-tag-manager__create-button" disabled={busy} onClick={() => { setCreating(true); setError('') }}>
|
||||
<Plus aria-hidden="true" size={14} />{t('tags.createButton')}
|
||||
</button>
|
||||
)}
|
||||
{tags.length === 0 ? (
|
||||
<p className="magic-notes-muted">{t('tags.managerEmpty')}</p>
|
||||
) : (
|
||||
|
||||
@@ -1776,6 +1776,22 @@ function MagicNotesWorkspaceView({
|
||||
setNarrowNotesOpen(false)
|
||||
document.getElementById('magic-notes-list-toggle')?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
const createNoteTag = async (name: string): Promise<MagicNoteTag | undefined> => {
|
||||
const operation = 'create-tag'
|
||||
if (!beginBusy(operation)) return undefined
|
||||
try {
|
||||
const tag = await window.goodbuddy.magicNotes.createTag({ name })
|
||||
notifySuccess(t('tags.created'))
|
||||
await refreshNotes(requestedNoteIdRef.current, true)
|
||||
return tag
|
||||
} catch (error) {
|
||||
notifyError(error)
|
||||
return undefined
|
||||
} finally {
|
||||
endBusy(operation)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (notesDrawerOpen) document.querySelector<HTMLInputElement>('#magic-library-panel-notes input[type="search"]')?.focus({ preventScroll: true })
|
||||
}, [notesDrawerOpen])
|
||||
@@ -3393,6 +3409,7 @@ function MagicNotesWorkspaceView({
|
||||
<MagicNoteTagManager
|
||||
tags={noteTags}
|
||||
onClose={() => setTagManagerOpen(false)}
|
||||
onCreate={createNoteTag}
|
||||
onRename={renameNoteTag}
|
||||
onDelete={deleteNoteTag}
|
||||
/>
|
||||
|
||||
@@ -135,12 +135,13 @@ export const magicNotes = {
|
||||
inputPlaceholder: 'Type and press Enter',
|
||||
suggestionsLabel: 'Existing tags',
|
||||
create: 'Create "{{name}}"',
|
||||
createButton: 'New tag',
|
||||
remove: 'Remove tag {{name}}',
|
||||
limitReached: 'A note can have at most {{count}} tags',
|
||||
manage: 'Manage tags',
|
||||
managerTitle: 'Manage tags',
|
||||
managerDescription: 'Renaming or deleting a tag updates every note. Notes themselves are never deleted.',
|
||||
managerEmpty: 'No tags yet. Open a note and add one below its title.',
|
||||
managerEmpty: 'No tags yet. Create one here or add one below a note title.',
|
||||
noteCountOne: '{{count}} note',
|
||||
noteCountOther: '{{count}} notes',
|
||||
rename: 'Rename',
|
||||
@@ -154,7 +155,8 @@ export const magicNotes = {
|
||||
mergeHint: 'A tag named "{{name}}" already exists. Saving merges the two tags.',
|
||||
renamed: 'Tag renamed',
|
||||
merged: 'Tag merged into "{{name}}"',
|
||||
deleted: 'Tag deleted'
|
||||
deleted: 'Tag deleted',
|
||||
created: 'Tag created'
|
||||
},
|
||||
todos: {
|
||||
listLabel: 'To-do list',
|
||||
|
||||
@@ -130,12 +130,13 @@ export const magicNotes = {
|
||||
inputPlaceholder: '输入后按回车',
|
||||
suggestionsLabel: '已有标签',
|
||||
create: '新建“{{name}}”',
|
||||
createButton: '新建标签',
|
||||
remove: '移除标签 {{name}}',
|
||||
limitReached: '每篇笔记最多 {{count}} 个标签',
|
||||
manage: '管理标签',
|
||||
managerTitle: '管理标签',
|
||||
managerDescription: '重命名或删除标签会同步到所有笔记,笔记本身不会被删除。',
|
||||
managerEmpty: '还没有标签。打开一篇笔记,在标题下方添加。',
|
||||
managerEmpty: '还没有标签。可以在这里新建,也可以在笔记标题下方添加。',
|
||||
noteCountOne: '{{count}} 篇笔记',
|
||||
noteCountOther: '{{count}} 篇笔记',
|
||||
rename: '重命名',
|
||||
@@ -149,7 +150,8 @@ export const magicNotes = {
|
||||
mergeHint: '已有同名标签“{{name}}”,保存后两个标签会合并。',
|
||||
renamed: '标签已重命名',
|
||||
merged: '标签已合并到“{{name}}”',
|
||||
deleted: '标签已删除'
|
||||
deleted: '标签已删除',
|
||||
created: '标签已创建'
|
||||
},
|
||||
todos: {
|
||||
listLabel: '待办列表',
|
||||
|
||||
@@ -1673,6 +1673,46 @@
|
||||
padding: var(--space-2) var(--space-5) var(--space-5);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-block: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__create > input {
|
||||
box-sizing: border-box;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: var(--control-height);
|
||||
flex: 1 1 auto;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__create > button {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--control-height);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__create > small {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.magic-note-tag-manager__create {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__create > input {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.magic-note-tag-manager__list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
@@ -2393,7 +2433,7 @@
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.magic-note-row:hover .magic-note-more,
|
||||
|
||||
@@ -2363,6 +2363,9 @@ export type DesktopApi = {
|
||||
create: (input: MagicNoteCreateInput) => Promise<MagicNoteCreateResult>
|
||||
update: (input: MagicNoteUpdateInput) => Promise<MagicNoteDetail>
|
||||
remove: (noteId: string) => Promise<void>
|
||||
createTag: (
|
||||
input: import('./magic-notes-contracts').MagicNoteTagCreateInput
|
||||
) => Promise<import('./magic-notes-contracts').MagicNoteTag>
|
||||
renameTag: (
|
||||
input: import('./magic-notes-contracts').MagicNoteTagRenameInput
|
||||
) => Promise<import('./magic-notes-contracts').MagicNoteTagRenameResult>
|
||||
|
||||
@@ -350,6 +350,7 @@ export const ipcChannels = {
|
||||
magicNotesCreate: 'magic-notes:create',
|
||||
magicNotesUpdate: 'magic-notes:update',
|
||||
magicNotesDelete: 'magic-notes:delete',
|
||||
magicNotesCreateTag: 'magic-notes:create-tag',
|
||||
magicNotesRenameTag: 'magic-notes:rename-tag',
|
||||
magicNotesDeleteTag: 'magic-notes:delete-tag',
|
||||
magicNotesCreateEntry: 'magic-notes:create-entry',
|
||||
|
||||
@@ -369,6 +369,11 @@ export type MagicNoteUpdateInput = z.infer<typeof magicNoteUpdateSchema>
|
||||
|
||||
export const magicNoteTagIdSchema = magicNoteIdSchema
|
||||
|
||||
export const magicNoteTagCreateSchema = z
|
||||
.object({ name: magicNoteTagNameSchema })
|
||||
.strict()
|
||||
export type MagicNoteTagCreateInput = z.infer<typeof magicNoteTagCreateSchema>
|
||||
|
||||
export const magicNoteTagRenameSchema = z
|
||||
.object({
|
||||
tagId: magicNoteTagIdSchema,
|
||||
|
||||
Reference in New Issue
Block a user