From 95edc48ca5ac6e93ca3a796864a51eb6aadd62d4 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Wed, 16 Sep 2026 18:57:07 +0800 Subject: [PATCH] fix: support piped content in note updates --- README.md | 4 +++ src/commands/notes/update.ts | 27 +++++++++++------- src/commands/team-notes/update.ts | 27 +++++++++++------- src/note-update.ts | 35 +++++++++++++++++++++++ test/note-update.test.ts | 46 +++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 src/note-update.ts create mode 100644 test/note-update.test.ts diff --git a/README.md b/README.md index 244b738..df0c672 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,8 @@ EXAMPLES $ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --readPermission=owner --writePermission=owner $ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2 + + cat README.md | hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA ``` ## `hackmd-cli team-folders` @@ -826,6 +828,8 @@ EXAMPLES $ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --readPermission=owner --writePermission=owner $ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2 + + cat README.md | hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA ``` ## `hackmd-cli teams` diff --git a/src/commands/notes/update.ts b/src/commands/notes/update.ts index 3530604..21ae05d 100644 --- a/src/commands/notes/update.ts +++ b/src/commands/notes/update.ts @@ -1,4 +1,4 @@ -import type {NotePermissionRole, UpdateNoteOptions} from '@hackmd/api' +import type {UpdateNoteOptions} from '@hackmd/api' import {Flags} from '@oclif/core' @@ -6,6 +6,8 @@ import HackMDCommand from '../../command' import { noteContent, noteId, notePermission, noteTags, parentFolderId, permalink, } from '../../flags' +import {buildNoteUpdatePayload} from '../../note-update' +import {safeStdinRead} from '../../utils' export default class Update extends HackMDCommand { static description = 'Update note' @@ -14,6 +16,7 @@ export default class Update extends HackMDCommand { "$ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --content='# A new title'", '$ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --readPermission=owner --writePermission=owner', '$ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2', + '$ cat README.md | hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA', ] static flags = { content: noteContent, @@ -34,18 +37,22 @@ export default class Update extends HackMDCommand { this.error('Flag noteId could not be empty') } - const payload: UpdateNoteOptions & {tags?: string[]} = {} - - if (content !== undefined) payload.content = content - if (parentFolderId !== undefined) payload.parentFolderId = parentFolderId - if (readPermission !== undefined) payload.readPermission = readPermission as NotePermissionRole - if (writePermission !== undefined) payload.writePermission = writePermission as NotePermissionRole - if (permalink !== undefined) payload.permalink = permalink - if (tags !== undefined) payload.tags = tags.split(',').map((t: string) => t.trim()).filter(Boolean) + const stdinContent = process.stdin.isTTY ? undefined : safeStdinRead() + let payload: UpdateNoteOptions + try { + payload = buildNoteUpdatePayload( + { + content, parentFolderId, permalink, readPermission, tags, writePermission, + }, + stdinContent, + ) + } catch (error) { + this.error(error as Error) + } try { const APIClient = await this.getAPIClient() - await APIClient.updateNote(noteId, payload as UpdateNoteOptions) + await APIClient.updateNote(noteId, payload) } catch (error) { this.log('Update note failed') this.error(error as Error) diff --git a/src/commands/team-notes/update.ts b/src/commands/team-notes/update.ts index 114133c..5b92981 100644 --- a/src/commands/team-notes/update.ts +++ b/src/commands/team-notes/update.ts @@ -1,4 +1,4 @@ -import type {NotePermissionRole, UpdateNoteOptions} from '@hackmd/api' +import type {UpdateNoteOptions} from '@hackmd/api' import {Flags} from '@oclif/core' @@ -6,6 +6,8 @@ import HackMDCommand from '../../command' import { noteContent, noteId, notePermission, noteTags, parentFolderId, permalink, teamPath, } from '../../flags' +import {buildNoteUpdatePayload} from '../../note-update' +import {safeStdinRead} from '../../utils' export default class Update extends HackMDCommand { static description = 'Update team note' @@ -14,6 +16,7 @@ export default class Update extends HackMDCommand { "$ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --content='# A new title'", '$ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --readPermission=owner --writePermission=owner', '$ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2', + '$ cat README.md | hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA', ] static flags = { content: noteContent, @@ -39,18 +42,22 @@ export default class Update extends HackMDCommand { this.error('Flag noteId could not be empty') } - const payload: UpdateNoteOptions & {tags?: string[]} = {} - - if (content !== undefined) payload.content = content - if (parentFolderId !== undefined) payload.parentFolderId = parentFolderId - if (readPermission !== undefined) payload.readPermission = readPermission as NotePermissionRole - if (writePermission !== undefined) payload.writePermission = writePermission as NotePermissionRole - if (permalink !== undefined) payload.permalink = permalink - if (tags !== undefined) payload.tags = tags.split(',').map((t: string) => t.trim()).filter(Boolean) + const stdinContent = process.stdin.isTTY ? undefined : safeStdinRead() + let payload: UpdateNoteOptions + try { + payload = buildNoteUpdatePayload( + { + content, parentFolderId, permalink, readPermission, tags, writePermission, + }, + stdinContent, + ) + } catch (error) { + this.error(error as Error) + } try { const APIClient = await this.getAPIClient() - await APIClient.updateTeamNote(teamPath, noteId, payload as UpdateNoteOptions) + await APIClient.updateTeamNote(teamPath, noteId, payload) } catch (error) { this.log('Update team note failed') this.error(error as Error) diff --git a/src/note-update.ts b/src/note-update.ts new file mode 100644 index 0000000..7d80736 --- /dev/null +++ b/src/note-update.ts @@ -0,0 +1,35 @@ +import type {NotePermissionRole, UpdateNoteOptions} from '@hackmd/api' + +export type NoteUpdateFlags = { + content?: string + parentFolderId?: string + permalink?: string + readPermission?: string + tags?: string + writePermission?: string +} + +export function buildNoteUpdatePayload( + {content, parentFolderId, permalink, readPermission, tags, writePermission}: NoteUpdateFlags, + stdinContent?: string, +): UpdateNoteOptions { + if (stdinContent && content !== undefined) { + throw new Error('Content cannot be provided from both stdin and --content') + } + + const payload: UpdateNoteOptions = {} + const resolvedContent = stdinContent || content + + if (resolvedContent !== undefined) payload.content = resolvedContent + if (parentFolderId !== undefined) payload.parentFolderId = parentFolderId + if (readPermission !== undefined) payload.readPermission = readPermission as NotePermissionRole + if (writePermission !== undefined) payload.writePermission = writePermission as NotePermissionRole + if (permalink !== undefined) payload.permalink = permalink + if (tags !== undefined) payload.tags = tags.split(',').map(tag => tag.trim()).filter(Boolean) + + if (Object.keys(payload).length === 0) { + throw new Error('Nothing to update') + } + + return payload +} diff --git a/test/note-update.test.ts b/test/note-update.test.ts new file mode 100644 index 0000000..be3b218 --- /dev/null +++ b/test/note-update.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import {buildNoteUpdatePayload} from '../src/note-update' + +describe('Note update payload', () => { + it('uses piped stdin content without changing it', () => { + const content = '# Piped content\n\nBody with trailing newline.\n' + + expect(buildNoteUpdatePayload({}, content)).to.deep.equal({content}) + }) + + it('uses --content when stdin is empty', () => { + expect(buildNoteUpdatePayload({content: '# Flag content'}, '')).to.deep.equal({ + content: '# Flag content', + }) + }) + + it('rejects content from both stdin and --content', () => { + expect(() => buildNoteUpdatePayload({content: '# Flag content'}, '# Piped content')) + .to.throw('Content cannot be provided from both stdin and --content') + }) + + it('rejects an empty update', () => { + expect(() => buildNoteUpdatePayload({}, '')).to.throw('Nothing to update') + }) + + it('allows metadata-only updates', () => { + expect(buildNoteUpdatePayload({ + parentFolderId: 'folder-id', + permalink: 'new-permalink', + readPermission: 'guest', + tags: ' tag1, tag2, ,', + writePermission: 'owner', + })).to.deep.equal({ + parentFolderId: 'folder-id', + permalink: 'new-permalink', + readPermission: 'guest', + tags: ['tag1', 'tag2'], + writePermission: 'owner', + }) + }) + + it('allows an explicit empty --content value', () => { + expect(buildNoteUpdatePayload({content: ''}, '')).to.deep.equal({content: ''}) + }) +})