/**
* Utility for executing jrnl CLI commands and parsing results
*/
import { spawn } from "child_process";
/**
* Options for executing a jrnl command
*/
interface JrnlExecutorOptions {
timeout?: number;
journal?: string;
maxBuffer?: number;
}
/**
* Result of a jrnl command execution
*/
interface JrnlExecutionResult {
stdout: string;
stderr: string;
success: boolean;
}
/**
* Default options for jrnl execution
*/
const DEFAULT_OPTIONS: JrnlExecutorOptions = {
timeout: 30000, // 30 seconds
maxBuffer: 1024 * 1024 * 5, // 5MB
};
/**
* Execute a jrnl command and return the result
*
* @param args Command line arguments to pass to jrnl
* @param options Execution options
* @returns Promise with execution result
*/
export async function executeJrnlCommand(
args: string[],
options: JrnlExecutorOptions = {},
): Promise<JrnlExecutionResult> {
const opts = { ...DEFAULT_OPTIONS, ...options };
let commandArgs = [...args];
// If journal is specified, add the --journal flag
if (opts.journal) {
commandArgs = ["--journal", opts.journal, ...commandArgs];
}
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let killed = false;
// Spawn jrnl process with arguments
const process = spawn("jrnl", commandArgs);
// Set timeout if specified
const timeoutId = opts.timeout
? setTimeout(() => {
process.kill();
killed = true;
resolve({
stdout,
stderr: stderr + "\nCommand timed out after " + opts.timeout + "ms",
success: false,
});
}, opts.timeout)
: null;
// Collect stdout
process.stdout.on("data", (data) => {
stdout += data.toString();
});
// Collect stderr
process.stderr.on("data", (data) => {
stderr += data.toString();
});
// Handle process completion
process.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (!killed) {
resolve({
stdout,
stderr,
success: code === 0,
});
}
});
// Handle errors
process.on("error", (err) => {
if (timeoutId) clearTimeout(timeoutId);
if (!killed) {
resolve({
stdout,
stderr: stderr + "\n" + err.message,
success: false,
});
}
});
});
}
/**
* Parse JSON output from jrnl command
*
* @param output Command output string
* @returns Parsed JSON object
*/
export function parseJrnlJsonOutput<T>(output: string): T {
try {
return JSON.parse(output.trim()) as T;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to parse jrnl output as JSON: ${error.message}`);
}
throw error;
}
}
/**
* Safely execute a jrnl command that outputs JSON and parse the result
*
* @param args Command line arguments
* @param options Execution options
* @returns Parsed JSON result
*/
export async function executeJrnlJsonCommand<T>(
args: string[],
options: JrnlExecutorOptions = {},
): Promise<T> {
// Ensure export flag is included to get JSON output
const commandArgs = [...args];
if (!commandArgs.includes("--export") && !commandArgs.includes("-j")) {
commandArgs.push("--export", "json");
}
const result = await executeJrnlCommand(commandArgs, options);
if (!result.success) {
throw new Error(`jrnl command failed: ${result.stderr}`);
}
return parseJrnlJsonOutput<T>(result.stdout);
}
export class JrnlExecutor {
async execute(args: string[]): Promise<string> {
const result = await executeJrnlCommand(args);
if (!result.success) {
throw new Error(`jrnl command failed: ${result.stderr}`);
}
// Clean jrnl output - remove decorative boxes and extra text
const cleaned = this.cleanJrnlOutput(result.stdout);
return cleaned;
}
private cleanJrnlOutput(output: string): string {
// Remove decorative box characters and summary lines
const lines = output.split("\n");
const cleanedLines = lines.filter((line) => {
// Filter out decorative box lines and summary text
return (
!line.match(/^[┏┓┗┛━ ]+$/) &&
!line.match(/^\s*\d+ entries? found\s*$/) &&
!line.match(/^\s*no entries? found\s*$/) &&
!line.match(/^Journals defined in config/) &&
line.trim() !== ""
);
});
const cleanedOutput = cleanedLines.join("\n").trim();
// Check if jrnl returned "no entries found" - return empty JSON structure
if (output.includes("no entries found") || cleanedOutput === "") {
return '{"tags": {}, "entries": []}';
}
// For JSON export, try to extract just the JSON part
if (cleanedOutput.includes("{") && cleanedOutput.includes("}")) {
const jsonStart = cleanedOutput.indexOf("{");
const jsonEnd = cleanedOutput.lastIndexOf("}") + 1;
return cleanedOutput.substring(jsonStart, jsonEnd);
}
return cleanedOutput;
}
}