diff --git a/README.md b/README.md index da7b9aa..4e7927c 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,46 @@ # Obsidian Jira Plugin -A robust plugin for Obsidian that enables seamless integration with Jira. This plugin allows you to manage Jira issues directly from your Obsidian vault, providing a bridge between your knowledge base and your project management workflow. +A lightweight plugin for Obsidian that syncs your Jira issues directly into your vault. Manage Jira tasks, update statuses, log work, and more — all within Obsidian. -## Features +Based on work [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira) -- **Import Issues from Jira**: Fetch and import issues from Jira into your Obsidian vault as markdown notes -- **Update Issues to Jira**: Make changes to your issues in Obsidian and push them back to Jira -- **Create New Issues**: Create new Jira issues directly from Obsidian notes -- **Markdown Conversion**: Automatically converts between Jira markup and Markdown -- **Template Support**: Use custom templates for your imported Jira issues +## Key Features -## Installation +- **Sync Jira Issues**: Import, update, and create Jira issues directly in Obsidian. +- **Flexible Templates**: Supports custom templates with dynamic fields for most basic and custom fields. +- **Status & Worklog Updates**: Pull possible statuses and log work directly from notes. -1. Open Obsidian -2. Go to Settings > Community plugins -3. Disable Safe mode if it's enabled -4. Click "Browse" and search for "Jira Issue Manager" -5. Install the plugin -6. Enable the plugin after installation +## Quick Start -## Configuration +1. **Install**: Go to Obsidian Settings > Community plugins > Browse, and search for "Jira Issue Manager" +2. **Configure**: Enter your Jira credentials (username, password, URL) in the plugin settings. +3. **Use**: + - **Import Issues**: Update the current note or pull new issue from Jira by its key. + - **Update Issues**: Edit issue notes and push changes back to Jira. + - **Create Issues**: Write a note with frontmatter (e.g., `summary: "Your issue summary"`) and use the command palette to create it in Jira. -1. After enabling the plugin, go to its settings tab -2. Enter your Jira credentials: - - **Username**: Your Jira username - - **Password**: Your Jira password - - **Jira URL**: The base URL of your Jira instance (e.g., `https://your-company.atlassian.net`) -3. Configure additional settings: - - **Issues Folder**: The folder where issues will be stored (default: `jira-issues`) - - **Session Cookie Name**: The name of the Jira session cookie (default: `JSESSIONID`) - - **Template Path**: Optional path to a template file for new issues +## Template Example -## How to Use - -### Importing Issues from Jira - -There are two ways to import an issue: - -1. **Using the Ribbon Icon**: Click the "Import issue from Jira" ribbon icon (file download icon) -2. **Using the Command Palette**: Press `Ctrl/Cmd+P` and search for "Get issue from Jira" - -Either method will open a modal where you can enter the Jira issue key (e.g., `PROJECT-123`). The issue will be imported as a markdown note in your configured issues folder. - -### Updating Issues to Jira - -1. Open the markdown note for the issue you want to update -2. Make your changes to the issue description -3. Press `Ctrl/Cmd+P` and search for "Update issue to Jira" -4. The plugin will push your changes back to Jira - -### Creating New Issues in Jira - -1. Create a new markdown note with the following frontmatter: - ```yaml - --- - summary: "Your issue summary here" - priority: "Medium" # Optional - --- - ``` -2. Write the issue description in the note body -3. Press `Ctrl/Cmd+P` and search for "Create issue in Jira" -4. Select the project and issue type from the modal prompts -5. The issue will be created in Jira, and the note will be updated with the issue key - -### Note Structure - -When an issue is imported or created, the plugin generates a note with the following structure: +Create dynamic templates with fields like this: ``` ---- -key: PROJECT-123 -summary: Issue summary -priority: Medium ---- +### Description +`jira-sync-section-description` +It's the multiline field. +It can hold several lines. +Until it's stopped by '# ' or other jira field -Your issue description in markdown format +### Users +`jira-sync-line-assignee` user1 +`jira-sync-line-reporter` user2 +`jira-sync-line-creator` user3 ``` -The plugin will respect the "Description" section in your note when updating or creating issues. +## Advanced Features -## Using Templates +- **Worklog Tracking**: Use `/docs/statistics.md` file from github (or write your own with similar frontmatter output) to track worklogs and push them for entire weeks. +- **Field Mapping**: Smart mapping for any field from Jira to your note and vice versa. Decide it to be synced or not and how exactly. -You can define a template for new issue notes. Create a markdown file anywhere in your vault, then set its path in the plugin settings. When importing or creating issues, this template will be used as the base for the new note. +## Learn More -## Tips and Tricks - -- **Issue Organization**: The plugin creates all issues in the configured issues folder, helping you keep Jira-related notes organized -- **Markdown Conversion**: The plugin automatically handles the conversion between Jira markup and Markdown, so you can write in standard Markdown syntax -- **Frontmatter Fields**: The plugin uses frontmatter to store issue metadata (key, summary, priority). Edit these fields to update the issue in Jira -- **Authentication**: The plugin stores session cookies securely in your browser's local storage - -## Troubleshooting - -- **Authentication Issues**: Ensure your Jira username, password, and URL are correct -- **Missing Issues Folder**: The plugin will automatically create the issues folder if it doesn't exist -- **Template Not Found**: Check that the template path in the settings is correct and the file exists - -## Security Note - -This plugin stores your Jira credentials in Obsidian's settings. Make sure to keep your vault secure and consider using environment variables or a more secure authentication method for sensitive environments. - -## License - -[MIT License](LICENSE) - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - ---- - -For more information or to report issues, please visit the [GitHub repository](https://github.com/your-username/obsidian-jira-plugin). +Visit the [GitHub repository](https://github.com/your-username/obsidian-jira-plugin) for detailed docs, stats, and updates. diff --git a/docs/statistics.md b/docs/statistics.md index 497c1d8..1b6d7a9 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -14,186 +14,206 @@ const files = app.vault.getMarkdownFiles().filter(file => file.path.startsWith(t const issueKeyRegex = /(?:^|\s)([A-Z]+-\d+)(?:\s|$)/; async function processFiles() { - 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); - } - }); - } - })); + 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); + } + }); + } + })); - // Group entries by week - const groupedByWeek = {}; + // Group entries by week + const groupedByWeek = {}; - // 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 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); - // 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", "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 + // 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", "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 of '+week})`) + .join(",\n"); + + // Display meta-bind controls + dv.paragraph(`Select a week to sent work log to Jira. Always reselect before pressing the button`); + dv.paragraph(`\`\`\`meta-bind INPUT[inlineSelect(option('[]', 'select week'), ${options}): jira_selected_week_data] \`\`\``); - - dv.paragraph(`\`\`\`meta-bind-button + + 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:update-work-log-jira \`\`\``); - } else { - dv.paragraph("No entries found."); - } + } else { + dv.paragraph("No entries found."); + } } // Process entries recursively with improved efficiency function processEntries(entries, fileName, issueKey, parentPath, groupedByWeek) { - if (!entries || !Array.isArray(entries)) return; + if (!entries || !Array.isArray(entries)) return; - 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]; + 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]; - // Initialize the week array if it doesn't exist - if (!groupedByWeek[weekKey]) groupedByWeek[weekKey] = []; + // Initialize the week array if it doesn't exist + if (!groupedByWeek[weekKey]) groupedByWeek[weekKey] = []; - // Calculate duration - let duration; - let endTime; - - if (entry.endTime) { - endTime = new Date(entry.endTime); - duration = formatDuration(endTime - startTime); - } else { - endTime = new Date(); - 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", ""), - 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); - } - } + // 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 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); + 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 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 + 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 units = [ + //{ label: "year", ms: 1000 * 60 * 60 * 24 * 365 }, + //{ label: "month", ms: 1000 * 60 * 60 * 24 * 30 }, + { label: "w", ms: 1000 * 60 * 60 * 24 * 7 }, + { label: "d", ms: 1000 * 60 * 60 * 24 }, + { label: "h", ms: 1000 * 60 * 60 }, + { label: "m", ms: 1000 * 60 }, + { label: "s", ms: 1000 } + ]; + + let remainingMs = ms; + let result = []; + + for (const unit of units) { + const value = Math.floor(remainingMs / unit.ms); + if (value > 0) { + result.push(`${value}${unit.label}`); + remainingMs %= unit.ms; + } + } + + return result.length > 0 ? result.join(" ") : "0s"; } + // 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]; - } + const dates = Object.keys(groupedByWeek).sort(); + + while (dates.length > max_weeks) { + const oldestDate = dates.shift(); + delete groupedByWeek[oldestDate]; + } } // Execute the main function diff --git a/manifest.json b/manifest.json index 358cb4a..ccbc68d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "obsidian-jira-sync", - "name": "Jira Issues Sync", + "name": "Jira Issue Manager", "version": "1.1.0", "minAppVersion": "1.7.7", - "description": "Get jira issues, update them, full sync", - "author": "ALamion", + "description": "Get jira issues, create and update them", + "author": "Alamion", "isDesktopOnly": false } diff --git a/src/api/issues.ts b/src/api/issues.ts index 91bcd1c..9df3efc 100644 --- a/src/api/issues.ts +++ b/src/api/issues.ts @@ -1,6 +1,7 @@ import JiraPlugin from "../main"; import {JiraIssue, JiraTransitionType} from "../interfaces"; import {baseRequest} from "./base"; +import {Notice} from "obsidian"; /** * Fetch an issue from Jira by its key @@ -80,3 +81,30 @@ export async function updateJiraStatus( const additional_url_path = `/issue/${issueKey}/transitions` return await baseRequest(plugin, 'post', additional_url_path, body) as Promise; } + +/** + * Add a work log entry to a Jira issue + */ +export async function addWorkLog( + plugin: JiraPlugin, + issueKey: string, + timeSpent: string, + startedAt: string, + comment: string = "", + showNotice: boolean = true +): Promise { + const payload = { + timeSpent, + started: startedAt, + comment + }; + + const additional_url_path = `/issue/${issueKey}/worklog`; + const response = await baseRequest(plugin, 'post', additional_url_path, JSON.stringify(payload)); + + if (showNotice) { + new Notice(`Work log added successfully to ${issueKey}`); + } + + return response; +} diff --git a/src/commands/addWorkLog.ts b/src/commands/addWorkLog.ts new file mode 100644 index 0000000..ee3cf9c --- /dev/null +++ b/src/commands/addWorkLog.ts @@ -0,0 +1,184 @@ +// commands/updateWorkLog.ts +import {Notice, TFile} from "obsidian"; +import JiraPlugin from "../main"; +import {getCurrentFileMainInfo} from "../file_operations/common_prepareData"; +import {WorkLogModal} from "../modals/issueWorkLogModal"; +import {addWorkLog, authenticate} from "../api"; + +/** + * Register the update work log command + */ +export function registerUpdateWorkLogCommand(plugin: JiraPlugin): void { + plugin.addCommand({ + id: "update-work-log-jira", + name: "Update work log in Jira", + callback: async () => { + if (!(await authenticate(plugin))) { + return; + } + + const file = plugin.app.workspace.getActiveFile(); + if (!file) { + new Notice("No active file found"); + return; + } + + // Try batch processing first, then fall back to manual entry + const batchProcessed = await processFrontmatterWorkLogs(plugin, file); + if (!batchProcessed) { + await processManualWorkLog(plugin); + } + }, + }); +} + +/** + * Process work logs from frontmatter data + */ +async function processFrontmatterWorkLogs(plugin: JiraPlugin, file: TFile): Promise { + try { + let workLogData: any[] = []; + let foundData = false; + + await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + if (frontmatter["jira_selected_week_data"]) { + try { + workLogData = JSON.parse(frontmatter["jira_selected_week_data"]); + foundData = true; + } catch (error) { + console.error("Failed to parse jira_selected_week_data:", error); + } + } + }); + if (!foundData || workLogData.length === 0) { + return false; + } + + // Process found work logs + await processWorkLogBatch(plugin, workLogData); + return true; + } catch (error) { + console.error("Error processing frontmatter work logs:", error); + new Notice("Failed to process work log data from frontmatter"); + return false; + } +} + +/** + * Process a single manual work log entry + */ +async function processManualWorkLog(plugin: JiraPlugin): Promise { + const { issueKey } = await getCurrentFileMainInfo(plugin); + + if (!issueKey) { + new Notice("No issue key found in the current file"); + return; + } + + new WorkLogModal(plugin.app, async (timeSpent: string, startDate: string, comment: string) => { + await addWorkLog(plugin, issueKey, timeSpent, startDate, comment); + }).open(); +} + +/** + * Process a batch of work logs + */ +async function processWorkLogBatch(plugin: JiraPlugin, workLogs: any[]): Promise { + const results = { + success: 0, + total: workLogs.length, + failures: [] as { reason: string; issueKeys: string[] }[] + }; + + for (const workLog of workLogs) { + try { + const { issueKey, startTime, duration, comment } = workLog; + + if (!issueKey || !startTime || !duration) { + addFailure(results, "Missing required fields", issueKey || "unknown"); + continue; + } + + const startDate = convertToStandardDate(startTime); + if (!startDate) { + addFailure(results, "Invalid start time format", issueKey); + continue; + } + const parsed_duration = parseDuration(duration); + console.log(`Duration: ${duration}, Parsed duration: ${parsed_duration}`); + if (parsed_duration === '') { + addFailure(results, "Duration must be at least 1 minute", issueKey); + continue; + } + + await addWorkLog(plugin, issueKey, parsed_duration, startDate, comment || "", false); + results.success++; + } catch (error) { + addFailure(results, error.message || "Unknown error", workLog.issueKey || "unknown"); + } + } + + // Show summary and errors + showResults(results); +} + +/** + * Show batch processing results + */ +function showResults(results: { success: number, total: number, failures: { reason: string; issueKeys: string[] }[] }): void { + new Notice(`Work logs updated: ${results.success}/${results.total} succeeded`); + + if (results.failures.length > 0) { + console.error("Work log update failures:", results.failures); + + results.failures.forEach(failure => { + if (failure.issueKeys.length > 0) { + new Notice(`Failed: ${failure.reason} for issues: ${failure.issueKeys.join(", ")}`); + } + }); + } +} + +/** + * Add a failure entry to the results object + */ +function addFailure(results: { failures: { reason: string; issueKeys: string[] }[] }, reason: string, issueKey: string): void { + const existingFailure = results.failures.find(f => f.reason === reason); + if (existingFailure) { + existingFailure.issueKeys.push(issueKey); + } else { + results.failures.push({ reason, issueKeys: [issueKey] }); + } +} + +/** + * Convert date from DD-MM-YYYY HH:MM format to YYYY-MM-DDTHH:MM:SS.000+0000 + */ +function convertToStandardDate(dateString: string): string | null { + try { + const [datePart, timePart] = dateString.split(' '); + const [day, month, year] = datePart.split('-'); + const [hours, minutes] = timePart.split(':'); + return `${year}-${month}-${day}T${hours}:${minutes}:00.000+0000`; + } catch (error) { + console.error("Error converting date:", error); + return null; + } +} + + +function parseDuration(input: string): string { + const regex = /(\d+)([wdhms])/g; + let match; + let validParts: string[] = []; + + while ((match = regex.exec(input)) !== null) { + const value = parseInt(match[1], 10); + const unit = match[2]; + + if (value === 0) continue; // Filter out "0w", "0d", "0h", "0m", "0s" + if (unit !== 's') validParts.push(`${value}${unit}`); // Exclude seconds + } + + return validParts.join(' '); +} diff --git a/src/main.ts b/src/main.ts index de49e51..b798ea4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { registerGetIssueCommand, registerCreateIssueCommand, registerGetIssueCommandWithKey, registerUpdateIssueStatusCommand } from "./commands"; +import {registerUpdateWorkLogCommand} from "./commands/addWorkLog"; /** * Main plugin class @@ -19,19 +20,33 @@ export default class JiraPlugin extends Plugin { async onload() { await this.loadSettings(); - // Add ribbon icon to import issues - this.addRibbonIcon( - "file-check", - "pulls all user issues from Jira", - () => { - // Use the getIssue command's functionality - // @ts-ignore - const getIssuesCommand = this.app.commands.commands["jira-plugin:get-all-issues"]; - if (getIssuesCommand) { - getIssuesCommand.callback(); - } - } - ); + // // Add ribbon icon to export issues + // this.addRibbonIcon( + // "book-up", + // "Push current Kanban issues statuses to Jira", + // () => { + // // Use the getIssue command's functionality + // // @ts-ignore + // const getIssuesCommand = this.app.commands.commands["jira-plugin:push-all-issues"]; + // if (getIssuesCommand) { + // getIssuesCommand.callback(); + // } + // } + // ); + // + // // Add ribbon icon to import issues + // this.addRibbonIcon( + // "book-down", + // "Pull Jira all issues statuses + sync statuses of Kanban", + // () => { + // // Use the getIssue command's functionality + // // @ts-ignore + // const getIssuesCommand = this.app.commands.commands["jira-plugin:pull-all-issues"]; + // if (getIssuesCommand) { + // getIssuesCommand.callback(); + // } + // } + // ); // Register all commands registerUpdateIssueCommand(this); @@ -40,6 +55,8 @@ export default class JiraPlugin extends Plugin { registerGetIssueCommandWithKey(this); registerCreateIssueCommand(this); + registerUpdateWorkLogCommand(this); + // Add settings tab this.addSettingTab(new JiraSettingTab(this.app, this)); } diff --git a/src/modals/issueWorkLogModal.ts b/src/modals/issueWorkLogModal.ts new file mode 100644 index 0000000..369d4d3 --- /dev/null +++ b/src/modals/issueWorkLogModal.ts @@ -0,0 +1,211 @@ +// modals/WorkLogModal.ts +import {App, Modal, Notice, Setting} from "obsidian"; + +/** + * Modal for adding work log entries + */ +export class WorkLogModal extends Modal { + private onSubmit: (timeSpent: string, startDate: string, workDescription: string) => void; + private timeSpent: string = ""; + private startDate: string = ""; + private workDescription: string = ""; + private displayDate: string = ""; + + constructor(app: App, onSubmit: (timeSpent: string, startDate: string, workDescription: string) => void) { + super(app); + this.onSubmit = onSubmit; + + // Set default to current date + const now = new Date(); + this.initializeDates(now); + } + + private initializeDates(date: Date) { + // Set the ISO format for API + this.startDate = date.toISOString().split('.')[0] + ".000+0000"; + + // Set the display format (01/Jun/21 09:00 AM) + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const day = String(date.getDate()).padStart(2, '0'); + const month = months[date.getMonth()]; + const year = String(date.getFullYear()).slice(2); + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const ampm = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + + this.displayDate = `${day}/${month}/${year} ${String(displayHours).padStart(2, '0')}:${minutes} ${ampm}`; + } + + private parseDisplayDate(displayDate: string): Date | null { + try { + // Parse format: 01/Jun/21 09:00 AM + const [datePart, timePart, ampm] = displayDate.split(' '); + const [day, month, year] = datePart.split('/'); + const [hours, minutes] = timePart.split(':'); + + const months = {'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5, + 'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11}; + + let hour = parseInt(hours); + if (ampm === 'PM' && hour < 12) hour += 12; + if (ampm === 'AM' && hour === 12) hour = 0; + + const fullYear = parseInt(year) + 2000; + + // Create date object + const date = new Date(fullYear, months[month as keyof typeof months], parseInt(day), hour, parseInt(minutes)); + if (isNaN(date.getTime())) throw new Error("Invalid date"); + + return date; + } catch (error) { + return null; + } + } + + onOpen() { + this.contentEl.createEl("h2", { text: "Add Work Log" }); + + // Time Spent field + new Setting(this.contentEl) + .setName("Time Spent") + .setDesc("Format: 3w 4d 12h 30m (weeks, days, hours, minutes)") + .addText((text) => + text + .setPlaceholder("e.g., 4h 30m") + .setValue(this.timeSpent) + .onChange((value) => { + this.timeSpent = value; + }) + ); + + // Start Date field with date picker icon + // const dateContainer = this.contentEl.createDiv("date-container"); + // dateContainer.style.display = "flex"; + // dateContainer.style.alignItems = "center"; + // dateContainer.style.gap = "10px"; + + const dateSetting = new Setting(this.contentEl) + .setName("Date Started") + .setDesc("Format: DD/MMM/YY HH:MM AM/PM") + .addText((text) => + text + .setPlaceholder("e.g., 01/Jun/21 09:00 AM") + .setValue(this.displayDate) + .onChange((value) => { + this.displayDate = value; + + // Parse display date and convert to ISO + const parsedDate = this.parseDisplayDate(value); + if (parsedDate) { + this.startDate = parsedDate.toISOString().split('.')[0] + ".000+0000"; + } + }) + ); + + // Add date picker later + // const datePickerButton = dateContainer.createEl("button", { cls: "date-picker-button" }); + // // datePickerButton.innerHTML = ``; + // datePickerButton.style.background = "transparent"; + // datePickerButton.style.border = "none"; + // datePickerButton.style.cursor = "pointer"; + // datePickerButton.style.padding = "4px"; + // setIcon(datePickerButton, "calendar") + // + // datePickerButton.addEventListener("click", (e) => { + // e.preventDefault(); + // // Use native date picker + // const dateInput = document.createElement("input"); + // dateInput.type = "date"; + // dateInput.style.display = "none"; + // document.body.appendChild(dateInput); + // + // dateInput.addEventListener("change", () => { + // if (dateInput.value) { + // const selectedDate = new Date(dateInput.value); + // // Keep the current time + // const currentDate = this.parseDisplayDate(this.displayDate) || new Date(); + // selectedDate.setHours(currentDate.getHours(), currentDate.getMinutes()); + // + // this.initializeDates(selectedDate); + // + // // Update the input field + // const dateInputField = this.contentEl.querySelector(".date-container input"); + // if (dateInputField) { + // (dateInputField as HTMLInputElement).value = this.displayDate; + // } + // } + // document.body.removeChild(dateInput); + // }); + // + // dateInput.click(); + // }); + + // this.contentEl.appendChild(dateContainer); + + // Work Description field + new Setting(this.contentEl) + .setName("Work Description") + .setDesc("Optional description of the work done") + .addTextArea((text) => + text + .setPlaceholder("Description of work done...") + .onChange((value) => { + this.workDescription = value; + }) + ) + .setClass("work-description-container"); + + // Make the textarea larger + const textareaComponent = this.contentEl.querySelector(".work-description-container textarea"); + if (textareaComponent) { + (textareaComponent as HTMLTextAreaElement).rows = 5; + (textareaComponent as HTMLTextAreaElement).style.width = "100%"; + } + + // Submit button + new Setting(this.contentEl) + .addButton((btn) => + btn + .setButtonText("Submit") + .setCta() + .onClick(() => { + if (!this.validateInputs()) { + return; + } + this.close(); + this.onSubmit(this.timeSpent, this.startDate, this.workDescription); + }) + ); + } + + /** + * Validate user inputs + */ + private validateInputs(): boolean { + if (!this.timeSpent.trim()) { + new Notice("Time spent is required"); + return false; + } + + if (!this.displayDate.trim()) { + new Notice("Start date is required"); + return false; + } + + // Validate time spent format + const timeSpentPattern = /^(\d+[wdhm]\s*)+$/; + if (!timeSpentPattern.test(this.timeSpent)) { + new Notice("Invalid time format. Use combinations of w (weeks), d (days), h (hours), m (minutes)"); + return false; + } + + // Validate date format by trying to parse it + if (!this.parseDisplayDate(this.displayDate)) { + new Notice("Invalid date format. Use DD/MMM/YY HH:MM AM/PM"); + return false; + } + + return true; + } +}