diff --git a/docs/statistics.md b/docs/statistics.md index e0ef5e1..497c1d8 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -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(); ``` diff --git a/docs/todo.md b/docs/todo.md deleted file mode 100644 index e69de29..0000000 diff --git a/src/commands/updateStatus.ts b/src/commands/updateStatus.ts index e423751..a51588f 100644 --- a/src/commands/updateStatus.ts +++ b/src/commands/updateStatus.ts @@ -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 { 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 { diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 8d4a2c1..08d35c9 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -11,6 +11,7 @@ export interface JiraSettings { sessionCookieName: string; templatePath: string; fieldMappings: Record; + fieldMappingsStrings: Record; } diff --git a/src/settings/JiraSettingTab.ts b/src/settings/JiraSettingTab.ts index 6f437f2..7752c5e 100644 --- a/src/settings/JiraSettingTab.ts +++ b/src/settings/JiraSettingTab.ts @@ -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 = {}; 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 = {}; + 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" }); diff --git a/src/settings/default.ts b/src/settings/default.ts index c5fae17..e79f97b 100644 --- a/src/settings/default.ts +++ b/src/settings/default.ts @@ -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: {} }; + diff --git a/src/tools/convertFunctionString.ts b/src/tools/convertFunctionString.ts index f35c499..b24928a 100644 --- a/src/tools/convertFunctionString.ts +++ b/src/tools/convertFunctionString.ts @@ -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 ""; diff --git a/src/tools/mappingObsidianJiraFields.ts b/src/tools/mappingObsidianJiraFields.ts index 30b537c..36a2959 100644 --- a/src/tools/mappingObsidianJiraFields.ts +++ b/src/tools/mappingObsidianJiraFields.ts @@ -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;