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
@@ -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;
}