diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 89fc9538..84c28659 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -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() } diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index a5d3670a..90c976c7 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -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') diff --git a/src/main/desktop-storage-contracts.ts b/src/main/desktop-storage-contracts.ts index 422ad382..5b604431 100644 --- a/src/main/desktop-storage-contracts.ts +++ b/src/main/desktop-storage-contracts.ts @@ -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', diff --git a/src/main/magic-notes/magic-notes-ipc.ts b/src/main/magic-notes/magic-notes-ipc.ts index a3a3cfb1..876c5312 100644 --- a/src/main/magic-notes/magic-notes-ipc.ts +++ b/src/main/magic-notes/magic-notes-ipc.ts @@ -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) diff --git a/src/preload/index.ts b/src/preload/index.ts index 67a309fb..19c86869 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, renameTag: (input) => ipcRenderer.invoke( ipcChannels.magicNotesRenameTag, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e3949948..909c588d 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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"); }), diff --git a/src/renderer/src/MagicNoteTags.tsx b/src/renderer/src/MagicNoteTags.tsx index e973af0e..1ec30e0b 100644 --- a/src/renderer/src/MagicNoteTags.tsx +++ b/src/renderer/src/MagicNoteTags.tsx @@ -317,11 +317,13 @@ export function MagicNoteTagManagerButton({ export function MagicNoteTagManager({ tags, onClose, + onCreate, onRename, onDelete }: { tags: MagicNoteTag[] onClose: () => void + onCreate: (name: string) => Promise onRename: (tag: MagicNoteTag, name: string) => Promise onDelete: (tag: MagicNoteTag) => Promise }): 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 => { + 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(
+ {creating ? ( +
{ event.preventDefault(); void submitCreate() }}> + { setCreateDraft(event.target.value); setError('') }} + /> + + + {error && {error}} +
+ ) : ( + + )} {tags.length === 0 ? (

{t('tags.managerEmpty')}

) : ( diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index 31003316..c496ea47 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -1776,6 +1776,22 @@ function MagicNotesWorkspaceView({ setNarrowNotesOpen(false) document.getElementById('magic-notes-list-toggle')?.focus({ preventScroll: true }) } + + const createNoteTag = async (name: string): Promise => { + 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('#magic-library-panel-notes input[type="search"]')?.focus({ preventScroll: true }) }, [notesDrawerOpen]) @@ -3393,6 +3409,7 @@ function MagicNotesWorkspaceView({ setTagManagerOpen(false)} + onCreate={createNoteTag} onRename={renameNoteTag} onDelete={deleteNoteTag} /> diff --git a/src/renderer/src/i18n/locales/en-US/magicNotes.ts b/src/renderer/src/i18n/locales/en-US/magicNotes.ts index ddfe3731..9a6b7d7e 100644 --- a/src/renderer/src/i18n/locales/en-US/magicNotes.ts +++ b/src/renderer/src/i18n/locales/en-US/magicNotes.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts index 2ebd3065..3c886355 100644 --- a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts +++ b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts @@ -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: '待办列表', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index b128aadd..c9cb250f 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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, diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 9f7694fe..5aa77545 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -2363,6 +2363,9 @@ export type DesktopApi = { create: (input: MagicNoteCreateInput) => Promise update: (input: MagicNoteUpdateInput) => Promise remove: (noteId: string) => Promise + createTag: ( + input: import('./magic-notes-contracts').MagicNoteTagCreateInput + ) => Promise renameTag: ( input: import('./magic-notes-contracts').MagicNoteTagRenameInput ) => Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 4261a92f..464c62f9 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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', diff --git a/src/shared/magic-notes-contracts.ts b/src/shared/magic-notes-contracts.ts index 170efb60..021f496e 100644 --- a/src/shared/magic-notes-contracts.ts +++ b/src/shared/magic-notes-contracts.ts @@ -369,6 +369,11 @@ export type MagicNoteUpdateInput = z.infer export const magicNoteTagIdSchema = magicNoteIdSchema +export const magicNoteTagCreateSchema = z + .object({ name: magicNoteTagNameSchema }) + .strict() +export type MagicNoteTagCreateInput = z.infer + export const magicNoteTagRenameSchema = z .object({ tagId: magicNoteTagIdSchema,