diff --git a/AGENTS.md b/AGENTS.md index 9602c13..1bf5552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ When bridge contracts change, validate all three repos. ### External CLI Command Surface - Daemon lifecycle: `daemon start`, `daemon stop`, `daemon status` -- Bridge commands: `create`, `search`, `search-tag`, `read`, `update`, `journal`, `status` +- Bridge commands: `create`, `create-md`, `search`, `search-tag`, `read`, `update`, `journal`, `status` ### Bridge Mapping and Compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c50a9e..bc75648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Enhanced `create` command with flashcard support (`-b/--back-text`, `--concept`, `--descriptor`). +- Added `create-md` command to allow creating native hierarchical Rem structures (including flashcards via `::`) from indented markdown strings. + ### Changed - Renamed the local OpenClaw skill package directory to `skills/` and updated live repository references, including diff --git a/docs/guides/command-reference.md b/docs/guides/command-reference.md index ef5d892..4c28482 100644 --- a/docs/guides/command-reference.md +++ b/docs/guides/command-reference.md @@ -134,6 +134,9 @@ remnote-cli create [options] | `--content-file <path>` | none | Read initial content from UTF-8 file (`-` for stdin) | | `--parent-id <id>` | none | Parent Rem ID | | `-t, --tags <tag...>` | none | One or more tags | +| `-b, --back-text <text>`| none | Back text for flashcard | +| `--concept` | false | Create as a Concept card | +| `--descriptor` | false | Create as a Descriptor card | Behavior rules: @@ -148,6 +151,37 @@ remnote-cli create "Meeting Notes" remnote-cli create "Project Plan" --content "Phase 1" --tags planning work --text remnote-cli create "Weekly Summary" --content-file /tmp/weekly-summary.md --text cat /tmp/weekly-summary.md | remnote-cli create "Weekly Summary" --content-file - --text +remnote-cli create "Photosynthesis" --back-text "Process by which plants make food" --concept --text +``` + +## create-md + +Create a hierarchical note tree in RemNote from a markdown string (indented bullets). Automatically parses indentations into parent-child Rem relationships using RemNote's native capabilities. + +```bash +remnote-cli create-md [options] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-c, --content <text>` | none | Markdown text containing the bulleted tree, flashcards inline supported | +| `--content-file <path>`| none | Read markdown content from UTF-8 file (`-` for stdin) | +| `--title <text>` | none | Optional root Rem title to enclose the entire tree | +| `--parent-id <id>` | none | Parent Rem ID where the tree will be created | +| `-t, --tags <tag...>` | none | Array of tags to apply to the root/title Rem | + +Behavior rules: + +- `--content` or `--content-file` is required. +- Provide a `title` if you want all items nested logically under a single new Rem. +- Use `- ` or `* ` for each bullet and use leading spaces for nesting levels +- You can batch create flashcards inline using `::` for Concept cards and `;;` for Descriptor cards. Refer to https://help.remnote.com/en/articles/9252072-how-to-import-flashcards-from-text#h_fc1588b3b7 for more information. + +Examples: + +```bash +remnote-cli create-md --content "- Programming Languages\n - Python\n - JavaScript" +remnote-cli create-md --title "Biology Terms" --content-file /tmp/biology.md --text ``` ## search diff --git a/src/cli.ts b/src/cli.ts index cffe2a2..c06b005 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { createRequire } from 'node:module'; import { DEFAULT_CONTROL_PORT } from './config.js'; import { registerDaemonCommand } from './commands/daemon.js'; import { registerCreateCommand } from './commands/create.js'; +import { registerCreateMdCommand } from './commands/create-md.js'; import { registerSearchByTagCommand, registerSearchCommand } from './commands/search.js'; import { registerReadCommand } from './commands/read.js'; import { registerUpdateCommand } from './commands/update.js'; @@ -26,6 +27,7 @@ export function createProgram(version: string): Command { registerDaemonCommand(program); registerCreateCommand(program); + registerCreateMdCommand(program); registerSearchCommand(program); registerSearchByTagCommand(program); registerReadCommand(program); diff --git a/src/commands/content-input.ts b/src/commands/content-input.ts index 366d579..c135fb3 100644 --- a/src/commands/content-input.ts +++ b/src/commands/content-input.ts @@ -66,7 +66,9 @@ export async function resolveOptionalInlineOrFileContent({ if (filePath !== undefined) { return readContentFileOrStdin(filePath, stdin); } - return inlineText; + + // convert literal \n strings into actual newline characters + return inlineText?.replace(/\\n/g, '\n'); } interface UpdateContentArgs { diff --git a/src/commands/create-md.ts b/src/commands/create-md.ts new file mode 100644 index 0000000..c87fa4d --- /dev/null +++ b/src/commands/create-md.ts @@ -0,0 +1,52 @@ +import { Command } from 'commander'; +import { DaemonClient } from '../client/daemon-client.js'; +import { formatResult, formatError, type OutputFormat } from '../output/formatter.js'; +import { EXIT } from '../config.js'; +import { resolveOptionalInlineOrFileContent } from './content-input.js'; + +export function registerCreateMdCommand(program: Command): void { + program + .command('create-md') + .description('Create a hierarchical note tree in RemNote from a markdown string') + .option('-c, --content <text>', 'Markdown content') + .option('--content-file <path>', 'Read markdown content from UTF-8 file ("-" for stdin)') + .option('--title <text>', 'Optional root Rem title to enclose the entire tree') + .option('--parent-id <id>', 'Parent Rem ID') + .option('-t, --tags <tags...>', 'Tags to add') + .action(async (opts) => { + const globalOpts = program.opts(); + const format: OutputFormat = globalOpts.text ? 'text' : 'json'; + const client = new DaemonClient(parseInt(globalOpts.controlPort, 10)); + + try { + const content = await resolveOptionalInlineOrFileContent({ + inlineText: opts.content as string | undefined, + filePath: opts.contentFile as string | undefined, + inlineFlag: '--content', + fileFlag: '--content-file', + }); + + if (content === undefined) { + throw new Error('Markdown content is required via --content or --content-file'); + } + + const payload: Record<string, unknown> = { content }; + if (opts.title) payload.title = opts.title; + if (opts.parentId) payload.parentId = opts.parentId; + if (opts.tags) payload.tags = opts.tags; + + const result = await client.execute('create_note_md', payload); + console.log( + formatResult(result, format, (data) => { + const r = data as Record<string, unknown>; + const ids = Array.isArray(r.remIds) ? r.remIds.join(', ') : 'unknown'; + return `Created markdown tree: ${opts.title || '(root)'} (IDs: ${ids})`; + }) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(formatError(message, format)); + process.exit(EXIT.ERROR); + } + }); +} diff --git a/src/commands/create.ts b/src/commands/create.ts index 68cbaf1..20bd4d4 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -12,6 +12,9 @@ export function registerCreateCommand(program: Command): void { .option('--content-file <path>', 'Read note content from UTF-8 file ("-" for stdin)') .option('--parent-id <id>', 'Parent Rem ID') .option('-t, --tags <tags...>', 'Tags to add') + .option('-b, --back-text <text>', 'Back text for creating a flashcard') + .option('--concept', 'Create as a Concept card (::)', false) + .option('--descriptor', 'Create as a Descriptor card (;;)', false) .action(async (title: string, opts) => { const globalOpts = program.opts(); const format: OutputFormat = globalOpts.text ? 'text' : 'json'; @@ -29,6 +32,9 @@ export function registerCreateCommand(program: Command): void { if (content !== undefined) payload.content = content; if (opts.parentId) payload.parentId = opts.parentId; if (opts.tags) payload.tags = opts.tags; + if (opts.backText !== undefined) payload.backText = opts.backText; + if (opts.concept) payload.isConcept = true; + if (opts.descriptor) payload.isDescriptor = true; const result = await client.execute('create_note', payload); console.log( diff --git a/test/integration/types.ts b/test/integration/types.ts index e5159de..4307d11 100644 --- a/test/integration/types.ts +++ b/test/integration/types.ts @@ -28,6 +28,8 @@ export interface SharedState { searchByTagTag?: string; noteAId?: string; noteBId?: string; + noteCId?: string; + mdTreeIds?: string[]; acceptWriteOperations?: boolean; acceptReplaceOperation?: boolean; } diff --git a/test/integration/workflows/03-create-search.ts b/test/integration/workflows/03-create-search.ts index 2749080..58a2399 100644 --- a/test/integration/workflows/03-create-search.ts +++ b/test/integration/workflows/03-create-search.ts @@ -236,16 +236,98 @@ export async function createSearchWorkflow( } } + // Step 3: Create flashcard note (with back-text) + { + const start = Date.now(); + try { + const result = (await ctx.cli.runExpectSuccess([ + 'create', + `[CLI-TEST] Flashcard Note ${ctx.runId}`, + '--parent-id', + state.integrationParentRemId as string, + '--back-text', + 'This is the back of the flashcard', + '--concept', + '--tags', + state.searchByTagTag as string, + ])) as Record<string, unknown>; + assertHasField(result, 'remId', 'create flashcard note'); + state.noteCId = result.remId as string; + steps.push({ label: 'Create flashcard note', passed: true, durationMs: Date.now() - start }); + } catch (e) { + steps.push({ + label: 'Create flashcard note', + passed: false, + durationMs: Date.now() - start, + error: (e as Error).message, + }); + } + } + + // Step 4: Create markdown tree with various flashcard types + { + const start = Date.now(); + try { + const markdownContent = [ + `- Flashcard Tree`, + ` - Basic Forward >> Answer`, + ` - Basic Backward << Answer`, + ` - Two-way :: Answer`, + ` - Disabled >- Answer`, + ` - Cloze with {{hidden}}{({hint text})} text`, + ` - Concept :: Definition`, + ` - Concept Forward :> Definition`, + ` - Concept Backward :< Definition`, + ` - Descriptor ;; Detail`, + ` - Multi-line >>>`, + ` - Card Item 1`, + ` - Card Item 2`, + ` - List-answer >>1.`, + ` - First list item`, + ` - Second list item`, + ` - Multiple-choice >>A)`, + ` - Correct option`, + ` - Wrong option` + ].join('\n') + + const result = (await withTempContentFile(markdownContent, async (contentPath) => { + return (await ctx.cli.runExpectSuccess([ + 'create-md', + '--parent-id', + state.integrationParentRemId as string, + '--content-file', + contentPath, + '--title', + `[CLI-TEST] Flashcard Tree ${ctx.runId}`, + '--tags', + state.searchByTagTag as string, + ])) as Record<string, unknown>; + })) as Record<string, unknown>; + + assertHasField(result, 'remIds', 'create markdown tree'); + assertIsArray(result.remIds, 'markdown tree remIds'); + state.mdTreeIds = result.remIds as string[]; + steps.push({ label: 'Create md tree with flashcards', passed: true, durationMs: Date.now() - start }); + } catch (e) { + steps.push({ + label: 'Create md tree with flashcards', + passed: false, + durationMs: Date.now() - start, + error: (e as Error).message, + }); + } + } + // Wait for indexing await new Promise((r) => setTimeout(r, INDEXING_DELAY_MS)); - // Step 3: Search for created notes + // Step 5: Search for created notes { const start = Date.now(); try { const result = (await ctx.cli.runExpectSuccess([ 'search', - `[CLI-TEST] ${ctx.runId}`, + `${ctx.runId}`, ])) as Record<string, unknown>; assertHasField(result, 'results', 'search results'); assertIsArray(result.results, 'search results'); @@ -269,11 +351,11 @@ export async function createSearchWorkflow( } } - // Step 4-6: Search with includeContent modes + // Step 6-8: Search with includeContent modes for (const mode of ['markdown', 'structured', 'none'] as const) { const start = Date.now(); const label = `Search includeContent=${mode} returns expected shape`; - const query = `[CLI-TEST] ${ctx.runId}`; + const query = `${ctx.runId}`; let debugResults: Array<Record<string, unknown>> | null = null; try { const result = (await ctx.cli.runExpectSuccess([ @@ -287,8 +369,8 @@ export async function createSearchWorkflow( const results = result.results as Array<Record<string, unknown>>; debugResults = results; assertTruthy(results.length >= 1, `search ${mode} should find rich note`); - assertTruthy(typeof state.noteBId === 'string', 'rich note remId should be recorded'); - const match = findMatchingSearchResult(results, state.noteBId as string); + assertTruthy(typeof state.mdTreeIds?.[0] === 'string', 'md tree root remId should be recorded'); + const match = findMatchingSearchResult(results, state.mdTreeIds?.[0] as string); assertSearchContentModeShape(match, mode); assertParentContext(match, state, `search ${mode} parent context`); steps.push({ @@ -303,7 +385,7 @@ export async function createSearchWorkflow( durationMs: Date.now() - start, error: `${(e as Error).message} | query=${JSON.stringify(query)} expectedRemId=${JSON.stringify( - state.noteBId ?? null + state.mdTreeIds?.[0] ?? null )}` + (debugResults ? ` resultCount=${debugResults.length} topResults=${JSON.stringify( @@ -314,7 +396,7 @@ export async function createSearchWorkflow( } } - // Step 7-9: Search by tag with includeContent modes + // Step 9-11: Search by tag with includeContent modes let expectedTagTarget: ExpectedTagTarget | undefined; { const start = Date.now(); diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index cf855d2..bb064a0 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -17,6 +17,7 @@ describe('createProgram', () => { expect(commandNames).toContain('daemon'); expect(commandNames).toContain('create'); + expect(commandNames).toContain('create-md'); expect(commandNames).toContain('search'); expect(commandNames).toContain('search-tag'); expect(commandNames).toContain('read'); diff --git a/test/unit/command-action-mapping.test.ts b/test/unit/command-action-mapping.test.ts index e743947..6058716 100644 --- a/test/unit/command-action-mapping.test.ts +++ b/test/unit/command-action-mapping.test.ts @@ -59,6 +59,49 @@ describe('command bridge action mapping', () => { executeSpy.mockRestore(); }); + it('maps create command with flashcard options to create_note', async () => { + const executeSpy = await runCommand([ + 'create', + 'Front', + '--back-text', + 'Back', + '--concept', + ]); + expect(executeSpy).toHaveBeenCalledWith('create_note', { + title: 'Front', + backText: 'Back', + isConcept: true, + }); + executeSpy.mockRestore(); + }); + + it('maps create-md command to create_note_md', async () => { + const executeSpy = await runCommand([ + 'create-md', + '--title', + 'Root', + '--content', + '- Item 1\n - Item 2\n', + '--tags', + 'md-tag', + ]); + expect(executeSpy).toHaveBeenCalledWith('create_note_md', { + title: 'Root', + content: '- Item 1\n - Item 2\n', + tags: ['md-tag'], + }); + executeSpy.mockRestore(); + }); + + it('maps create-md --content-file to create_note_md', async () => { + const filePath = await createTempContentFile('- Item from file'); + const executeSpy = await runCommand(['create-md', '--content-file', filePath]); + expect(executeSpy).toHaveBeenCalledWith('create_note_md', { + content: '- Item from file', + }); + executeSpy.mockRestore(); + }); + it('maps read command to read_note', async () => { const executeSpy = await runCommand(['read', 'abc123', '--depth', '2']); expect(executeSpy).toHaveBeenCalledWith('read_note', { remId: 'abc123', depth: 2 }); diff --git a/test/unit/content-input.test.ts b/test/unit/content-input.test.ts index fe19e35..01ae98e 100644 --- a/test/unit/content-input.test.ts +++ b/test/unit/content-input.test.ts @@ -75,6 +75,28 @@ with "quotes" and \`ticks\``; ).rejects.toThrow('Cannot use --content and --content-file together'); }); + it('un-escapes literal \\n in inline content', async () => { + const inlineText = '- line 1\\n- line 2'; + const resolved = await resolveOptionalInlineOrFileContent({ + inlineText, + filePath: undefined, + inlineFlag: '--content', + fileFlag: '--content-file', + }); + expect(resolved).toBe('- line 1\n- line 2'); + }); + + it('un-escapes literal \\n in inline content', async () => { + const inlineText = "- line 1\n- line 2"; + const resolved = await resolveOptionalInlineOrFileContent({ + inlineText, + filePath: undefined, + inlineFlag: '--content', + fileFlag: '--content-file', + }); + expect(resolved).toBe('- line 1\n- line 2'); + }); + it('resolves update replacement content from --replace-file', async () => { const path = await createTempFile('replace body'); await expect(