diff --git a/src/default/obsidianJiraFieldsMapping.ts b/src/default/obsidianJiraFieldsMapping.ts index 95e7d26..a08219b 100644 --- a/src/default/obsidianJiraFieldsMapping.ts +++ b/src/default/obsidianJiraFieldsMapping.ts @@ -1,4 +1,6 @@ import { JiraIssue } from '../interfaces'; +import { jiraToMarkdown } from '../tools/markdownHtml'; +import { adfToMarkdown } from '../tools/markdownToAdf'; export interface FieldMapping { toJira: (value: any) => any; @@ -12,7 +14,7 @@ export const obsidianJiraFieldMappings: Record = { }, description: { toJira: () => null, - fromJira: (issue) => issue.fields.description, + fromJira: (issue) => jiraToMarkdown(issue.fields.description), }, key: { toJira: () => null, @@ -74,4 +76,23 @@ export const obsidianJiraFieldMappings: Record = { toJira: () => null, fromJira: (issue) => issue.fields.aggregateprogress.percent + '%', }, + comments: { + toJira: () => null, + fromJira: (issue) => { + const comments = issue.fields.comment?.comments; + if (!comments?.length) return ''; + return comments + .map((c: any) => { + const author = c.author?.displayName ?? 'Unknown'; + const date = c.created ? c.created.replace('T', ' ').substring(0, 19) : ''; + const body = adfToMarkdown(c.body) ?? ''; + const calloutBody = body + .split('\n') + .map((l: string) => (l === '' ? '>' : `> ${l}`)) + .join('\n'); + return `> [!note]+ ${author} — ${date}\n> \n${calloutBody}`; + }) + .join('\n\n'); + }, + }, }; diff --git a/src/localization/source/en/settings/fm.yaml b/src/localization/source/en/settings/fm.yaml index 535d165..cb27ec8 100644 --- a/src/localization/source/en/settings/fm.yaml +++ b/src/localization/source/en/settings/fm.yaml @@ -1,5 +1,13 @@ title: Field mappings -desc: Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function. +desc: | + Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function. + + To Jira: + - value - the value of field in your .md file - can be a text string from file contents or a field value from frontmatter + + From Jira: + - issue - raw data from Jira (can be seen in lower `Fetch issue settings` section) + - data_source - either parsed sections or frontmatter data in dictionary format, where keys are field names and values are field values fv: name: Enable field validation desc: Check fields before saving based on the standard API response (validation does not account for custom fields) diff --git a/src/localization/source/ru/settings/fm.yaml b/src/localization/source/ru/settings/fm.yaml index 7e4d972..1ed9197 100644 --- a/src/localization/source/ru/settings/fm.yaml +++ b/src/localization/source/ru/settings/fm.yaml @@ -1,6 +1,13 @@ title: Сопоставление полей -desc: Настройте, как поля будут сопоставляться между Obsidian и Jira. Для каждого - поля требуется функция преобразования 'В Jira' и 'Из Jira'. +desc: | + Настройте сопоставление полей между Obsidian и Jira. Для каждого поля необходимо указать функции преобразования "В Jira" и "Из Jira". + + В Jira: + - value — значение поля в .md файле — это может быть текст из содержимого файла или значение поля из frontmatter + + Из Jira: + - issue — необработанные данные из Jira (можно увидеть в нижнем разделе "Настройка получаемых полей") + - data_source — либо спаршенные секции данных из файла, либо данные frontmatter. Показывается в формате словаря, где ключи — это названия полей, а значения — данные из файла fv: name: Включить проверку полей desc: Проверять поля перед сохранением базируясь на стандартном ответе API diff --git a/src/settings/components/FieldMappingsComponent.ts b/src/settings/components/FieldMappingsComponent.ts index cebf143..c05427c 100644 --- a/src/settings/components/FieldMappingsComponent.ts +++ b/src/settings/components/FieldMappingsComponent.ts @@ -35,6 +35,7 @@ export class FieldMappingsComponent implements SettingsComponent { // Add explanation mappingSection.createEl('p', { text: t('desc'), + cls: 'break-line', }); // Add validation toggle diff --git a/src/tools/convertFunctionString.ts b/src/tools/convertFunctionString.ts index 4b548e4..9d43aa6 100644 --- a/src/tools/convertFunctionString.ts +++ b/src/tools/convertFunctionString.ts @@ -5,6 +5,7 @@ import { debugLog } from './debugLogging'; import { FieldMapping } 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']; @@ -12,6 +13,8 @@ const SYNTAX_KEYWORDS = ['return', 'if', 'else', 'for', 'while', 'switch', 'try' const SAFE_GLOBALS = { jiraToMarkdown, markdownToJira, + markdownToAdf, + adfToMarkdown, JSON: { parse: JSON.parse, stringify: JSON.stringify, @@ -200,6 +203,8 @@ export async function safeStringToFunction( const context = { jiraToMarkdown, markdownToJira, + markdownToAdf, + adfToMarkdown, JSON, Math, Date, @@ -263,39 +268,34 @@ export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: bo if (!baseStr) return baseStr; + const fnStr = fn.toString().trim(); + + const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/); + if (paramMatch) { if (isFromJira) { - const fnStr = fn.toString().trim(); + const params = (paramMatch[1] || paramMatch[2] || '').split(',').map((p) => p.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; - let resultStr = baseStr; - - if (params.length >= 1 && params[0]) { - const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g'); - resultStr = resultStr.replace(regex1, 'issue'); - } - if (params.length >= 2 && params[1]) { - const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g'); - resultStr = resultStr.replace(regex2, 'data_source'); - } - - return resultStr; + if (params.length >= 1 && params[0]) { + const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g'); + resultStr = resultStr.replace(regex1, 'issue'); } + if (params.length >= 2 && params[1]) { + const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g'); + resultStr = resultStr.replace(regex2, 'data_source'); + } + + return resultStr; } else { - const fnStr = fn.toString().trim(); + const param = (paramMatch[1] || paramMatch[2] || '').trim(); - const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/); - if (paramMatch) { - const param = (paramMatch[1] || paramMatch[2] || '').trim(); - - if (param) { - const regex = new RegExp(`\\b${param}\\b`, 'g'); - return baseStr.replace(regex, 'value'); - } + if (param) { + const regex = new RegExp(`\\b${param}\\b`, 'g'); + return baseStr.replace(regex, 'value'); } } + } return baseStr; } @@ -303,6 +303,10 @@ export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: bo 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); diff --git a/src/tools/mapObsidianJiraFields.ts b/src/tools/mapObsidianJiraFields.ts index afbc213..d8d7934 100644 --- a/src/tools/mapObsidianJiraFields.ts +++ b/src/tools/mapObsidianJiraFields.ts @@ -1,5 +1,4 @@ import { JiraIssue } from '../interfaces'; -import { jiraToMarkdown } from './markdownHtml'; import { Notice, TFile } from 'obsidian'; import JiraPlugin from '../main'; import { extractAllJiraSyncValuesFromContent, updateJiraSyncContent } from './sectionTools'; @@ -67,7 +66,7 @@ export async function updateJiraToLocal(plugin: JiraPlugin, file: TFile, issue: let updatedContent = fileContent; let updatesDict: Record = {}; for (const [fieldName, fieldValue] of Object.entries(syncSections)) { - updatesDict[fieldName] = jiraToMarkdown(fieldValue); + updatesDict[fieldName] = fieldValue; } debugLog(`Updating sync sections: ${JSON.stringify(updatesDict)}`); diff --git a/src/tools/markdownToAdf.ts b/src/tools/markdownToAdf.ts new file mode 100644 index 0000000..a042a9e --- /dev/null +++ b/src/tools/markdownToAdf.ts @@ -0,0 +1,430 @@ +interface AdfTextMark { + type: 'strong' | 'em' | 'code' | 'link'; + attrs?: { href: string }; +} + +interface AdfTextNode { + type: 'text'; + text: string; + marks?: AdfTextMark[]; +} + +interface AdfInlineNode { + type: 'hardBreak'; +} + +type AdfInlineContent = AdfTextNode | AdfInlineNode; + +interface AdfParagraphNode { + type: 'paragraph'; + content: AdfInlineContent[]; +} + +interface AdfHeadingNode { + type: 'heading'; + attrs: { level: number }; + content: AdfInlineContent[]; +} + +interface AdfCodeBlockNode { + type: 'codeBlock'; + attrs: { language: string }; + content: [{ type: 'text'; text: string }]; +} + +interface AdfListItemNode { + type: 'listItem'; + content: [AdfParagraphNode]; +} + +interface AdfBulletListNode { + type: 'bulletList'; + content: AdfListItemNode[]; +} + +interface AdfOrderedListNode { + type: 'orderedList'; + content: AdfListItemNode[]; +} + +interface AdfTaskListNode { + type: 'taskList'; + content: AdfListItemNode[]; + attrs?: { localId: string }; +} + +interface AdfRuleNode { + type: 'rule'; +} + +type AdfBlockNode = + | AdfParagraphNode + | AdfHeadingNode + | AdfCodeBlockNode + | AdfBulletListNode + | AdfOrderedListNode + | AdfTaskListNode + | AdfRuleNode; + +interface AdfDoc { + version: 1; + type: 'doc'; + content: AdfBlockNode[]; +} + +function parseInline(text: string): AdfInlineContent[] { + const nodes: AdfInlineContent[] = []; + const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)|\[\[[^\]]+\]\])/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push({ type: 'text', text: text.slice(lastIndex, match.index) }); + } + if (match[2] !== undefined) { + // **bold** + nodes.push({ type: 'text', text: match[2], marks: [{ type: 'strong' }] }); + } else if (match[3] !== undefined) { + // *italic* + nodes.push({ type: 'text', text: match[3], marks: [{ type: 'em' }] }); + } else if (match[4] !== undefined) { + // `code` + nodes.push({ type: 'text', text: match[4], marks: [{ type: 'code' }] }); + } else if (match[5] !== undefined) { + // [text](url) — convert to ADF link mark + nodes.push({ type: 'text', text: match[5], marks: [{ type: 'link', attrs: { href: match[6] } }] }); + } else { + // [[wikilink]] — preserve as plain text so round-trip survives + nodes.push({ type: 'text', text: match[0] }); + } + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push({ type: 'text', text: text.slice(lastIndex) }); + } + + return nodes.length > 0 ? nodes : [{ type: 'text', text }]; +} + +export function markdownToAdf(markdown: string): AdfDoc | null { + if (!markdown || !markdown.trim()) return null; + + const lines = markdown.split('\n'); + const content: AdfBlockNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Fenced code block + if (line.startsWith('```')) { + const lang = line.slice(3).trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + content.push({ + type: 'codeBlock', + attrs: { language: lang }, + content: [{ type: 'text', text: codeLines.join('\n') }], + }); + i++; + continue; + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.*)/); + if (headingMatch) { + content.push({ + type: 'heading', + attrs: { level: headingMatch[1].length }, + content: parseInline(headingMatch[2]), + }); + i++; + continue; + } + + // Horizontal rule + if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) { + content.push({ type: 'rule' }); + i++; + continue; + } + + // Task list (checkboxes) — must be checked before bullet list + if (line.match(/^[-*+]\s+\[[ xX]\]\s*/)) { + const items: any[] = []; + let taskCounter = 0; + while (i < lines.length && lines[i].match(/^[-*+]\s+\[[ xX]\]\s*/)) { + const checkMatch = lines[i].match(/^[-*+]\s+\[([ xX])\]\s*(.*)/); + if (checkMatch) { + const state = checkMatch[1].toLowerCase() === 'x' ? 'DONE' : 'TODO'; + // taskItem only allows inline nodes — flatten sub-items as hardBreak + inline + const inlineContent: AdfInlineContent[] = parseInline(checkMatch[2]); + i++; + while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) { + inlineContent.push({ type: 'hardBreak' }); + inlineContent.push(...parseInline('- ' + lines[i].replace(/^\s+[-*+]\s+/, ''))); + i++; + } + items.push({ + type: 'taskItem', + attrs: { localId: `task-${Date.now()}-${taskCounter++}`, state }, + content: inlineContent, + }); + } else { + i++; + } + } + content.push({ type: 'taskList', attrs: { localId: `tasklist-${Date.now()}` }, content: items }); + continue; + } + + // Bullet list + if (line.match(/^[-*+]\s+/)) { + const items: AdfListItemNode[] = []; + while (i < lines.length && lines[i].match(/^[-*+]\s+/)) { + const itemContent: any[] = [ + { type: 'paragraph', content: parseInline(lines[i].replace(/^[-*+]\s+/, '')) }, + ]; + i++; + const subItems: AdfListItemNode[] = []; + while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) { + subItems.push({ + type: 'listItem', + content: [{ type: 'paragraph', content: parseInline(lines[i].replace(/^\s+[-*+]\s+/, '')) }], + }); + i++; + } + if (subItems.length > 0) { + itemContent.push({ type: 'bulletList', content: subItems }); + } + items.push({ type: 'listItem', content: itemContent as [AdfParagraphNode] }); + } + content.push({ type: 'bulletList', content: items }); + continue; + } + + // Ordered list + if (line.match(/^\d+\.\s+/)) { + const items: AdfListItemNode[] = []; + while (i < lines.length && lines[i].match(/^\d+\.\s+/)) { + const itemContent: any[] = [ + { type: 'paragraph', content: parseInline(lines[i].replace(/^\d+\.\s+/, '')) }, + ]; + i++; + const subItems: AdfListItemNode[] = []; + while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) { + subItems.push({ + type: 'listItem', + content: [{ type: 'paragraph', content: parseInline(lines[i].replace(/^\s+[-*+]\s+/, '')) }], + }); + i++; + } + if (subItems.length > 0) { + itemContent.push({ type: 'bulletList', content: subItems }); + } + items.push({ type: 'listItem', content: itemContent as [AdfParagraphNode] }); + } + content.push({ type: 'orderedList', content: items }); + continue; + } + + // Empty line + if (line.trim() === '') { + i++; + continue; + } + + // Paragraph — collect until empty line or block-level element + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== '' && + !lines[i].match(/^#{1,6}\s/) && + !lines[i].match(/^[-*+]\s+\[[ xX]\]\s*/) && + !lines[i].match(/^[-*+]\s/) && + !lines[i].match(/^\d+\.\s/) && + !lines[i].startsWith('```') && + !lines[i].match(/^(-{3,}|\*{3,}|_{3,})$/) + ) { + paraLines.push(lines[i]); + i++; + } + + if (paraLines.length > 0) { + content.push({ + type: 'paragraph', + content: parseInline(paraLines.join('\n')), + }); + } + } + + return content.length > 0 ? { version: 1, type: 'doc', content } : null; +} + +function adfInlineToMarkdown(nodes: any[]): string { + if (!nodes) return ''; + return nodes + .map((node) => { + if (node.type === 'hardBreak') return '\n'; + if (node.type === 'mention') return node.attrs?.text || node.attrs?.displayName || ''; + if (node.type === 'emoji') return node.attrs?.text || node.attrs?.shortName || ''; + if (node.type === 'inlineCard') { + const url = node.attrs?.url || ''; + return url ? `[${url}](${url})` : ''; + } + if (node.type !== 'text') return ''; + const text = node.text || ''; + const marks: string[] = (node.marks || []).map((m: any) => m.type); + const linkMark = (node.marks || []).find((m: any) => m.type === 'link'); + let result = text; + if (marks.includes('code')) return `\`${result}\``; + if (marks.includes('strike')) result = `~~${result}~~`; + if (marks.includes('strong')) result = `**${result}**`; + if (marks.includes('em')) result = `*${result}*`; + if (linkMark) result = `[${result}](${linkMark.attrs?.href || ''})`; + return result; + }) + .join(''); +} + +function adfBlockToMarkdown(node: any): string { + if (!node) return ''; + + switch (node.type) { + case 'heading': { + const level = node.attrs?.level || 1; + const text = adfInlineToMarkdown(node.content || []); + return `${'#'.repeat(level)} ${text}`; + } + case 'paragraph': { + const text = adfInlineToMarkdown(node.content || []); + return text; + } + case 'codeBlock': { + const lang = node.attrs?.language || ''; + const code = (node.content || []).map((n: any) => n.text || '').join(''); + return `\`\`\`${lang}\n${code}\n\`\`\``; + } + case 'bulletList': { + return (node.content || []) + .map((item: any) => { + const blocks: any[] = item.content || []; + const first = blocks[0]; + const rest = blocks.slice(1); + const mainText = first ? adfBlockToMarkdown(first) : ''; + const nested = rest + .map((b: any) => adfBlockToMarkdown(b)) + .filter((s: string) => s.trim() !== '') + .map((s: string) => + s + .split('\n') + .map((l: string) => ' ' + l) + .join('\n'), + ) + .join('\n'); + return `- ${mainText}${nested ? '\n' + nested : ''}`; + }) + .join('\n'); + } + case 'orderedList': { + return (node.content || []) + .map((item: any, idx: number) => { + const blocks: any[] = item.content || []; + const first = blocks[0]; + const rest = blocks.slice(1); + const mainText = first ? adfBlockToMarkdown(first) : ''; + const nested = rest + .map((b: any) => adfBlockToMarkdown(b)) + .filter((s: string) => s.trim() !== '') + .map((s: string) => + s + .split('\n') + .map((l: string) => ' ' + l) + .join('\n'), + ) + .join('\n'); + return `${idx + 1}. ${mainText}${nested ? '\n' + nested : ''}`; + }) + .join('\n'); + } + case 'taskList': { + return (node.content || []) + .map((item: any) => { + const checked = item.attrs?.state === 'DONE'; + const blocks: any[] = item.content || []; + const first = blocks[0]; + const rest = blocks.slice(1); + const rawText = + first?.type === 'paragraph' + ? adfInlineToMarkdown(first.content || []) + : adfInlineToMarkdown(blocks); + // Indent continuation lines (e.g. hardBreak + sub-item text) + const textLines = rawText.split('\n'); + const fullText = + textLines[0] + + (textLines.slice(1).length + ? '\n' + + textLines + .slice(1) + .map((l: string) => ' ' + l) + .join('\n') + : ''); + const nested = rest + .map((b: any) => adfBlockToMarkdown(b)) + .filter((s: string) => s.trim() !== '') + .map((s: string) => + s + .split('\n') + .map((l: string) => ' ' + l) + .join('\n'), + ) + .join('\n'); + return `- [${checked ? 'x' : ' '}] ${fullText}${nested ? '\n' + nested : ''}`; + }) + .join('\n'); + } + case 'rule': + return '---'; + case 'blockquote': { + const inner = (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n'); + return inner + .split('\n') + .map((line: string) => `> ${line}`) + .join('\n'); + } + case 'table': { + const rows: any[] = node.content || []; + const mdRows = rows.map((row: any) => { + const cells = (row.content || []).map((cell: any) => + (cell.content || []) + .map((n: any) => adfBlockToMarkdown(n)) + .join(' ') + .replace(/\|/g, '\\|'), + ); + return `| ${cells.join(' | ')} |`; + }); + if (mdRows.length === 0) return ''; + const sep = `| ${rows[0].content.map(() => '---').join(' | ')} |`; + return [mdRows[0], sep, ...mdRows.slice(1)].join('\n'); + } + case 'panel': + case 'expand': + case 'layoutSection': + case 'layoutColumn': + return (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n\n'); + default: + return (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n'); + } +} + +export function adfToMarkdown(adf: any): string { + if (!adf || typeof adf !== 'object') return ''; + const blocks: string[] = (adf.content || []).map((node: any) => adfBlockToMarkdown(node)); + return blocks.filter((b) => b !== '').join('\n\n'); +} diff --git a/styles.css b/styles.css index cba946b..b69fd0a 100644 --- a/styles.css +++ b/styles.css @@ -1470,3 +1470,7 @@ code.hljs { background: var(--interactive-hover); } } + +.break-line { + white-space: pre-line; +}