Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"dependencies": {
"@oclif/core": "^4",
"@oclif/plugin-autocomplete": "^3.2.45",
"docutray": "^0.1.3",
"docutray": "^0.1.4",
"open": "^11.0.0"
},
"devDependencies": {
Expand Down
8 changes: 5 additions & 3 deletions src/commands/types/create.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import {Flags} from '@oclif/core'
import type {ConversionMode} from 'docutray'

import {BaseCommand} from '../../base-command.js'
import {createClient} from '../../client.js'
import {outputError, outputKeyValue, outputSuccess, setForceJson} from '../../output.js'
import {parseSchema} from '../../parse-schema.js'

export default class TypesCreate extends BaseCommand {
static description = `Create a new document type. Defines an extraction schema that DocuTray uses when converting documents. Requires a name, code, description, and JSON schema. The schema can be provided as a file path or inline JSON string.`
static description = `Create a new document type. Defines an extraction schema that DocuTray uses when converting documents. Requires a name, code, description, and JSON schema. The schema can be provided as a file path or inline JSON string. Files exported via "types export" are also accepted: the inner jsonSchema is extracted automatically.`

static examples = [
{command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema schema.json', description: 'Create from a schema file'},
{command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema \'{"type":"object","properties":{"total":{"type":"number"}}}\'', description: 'Create with inline JSON schema'},
{command: '<%= config.bin %> types export factura -o factura.json && <%= config.bin %> types create --name "Factura Copy" --code factura_copy --description "Copy of factura" --schema factura.json', description: 'Round-trip: export an existing type and re-create it'},
{command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema schema.json --publish', description: 'Create and publish immediately'},
{command: '<%= config.bin %> types create --name "Invoice" --code invoice --description "Standard invoice" --schema schema.json --conversion-mode toon', description: 'Create with a specific conversion mode'},
]
Expand Down Expand Up @@ -39,7 +41,7 @@ export default class TypesCreate extends BaseCommand {
const client = createClient()
const result = await client.documentTypes.create({
codeType: flags.code,
conversionMode: flags['conversion-mode'] as 'json' | 'toon' | 'multi_prompt' | undefined,
conversionMode: flags['conversion-mode'] as ConversionMode | undefined,
description: flags.description,
identifyPromptHints: flags['identify-hints'],
isDraft: flags.publish ? false : flags.draft,
Expand All @@ -54,7 +56,7 @@ export default class TypesCreate extends BaseCommand {
{key: 'Name', value: result.name},
{key: 'Description', value: result.description || '(none)'},
{key: 'Draft', value: result.isDraft ? 'yes' : 'no'},
{key: 'Mode', value: (() => { const mode = (result as unknown as Record<string, unknown>).conversionMode; return typeof mode === 'string' && mode.trim() !== '' ? mode : 'json'; })()},
{key: 'Mode', value: result.conversionMode || 'json'},
])

outputSuccess({codeType: result.codeType, id: result.id}, `Created document type "${result.codeType}"`)
Expand Down
4 changes: 2 additions & 2 deletions src/commands/types/update.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Args, Flags} from '@oclif/core'
import type {DocumentTypeUpdateParams} from 'docutray'
import type {ConversionMode, DocumentTypeUpdateParams} from 'docutray'

import {BaseCommand} from '../../base-command.js'
import {createClient} from '../../client.js'
Expand Down Expand Up @@ -46,7 +46,7 @@ export default class TypesUpdate extends BaseCommand {
if (flags.schema !== undefined) params.jsonSchema = parseSchema(flags.schema)
if (flags['prompt-hints'] !== undefined) params.promptHints = flags['prompt-hints']
if (flags['identify-hints'] !== undefined) params.identifyPromptHints = flags['identify-hints']
if (flags['conversion-mode'] !== undefined) params.conversionMode = flags['conversion-mode'] as 'json' | 'toon' | 'multi_prompt'
if (flags['conversion-mode'] !== undefined) params.conversionMode = flags['conversion-mode'] as ConversionMode
if (flags['keep-ordering'] !== undefined) params.keepPropertyOrdering = flags['keep-ordering']
if (flags.publish) params.isDraft = false
else if (flags.draft !== undefined) params.isDraft = flags.draft
Expand Down
34 changes: 22 additions & 12 deletions src/parse-schema.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import {existsSync, readFileSync} from 'node:fs'

function unwrap(parsed: unknown): Record<string, unknown> {
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('JSON schema must be an object')
}

const obj = parsed as Record<string, unknown>
// Accept full DocumentType payloads (from `types export` / `types get`) by
// unwrapping the inner jsonSchema. Lets users round-trip without piping jq.
if (
'jsonSchema' in obj &&
typeof obj.jsonSchema === 'object' &&
obj.jsonSchema !== null &&
!Array.isArray(obj.jsonSchema)
) {
return obj.jsonSchema as Record<string, unknown>
}

return obj
}

export function parseSchema(input: string): Record<string, unknown> {
// Try as file path first
if (existsSync(input)) {
const content = readFileSync(input, 'utf8')
try {
const parsed = JSON.parse(content)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('JSON schema must be an object')
}

return parsed as Record<string, unknown>
return unwrap(JSON.parse(content))
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON in schema file "${input}": ${error.message}`)
Expand All @@ -22,12 +37,7 @@ export function parseSchema(input: string): Record<string, unknown> {

// Try as inline JSON
try {
const parsed = JSON.parse(input)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('JSON schema must be an object')
}

return parsed as Record<string, unknown>
return unwrap(JSON.parse(input))
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid schema: not a valid file path or JSON string`)
Expand Down
103 changes: 103 additions & 0 deletions test/parse-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'

vi.mock('node:fs', () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ''),
}))

import {existsSync, readFileSync} from 'node:fs'

import {parseSchema} from '../src/parse-schema.js'

const mockExistsSync = vi.mocked(existsSync)
const mockReadFileSync = vi.mocked(readFileSync)

describe('parseSchema', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExistsSync.mockReturnValue(false)
})

describe('inline JSON', () => {
it('parses a raw JSON Schema', () => {
const schema = '{"type":"object","properties":{"total":{"type":"number"}},"required":["total"]}'
expect(parseSchema(schema)).toEqual({
properties: {total: {type: 'number'}},
required: ['total'],
type: 'object',
})
})

it('unwraps a full DocumentType payload by extracting jsonSchema', () => {
const documentType = {
codeType: 'factura',
createdAt: '2026-01-01T00:00:00Z',
description: 'Factura electrónica',
id: 'dt_123',
isDraft: false,
isPublic: true,
jsonSchema: {properties: {total: {type: 'number'}}, required: ['total'], type: 'object'},
name: 'Factura',
status: 'PUBLISHED',
updatedAt: '2026-01-02T00:00:00Z',
}

expect(parseSchema(JSON.stringify(documentType))).toEqual(documentType.jsonSchema)
})

it('returns the object unchanged when jsonSchema key is absent', () => {
const schema = '{"type":"object","properties":{}}'
expect(parseSchema(schema)).toEqual({properties: {}, type: 'object'})
})

it('does not unwrap when jsonSchema is null', () => {
const payload = {jsonSchema: null, properties: {}, type: 'object'}
expect(parseSchema(JSON.stringify(payload))).toEqual(payload)
})

it('does not unwrap when jsonSchema is not an object', () => {
const payload = {jsonSchema: 'not-an-object', type: 'object'}
expect(parseSchema(JSON.stringify(payload))).toEqual(payload)
})

it('rejects arrays', () => {
expect(() => parseSchema('[1,2,3]')).toThrow('JSON schema must be an object')
})

it('rejects invalid JSON', () => {
expect(() => parseSchema('not-json')).toThrow('not a valid file path or JSON string')
})
})

describe('file path', () => {
it('reads and parses a raw schema file', () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue('{"type":"object","properties":{"amount":{"type":"number"}}}')

expect(parseSchema('schema.json')).toEqual({
properties: {amount: {type: 'number'}},
type: 'object',
})
})

it('unwraps a DocumentType payload from a file (round-trip)', () => {
const documentType = {
codeType: 'factura',
id: 'dt_123',
jsonSchema: {properties: {}, required: [], type: 'object'},
name: 'Factura',
}
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue(JSON.stringify(documentType))

expect(parseSchema('factura.json')).toEqual(documentType.jsonSchema)
})

it('rejects invalid JSON in a file', () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue('not valid json')

expect(() => parseSchema('bad.json')).toThrow('Invalid JSON in schema file "bad.json"')
})
})
})
Loading