- @slipstyle's ADF converter (tweaked)
- More broad description of Field Mapping settings section
- a few minor fixes of how lambda js functions convert into strings
This commit is contained in:
Alamion 2026-04-30 01:44:22 +03:00
commit 6e35db6254
No known key found for this signature in database
GPG key ID: 0DBEAC95BA38C9EF
8 changed files with 505 additions and 31 deletions

View file

@ -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<string, FieldMapping> = {
},
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<string, FieldMapping> = {
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');
},
},
};

View file

@ -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)

View file

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

View file

@ -35,6 +35,7 @@ export class FieldMappingsComponent implements SettingsComponent {
// Add explanation
mappingSection.createEl('p', {
text: t('desc'),
cls: 'break-line',
});
// Add validation toggle

View file

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

View file

@ -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<string, string> = {};
for (const [fieldName, fieldValue] of Object.entries(syncSections)) {
updatesDict[fieldName] = jiraToMarkdown(fieldValue);
updatesDict[fieldName] = fieldValue;
}
debugLog(`Updating sync sections: ${JSON.stringify(updatesDict)}`);

430
src/tools/markdownToAdf.ts Normal file
View file

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

View file

@ -1470,3 +1470,7 @@ code.hljs {
background: var(--interactive-hover);
}
}
.break-line {
white-space: pre-line;
}