feat(magic-notes): improve tag management UI

This commit is contained in:
mesalogo
2026-10-07 21:05:14 +08:00
parent e0607a153f
commit 13c29badfb
14 changed files with 160 additions and 25 deletions
+13 -9
View File
@@ -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()
}
+18 -10
View File
@@ -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')
+1 -1
View File
@@ -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',
+6
View File
@@ -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)
+6
View File
@@ -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,
+1
View File
@@ -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");
}),
+40
View File
@@ -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>
) : (
+17
View File
@@ -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: '待办列表',
+41 -1
View File
@@ -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,
+3
View File
@@ -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>
+1
View File
@@ -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',
+5
View File
@@ -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,