feat(properties): prepare property commands before execution
Build and Push Docker Container / build-and-push (push) Successful in 4m28s

- Create missing note files before running property:set.
- Default date-only datetime values to midnight.
- Keep property names out of path authorization checks.
- Split policy, vault, daily note, and command target helpers.
- Split n8n node args, description, output, and API response helpers.
- Split API tests by auth, command, daily note, and property behavior.
- Bump API and n8n node versions.
This commit is contained in:
2026-06-24 14:29:48 +02:00
parent e8e31add11
commit c34dab2cca
25 changed files with 1099 additions and 1018 deletions
@@ -4,294 +4,14 @@ import type {
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { compactArgs, executeObsidianCommand, kv } from '../../shared/ObsidianApiClient';
import { executeObsidianCommand } from '../../shared/ObsidianApiClient';
import { buildArgs } from './argBuilders';
import { nodeDescription } from './description';
import { outputForResult } from './output';
export class ObsidianApi implements INodeType {
description: INodeTypeDescription = {
displayName: 'Obsidian API',
name: 'obsidianApi',
icon: 'file:obsidian.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Run official Obsidian CLI commands through the Obsidian API Docker service',
defaults: {
name: 'Obsidian API',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'obsidianApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Daily Note', value: 'daily' },
{ name: 'Note', value: 'note' },
{ name: 'Property', value: 'property' },
{ name: 'Raw Command', value: 'raw' },
{ name: 'Search', value: 'search' },
{ name: 'Vault', value: 'vault' },
],
default: 'note',
},
// Note operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['note'] } },
options: [
{ name: 'Append', value: 'append' },
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
{ name: 'List Files', value: 'files' },
{ name: 'Prepend', value: 'prepend' },
{ name: 'Read', value: 'read' },
{ name: 'Rename', value: 'rename' },
],
default: 'create',
},
{
displayName: 'Path',
name: 'path',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'delete', 'prepend', 'read', 'rename'] } },
default: '',
placeholder: 'Inbox/Note.md',
required: true,
},
{
displayName: 'Content',
name: 'content',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'prepend'] } },
default: '',
required: true,
},
{
displayName: 'Overwrite',
name: 'overwrite',
type: 'boolean',
displayOptions: { show: { resource: ['note'], operation: ['create'] } },
default: false,
},
{
displayName: 'New Name',
name: 'newName',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['rename'] } },
default: '',
required: true,
},
{
displayName: 'Folder',
name: 'folder',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['files'] } },
default: '',
placeholder: 'Inbox',
},
// Daily note operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['daily'] } },
options: [
{ name: 'Append', value: 'dailyAppend' },
{ name: 'Path', value: 'dailyPath' },
{ name: 'Prepend', value: 'dailyPrepend' },
{ name: 'Read', value: 'dailyRead' },
],
default: 'dailyAppend',
},
{
displayName: 'Content',
name: 'dailyContent',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['daily'], operation: ['dailyAppend', 'dailyPrepend'] } },
default: '',
required: true,
},
// Property operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['property'] } },
options: [
{ name: 'List', value: 'properties' },
{ name: 'Read', value: 'propertyRead' },
{ name: 'Remove', value: 'propertyRemove' },
{ name: 'Set', value: 'propertySet' },
],
default: 'propertySet',
},
{
displayName: 'Path',
name: 'propertyPath',
type: 'string',
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet'] } },
default: '',
placeholder: 'Inbox/Note.md',
required: true,
},
{
displayName: 'Name',
name: 'propertyName',
type: 'string',
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet', 'properties'] } },
default: '',
placeholder: 'tags',
description: 'Frontmatter/property name, e.g. tags, date, description',
},
{
displayName: 'Value',
name: 'propertyValue',
type: 'string',
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
default: '',
placeholder: 'kontext, feedback-browser-cli-tests',
description: 'Value passed to the official CLI property:set command',
required: true,
},
{
displayName: 'Type',
name: 'propertyType',
type: 'options',
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
options: [
{ name: 'Text', value: 'text' },
{ name: 'List', value: 'list' },
{ name: 'Number', value: 'number' },
{ name: 'Checkbox', value: 'checkbox' },
{ name: 'Date', value: 'date' },
{ name: 'Date Time', value: 'datetime' },
],
default: 'text',
description: 'Obsidian property type. Use List for tags and Date for date.',
},
{
displayName: 'Format',
name: 'propertiesFormat',
type: 'options',
displayOptions: { show: { resource: ['property'], operation: ['properties'] } },
options: [
{ name: 'YAML', value: 'yaml' },
{ name: 'JSON', value: 'json' },
{ name: 'TSV', value: 'tsv' },
],
default: 'yaml',
},
// Search operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Search', value: 'search' },
{ name: 'Search With Context', value: 'searchContext' },
],
default: 'search',
},
{
displayName: 'Query',
name: 'query',
type: 'string',
displayOptions: { show: { resource: ['search'] } },
default: '',
required: true,
},
{
displayName: 'Format',
name: 'format',
type: 'options',
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Text', value: 'text' },
{ name: 'JSON', value: 'json' },
],
default: 'text',
},
// Vault operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['vault'] } },
options: [
{ name: 'Info', value: 'vaultInfo' },
{ name: 'List Vaults', value: 'vaults' },
],
default: 'vaultInfo',
},
// Raw command
{
displayName: 'Args JSON',
name: 'rawArgs',
type: 'json',
displayOptions: { show: { resource: ['raw'] } },
default: '["version"]',
description: 'Structured CLI args array. Example: ["create", "path=Inbox/Test.md", "content=Hello", "overwrite"]. Do not include shell strings.',
required: true,
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
options: [
{
displayName: 'Timeout Seconds',
name: 'timeout',
type: 'number',
default: 60,
description: 'Command timeout in seconds',
},
{
displayName: 'Vault',
name: 'vault',
type: 'string',
default: '',
placeholder: 'work',
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
},
{
displayName: 'Verbose',
name: 'verbose',
type: 'boolean',
default: true,
description: 'Only used by the List Vaults operation',
},
],
},
],
};
description: INodeTypeDescription = nodeDescription;
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
@@ -304,98 +24,10 @@ export class ObsidianApi implements INodeType {
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as Record<string, unknown>;
const vault = String(additionalFields.vault || '');
const timeout = Number(additionalFields.timeout ?? 60);
let args: string[];
switch (resource) {
case 'note': {
args = buildNoteArgs(this, operation, itemIndex);
break;
}
case 'daily': {
args = buildDailyArgs(this, operation, itemIndex);
break;
}
case 'search': {
args = buildSearchArgs(this, operation, itemIndex);
break;
}
case 'property': {
args = buildPropertyArgs(this, operation, itemIndex);
break;
}
case 'vault': {
args = buildVaultArgs(this, operation, itemIndex, additionalFields);
break;
}
case 'raw': {
args = parseRawArgs(this, itemIndex);
break;
}
default:
throw new NodeOperationError(this.getNode(), `Unsupported resource: ${resource}`, { itemIndex });
}
const args = buildArgs(this, resource, operation, itemIndex, additionalFields);
const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout });
// Always keep n8n output minimal/clean.
// - Prefer parsed stdout (e.g. vault/vaults)
// - Otherwise keep stdout as-is
// Build a stable, minimal output shape for n8n.
// We try to expose the single most useful field name per operation.
const stdout = result.stdout ?? '';
// Daily notes
if (resource === 'daily' && operation === 'dailyPath') {
const out: any = { path: stdout };
if (result.stderr) out.stderr = result.stderr;
if (result.timed_out) out.timed_out = result.timed_out;
returnData.push({ json: out, pairedItem: { item: itemIndex } });
continue;
}
if (resource === 'daily' && operation === 'dailyRead') {
const out: any = { content: stdout };
if (result.stderr) out.stderr = result.stderr;
if (result.timed_out) out.timed_out = result.timed_out;
returnData.push({ json: out, pairedItem: { item: itemIndex } });
continue;
}
// Notes
if (resource === 'note' && operation === 'read') {
const out: any = { content: stdout };
if (result.stderr) out.stderr = result.stderr;
if (result.timed_out) out.timed_out = result.timed_out;
returnData.push({ json: out, pairedItem: { item: itemIndex } });
continue;
}
// Properties: keep CLI output because read/list return useful data.
if (resource === 'property' && (operation === 'propertyRead' || operation === 'properties')) {
const out: any = { content: stdout };
if (result.stderr) out.stderr = result.stderr;
if (result.timed_out) out.timed_out = result.timed_out;
returnData.push({ json: out, pairedItem: { item: itemIndex } });
continue;
}
// vault and vaults are parsed as structured JSON
const parsed = (result as any).parsed_stdout;
if (parsed) {
const out: any = parsed;
if (result.timed_out) out.timed_out = result.timed_out;
if (result.stderr) out.stderr = result.stderr;
returnData.push({ json: out, pairedItem: { item: itemIndex } });
continue;
}
// Everything else (writes, deletes, rename, list files, etc.):
// keep output clean/stable and only confirm success.
const out: any = { ok: true };
if (result.stderr) out.stderr = result.stderr;
if (result.timed_out) out.timed_out = result.timed_out;
returnData.push({ json: out, pairedItem: { item: itemIndex } });
returnData.push(outputForResult(result, resource, operation, itemIndex));
} catch (error) {
if (this.continueOnFail()) {
const message = error instanceof Error ? error.message : String(error);
@@ -409,100 +41,3 @@ export class ObsidianApi implements INodeType {
return [returnData];
}
}
function buildNoteArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const path = context.getNodeParameter('path', itemIndex, '') as string;
switch (operation) {
case 'create':
return compactArgs(['create', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string), kv('overwrite', context.getNodeParameter('overwrite', itemIndex) as boolean)]);
case 'read':
return compactArgs(['read', kv('path', path)]);
case 'append':
return compactArgs(['append', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'prepend':
return compactArgs(['prepend', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'delete':
return compactArgs(['delete', kv('path', path)]);
case 'rename':
return compactArgs(['rename', kv('path', path), kv('name', context.getNodeParameter('newName', itemIndex) as string)]);
case 'files':
return compactArgs(['files', kv('folder', context.getNodeParameter('folder', itemIndex, '') as string)]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported note operation: ${operation}`, { itemIndex });
}
}
function buildDailyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
switch (operation) {
case 'dailyAppend':
return compactArgs(['daily:append', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyPrepend':
return compactArgs(['daily:prepend', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyRead':
return ['daily:read'];
case 'dailyPath':
return ['daily:path'];
default:
throw new NodeOperationError(context.getNode(), `Unsupported daily operation: ${operation}`, { itemIndex });
}
}
function buildSearchArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const command = operation === 'searchContext' ? 'search:context' : 'search';
return compactArgs([
command,
kv('query', context.getNodeParameter('query', itemIndex) as string),
kv('format', context.getNodeParameter('format', itemIndex) as string),
]);
}
function buildPropertyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const path = context.getNodeParameter('propertyPath', itemIndex, '') as string;
const name = context.getNodeParameter('propertyName', itemIndex, '') as string;
switch (operation) {
case 'properties':
return compactArgs([
'properties',
kv('name', name),
kv('format', context.getNodeParameter('propertiesFormat', itemIndex) as string),
]);
case 'propertyRead':
return compactArgs(['property:read', kv('path', path), kv('name', name)]);
case 'propertyRemove':
return compactArgs(['property:remove', kv('path', path), kv('name', name)]);
case 'propertySet':
return compactArgs([
'property:set',
kv('path', path),
kv('name', name),
kv('value', context.getNodeParameter('propertyValue', itemIndex) as string),
kv('type', context.getNodeParameter('propertyType', itemIndex) as string),
]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported property operation: ${operation}`, { itemIndex });
}
}
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number, additionalFields: Record<string, unknown>): string[] {
switch (operation) {
case 'vaultInfo':
return ['vault'];
case 'vaults': {
const verbose = additionalFields.verbose === undefined ? true : Boolean(additionalFields.verbose);
return compactArgs(['vaults', kv('verbose', verbose)]);
}
default:
throw new NodeOperationError(context.getNode(), `Unsupported vault operation: ${operation}`, { itemIndex });
}
}
function parseRawArgs(context: IExecuteFunctions, itemIndex: number): string[] {
const raw = context.getNodeParameter('rawArgs', itemIndex) as string | string[];
const parsed = Array.isArray(raw) ? raw : JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
throw new NodeOperationError(context.getNode(), 'Args JSON must be an array of strings', { itemIndex });
}
return parsed;
}
@@ -0,0 +1,119 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { compactArgs, kv } from '../../shared/ObsidianApiClient';
export function buildArgs(context: IExecuteFunctions, resource: string, operation: string, itemIndex: number, additionalFields: Record<string, unknown>): string[] {
switch (resource) {
case 'note':
return buildNoteArgs(context, operation, itemIndex);
case 'daily':
return buildDailyArgs(context, operation, itemIndex);
case 'search':
return buildSearchArgs(context, operation, itemIndex);
case 'property':
return buildPropertyArgs(context, operation, itemIndex);
case 'vault':
return buildVaultArgs(context, operation, itemIndex, additionalFields);
case 'raw':
return parseRawArgs(context, itemIndex);
default:
throw new NodeOperationError(context.getNode(), `Unsupported resource: ${resource}`, { itemIndex });
}
}
function buildNoteArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const path = context.getNodeParameter('path', itemIndex, '') as string;
switch (operation) {
case 'create':
return compactArgs(['create', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string), kv('overwrite', context.getNodeParameter('overwrite', itemIndex) as boolean)]);
case 'read':
return compactArgs(['read', kv('path', path)]);
case 'append':
return compactArgs(['append', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'prepend':
return compactArgs(['prepend', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'delete':
return compactArgs(['delete', kv('path', path)]);
case 'rename':
return compactArgs(['rename', kv('path', path), kv('name', context.getNodeParameter('newName', itemIndex) as string)]);
case 'files':
return compactArgs(['files', kv('folder', context.getNodeParameter('folder', itemIndex, '') as string)]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported note operation: ${operation}`, { itemIndex });
}
}
function buildDailyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
switch (operation) {
case 'dailyAppend':
return compactArgs(['daily:append', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyPrepend':
return compactArgs(['daily:prepend', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyRead':
return ['daily:read'];
case 'dailyPath':
return ['daily:path'];
default:
throw new NodeOperationError(context.getNode(), `Unsupported daily operation: ${operation}`, { itemIndex });
}
}
function buildSearchArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const command = operation === 'searchContext' ? 'search:context' : 'search';
return compactArgs([
command,
kv('query', context.getNodeParameter('query', itemIndex) as string),
kv('format', context.getNodeParameter('format', itemIndex) as string),
]);
}
function buildPropertyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const path = context.getNodeParameter('propertyPath', itemIndex, '') as string;
const name = context.getNodeParameter('propertyName', itemIndex, '') as string;
switch (operation) {
case 'properties':
return compactArgs([
'properties',
kv('name', name),
kv('format', context.getNodeParameter('propertiesFormat', itemIndex) as string),
]);
case 'propertyRead':
return compactArgs(['property:read', kv('path', path), kv('name', name)]);
case 'propertyRemove':
return compactArgs(['property:remove', kv('path', path), kv('name', name)]);
case 'propertySet':
return compactArgs([
'property:set',
kv('path', path),
kv('name', name),
kv('value', context.getNodeParameter('propertyValue', itemIndex) as string),
kv('type', context.getNodeParameter('propertyType', itemIndex) as string),
]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported property operation: ${operation}`, { itemIndex });
}
}
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number, additionalFields: Record<string, unknown>): string[] {
switch (operation) {
case 'vaultInfo':
return ['vault'];
case 'vaults': {
const verbose = additionalFields.verbose === undefined ? true : Boolean(additionalFields.verbose);
return compactArgs(['vaults', kv('verbose', verbose)]);
}
default:
throw new NodeOperationError(context.getNode(), `Unsupported vault operation: ${operation}`, { itemIndex });
}
}
function parseRawArgs(context: IExecuteFunctions, itemIndex: number): string[] {
const raw = context.getNodeParameter('rawArgs', itemIndex) as string | string[];
const parsed = Array.isArray(raw) ? raw : JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
throw new NodeOperationError(context.getNode(), 'Args JSON must be an array of strings', { itemIndex });
}
return parsed;
}
@@ -0,0 +1,273 @@
import type { INodeTypeDescription } from 'n8n-workflow';
export const nodeDescription: INodeTypeDescription = {
displayName: 'Obsidian API',
name: 'obsidianApi',
icon: 'file:obsidian.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Run official Obsidian CLI commands through the Obsidian API Docker service',
defaults: {
name: 'Obsidian API',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'obsidianApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Daily Note', value: 'daily' },
{ name: 'Note', value: 'note' },
{ name: 'Property', value: 'property' },
{ name: 'Raw Command', value: 'raw' },
{ name: 'Search', value: 'search' },
{ name: 'Vault', value: 'vault' },
],
default: 'note',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['note'] } },
options: [
{ name: 'Append', value: 'append' },
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
{ name: 'List Files', value: 'files' },
{ name: 'Prepend', value: 'prepend' },
{ name: 'Read', value: 'read' },
{ name: 'Rename', value: 'rename' },
],
default: 'create',
},
{
displayName: 'Path',
name: 'path',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'delete', 'prepend', 'read', 'rename'] } },
default: '',
placeholder: 'Inbox/Note.md',
required: true,
},
{
displayName: 'Content',
name: 'content',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'prepend'] } },
default: '',
required: true,
},
{
displayName: 'Overwrite',
name: 'overwrite',
type: 'boolean',
displayOptions: { show: { resource: ['note'], operation: ['create'] } },
default: false,
},
{
displayName: 'New Name',
name: 'newName',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['rename'] } },
default: '',
required: true,
},
{
displayName: 'Folder',
name: 'folder',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['files'] } },
default: '',
placeholder: 'Inbox',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['daily'] } },
options: [
{ name: 'Append', value: 'dailyAppend' },
{ name: 'Path', value: 'dailyPath' },
{ name: 'Prepend', value: 'dailyPrepend' },
{ name: 'Read', value: 'dailyRead' },
],
default: 'dailyAppend',
},
{
displayName: 'Content',
name: 'dailyContent',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['daily'], operation: ['dailyAppend', 'dailyPrepend'] } },
default: '',
required: true,
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['property'] } },
options: [
{ name: 'List', value: 'properties' },
{ name: 'Read', value: 'propertyRead' },
{ name: 'Remove', value: 'propertyRemove' },
{ name: 'Set', value: 'propertySet' },
],
default: 'propertySet',
},
{
displayName: 'Path',
name: 'propertyPath',
type: 'string',
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet'] } },
default: '',
placeholder: 'Inbox/Note.md',
required: true,
},
{
displayName: 'Name',
name: 'propertyName',
type: 'string',
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet', 'properties'] } },
default: '',
placeholder: 'tags',
description: 'Frontmatter/property name, e.g. tags, date, description',
},
{
displayName: 'Value',
name: 'propertyValue',
type: 'string',
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
default: '',
placeholder: 'kontext, feedback-browser-cli-tests',
description: 'Value passed to the official CLI property:set command',
required: true,
},
{
displayName: 'Type',
name: 'propertyType',
type: 'options',
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
options: [
{ name: 'Text', value: 'text' },
{ name: 'List', value: 'list' },
{ name: 'Number', value: 'number' },
{ name: 'Checkbox', value: 'checkbox' },
{ name: 'Date', value: 'date' },
{ name: 'Date Time', value: 'datetime' },
],
default: 'text',
description: 'Obsidian property type. Use List for tags and Date for date.',
},
{
displayName: 'Format',
name: 'propertiesFormat',
type: 'options',
displayOptions: { show: { resource: ['property'], operation: ['properties'] } },
options: [
{ name: 'YAML', value: 'yaml' },
{ name: 'JSON', value: 'json' },
{ name: 'TSV', value: 'tsv' },
],
default: 'yaml',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Search', value: 'search' },
{ name: 'Search With Context', value: 'searchContext' },
],
default: 'search',
},
{
displayName: 'Query',
name: 'query',
type: 'string',
displayOptions: { show: { resource: ['search'] } },
default: '',
required: true,
},
{
displayName: 'Format',
name: 'format',
type: 'options',
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Text', value: 'text' },
{ name: 'JSON', value: 'json' },
],
default: 'text',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['vault'] } },
options: [
{ name: 'Info', value: 'vaultInfo' },
{ name: 'List Vaults', value: 'vaults' },
],
default: 'vaultInfo',
},
{
displayName: 'Args JSON',
name: 'rawArgs',
type: 'json',
displayOptions: { show: { resource: ['raw'] } },
default: '["version"]',
description: 'Structured CLI args array. Example: ["create", "path=Inbox/Test.md", "content=Hello", "overwrite"]. Do not include shell strings.',
required: true,
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
options: [
{
displayName: 'Timeout Seconds',
name: 'timeout',
type: 'number',
default: 60,
description: 'Command timeout in seconds',
},
{
displayName: 'Vault',
name: 'vault',
type: 'string',
default: '',
placeholder: 'work',
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
},
{
displayName: 'Verbose',
name: 'verbose',
type: 'boolean',
default: true,
description: 'Only used by the List Vaults operation',
},
],
},
],
};
@@ -0,0 +1,32 @@
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import type { ObsidianCommandResult } from '../../shared/ObsidianApiClient';
export function outputForResult(result: ObsidianCommandResult, resource: string, operation: string, itemIndex: number): INodeExecutionData {
const stdout = result.stdout ?? '';
if (resource === 'daily' && operation === 'dailyPath') {
return paired({ path: stdout }, result, itemIndex);
}
if (resource === 'daily' && operation === 'dailyRead') {
return paired({ content: stdout }, result, itemIndex);
}
if (resource === 'note' && operation === 'read') {
return paired({ content: stdout }, result, itemIndex);
}
if (resource === 'property' && (operation === 'propertyRead' || operation === 'properties')) {
return paired({ content: stdout }, result, itemIndex);
}
const parsed = result.parsed_stdout;
if (parsed) return paired(parsed, result, itemIndex);
return paired({ ok: true }, result, itemIndex);
}
function paired(json: IDataObject, result: ObsidianCommandResult, itemIndex: number): INodeExecutionData {
const out: IDataObject = { ...json };
if (result.stderr) out.stderr = result.stderr;
if (result.timed_out) out.timed_out = result.timed_out;
return { json: out, pairedItem: { item: itemIndex } };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "n8n-nodes-obsidian-cli-api",
"version": "0.2.6",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "n8n-nodes-obsidian-cli-api",
"version": "0.2.6",
"version": "0.2.7",
"license": "MIT",
"devDependencies": {
"@types/node": "^24.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "n8n-nodes-obsidian-cli-api",
"version": "0.2.6",
"version": "0.2.7",
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
"license": "MIT",
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
@@ -3,7 +3,10 @@ import type {
IDataObject,
IHttpRequestOptions,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { toCleanApiError } from './errors';
import { maybeParseStdout, normalizeCommandResult } from './response';
export interface ObsidianCommandResult extends IDataObject {
id: string;
@@ -87,140 +90,3 @@ export async function executeObsidianCommand(
return result;
}
function toCleanApiError(
context: IExecuteFunctions,
error: unknown,
itemIndex: number,
): Error {
const err = isObject(error) ? error : {};
const statusCode = Number(
err.statusCode ?? err.status ?? (isObject(err.response) ? err.response.statusCode : undefined) ?? 0,
);
const body = extractErrorBody(err);
const detail = extractErrorMessage(body) || (typeof err.message === 'string' ? err.message : '');
const statusText = statusCode ? `HTTP ${statusCode}` : 'Request failed';
const message = detail
? `Obsidian API error (${statusText}): ${detail}`
: `Obsidian API error (${statusText})`;
// Build a plain-object error so n8n never tries to serialize the raw HTTP
// response (it contains circular fields like res.socket._httpMessage).
return new NodeApiError(
context.getNode(),
{ message, ...(statusCode ? { statusCode } : {}), ...(detail ? { detail } : {}) },
{ message, description: detail || undefined, httpCode: statusCode ? String(statusCode) : undefined, itemIndex },
);
}
function extractErrorBody(err: Record<string, unknown>): unknown {
if ('body' in err && err.body !== undefined) return err.body;
const response = err.response;
if (isObject(response)) {
if ('body' in response && response.body !== undefined) return response.body;
if ('data' in response && response.data !== undefined) return response.data;
}
if ('error' in err && err.error !== undefined) return err.error;
return undefined;
}
function extractErrorMessage(body: unknown): string {
let data: unknown = body;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed) return '';
try {
data = JSON.parse(trimmed);
} catch {
return trimmed;
}
}
if (isObject(data)) {
const candidate = data.detail ?? data.message ?? data.error;
if (typeof candidate === 'string' && candidate) return candidate;
if (isObject(candidate) && typeof candidate.message === 'string') return candidate.message;
}
return '';
}
function maybeParseStdout(result: ObsidianCommandResult): IDataObject | undefined {
const cmd = result.command;
const hasVault = cmd.includes('vault');
const hasVaults = cmd.includes('vaults');
if (!hasVault && !hasVaults) return undefined;
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return undefined;
// vaults output is typically a 2-col tabular format: <name>\t<containerPath>
if (hasVaults) {
const vaults: Array<{ name: string; path: string }> = [];
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 2) continue;
const name = parts[0].trim();
const path = parts.slice(1).join('\t').trim();
if (!name || !path) continue;
vaults.push({ name, path });
}
// Fallback: if parsing failed, return raw lines
if (!vaults.length) return { vaults: lines };
return { vaults };
}
// vault output is often key\tvalue pairs
const kv: Record<string, string | number> = {};
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 2) continue;
const key = parts[0].trim();
const rawValue = parts.slice(1).join('\t').trim();
if (!key || !rawValue) continue;
if (/^-?\d+$/.test(rawValue)) kv[key] = Number(rawValue);
else kv[key] = rawValue;
}
return Object.keys(kv).length ? kv : undefined;
}
function normalizeCommandResult(response: unknown): ObsidianCommandResult {
const data = extractResponseBody(response);
const result = typeof data === 'string' ? JSON.parse(data) : data;
if (!isObject(result)) {
throw new Error('Obsidian API returned a non-object response');
}
return {
id: String(result.id ?? ''),
command: Array.isArray(result.command) ? result.command.map(String) : [],
cwd: String(result.cwd ?? ''),
exit_code: Number(result.exit_code ?? 0),
stdout: String(result.stdout ?? ''),
stderr: String(result.stderr ?? ''),
duration_ms: Number(result.duration_ms ?? 0),
timed_out: Boolean(result.timed_out),
};
}
function extractResponseBody(response: unknown): unknown {
if (!isObject(response)) return response;
// Some n8n versions/helpers can return a full response object. Never pass that
// through to node output because it contains circular fields like res.socket.
if ('body' in response) return response.body;
if ('data' in response) return response.data;
return response;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -0,0 +1,60 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
export function toCleanApiError(
context: IExecuteFunctions,
error: unknown,
itemIndex: number,
): Error {
const err = isRecord(error) ? error : {};
const statusCode = Number(
err.statusCode ?? err.status ?? (isRecord(err.response) ? err.response.statusCode : undefined) ?? 0,
);
const body = extractErrorBody(err);
const detail = extractErrorMessage(body) || (typeof err.message === 'string' ? err.message : '');
const statusText = statusCode ? `HTTP ${statusCode}` : 'Request failed';
const message = detail
? `Obsidian API error (${statusText}): ${detail}`
: `Obsidian API error (${statusText})`;
return new NodeApiError(
context.getNode(),
{ message, ...(statusCode ? { statusCode } : {}), ...(detail ? { detail } : {}) },
{ message, description: detail || undefined, httpCode: statusCode ? String(statusCode) : undefined, itemIndex },
);
}
function extractErrorBody(err: Record<string, unknown>): unknown {
if ('body' in err && err.body !== undefined) return err.body;
const response = err.response;
if (isRecord(response)) {
if ('body' in response && response.body !== undefined) return response.body;
if ('data' in response && response.data !== undefined) return response.data;
}
if ('error' in err && err.error !== undefined) return err.error;
return undefined;
}
function extractErrorMessage(body: unknown): string {
let data: unknown = body;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed) return '';
try {
data = JSON.parse(trimmed);
} catch {
return trimmed;
}
}
if (isRecord(data)) {
const candidate = data.detail ?? data.message ?? data.error;
if (typeof candidate === 'string' && candidate) return candidate;
if (isRecord(candidate) && typeof candidate.message === 'string') return candidate.message;
}
return '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -0,0 +1,77 @@
import type { IDataObject } from 'n8n-workflow';
import type { ObsidianCommandResult } from './ObsidianApiClient';
export function normalizeCommandResult(response: unknown): ObsidianCommandResult {
const data = extractResponseBody(response);
const result = typeof data === 'string' ? JSON.parse(data) : data;
if (!isRecord(result)) {
throw new Error('Obsidian API returned a non-object response');
}
return {
id: String(result.id ?? ''),
command: Array.isArray(result.command) ? result.command.map(String) : [],
cwd: String(result.cwd ?? ''),
exit_code: Number(result.exit_code ?? 0),
stdout: String(result.stdout ?? ''),
stderr: String(result.stderr ?? ''),
duration_ms: Number(result.duration_ms ?? 0),
timed_out: Boolean(result.timed_out),
};
}
export function maybeParseStdout(result: ObsidianCommandResult): IDataObject | undefined {
const cmd = result.command;
const hasVault = cmd.includes('vault');
const hasVaults = cmd.includes('vaults');
if (!hasVault && !hasVaults) return undefined;
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return undefined;
if (hasVaults) return parseVaultsOutput(lines);
return parseVaultOutput(lines);
}
function parseVaultsOutput(lines: string[]): IDataObject {
const vaults: Array<{ name: string; path: string }> = [];
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 2) continue;
const name = parts[0].trim();
const path = parts.slice(1).join('\t').trim();
if (!name || !path) continue;
vaults.push({ name, path });
}
return vaults.length ? { vaults } : { vaults: lines };
}
function parseVaultOutput(lines: string[]): IDataObject | undefined {
const kv: Record<string, string | number> = {};
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 2) continue;
const key = parts[0].trim();
const rawValue = parts.slice(1).join('\t').trim();
if (!key || !rawValue) continue;
kv[key] = /^-?\d+$/.test(rawValue) ? Number(rawValue) : rawValue;
}
return Object.keys(kv).length ? kv : undefined;
}
function extractResponseBody(response: unknown): unknown {
if (!isRecord(response)) return response;
if ('body' in response) return response.body;
if ('data' in response) return response.data;
return response;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}