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
+47
View File
@@ -0,0 +1,47 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class CommandTarget:
command:str|None
vault:str|None
paths:tuple[str, ...]
DEFAULT_PATH_KEYS = {"path", "file", "folder", "to"}
PATH_KEYS_BY_COMMAND = {
"rename": DEFAULT_PATH_KEYS | {"name"},
}
def parse_kv_arg(arg:str) -> tuple[str, str]|None:
if "=" not in arg:
return None
key, value = arg.split("=", 1)
if not key:
return None
return key, value
def path_keys_for_command(command:str|None) -> set[str]:
return PATH_KEYS_BY_COMMAND.get(command or "", DEFAULT_PATH_KEYS)
def extract_command_target(args:list[str]) -> CommandTarget:
vault:str|None = None
command:str|None = None
paths:list[str] = []
for arg in args:
parsed = parse_kv_arg(arg)
if parsed and parsed[0] == "vault" and command is None:
vault = parsed[1]
continue
command = arg
break
path_keys = path_keys_for_command(command)
for arg in args:
parsed = parse_kv_arg(arg)
if not parsed:
continue
key, value = parsed
if key in path_keys and value:
paths.append(value)
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from pathlib import Path
import json
def moment_to_strftime(format_string:str) -> str:
replacements = [
("YYYY", "%Y"),
("YY", "%y"),
("MMMM", "%B"),
("MMM", "%b"),
("MM", "%m"),
("DDDD", "%j"),
("DD", "%d"),
("dddd", "%A"),
("ddd", "%a"),
]
converted = format_string or "YYYY-MM-DD"
for moment_token, strftime_token in replacements:
converted = converted.replace(moment_token, strftime_token)
return converted
def daily_note_path_for_vault(vault_root:Path, now:datetime|None = None) -> str:
settings_path = vault_root / ".obsidian" / "daily-notes.json"
folder = ""
format_string = "YYYY-MM-DD"
if settings_path.exists() and settings_path.is_file():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
if isinstance(settings, dict):
folder_value = settings.get("folder")
format_value = settings.get("format")
if isinstance(folder_value, str):
folder = folder_value.strip().strip("/")
if isinstance(format_value, str) and format_value.strip():
format_string = format_value.strip()
except (OSError, json.JSONDecodeError):
pass
note_name = (now or datetime.now()).strftime(moment_to_strftime(format_string))
if not note_name.endswith(".md"):
note_name += ".md"
return f"{folder}/{note_name}" if folder else note_name
+2 -1
View File
@@ -10,7 +10,8 @@ from starlette.routing import Route
from app import auth from app import auth
from app.auth import require_auth from app.auth import require_auth
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
from app.policy import authorize_command, vault_for_path from app.policy import authorize_command
from app.vaults import vault_for_path
from app.runner import execute_command from app.runner import execute_command
def error(status_code:int, detail:str) -> JSONResponse: def error(status_code:int, detail:str) -> JSONResponse:
+24
View File
@@ -0,0 +1,24 @@
def normalize_policy_path(value:str) -> str:
normalized = value.strip().replace("\\", "/").lstrip("/")
while "//" in normalized:
normalized = normalized.replace("//", "/")
return normalized
def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool:
normalized = normalize_policy_path(path)
for pattern in allowed_patterns:
pattern_normalized = normalize_policy_path(pattern)
if pattern_normalized == "*":
return True
if pattern_normalized.endswith("/**"):
prefix = pattern_normalized[:-3].rstrip("/")
if normalized == prefix or normalized.startswith(prefix + "/"):
return True
elif pattern_normalized.endswith("/"):
if normalized.startswith(pattern_normalized):
return True
elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"):
return True
return False
+5 -139
View File
@@ -1,10 +1,12 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import json
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS from app.command_target import CommandTarget, extract_command_target, parse_kv_arg
from app.config import DEFAULT_VAULT_NAME
from app.daily_notes import daily_note_path_for_vault
from app.path_policy import is_path_allowed, normalize_policy_path
from app.vaults import vault_for_path, vault_root_for_target
@dataclass(frozen=True) @dataclass(frozen=True)
class TokenPolicy: class TokenPolicy:
@@ -23,16 +25,6 @@ class TokenPolicy:
def allows_all_commands(self) -> bool: def allows_all_commands(self) -> bool:
return "*" in self.commands return "*" in self.commands
@dataclass(frozen=True)
class CommandTarget:
command:str|None
vault:str|None
paths:tuple[str, ...]
DEFAULT_PATH_KEYS = {"path", "file", "folder", "to"}
PATH_KEYS_BY_COMMAND = {
"rename": DEFAULT_PATH_KEYS | {"name"},
}
PATH_REQUIRED_WHEN_RESTRICTED = { PATH_REQUIRED_WHEN_RESTRICTED = {
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff", "append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
"search", "search:context", "files", "folders", "search", "search:context", "files", "folders",
@@ -51,132 +43,6 @@ def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
return tuple(value) return tuple(value)
raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays") raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays")
def vault_for_path(path:Path) -> str|None:
resolved = path.resolve()
best_name:str|None = None
best_len = -1
for name, vault_path in REGISTERED_VAULTS.items():
root = Path(vault_path).resolve()
try:
resolved.relative_to(root)
except ValueError:
continue
root_len = len(str(root))
if root_len > best_len:
best_name = name
best_len = root_len
return best_name
def normalize_policy_path(value:str) -> str:
normalized = value.strip().replace("\\", "/").lstrip("/")
while "//" in normalized:
normalized = normalized.replace("//", "/")
return normalized
def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool:
normalized = normalize_policy_path(path)
for pattern in allowed_patterns:
pattern_normalized = normalize_policy_path(pattern)
if pattern_normalized == "*":
return True
if pattern_normalized.endswith("/**"):
prefix = pattern_normalized[:-3].rstrip("/")
if normalized == prefix or normalized.startswith(prefix + "/"):
return True
elif pattern_normalized.endswith("/"):
if normalized.startswith(pattern_normalized):
return True
elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"):
return True
return False
def parse_kv_arg(arg:str) -> tuple[str, str]|None:
if "=" not in arg:
return None
key, value = arg.split("=", 1)
if not key:
return None
return key, value
def path_keys_for_command(command:str|None) -> set[str]:
return PATH_KEYS_BY_COMMAND.get(command or "", DEFAULT_PATH_KEYS)
def extract_command_target(args:list[str]) -> CommandTarget:
vault:str|None = None
command:str|None = None
paths:list[str] = []
for arg in args:
parsed = parse_kv_arg(arg)
if parsed and parsed[0] == "vault" and command is None:
vault = parsed[1]
continue
command = arg
break
path_keys = path_keys_for_command(command)
for arg in args:
parsed = parse_kv_arg(arg)
if not parsed:
continue
key, value = parsed
if key in path_keys and value:
paths.append(value)
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
def moment_to_strftime(format_string:str) -> str:
replacements = [
("YYYY", "%Y"),
("YY", "%y"),
("MMMM", "%B"),
("MMM", "%b"),
("MM", "%m"),
("DDDD", "%j"),
("DD", "%d"),
("dddd", "%A"),
("ddd", "%a"),
]
converted = format_string or "YYYY-MM-DD"
for moment_token, strftime_token in replacements:
converted = converted.replace(moment_token, strftime_token)
return converted
def daily_note_path_for_vault(vault_root:Path, now:datetime|None = None) -> str:
settings_path = vault_root / ".obsidian" / "daily-notes.json"
folder = ""
format_string = "YYYY-MM-DD"
if settings_path.exists() and settings_path.is_file():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
if isinstance(settings, dict):
folder_value = settings.get("folder")
format_value = settings.get("format")
if isinstance(folder_value, str):
folder = folder_value.strip().strip("/")
if isinstance(format_value, str) and format_value.strip():
format_string = format_value.strip()
except (OSError, json.JSONDecodeError):
pass
note_name = (now or datetime.now()).strftime(moment_to_strftime(format_string))
if not note_name.endswith(".md"):
note_name += ".md"
return f"{folder}/{note_name}" if folder else note_name
def vault_root_for_target(target:CommandTarget, cwd:Path) -> Path:
if target.vault and target.vault in REGISTERED_VAULTS:
return Path(REGISTERED_VAULTS[target.vault]).resolve()
vault_name = vault_for_path(cwd) or DEFAULT_VAULT_NAME
return Path(REGISTERED_VAULTS.get(vault_name, cwd)).resolve()
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None: def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
if policy is None: if policy is None:
return return
+55
View File
@@ -0,0 +1,55 @@
import re
from pathlib import Path
from app.command_target import extract_command_target, parse_kv_arg
from app.vaults import vault_root_for_target
DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T?$")
def arg_value(args:list[str], name:str) -> str|None:
for arg in args:
parsed = parse_kv_arg(arg)
if parsed and parsed[0] == name and parsed[1]:
return parsed[1]
return None
def normalize_property_datetime_args(args:list[str]) -> list[str]:
target = extract_command_target(args)
if target.command != "property:set" or arg_value(args, "type") != "datetime":
return args
value = arg_value(args, "value")
if not value or not DATE_ONLY_RE.match(value):
return args
normalized_value = f"{value.rstrip('T')}T00:00:00"
return [f"value={normalized_value}" if parse_kv_arg(arg) == ("value", value) else arg for arg in args]
def vault_note_path(vault_root:Path, path:str) -> Path:
note_path = (vault_root / path).resolve()
try:
note_path.relative_to(vault_root.resolve())
except ValueError as exc:
raise PermissionError(f"path escapes vault: {path}") from exc
return note_path
def create_missing_property_file(args:list[str], cwd:Path) -> None:
target = extract_command_target(args)
if target.command != "property:set":
return
path = arg_value(args, "path")
if not path:
return
note_path = vault_note_path(vault_root_for_target(target, cwd), path)
if note_path.exists():
return
note_path.parent.mkdir(parents=True, exist_ok=True)
note_path.write_text("", encoding="utf-8")
def prepare_property_args(args:list[str], cwd:Path) -> list[str]:
args = normalize_property_datetime_args(args)
create_missing_property_file(args, cwd)
return args
+8
View File
@@ -4,12 +4,20 @@ from pathlib import Path
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from app.config import CLI_BIN from app.config import CLI_BIN
from app.property_commands import prepare_property_args
def error(status_code:int, detail:str) -> JSONResponse: def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code) return JSONResponse({"detail": detail}, status_code=status_code)
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse: async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
command_id = str(uuid.uuid4()) command_id = str(uuid.uuid4())
try:
args = prepare_property_args(args, cwd)
except PermissionError as exc:
return error(403, str(exc))
except OSError as exc:
return error(500, f"failed to prepare property command: {exc}")
command = [CLI_BIN, *args] command = [CLI_BIN, *args]
started = time.perf_counter() started = time.perf_counter()
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
from app.command_target import CommandTarget
def vault_for_path(path:Path) -> str|None:
resolved = path.resolve()
best_name:str|None = None
best_len = -1
for name, vault_path in REGISTERED_VAULTS.items():
root = Path(vault_path).resolve()
try:
resolved.relative_to(root)
except ValueError:
continue
root_len = len(str(root))
if root_len > best_len:
best_name = name
best_len = root_len
return best_name
def vault_root_for_target(target:CommandTarget, cwd:Path) -> Path:
if target.vault and target.vault in REGISTERED_VAULTS:
return Path(REGISTERED_VAULTS[target.vault]).resolve()
vault_name = vault_for_path(cwd) or DEFAULT_VAULT_NAME
return Path(REGISTERED_VAULTS.get(vault_name, cwd)).resolve()
@@ -4,294 +4,14 @@ import type {
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
} from 'n8n-workflow'; } 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 { export class ObsidianApi implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = nodeDescription;
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',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData(); const items = this.getInputData();
@@ -304,98 +24,10 @@ export class ObsidianApi implements INodeType {
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as Record<string, unknown>; const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as Record<string, unknown>;
const vault = String(additionalFields.vault || ''); const vault = String(additionalFields.vault || '');
const timeout = Number(additionalFields.timeout ?? 60); const timeout = Number(additionalFields.timeout ?? 60);
const args = buildArgs(this, resource, operation, itemIndex, additionalFields);
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 result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout }); const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout });
// Always keep n8n output minimal/clean. returnData.push(outputForResult(result, resource, operation, itemIndex));
// - 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 } });
} catch (error) { } catch (error) {
if (this.continueOnFail()) { if (this.continueOnFail()) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -409,100 +41,3 @@ export class ObsidianApi implements INodeType {
return [returnData]; 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", "name": "n8n-nodes-obsidian-cli-api",
"version": "0.2.6", "version": "0.2.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n8n-nodes-obsidian-cli-api", "name": "n8n-nodes-obsidian-cli-api",
"version": "0.2.6", "version": "0.2.7",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-obsidian-cli-api", "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.", "description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
"license": "MIT", "license": "MIT",
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api", "homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
@@ -3,7 +3,10 @@ import type {
IDataObject, IDataObject,
IHttpRequestOptions, IHttpRequestOptions,
} from 'n8n-workflow'; } 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 { export interface ObsidianCommandResult extends IDataObject {
id: string; id: string;
@@ -87,140 +90,3 @@ export async function executeObsidianCommand(
return result; 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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "obsidian-api" name = "obsidian-api"
version = "1.0.4" version = "1.0.6"
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses." description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
+46
View File
@@ -0,0 +1,46 @@
import base64
import hashlib
import hmac
import json
import os
import time
from starlette.testclient import TestClient
os.environ.setdefault("OBSIDIAN_JWT_SECRET", "test-jwt-secret")
os.environ.setdefault("OBSIDIAN_CLI_BIN", "python")
os.environ.setdefault("OBSIDIAN_VAULT_PATH", ".")
import app.auth as authmod # noqa: E402
from app.main import app # noqa: E402
client = TestClient(app)
def make_jwt(claims:dict, secret:bytes = b"test-jwt-secret") -> str:
header = {"alg": "HS256", "typ": "JWT"}
def enc(value:dict) -> str:
raw = json.dumps(value, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
signing_input = f"{enc(header)}.{enc(claims)}"
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def jwt(claims:dict|None = None) -> str:
claims = claims or {}
claims.setdefault("sub", "tests")
claims.setdefault("vaults", ["*"])
claims.setdefault("paths", ["*"])
claims.setdefault("commands", ["*"])
claims.setdefault("exp", int(time.time()) + 3600)
return make_jwt(claims)
def use_jwt_auth(monkeypatch, claims:dict) -> str:
monkeypatch.setattr(authmod, "JWT_SECRET", "test-jwt-secret")
return jwt(claims)
def use_work_vault(monkeypatch, vault) -> None:
monkeypatch.setattr("app.vaults.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.vaults.DEFAULT_VAULT_NAME", "work")
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
-263
View File
@@ -1,263 +0,0 @@
import hashlib, base64, json, hmac
import time, os
from starlette.testclient import TestClient
os.environ.setdefault("OBSIDIAN_JWT_SECRET", "test-jwt-secret")
os.environ.setdefault("OBSIDIAN_CLI_BIN", "python")
os.environ.setdefault("OBSIDIAN_VAULT_PATH", ".")
import app.auth as authmod # noqa: E402
from app.main import app # noqa: E402
client = TestClient(app)
def make_jwt(claims:dict, secret:bytes = b"test-jwt-secret") -> str:
header = {"alg": "HS256", "typ": "JWT"}
def enc(value:dict) -> str:
raw = json.dumps(value, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
signing_input = f"{enc(header)}.{enc(claims)}"
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def jwt(claims:dict|None = None) -> str:
claims = claims or {}
claims.setdefault("sub", "tests")
claims.setdefault("vaults", ["*"])
claims.setdefault("paths", ["*"])
claims.setdefault("commands", ["*"])
claims.setdefault("exp", int(time.time()) + 3600)
return make_jwt(claims)
def use_jwt_auth(monkeypatch, claims:dict) -> str:
monkeypatch.setattr(authmod, "JWT_SECRET", "test-jwt-secret")
return jwt(claims)
def test_health():
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "vault" in body
assert body["cli"] == "python"
assert body["jwt"] is True
def test_command_requires_auth():
response = client.post("/commands", json={"args": ["--version"]})
assert response.status_code == 401
assert response.json()["detail"] == "missing Authorization header; expected 'Bearer <jwt>'"
def test_invalid_token_reports_reason(monkeypatch):
monkeypatch.setattr("app.auth.JWT_SECRET", "secret")
response = client.post(
"/commands",
headers={"Authorization": "Bearer not-a-jwt"},
json={"args": ["--version"]},
)
assert response.status_code == 401
assert response.json()["detail"].startswith("invalid token:")
def test_command_runs_configured_cli_only():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["--version"], "timeout": 5},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 0
assert body["command"] == ["python", "--version"]
assert "Python" in body["stdout"]
assert body["timed_out"] is False
def test_plain_command_string_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"command": "--version"},
)
assert response.status_code == 400
assert response.json()["detail"] == "command strings are not allowed; use args as a list of strings"
def test_shell_mode_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"mode": "shell", "args": ["echo nope"]},
)
assert response.status_code == 400
assert response.json()["detail"] == "mode is not allowed; only the configured Obsidian CLI can be executed"
def test_command_timeout():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["-c", "import time; time.sleep(2)"], "timeout": 0.1},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 124
assert body["timed_out"] is True
def test_jwt_claims_become_policy():
token = jwt({"sub": "file", "vaults": ["work"], "paths": ["Inbox/"], "commands": ["read"]})
claims = authmod.decode_hs256_jwt(token, b"test-jwt-secret")
policy = authmod.policy_from_jwt_claims(token, claims)
assert policy.token == token
assert policy.name == "file"
assert policy.vaults == ("work",)
assert policy.paths == ("Inbox/",)
assert policy.commands == ("read",)
def test_token_policy_restricts_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "limited", "commands": ["read"], "vaults": ["*"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["create", "path=Inbox/Nope.md", "content=Nope"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to run command: create")
def test_token_policy_restricts_vault(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "work", "commands": ["*"], "vaults": ["work"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=personal", "read", "path=Inbox/Test.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access vault: personal")
def test_token_policy_restricts_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Private/Secret.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Private/Secret.md")
def test_property_set_name_is_not_treated_as_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
def test_rename_name_is_treated_as_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.md")
def test_token_policy_allows_allowed_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["-c", "print('ok')"]},
)
assert response.status_code == 403
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Inbox/Allowed.md"]},
)
# The authorization layer allows this. The fake Python CLI then fails because
# "read" is not a Python flag, which is fine for this policy test.
assert response.status_code == 200
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"}))
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"]
def test_path_restricted_token_allows_vault_info_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append", "vault", "vaults"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault"]
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vaults"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vaults"]
def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"}))
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")
+87
View File
@@ -0,0 +1,87 @@
import app.auth as authmod
from conftest import client, jwt, use_jwt_auth
def test_command_requires_auth():
response = client.post("/commands", json={"args": ["--version"]})
assert response.status_code == 401
assert response.json()["detail"] == "missing Authorization header; expected 'Bearer <jwt>'"
def test_invalid_token_reports_reason(monkeypatch):
monkeypatch.setattr("app.auth.JWT_SECRET", "secret")
response = client.post(
"/commands",
headers={"Authorization": "Bearer not-a-jwt"},
json={"args": ["--version"]},
)
assert response.status_code == 401
assert response.json()["detail"].startswith("invalid token:")
def test_jwt_claims_become_policy():
token = jwt({"sub": "file", "vaults": ["work"], "paths": ["Inbox/"], "commands": ["read"]})
claims = authmod.decode_hs256_jwt(token, b"test-jwt-secret")
policy = authmod.policy_from_jwt_claims(token, claims)
assert policy.token == token
assert policy.name == "file"
assert policy.vaults == ("work",)
assert policy.paths == ("Inbox/",)
assert policy.commands == ("read",)
def test_token_policy_restricts_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "limited", "commands": ["read"], "vaults": ["*"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["create", "path=Inbox/Nope.md", "content=Nope"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to run command: create")
def test_token_policy_restricts_vault(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "work", "commands": ["*"], "vaults": ["work"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=personal", "read", "path=Inbox/Test.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access vault: personal")
def test_token_policy_restricts_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Private/Secret.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Private/Secret.md")
def test_token_policy_allows_allowed_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["-c", "print('ok')"]},
)
assert response.status_code == 403
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Inbox/Allowed.md"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
+57
View File
@@ -0,0 +1,57 @@
from conftest import client, jwt
def test_health():
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "vault" in body
assert body["cli"] == "python"
assert body["jwt"] is True
def test_command_runs_configured_cli_only():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["--version"], "timeout": 5},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 0
assert body["command"] == ["python", "--version"]
assert "Python" in body["stdout"]
assert body["timed_out"] is False
def test_plain_command_string_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"command": "--version"},
)
assert response.status_code == 400
assert response.json()["detail"] == "command strings are not allowed; use args as a list of strings"
def test_shell_mode_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"mode": "shell", "args": ["echo nope"]},
)
assert response.status_code == 400
assert response.json()["detail"] == "mode is not allowed; only the configured Obsidian CLI can be executed"
def test_command_timeout():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["-c", "import time; time.sleep(2)"], "timeout": 0.1},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 124
assert body["timed_out"] is True
+60
View File
@@ -0,0 +1,60 @@
import json
from conftest import client, use_jwt_auth, use_work_vault
def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"}))
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"]
def test_path_restricted_token_allows_vault_info_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append", "vault", "vaults"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault"]
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vaults"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vaults"]
def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"}))
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")
+58
View File
@@ -0,0 +1,58 @@
from conftest import client, use_jwt_auth, use_work_vault
def test_property_set_name_is_not_treated_as_path(monkeypatch, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
def test_property_set_creates_missing_file(monkeypatch, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=Daniel", "type=text"], "cwd": str(vault)},
)
assert response.status_code == 200
assert (vault / "00. Journal" / "New.md").exists()
def test_property_set_datetime_defaults_missing_time(monkeypatch, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=2026-06-24T", "type=datetime"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=2026-06-24T00:00:00", "type=datetime"]
def test_rename_name_is_treated_as_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.md")
Generated
+1 -1
View File
@@ -123,7 +123,7 @@ wheels = [
[[package]] [[package]]
name = "obsidian-api" name = "obsidian-api"
version = "1.0.4" version = "1.0.6"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "starlette" }, { name = "starlette" },