field mapping fix, statistics.md fixes and optimizations.

This commit is contained in:
vsemenov 2025-03-14 20:26:44 +03:00
parent b0c1e4dfd4
commit 534c63d019
No known key found for this signature in database
GPG key ID: 05B153011396F0BC
8 changed files with 303 additions and 183 deletions

View file

@ -1,187 +1,201 @@
---
jira_selected_week: 2025-03-10
jira_selected_week_data: '[]'
---
```dataviewjs
const tasksDirectory = "Kanban";
const show_max_weeks = 3
const show_max_weeks = 3;
// Get all markdown files in the Kanban directory
const files = app.vault.getMarkdownFiles().filter(file => file.path.startsWith(tasksDirectory));
let timekeepBlocks = [];
// Regular expression to extract issue key (assuming it's in format like "PROJECT-123" in the file content)
const issueKeyRegex = /(?:^|\s)([A-Z]+-\d+)(?:\s|$)/;
async function processFiles() {
for (const file of files) {
const content = await app.vault.read(file);
const timekeepMatches = content.match(/```timekeep\n([\s\S]*?)```/g);
let timekeepBlocks = [];
// Process all files in one go
await Promise.all(files.map(async file => {
const content = await app.vault.read(file);
// Extract issue key from file content instead of using file.key
let issueKey = null;
const keyMatch = content.match(issueKeyRegex);
if (keyMatch) {
issueKey = keyMatch[1];
}
// Match all timekeep blocks using a single regex
const timekeepMatches = content.match(/```timekeep\n([\s\S]*?)```/g);
if (timekeepMatches) {
timekeepMatches.forEach(block => {
try {
// Extract JSON content and parse it
const jsonContent = block.replace(/```timekeep\n/, "").replace(/```/, "").trim();
const data = JSON.parse(jsonContent);
timekeepBlocks.push({
file: file.name,
path: file.path,
issueKey: issueKey, // Use extracted issue key
data: data
});
} catch (error) {
console.error(`Error parsing JSON in ${file.name}:`, error);
}
});
}
}));
if (timekeepMatches) {
timekeepMatches.forEach(block => {
try {
const jsonContent = block.replace(/```timekeep\n/, "").replace(/```/, "").trim();
const data = JSON.parse(jsonContent);
timekeepBlocks.push({
file: file.name,
path: file.path,
data: data,
jira_task: file.jira
});
} catch (error) {
console.error(`Error parsing JSON in ${file.name}:`, error);
}
});
}
}
// Group entries by week
const groupedByWeek = {};
const groupedByWeek = {};
const jiraData = {};
window.jiraData = jiraData;
// Process all timekeep blocks once
timekeepBlocks.forEach(block => {
processEntries(block.data.entries, block.file, block.issueKey, "", groupedByWeek);
});
// Trim old entries to show only the most recent weeks
trimOldEntries(groupedByWeek, show_max_weeks);
// Process all timekeep blocks with recursive entries
timekeepBlocks.forEach(block => {
processEntries(block.data.entries, block.file, "", groupedByWeek, jiraData, block);
});
trimOldEntries(groupedByWeek, show_max_weeks)
trimOldEntries(jiraData, show_max_weeks)
// Display results
if (Object.keys(groupedByWeek).length > 0) {
const sortedWeeks = Object.keys(groupedByWeek).sort()
for (const week of sortedWeeks) {
// Display results
if (Object.keys(groupedByWeek).length > 0) {
// Sort weeks chronologically
const sortedWeeks = Object.keys(groupedByWeek).sort();
for (const week of sortedWeeks) {
const entries = groupedByWeek[week];
dv.header(3, `Week of ${week}`);
dv.table(
["Task", "Block Path", "Start Time", "End Time", "Duration"],
entries.map(entry => [
entry.file,
entry.blockPath,
entry.startTime,
entry.endTime,
entry.duration
])
);
}
let options = '';
for (const [week, entries] of Object.entries(jiraData)) {
options += `option(${week}),\n`;
}
if (options.length > 2) options = options.substring(0, options.length - 2);
dv.paragraph(`\`\`\`meta-bind
INPUT[inlineSelect(${options}): jira_selected_week]
dv.header(3, `Week of ${week}`);
dv.table(
["Task", "Block Path", "Start Time", "Duration"],
entries.map(entry => [
entry.file,
entry.blockPath,
entry.startTime,
entry.duration
])
);
}
// Create options for metabind select
const options = sortedWeeks
.map(week => `option('${JSON.stringify(groupedByWeek[week])}', ${week})`)
.join(",\n");
// Display meta-bind controls
dv.paragraph(`\`\`\`meta-bind
INPUT[inlineSelect(option('[]', 'select week'), ${options}): jira_selected_week_data]
\`\`\``);
dv.paragraph(`\`\`\`meta-bind-button
label: Send
dv.paragraph(`\`\`\`meta-bind-button
label: Send worklog to Jira
hidden: false
id: ""
style: primary
actions:
- type: command
command: obsidian-jira-sync:sent_week
command: obsidian-jira-sync:sent_week
\`\`\``);
} else {
dv.paragraph("No entries found.");
}
} else {
dv.paragraph("No entries found.");
}
}
// Recursive function to process entries and their subentries
function processEntries(entries, fileName, parentPath, groupedByWeek, jiraData, originalBlock) {
if (!entries || !Array.isArray(entries)) return;
// Process entries recursively with improved efficiency
function processEntries(entries, fileName, issueKey, parentPath, groupedByWeek) {
if (!entries || !Array.isArray(entries)) return;
entries.forEach(entry => {
// Create the current block path
const currentPath = parentPath ? `${parentPath} > ${entry.name}` : entry.name;
// Process this entry if it has a start time
if (entry.startTime) {
const startTime = new Date(entry.startTime);
const weekStart = getWeekStartDate(startTime);
const weekKey = weekStart.toISOString().split('T')[0];
for (const entry of entries) {
// Create the current block path
const currentPath = parentPath ? `${parentPath} > ${entry.name}` : entry.name;
// Process this entry if it has a start time
if (entry.startTime) {
const startTime = new Date(entry.startTime);
const weekStart = getWeekStartDate(startTime);
const weekKey = weekStart.toISOString().split('T')[0];
if (!groupedByWeek[weekKey]) groupedByWeek[weekKey] = [];
if (!jiraData[weekKey]) jiraData[weekKey] = [];
// Initialize the week array if it doesn't exist
if (!groupedByWeek[weekKey]) groupedByWeek[weekKey] = [];
// Calculate duration
let duration;
let endTime = entry.endTime ? new Date(entry.endTime) : new Date();
if (entry.endTime) {
duration = formatDuration(endTime - startTime);
} else {
duration = "ongoing";
}
// Calculate duration
let duration;
let endTime;
if (entry.endTime) {
endTime = new Date(entry.endTime);
duration = formatDuration(endTime - startTime);
} else {
endTime = new Date();
duration = "ongoing";
}
// Add to grouped data
groupedByWeek[weekKey].push({
file: fileName.replace(".md", ""),
blockPath: currentPath,
startTime: formatDate(startTime, 'DD-MM-YYYY HH:mm'),
endTime: entry.endTime ? formatDate(endTime, 'DD-MM-YYYY HH:mm') : "ongoing",
duration: duration
});
// Add to Jira data
jiraData[weekKey].push({
task: originalBlock,
time_block: {
...entry,
path: currentPath
}
});
}
// Process subentries recursively
if (entry.subEntries && Array.isArray(entry.subEntries)) {
processEntries(entry.subEntries, fileName, currentPath, groupedByWeek, jiraData, originalBlock);
}
});
// Add to grouped data
groupedByWeek[weekKey].push({
file: fileName.replace(".md", ""),
issueKey: issueKey,
blockPath: currentPath,
startTime: formatDate(startTime, 'DD-MM-YYYY HH:mm'),
endTime: entry.endTime ? formatDate(endTime, 'DD-MM-YYYY HH:mm') : "ongoing",
duration: duration
});
}
// Process subentries recursively
if (entry.subEntries && Array.isArray(entry.subEntries)) {
processEntries(entry.subEntries, fileName, issueKey, currentPath, groupedByWeek);
}
}
}
// Date formatting helper
function formatDate(date, format) {
const pad = (num) => num.toString().padStart(2, '0');
const pad = (num) => num.toString().padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds);
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes);
}
// Get the start date of the week for a given date
function getWeekStartDate(date) {
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
return new Date(date.setDate(diff));
const result = new Date(date);
const day = result.getDay();
const diff = result.getDate() - day + (day === 0 ? -6 : 1); // Adjust when day is Sunday
result.setDate(diff);
return new Date(result.setHours(0, 0, 0, 0)); // Start of the day
}
// Format milliseconds to readable duration
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / (1000 * 60)) % 60;
const hours = Math.floor(ms / (1000 * 60 * 60));
return `${hours}h ${minutes}m ${seconds}s`;
const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / (1000 * 60)) % 60;
const hours = Math.floor(ms / (1000 * 60 * 60));
return `${hours}h ${minutes}m ${seconds}s`;
}
function trimOldEntries(example_object, max_days) {
let dates = Object.keys(example_object).sort();
while (dates.length > max_days) {
let oldestDate = dates.shift();
delete example_object[oldestDate];
}
// Remove oldest entries beyond the maximum number of weeks to display
function trimOldEntries(groupedByWeek, max_weeks) {
const dates = Object.keys(groupedByWeek).sort();
while (dates.length > max_weeks) {
const oldestDate = dates.shift();
delete groupedByWeek[oldestDate];
}
}
// Execute the main function
processFiles();
```

View file

View file

@ -1,9 +1,7 @@
import { Notice } from "obsidian";
import JiraPlugin from "../main";
import {authenticate, fetchIssueTransitions} from "../api";
import {updateIssueFromFile, updateStatusFromFile} from "../file_operations/createUpdateIssue";
import {IssueSearchModal} from "../modals";
import {fetchAndOpenIssue} from "./getIssue";
import {updateStatusFromFile} from "../file_operations/createUpdateIssue";
import {IssueStatusModal} from "../modals/IssueStatusModal";
import {getCurrentFileMainInfo} from "../file_operations/common_prepareData";
import {JiraTransitionType} from "../interfaces";
@ -35,8 +33,8 @@ export async function updateIssueStatus(plugin: JiraPlugin): Promise<void> {
new Notice("No active file");
return;
}
const issueKey = await getCurrentFileMainInfo(plugin);
const issueTransitions = await fetchIssueTransitions(plugin, issueKey);
const {issueKey} = await getCurrentFileMainInfo(plugin);
const issueTransitions = await fetchIssueTransitions(plugin, issueKey as string);
// Update the issue with all data from the file
new IssueStatusModal(plugin.app, issueTransitions, async (transition: JiraTransitionType) => {
try {

View file

@ -11,6 +11,7 @@ export interface JiraSettings {
sessionCookieName: string;
templatePath: string;
fieldMappings: Record<string, FieldMapping>;
fieldMappingsStrings: Record<string, { toJira: string; fromJira: string }>;
}

View file

@ -202,6 +202,10 @@ export class JiraSettingTab extends PluginSettingTab {
}
});
// Store the string representations directly in a separate property
this.plugin.settings.fieldMappingsStrings = mappings;
// 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);
@ -217,7 +221,7 @@ export class JiraSettingTab extends PluginSettingTab {
}
}
// Save as stringified JSON
// Save the functional mappings
this.plugin.settings.fieldMappings = transformedMappings;
await this.plugin.saveSettings();
};
@ -240,33 +244,77 @@ export class JiraSettingTab extends PluginSettingTab {
cls: "reset-field-mappings-btn"
})
setIcon(resetBtn, "refresh-cw")
resetBtn.addEventListener("click", () => {
this.plugin.settings.fieldMappings = fieldMappings;
this.plugin.settings.fieldMappings = {};
this.plugin.settings.fieldMappingsStrings = {};
loadExistingMappings();
this.plugin.saveSettings();
});
const reloadBtn = buttonView.createEl("button", {
text: "",
cls: "reset-field-mappings-btn"
})
setIcon(reloadBtn, "list-restart")
reloadBtn.addEventListener("click", () => {
this.plugin.settings.fieldMappings = fieldMappings;
// Also reset the string representations with default values
const defaultMappingsStrings: Record<string, { toJira: string; fromJira: string }> = {};
for (const [fieldName, mapping] of Object.entries(fieldMappings)) {
defaultMappingsStrings[fieldName] = {
toJira: functionToArrowString(mapping.toJira),
fromJira: functionToArrowString(mapping.fromJira)
};
}
this.plugin.settings.fieldMappingsStrings = defaultMappingsStrings;
loadExistingMappings();
this.plugin.saveSettings();
});
// Load existing mappings if available
const loadExistingMappings = () => {
if (this.plugin.settings.fieldMappings) {
try {
fieldsList.innerHTML = "";
const existingMappings = this.plugin.settings.fieldMappings;
for (const [fieldName, mapping] of Object.entries(existingMappings)) {
if (mapping && typeof mapping === 'object' && 'toJira' in mapping && 'fromJira' in mapping) {
addFieldMapping(
fieldName,
functionToArrowString(mapping.toJira),
functionToArrowString(mapping.fromJira)
);
}
// Clear existing field list
fieldsList.innerHTML = "";
// If we have string representations stored, use those
if (this.plugin.settings.fieldMappingsStrings &&
Object.keys(this.plugin.settings.fieldMappingsStrings).length > 0) {
const savedMappings = this.plugin.settings.fieldMappingsStrings;
for (const [fieldName, mapping] of Object.entries(savedMappings)) {
if (mapping && typeof mapping === 'object' && 'toJira' in mapping && 'fromJira' in mapping) {
addFieldMapping(
fieldName,
mapping.toJira,
mapping.fromJira
);
}
} catch (e) {
console.error("Failed to parse existing field mappings", e);
}
}
}
loadExistingMappings()
// Otherwise, try to use the function mappings and convert them to strings
else if (this.plugin.settings.fieldMappings &&
Object.keys(this.plugin.settings.fieldMappings).length > 0) {
const existingMappings = this.plugin.settings.fieldMappings;
for (const [fieldName, mapping] of Object.entries(existingMappings)) {
if (mapping && typeof mapping === 'object' && 'toJira' in mapping && 'fromJira' in mapping) {
addFieldMapping(
fieldName,
functionToArrowString(mapping.toJira),
functionToArrowString(mapping.fromJira)
);
}
}
// Create the string representations for future use
saveFieldMappings();
}
};
// Make sure to call this function after it's defined
loadExistingMappings();
// Add example mappings section
const examplesSection = mappingSection.createDiv({ cls: "mapping-examples" });

View file

@ -1,5 +1,5 @@
import {JiraSettings} from "../interfaces";
import {fieldMappings} from "../tools/mappingObsidianJiraFields";
// import {fieldMappings} from "../tools/mappingObsidianJiraFields";
/**
* Default plugin settings
@ -11,5 +11,7 @@ export const DEFAULT_SETTINGS: JiraSettings = {
issuesFolder: "jira-issues",
sessionCookieName: "JSESSIONID",
templatePath: "",
fieldMappings: fieldMappings,
fieldMappings: {},
fieldMappingsStrings: {}
};

View file

@ -1,3 +1,5 @@
import {Notice} from "obsidian";
const forbiddenPatterns = ["document", "window", "eval", "Function", "fetch"]; // Prevent unsafe code execution
import { jiraToMarkdown, markdownToJira } from "../markdown_html";
@ -18,11 +20,31 @@ export function safeStringToFunction(fnString: string): Function | null {
// Ensure function body does not contain unsafe references
if (!isValidFunctionBody(body)) {
console.warn(`Unsafe function detected: ${fnString}`);
new Notice(`Unsafe function detected: ${fnString}`);
return null;
}
// Create a new function dynamically
return new Function(params, `return ${body};`);
// Create a function that has access to our utility functions
return function(...args: any[]) {
// Create a context with our utility functions
const context = {
jiraToMarkdown,
markdownToJira
};
// Create a new function with the utility functions in scope
const fn = new Function(
...params.split(',').map(p => p.trim()),
`
const jiraToMarkdown = this.jiraToMarkdown;
const markdownToJira = this.markdownToJira;
return ${body};
`
);
// Execute the function with our context
return fn.apply(context, args);
};
} catch (error) {
console.error("Failed to parse function string:", error);
return null;
@ -33,18 +55,35 @@ export function functionToArrowString(fn: Function): string {
try {
const fnStr = fn.toString().trim();
// Match function body and parameters from normal or arrow function formats
const arrowMatch = fnStr.match(/^\s*\(?([\w\s,]*)\)?\s*=>\s*(.+)$/s);
// Handle arrow functions (both single-line and multi-line)
const arrowMatch = fnStr.match(/^\s*\(?([^)]*)\)?\s*=>\s*(.+)$/s);
if (arrowMatch) {
return `(${arrowMatch[1].trim()}) => ${arrowMatch[2].trim()}`;
const params = arrowMatch[1].trim();
const body = arrowMatch[2].trim();
return `(${params}) => ${body}`;
} else {
console.error("Failed to parse arrow function:", fnStr);
}
const normalFunctionMatch = fnStr.match(/^function\s*\(?([\w\s,]*)\)?\s*\{([\s\S]*)\}$/);
if (normalFunctionMatch) {
return `(${normalFunctionMatch[1].trim()}) => ${normalFunctionMatch[2].trim().replace(/^\s*return\s*/, '').replace(/\s*;\s*$/, '')}`;
// Handle regular named and anonymous functions
const functionMatch = fnStr.match(/^(?:function\s+[\w$]*\s*)?\(([^)]*)\)\s*\{([\s\S]*)\}$/);
if (functionMatch) {
const params = functionMatch[1].trim();
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) {
body = returnMatch[1];
}
return `(${params}) => ${body}`;
} else {
console.error("Failed to parse function:", fnStr);
}
return fnStr; // Fallback, if we can't match the expected pattern
// Return original string if no patterns match
return fnStr;
} catch (error) {
console.error("Error converting function to string:", error);
return "";

View file

@ -1,6 +1,6 @@
import { JiraIssue } from "../interfaces";
import {jiraToMarkdown, markdownToJira} from "../markdown_html";
import {TFile} from "obsidian";
import {Notice, TFile} from "obsidian";
import JiraPlugin from "../main";
import {extractAllJiraSyncValuesFromContent, updateJiraSyncContent} from "./sectionTools";
@ -112,10 +112,16 @@ export function localToJiraFields(
if (key in customFieldMappings) {
const mapping = customFieldMappings[key];
// Skip fields that shouldn't be sent to Jira
if (mapping.toJira(value) === null) continue;
try {
// Skip fields that shouldn't be sent to Jira
if (mapping.toJira(value) === null) continue;
jiraFields[key] = mapping.toJira(value);
jiraFields[key] = mapping.toJira(value);
} catch (e) {
console.error(`Error mapping for ${key}: ${e}`);
new Notice(`Error mapping for ${key}: ${e}`);
}
}
// Handle custom fields
else {
@ -178,7 +184,13 @@ export function updateLocalRecordsFromJira(
if (key in frontmatter || key === 'key' || key === 'summary') {
let value = issue.fields[key];
if (key in customFieldMappings) {
value = customFieldMappings[key].fromJira(issue, {...frontmatter, ...sections});
try{
value = customFieldMappings[key].fromJira(issue, {...frontmatter, ...sections});
} catch (e) {
console.error(`Error mapping for ${key}: ${e}`);
new Notice(`Error mapping for ${key}: ${e}`);
continue;
}
}
if (value !== null && value !== undefined) {
frontmatter[key] = value;
@ -192,7 +204,13 @@ export function updateLocalRecordsFromJira(
if (key in sections) {
let value = issue.fields[key];
if (key in customFieldMappings) {
value = customFieldMappings[key].fromJira(issue, {...frontmatter, ...sections});
try {
value = customFieldMappings[key].fromJira(issue, {...frontmatter, ...sections});
} catch (e) {
console.error(`Error mapping for ${key}: ${e}`);
new Notice(`Error mapping for ${key}: ${e}`);
continue;
}
}
if (value !== null && value !== undefined) {
sections[key] = value;