Now you can send Jira workLogs either from issue or from statistics. + readme update

This commit is contained in:
Alamion 2025-03-16 04:58:19 +03:00
parent 534c63d019
commit 7540f0bcd0
No known key found for this signature in database
GPG key ID: E5712F673595B31D
7 changed files with 647 additions and 254 deletions

125
README.md
View file

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

View file

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

View file

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

View file

@ -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<JiraIssue>;
}
/**
* 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<any> {
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;
}

184
src/commands/addWorkLog.ts Normal file
View file

@ -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<boolean> {
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<void> {
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<void> {
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(' ');
}

View file

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

View file

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>`;
// 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;
}
}