alamion_obsidian-jira-sync/src/tools/convertFunctionString.ts
Alamion a3211bcdb2
Fix:
- multiple fixes and code refactors for last feats to work correctly
- added Jira api_version variable to field mapping context
2026-05-04 21:05:29 +03:00

360 lines
10 KiB
TypeScript

import { Notice } from 'obsidian';
import { JiraIssue } from '../interfaces';
import { parse } from 'acorn';
import { debugLog } from './debugLogging';
import { FieldMapping, FROM_JIRA_PARAMS, TO_JIRA_PARAMS } from '../default/obsidianJiraFieldsMapping';
import { defaultIssue } from '../default/defaultIssue';
import { jiraToMarkdown, markdownToJira } from './markdownHtml';
import { markdownToAdf, adfToMarkdown } from './markdownToAdf';
// Constants for validation and error messages
const FORBIDDEN_PATTERNS = ['document', 'window', 'eval', 'Function', 'fetch', 'setTimeout', 'globalThis'];
const SYNTAX_KEYWORDS = ['return', 'if', 'else', 'for', 'while', 'switch', 'try', 'catch'];
const SAFE_GLOBALS = {
jiraToMarkdown,
markdownToJira,
markdownToAdf,
adfToMarkdown,
JSON: {
parse: JSON.parse,
stringify: JSON.stringify,
},
Math: Math,
Date: Date,
String: String,
Number: Number,
Boolean: Boolean,
Array: Array,
Object: Object,
};
export function validateFunctionStringBrowser(
fnString: string,
approved_vars: string[] = [],
): Promise<{ isValid: boolean; errorMessage?: string }> {
return new Promise((resolve) => {
try {
parse(fnString, { ecmaVersion: 'latest' });
} catch (error) {
return resolve({
isValid: false,
errorMessage: `Syntax error: ${(error as Error).message}`,
});
}
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
const iframeWindow = iframe.contentWindow as Window;
try {
let testFnString = fnString;
if (
isSimpleExpression(fnString) ||
(fnString.startsWith('{') && fnString.endsWith('}') && !fnString.includes('return'))
) {
const varDeclarations = approved_vars
.map((varName) => {
if (varName === 'issue') return `${varName} = ${JSON.stringify(defaultIssue)}`;
if (varName === 'api_version') return `${varName} = "2"`;
if (varName === 'value') return `${varName} = "test value"`;
if (varName === 'data_source') return `${varName} = {}`;
return `${varName} = {}`;
})
.join(', ');
testFnString = `(${varDeclarations}) => ${fnString}`;
}
debugLog(`checking function: ${testFnString.substring(0, 100)}`);
const context: Record<string, any> = {};
Object.keys(SAFE_GLOBALS).forEach((key) => {
context[key] = (SAFE_GLOBALS as any)[key];
});
const safeBuiltIns = [
'Infinity',
'NaN',
'undefined',
'isFinite',
'isNaN',
'parseFloat',
'parseInt',
'decodeURI',
'decodeURIComponent',
'encodeURI',
'encodeURIComponent',
];
safeBuiltIns.forEach((key) => {
context[key] = (iframeWindow as any)[key];
});
const testArgs = approved_vars.map((varName) => {
if (varName === 'issue') return defaultIssue;
if (varName === 'api_version') return '2';
if (varName === 'value') return 'test value';
if (varName === 'data_source') return {};
return {};
});
const contextKeys = Object.keys(context);
const contextValues = contextKeys.map((key) => context[key]);
const func = new Function(...approved_vars, ...contextKeys, `return (${fnString});`);
func(...testArgs, ...contextValues);
resolve({ isValid: true });
} catch (error) {
debugLog('Not valid');
resolve({
isValid: false,
errorMessage: `Runtime error: ${(error as Error).message}`,
});
} finally {
document.body.removeChild(iframe);
}
});
}
export async function validateFunctionString(
fnString: string,
approved_vars: string[] = [],
): Promise<{ isValid: boolean; errorMessage?: string }> {
// Check for null, undefined or empty string
if (!fnString || fnString.trim().length === 0) {
return { isValid: true };
}
// Check for forbidden patterns (security)
const foundForbidden = FORBIDDEN_PATTERNS.find((pattern) => fnString.includes(pattern));
if (foundForbidden) {
return {
isValid: false,
errorMessage: `Unsafe reference detected: '${foundForbidden}' is not allowed`,
};
}
return validateFunctionStringBrowser(fnString, approved_vars);
}
/**
* Determines if the function string is a simple expression or complex function body
* @param fnString - The function string to analyze
* @returns Whether the string is a simple expression (vs. a full function body)
*/
function isSimpleExpression(fnString: string): boolean {
const trimmed = fnString.trim();
// Check if it's a full arrow function
if (trimmed.match(/^\s*\(.*?\)\s*=>\s*.*$/s)) {
return false;
}
// Check if it has typical function body syntax elements
const hasComplexSyntax = SYNTAX_KEYWORDS.some((keyword) =>
// Match full keywords, not substrings
new RegExp(`\\b${keyword}\\b`).test(trimmed),
);
// Has curly braces which might indicate a code block
const hasCodeBlock = trimmed.includes('{') && trimmed.includes('}') && trimmed.includes('return');
return !hasComplexSyntax && !hasCodeBlock;
}
/**
* Safely converts a string into a function that can be executed with proper context
* @param exprString - The function or expression string to convert
* @param type - The type of transformation function ('toJira' or 'fromJira')
* @param extraValidate - Whether to validate the function string on syntax, type and other errors
* @returns A wrapped function or null if conversion fails
*/
export async function safeStringToFunction(
exprString: string,
type: 'toJira' | 'fromJira' = 'toJira',
extraValidate: boolean = true,
): Promise<((...args: any[]) => any) | null> {
try {
if (exprString.trim().length === 0) {
return () => null;
}
if (extraValidate) {
const validation = await validateFunctionString(
exprString,
type === 'fromJira' ? FROM_JIRA_PARAMS : TO_JIRA_PARAMS,
);
if (!validation.isValid) {
console.warn(`Invalid function: ${validation.errorMessage}`);
// new Notice(`Invalid function: ${validation.errorMessage}`);
return null;
}
}
const arrowFnMatch = exprString.match(/^\s*\((.*?)\)\s*=>\s*(.*)$/s);
let body = arrowFnMatch ? arrowFnMatch[2].trim() : exprString.trim();
if (isSimpleExpression(body)) {
body = `return ${body};`;
}
if (body.startsWith('{') && body.endsWith('}') && !body.includes('return')) {
body = `{ return ${body.slice(1, -1)} }`;
}
const context = {
jiraToMarkdown,
markdownToJira,
markdownToAdf,
adfToMarkdown,
JSON,
Math,
Date,
String,
Number,
Boolean,
Array,
Object,
};
if (type === 'toJira') {
return function (value: any, api_version?: '2' | '3') {
const fn = new Function(
...TO_JIRA_PARAMS,
'context',
`
with (context) {
try {
${body}
} catch (e) {
console.error("Error in toJira function:", e);
return null;
}
}
`,
);
return fn.call(context, value, api_version, context);
};
} else {
// fromJira
return function (issue: JiraIssue, api_version?: '2' | '3', data_source?: Record<string, any>) {
const fn = new Function(
...FROM_JIRA_PARAMS,
'context',
`
with (context) {
try {
${body}
} catch (e) {
console.error("Error in fromJira function:", e);
return null;
}
}
`,
);
return fn.call(context, issue, api_version, data_source, context);
};
}
} catch (error) {
console.error('Failed to parse function:', error);
new Notice(`Failed to create function: ${(error as Error).message}`);
return null;
}
}
export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: boolean = false): string {
const baseStr = functionToExpressionString(fn);
if (!baseStr) return baseStr;
const fnStr = fn.toString().trim();
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
if (paramMatch) {
const params = (paramMatch[1] || paramMatch[2] || '').split(',').map((p) => p.trim());
let resultStr = baseStr;
if (params.length >= 1 && params[0]) {
const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g');
resultStr = isFromJira ? resultStr.replace(regex1, 'issue') : resultStr.replace(regex1, 'value');
}
if (params.length >= 2 && params[1]) {
const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g');
resultStr = resultStr.replace(regex2, 'api_version');
}
if (params.length >= 3 && params[2]) {
const regex3 = new RegExp(`\\b${params[2]}\\b`, 'g');
resultStr = resultStr.replace(regex3, 'data_source');
}
return resultStr;
}
return baseStr;
}
export function functionToExpressionString(fn: (...args: any[]) => any): string {
try {
const fnStr = fn.toString().trim();
if (fnStr.contains('\n')) {
// return everything for multiline funcs
return fnStr
.split('\n')
.map((s: string) => s.trim())
.join('\n');
}
// Handle arrow functions
const arrowMatch = fnStr.match(/^\s*\(?([^)]*)\)?\s*=>\s*(.+)$/s);
if (arrowMatch) {
return arrowMatch[2].trim();
}
// Handle regular functions
const functionMatch = fnStr.match(/^(?:function\s+[\w$]*\s*)?\(([^)]*)\)\s*\{([\s\S]*)\}$/);
if (functionMatch) {
let body = functionMatch[2].trim();
// If body contains just a return statement, simplify it
const returnMatch = body.match(/^\s*return\s+([\s\S]*?)\s*;\s*$/);
if (returnMatch) {
return returnMatch[1].trim();
}
}
// If we can't parse it properly, return empty string
console.error('Failed to extract expression from function:', fnStr);
new Notice('Failed to extract expression from function');
return '';
} catch (error) {
console.error('Error converting function to expression string:', error);
return '';
}
}
export async function transform_string_to_functions_mappings(
mappings: Record<string, { toJira: string; fromJira: string }>,
extraValidate: boolean = true,
) {
// Also convert to functions for runtime use
const transformedMappings: Record<string, FieldMapping> = {};
for (const [fieldName, { toJira, fromJira }] of Object.entries(mappings)) {
const toJiraFn = safeStringToFunction(toJira, 'toJira', extraValidate);
const fromJiraFn = safeStringToFunction(fromJira, 'fromJira', extraValidate);
if (toJiraFn && fromJiraFn) {
transformedMappings[fieldName] = {
toJira: (await toJiraFn) as (value: any, api_version?: '2' | '3') => any,
fromJira: (await fromJiraFn) as (
issue: JiraIssue,
api_version?: '2' | '3',
data_source?: Record<string, any>,
) => any,
};
} else {
console.warn(`Invalid function in field: ${fieldName}`);
}
}
return transformedMappings;
}