mirror of
https://github.com/alamion/obsidian-jira-sync.git
synced 2026-07-22 05:43:04 +00:00
JQL update:
- JQL pull tasks (with preview) - Timekeep settings menu full rework - bunch of small fixes with data transformation and pointers - now caching Jira key -> file path to increase performance - new pointer - `jira-sync-inline-*` - readme slight rework
This commit is contained in:
parent
7824d7bc7e
commit
ef9c7259fa
50 changed files with 2577 additions and 885 deletions
142
README.md
142
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Jira syncthing plugin
|
||||
# Jira Sync Plugin for Obsidian
|
||||
|
||||
Tired of Jira plugins that only give you basic issue links and descriptions? This one's different. It brings **your entire Jira workflow into Obsidian** with customizable templates and deep field mapping—including all those custom fields your team added over the years.
|
||||
Tired of Jira plugins that only give you basic issue links and descriptions? This one's different. It brings **your entire Jira workflow into Obsidian** with customizable templates, deep field mapping, and seamless two-way synchronization—including all those custom fields your team added over the years.
|
||||
|
||||
Originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira), but now packed with features you won't find elsewhere.
|
||||
|
||||
|
|
@ -10,6 +10,8 @@ Other plugins treat Jira issues as read-only reference material. This one lets y
|
|||
- **Build full-featured issue templates** that mirror your Jira workflow
|
||||
- **Sync ANY field**—even custom ones from plugins like ScriptRunner or Insight
|
||||
- **Create new fields** with dynamic field mapping (e.g. auto-generate browse URLs from `issue.self + issue.key` from API response)
|
||||
- **Track time seamlessly** with integrated work log statistics and batch operations
|
||||
- **Work offline** in Obsidian, then sync everything back to Jira when ready
|
||||
|
||||
> **Pro Tip**: While TypeScript skills help for advanced mappings, we include ready-made mappings for 90% of basic use cases.
|
||||
|
||||
|
|
@ -17,68 +19,124 @@ Other plugins treat Jira issues as read-only reference material. This one lets y
|
|||
|
||||
### 🧩 Your Jira, your template
|
||||
Create Obsidian notes that look exactly like your team's Jira workflow. Pull in:
|
||||
- Standard fields (status, assignee)
|
||||
- Custom fields (progress bars, sprint IDs)
|
||||
- Standard fields (status, assignee, priority)
|
||||
- Custom fields (progress bars, sprint IDs, epic links)
|
||||
- Plugin fields (ScriptRunner outputs, Insight assets)
|
||||
- Inline indicators that work anywhere in your text
|
||||
|
||||
### Brief example:
|
||||
|
||||
#### Here is how template can look like:
|
||||
```markdown
|
||||
---
|
||||
key: JIR-1
|
||||
status: In Progress <!-- Built-in field -->
|
||||
priority: Medium <!-- Another built-in field -->
|
||||
sprint: Mobile-Q2-24 <!-- Custom field -->
|
||||
epic: API-Overhaul <!-- Another Custom field -->
|
||||
link: http://jira.local:8000/browse/JIR-1 <!-- Built-in auto-generated link -->
|
||||
key: ""
|
||||
status: "" <!-- Built-in field -->
|
||||
priority: "" <!-- Another built-in field -->
|
||||
sprint: "" <!-- Custom field -->
|
||||
epic: "" <!-- Another Custom field -->
|
||||
link: "" <!-- Built-in auto-generated link -->
|
||||
---
|
||||
|
||||
### Customer Impact
|
||||
`jira-sync-section-customfield_10842` <!-- From your CRM plugin -->
|
||||
### Customer Impact `jira-sync-section-customfield_10842` <!-- From your CRM plugin -->
|
||||
|
||||
|
||||
### Other
|
||||
User `jira-sync-inline-start-assignee``jira-sync-inline-end` should be working on this. <!-- Inline indicator, built-in -->
|
||||
|
||||
Expected time spent on the task: `jira-sync-line-originalEstimate` <!-- Line indicator, custom -->
|
||||
```
|
||||
|
||||
#### Here is how it will look after syncing:
|
||||
```markdown
|
||||
---
|
||||
key: JIR-1234
|
||||
status: In Progress
|
||||
priority: Medium
|
||||
sprint: Mobile-Q2-24
|
||||
epic: API-Overhaul
|
||||
link: http://jira.local:8000/browse/JIR-1234
|
||||
---
|
||||
|
||||
### Customer Impact `jira-sync-section-customfield_10842`
|
||||
The current API structure is too fragmented and requires standardization.
|
||||
|
||||
### Other
|
||||
User `jira-sync-inline-start-assignee`Jack A.M.`jira-sync-inline-end` should be working on this.
|
||||
|
||||
Expected time spent on the task: `jira-sync-line-originalEstimate`1w 3d
|
||||
```
|
||||
|
||||
#### Here is how the user will see it most cases (all indicators are hidden from view):
|
||||
```markdown
|
||||
---
|
||||
key: JIR-1234
|
||||
status: In Progress
|
||||
priority: Medium
|
||||
sprint: Mobile-Q2-24
|
||||
epic: API-Overhaul
|
||||
link: http://jira.local:8000/browse/JIR-1234
|
||||
---
|
||||
|
||||
### Customer Impact
|
||||
The current API structure is too fragmented and requires standardization.
|
||||
|
||||
### Other
|
||||
User Jack A.M. should be working on this.
|
||||
|
||||
Expected time spent on the task: 1w 3d
|
||||
```
|
||||
|
||||
### 🔐 Multiple authentication methods
|
||||
Choose the authentication that fits your security requirements:
|
||||
- **Bearer Token (PAT)** - Personal Access Token for secure API access
|
||||
- **Basic Auth (Username + PAT)** - Username with Personal Access Token
|
||||
- **Session Cookie (Username + Password)** - Traditional authentication
|
||||
|
||||
> **Security Note**: When using PAT authentication, ensure `write:jira-work` and `read:jira-work` scopes are enabled.
|
||||
|
||||
### 🔄 Two-way sync that doesn't fight you
|
||||
- **Smart conflict resolution** when notes change locally while syncing
|
||||
- **Partial updates**—edit just the fields you care about
|
||||
- **Worklog batching** push a week's worth of time entries at once
|
||||
- **Status management** update issue status directly from Obsidian
|
||||
|
||||
### ⚙️ Field mapping kitchen
|
||||
We include mappings for:
|
||||
- Basic fields (like status, priority)
|
||||
- Temporal fields (created, updated)
|
||||
- Calculated fields (progress %, time estimates)
|
||||
- **Bring your own** for custom integrations
|
||||
- Basic fields (like status, priority, assignee)
|
||||
- Temporal fields (created, updated, due dates)
|
||||
- Calculated fields (progress %, time estimates, custom formulas)
|
||||
- **Bring your own** for custom integrations and complex transformations
|
||||
|
||||
### 📊 Integrated work log statistics
|
||||
- **No external plugins required** - everything is built-in
|
||||
- **Dynamic time period selection** - view stats for days, weeks, or months
|
||||
- **Batch work log submission** - send multiple time entries at once
|
||||
- **Visual progress tracking** - see your work patterns at a glance
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **Install**: Community plugins → Search "Jira Issue Manager"
|
||||
2. **Connect**: Settings → Add your Jira URL + credentials
|
||||
2. **Connect**: Settings → Choose authentication method + add your Jira URL + credentials
|
||||
3. **Go**:
|
||||
- `Get issue from Jira with custom key` from command palette
|
||||
- Edit like any note
|
||||
- When you are ready -> `Update issue in Jira` - changes sync back.
|
||||
- Edit like any note using our flexible indicator system
|
||||
- When you are ready → `Update issue in Jira` - changes sync back
|
||||
|
||||
## Real-world template
|
||||
## What makes us different
|
||||
|
||||
- **🎯 No external dependencies** - Statistics and work tracking built right in
|
||||
- **🔧 Flexible indicators** - Use `section`, `line`, or `inline` patterns wherever you need them
|
||||
- **📈 Real-time insights** - See your work patterns and productivity trends
|
||||
- **🔄 True two-way sync** - Create, update, and manage issues entirely from Obsidian
|
||||
- **⚡ Batch operations** - Handle multiple work logs and updates efficiently
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed setup, configuration, and advanced usage:
|
||||
- [English Guide](docs/how_to_en.md)
|
||||
- [Russian Guide](docs/how_to_ru.md)
|
||||
- [Template Examples](docs/template_example.md)
|
||||
|
||||
```markdown
|
||||
---
|
||||
key: DEV-42
|
||||
status: In Progress
|
||||
priority: Medium
|
||||
progress: 75%
|
||||
epic: API-Overhaul <!-- Custom field -->
|
||||
risk: Medium <!-- Another custom field -->
|
||||
updated: 2025-01-01T00:00:00.000+0000
|
||||
---
|
||||
|
||||
`jira-sync-section-description`
|
||||
The auth middleware needs to...
|
||||
|
||||
`jira-sync-section-customfield_30122`
|
||||
@alice @bob <!-- Custom user picker field -->
|
||||
|
||||
<!-- Auto-generated link -->
|
||||
`jira-sync-line-openLink` [Open in Jira](http://jira/browse/DEV-42)
|
||||
```
|
||||
|
||||
|
||||
|
||||
Docs with more detailed info and examples: [GitHub](https://github.com/Alamion/obsidian-jira-sync/tree/master/docs)
|
||||
**Ready to transform your Jira workflow?** Install the plugin and start building your perfect issue management system in Obsidian today!
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
The functionality of the plugin can be divided into several parts.
|
||||
|
||||
### Basic Functionality
|
||||
For basic functionality, it is enough to specify Jira credentials (username, password, URL) and the folder for created tasks.
|
||||
For basic functionality, it is enough to specify Jira credentials and the folder for created tasks.
|
||||
|
||||
When running `Get issues from Jira with custom key`, the command will download the latest tasks from Jira and save them in the specified folder.
|
||||
**Authentication Methods**
|
||||
The plugin supports three authentication methods:
|
||||
- **Bearer Token (PAT)** - Personal Access Token authentication
|
||||
- **Basic Auth (Username + PAT)** - Username with Personal Access Token
|
||||
- **Session Cookie (Username + Password)** - Traditional username and password authentication
|
||||
|
||||
**Important Note for Auth Keys**: If using Personal Access Token authentication, the `write:jira-work` and `read:jira-work` scopes are essential for the plugin to function properly.
|
||||
|
||||
When running `Get issue from Jira with custom key`, the command will download the latest tasks from Jira and save them in the specified folder.
|
||||
|
||||
Without a template, such a page will be completely empty except for its title, so using a template is highly recommended. The template is used only when creating a new page.
|
||||
|
||||
#### Template
|
||||
The template can consist of several different parts:
|
||||
1. **formatter** - meta-information at the top of the screen. When specifying keys for it and using any `Get issues from Jira` variant, they will be populated with corresponding fields from the response data.
|
||||
2. **body** - the main content of the page. When using indicators like `jira-sync-section-*` or `jira-sync-line-*`, they will be filled with the corresponding fields from the response data. The difference between these two options is as follows: `line` reads and writes values from the current line, separating the indicator and value with a space. `section` reads values from multiple lines after the indicator, stopping only at another indicator or a heading. An example can be found in [[docs/template_example]].
|
||||
2. **body** - the main content of the page. When using indicators like `jira-sync-section-*`, `jira-sync-line-*`, or `jira-sync-inline-start-*`, they will be filled with the corresponding fields from the response data. The difference between these options is as follows: `line` reads and writes values from the current line, separating the indicator and value with a space. `section` reads values from multiple lines after the indicator, stopping only at another indicator or a heading. `inline` allows indicators to be placed anywhere in the text, not strictly at the beginning of lines. All indicators are invisible until highlighted with the mouse, providing a cleaner editing experience.
|
||||
|
||||
An example can be found in [[docs/template_example]].
|
||||
|
||||
It is highly recommended to specify basic values in the template's formatter: `key` - the Jira task ID used for updates, `summary` - the Jira task title, and `status` - the current status of the task in Jira.
|
||||
|
||||
|
|
@ -22,10 +32,12 @@ Not all fields are predefined, and some may need adjustments. For example, the t
|
|||
|
||||
Currently, the plugin provides the following commands:
|
||||
- `Get issue from Jira with custom key` - allows creating a file in the configured folder that imports information from Jira using a manually specified ID.
|
||||
- `Batch Fetch Issues by JQL` - allows mass issue import from Jira using a JQL query.
|
||||
- `Get current issue from Jira` - allows updating the active file if its formatter contains a `key` (the Jira task ID).
|
||||
- `Update issue in Jira` - allows updating the information from the file in Jira using the key specified in the formatter. Some system fields (e.g., `status`) cannot be changed this way and have dedicated commands.
|
||||
- `Create issue in Jira` - allows creating a new task in Jira. The formatter must include `summary` (task title) and optionally `project` and `issuetype` (the latter two can be selected from existing options during creation).
|
||||
- `Update work log in Jira` - enables tracking time spent on a task. Currently, this is not reflected in the file, but it will be available in future updates. If the formatter contains `jira_worklog_batch` (as described in [[docs/jira_worklog_batch]]), instead of manual entry, a batch of data from `jira_worklog_batch` will be sent, updating each listed entity.
|
||||
- `Update work log in Jira manually` - enables manual time tracking for a task. Currently, this is not reflected in the file, but it will be available in future updates.
|
||||
- `Update work log in Jira by batch` - enables batch time tracking. If the formatter contains `jira_worklog_batch`, a batch of data from `jira_worklog_batch` will be sent, updating each listed entity.
|
||||
- `Update issue status in Jira` - allows updating a task's status by selecting one of the available options.
|
||||
|
||||
### Advanced Usage
|
||||
|
|
@ -42,61 +54,28 @@ It will look something like this:
|
|||

|
||||
|
||||
#### Statistics
|
||||
This is a pre-configured file [[docs/statistics]]. To work with it, several additional plugins are required:
|
||||
- **Timekeep** for tracking time spent on tasks - each task can have a multi-level timer started, stopped, and edited.
|
||||
- **Dataview** with JavaScript queries enabled in settings to create a dynamic table of total time spent over the last few weeks.
|
||||
- **Meta Bind** for selecting the desired week and submitting work data for the week with a single button.
|
||||
|
||||
As mentioned, the statistics page allows sending a batch of work data for the target week, reducing manual input time.
|
||||
Statistics are now integrated into the plugin and can be accessed through the plugin settings in the "Timekeep work log statistics" section. This feature provides a dynamically generated table (or series of tables) showing work statistics and allowing you to send work logs to Jira. You can conveniently select time periods for calculation and transfer work log information to Jira directly from the settings interface.
|
||||
|
||||
With Obsidian themes disabled, the table looks like this:
|
||||
|
||||

|
||||
|
||||
The data batch format for submission, if you want to create an alternative [[docs/statistics]] variant:
|
||||
```json
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issueKey": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z]+-\\d+$",
|
||||
"description": "Task key in the Jira system"
|
||||
},
|
||||
"startTime": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Start time in DD-MM-YYYY HH:MM format"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"description": "Task duration in weeks (w), days (d), hours (h), minutes (m)"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"description": "Job description"
|
||||
}
|
||||
},
|
||||
"required": ["issueKey", "startTime", "duration"]
|
||||
}
|
||||
}
|
||||
```
|
||||
Example:
|
||||
### Plugin Settings
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"issueKey": "JIR-2",
|
||||
"startTime": "17-03-2025 17:00",
|
||||
"duration": "1h 5m"
|
||||
},
|
||||
{
|
||||
"issueKey": "JIR-2",
|
||||
"startTime": "18-03-2025 12:00",
|
||||
"duration": "1w 5d",
|
||||
"comment": "This won't be met in statistics since it's a weekly push, but you can modify [[docs/statistics]], for example, to push once per month."
|
||||
}
|
||||
]
|
||||
```
|
||||
#### Connection Settings
|
||||
This section allows you to select the authentication method and enter corresponding credentials. The plugin supports three authentication methods: Bearer Token (PAT), Basic Auth (Username + PAT), and Session Cookie (Username + Password). If using Personal Access Token authentication, ensure the `write:jira-work` and `read:jira-work` scopes are enabled for proper functionality.
|
||||
|
||||
#### General Settings
|
||||
This section allows you to select the folder where new issues created using commands will be stored. This folder is also used for searching issues in the Timekeep work log statistics section.
|
||||
|
||||
#### Field Mapping
|
||||
This section provides non-standard mappings (in JavaScript format) of information from Jira to Obsidian and vice versa. You can configure custom field transformations and mappings for any additional fields received from Jira.
|
||||
|
||||
#### Raw Issue Viewer
|
||||
This section displays raw API output for entered issues, which should help with writing field mappings. This data is not saved and is provided for debugging and development purposes.
|
||||
|
||||
#### Test Field Mapping
|
||||
This section provides a preview of mapping results for issues from the raw issue viewer. This feature helps with testing and refining field mappings. The data is not saved and is provided for convenience when working with mappings.
|
||||
|
||||
#### Timekeep Work Log Statistics
|
||||
This section provides a dynamically generated table (or series of tables, see image) showing work statistics and providing the ability to send work logs to Jira. You can select time periods for calculation and transfer work log information to Jira directly from this interface.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
Функционал плагина можно разделить на несколько частей.
|
||||
|
||||
### Базовый функционал
|
||||
Для него достаточно указать креды Jira (username, password, URL) и папку с создаваемыми задачами.
|
||||
Для него достаточно указать креды Jira и папку с создаваемыми задачами.
|
||||
|
||||
При запуске `Get issues from Jira with custom key` команда будет скачивать актуальные задачи из Jira и сохранять их в указанную папку.
|
||||
**Методы аутентификации**
|
||||
Плагин поддерживает три метода аутентификации:
|
||||
- **Bearer Token (PAT)** - аутентификация по персональному токену доступа
|
||||
- **Basic Auth (Username + PAT)** - имя пользователя с персональным токеном доступа
|
||||
- **Session Cookie (Username + Password)** - традиционная аутентификация по имени пользователя и паролю
|
||||
|
||||
**Важное замечание для ключей аутентификации**: При использовании аутентификации по персональному токену доступа, области `write:jira-work` и `read:jira-work` являются обязательными для корректной работы плагина.
|
||||
|
||||
При запуске `Get issue from Jira with custom key` команда будет скачивать актуальные задачи из Jira и сохранять их в указанной папке.
|
||||
|
||||
Без шаблона такая страничка будет полностью пустой за исключением её названия, так что шаблон настоятельно рекомендуется к использованию. Шаблон используется только при создании новой страницы.
|
||||
|
||||
#### Шаблон
|
||||
Шаблон можно составить из нескольких разных частей:
|
||||
1. **formatter** - он же метаинформация сверху экрана. При указании ключей для него и любом варианте `Get issues from Jira` они будут заполняться соответствующими полями из ответных данных.
|
||||
2. **body** - основное содержимое страницы. При написании указателя вида `jira-sync-section-*` или `jira-sync-line-*` они будут заполняться соответствующими полями из ответных данных. Разница между этими двумя вариантами в следующем: `line` читает и пишет значение из текущей строки, разделяя указатель и значение пробелом. `section` читает значение из ряда строк после указателя, останавливаясь только при другом указателе или заголовке. Пример можно посмотреть в [[docs/template_example]]
|
||||
2. **body** - основное содержимое страницы. При написании указателя вида `jira-sync-section-*`, `jira-sync-line-*` или `jira-sync-inline-start-*` они будут заполняться соответствующими полями из ответных данных. Разница между этими вариантами в следующем: `line` читает и пишет значение из текущей строки, разделяя указатель и значение пробелом. `section` читает значение из ряда строк после указателя, останавливаясь только при другом указателе или заголовке. `inline` позволяет индикаторам располагаться где угодно, а не строго в начале строки. Все индикаторы являются невидимыми пока не выделишь их мышкой, обеспечивая более чистый опыт редактирования.
|
||||
|
||||
Пример можно посмотреть в [[docs/template_example]]
|
||||
|
||||
Настоятельно рекомендуется указать в formatter шаблоона базовые значения: `key` - id задачи в Jira, по которому производится обновление, `summary` - название задачи в Jira, `status` - текущий статус задачи в Jira.
|
||||
|
||||
|
|
@ -22,10 +32,12 @@ formatter имеет приоритет и будет перезаписыват
|
|||
|
||||
На текущий момент плагин предоставляет следующие команды:
|
||||
- `Get issue from Jira with custom key` - позволяет создать в папке, указанной в настройках, файл, импортирующий информацию из Jira по указанному вручную id.
|
||||
- `Get issue from Jira` - позволяет обновить активный файл, если в его formatter указан key - id задачи Jira.
|
||||
- `Update issue from Jira` - позволяет обновить информацию из файла в Jira по указанному в formatter ключу. Ряд системных полей (например, `status`, таким образом изменить нельзя. Для них созданы отдельные команды)
|
||||
- `Create issue from Jira` - позволяет создать в Jira новую задачу. В formatter обязательно нужно указать summary - название задачи и, опционально, `project` и `issuetype` (последние два можно выбрать из существующих при создании)
|
||||
- `Update work log in Jira` - позволяет вести учёт потраченного на задачу времени. В данный момент он никак не отображается в файле, это будет в ближайших обновлениях. Если в файле в formatter есть `jira_worklog_batch` (как это в [[docs/jira_worklog_batch]]), то вместо ручного заполнения будет послан батч данных из `jira_worklog_batch` с обновлением каждой из представленных сущностей.
|
||||
- `Batch Fetch Issues by JQL` - позволяет получить задачи по JQL и создать/обновить все соответствующие заметки.
|
||||
- `Get current issue from Jira` - позволяет обновить активный файл, если в его formatter указан key - id задачи Jira.
|
||||
- `Update issue in Jira` - позволяет обновить информацию из файла в Jira по указанному в formatter ключу. Ряд системных полей (например, `status`, таким образом изменить нельзя. Для них созданы отдельные команды)
|
||||
- `Create issue in Jira` - позволяет создать в Jira новую задачу. В formatter обязательно нужно указать summary - название задачи и, опционально, `project` и `issuetype` (последние два можно выбрать из существующих при создании)
|
||||
- `Update work log in Jira manually` - позволяет вести учёт потраченного на задачу времени вручную. В данный момент он никак не отображается в файле, это будет в ближайших обновлениях.
|
||||
- `Update work log in Jira by batch` - позволяет вести учёт потраченного на задачу времени батчем. Если в файле в formatter есть `jira_worklog_batch`, то вместо ручного заполнения будет послан батч данных из `jira_worklog_batch` с обновлением каждой из представленных сущностей.
|
||||
- `Update issue status in Jira` - позволяет обновить статус задачи, выбрав один из возможных вариантов.
|
||||
|
||||
### Продвинутое использование
|
||||
|
|
@ -42,60 +54,28 @@ formatter имеет приоритет и будет перезаписыват
|
|||

|
||||
|
||||
#### Статистика
|
||||
Это предзаготовленный файл [[docs/statistics]]. Для работы с ним потребуется несколько дополнительных плагинов:
|
||||
- **Timekeep** для ведения времени работы с задачами - в каждой задаче можно запускать, останавливать и редактировать многоуровневый таймер;
|
||||
- **Dataview** с включенным в настройках JavaScript queries для создания динамичной таблички всего потраченного времени за последние пару недель;
|
||||
- **Meta Bind** для выбора нужной недели и отправки данных о работе за неделю одной кнопкой.
|
||||
|
||||
Как было написано, страница статистики позволяет отправить сразу батч данных о работе за целевую неделю, сокращая время для ручного труда.
|
||||
Статистика теперь интегрирована в плагин и доступна через настройки плагина в секции "Timekeep work log statistics" ("Статистика ведения журнала работы через Timekeep"). Эта функция предоставляет динамически генерируемую таблицу (или ряд таблиц), показывающую статистику работы и позволяющую отправлять журналы работ в Jira. Там же можно удобно выбирать временные промежутки для которых проводится расчёт и передавать информацию ведения журнала работы в Jira.
|
||||
|
||||
Выглядит табличка с выключенными темами Обсидиана примерно так:
|
||||
|
||||

|
||||
|
||||
Формат батча данных для отправки данных если захочется сделать альтернативный [[docs/statistics]] вариант:
|
||||
```json
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issueKey": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z]+-\\d+$",
|
||||
"description": "Ключ задачи в системе Jira"
|
||||
},
|
||||
"startTime": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Время начала в формате DD-MM-YYYY HH:MM"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"description": "Продолжительность задачи в неделях (w), днях (d), часах (h), минутах (m)"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"description": "описание проделанной работы"
|
||||
}
|
||||
},
|
||||
"required": ["issueKey", "startTime", "duration"]
|
||||
}
|
||||
}
|
||||
```
|
||||
Пример:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"issueKey": "JIR-2",
|
||||
"startTime": "17-03-2025 17:00",
|
||||
"duration": "1h 5m"
|
||||
},
|
||||
{
|
||||
"issueKey": "JIR-2",
|
||||
"startTime": "18-03-2025 12:00",
|
||||
"duration": "1w 5d",
|
||||
"comment": "Подобная продолжительность не будет отражена в статистике, так как подразумевается срабатывание лишь раз в неделю, но вы можете изменить [[docs/statistics]], например, на срабатывание раз в месяц"
|
||||
}
|
||||
]
|
||||
```
|
||||
### Настройки плагина
|
||||
|
||||
#### Connection Settings
|
||||
Эта секция позволяет выбрать метод аутентификации и ввести соответствующие креды. Плагин поддерживает три метода аутентификации: Bearer Token (PAT), Basic Auth (Username + PAT) и Session Cookie (Username + Password). При использовании аутентификации по персональному токену доступа убедитесь, что области `write:jira-work` и `read:jira-work` включены для корректной работы.
|
||||
|
||||
#### General Settings
|
||||
Эта секция позволяет выбрать папку, где будут храниться новые issues созданные с помощью команд. Эта папка также используется для поиска issues в разделе Timekeep work log statistics.
|
||||
|
||||
#### Field Mapping
|
||||
Эта секция предоставляет нестандартные маппинги (в JavaScript формате) информации из Jira в Obsidian и обратно. Вы можете настроить кастомные трансформации полей и маппинги для любых дополнительных полей, получаемых из Jira.
|
||||
|
||||
#### Raw Issue Viewer
|
||||
Эта секция отображает сырой вывод API для введённых issues, что должно помочь с написанием маппингов полей. Эти данные не сохраняются и предоставляются для отладки и разработки.
|
||||
|
||||
#### Test Field Mapping
|
||||
Эта секция предоставляет предпоказ результатов маппинга для issues из raw issue viewer. Эта функция помогает с тестированием и улучшением маппингов полей. Данные не сохраняются и предоставляются для удобства при работе с маппингами.
|
||||
|
||||
#### Timekeep Work Log Statistics
|
||||
Эта секция предоставляет динамически генерируемую таблицу (или ряд таблиц, см. изображение), показывающую статистику работы и дающую возможность отправить журнал работ в Jira. Вы можете выбирать временные промежутки для расчёта и передавать информацию ведения журнала работы в Jira прямо из этого интерфейса.
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
jira_worklog_batch: '[]'
|
||||
---
|
||||
|
||||
To set folder searching issue files in, redact in one of last lines `t="Jira Issues"`
|
||||
To set number of weeks shown, redact in one of last lines `e=3`
|
||||
```dataviewjs
|
||||
var exports=(()=>{var u=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var b=Object.prototype.hasOwnProperty;var k=(o,t)=>{for(var s in t)u(o,s,{get:t[s],enumerable:!0})},y=(o,t,s,e)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of f(t))!b.call(o,i)&&i!==s&&u(o,i,{get:()=>t[i],enumerable:!(e=h(t,i))||e.enumerable});return o};var D=o=>y(u({},"__esModule",{value:!0}),o);var T={};k(T,{TimekeepProcessor:()=>g,runTimekeep:()=>v});var g=class{constructor(t="Kanban",s=3){this.tasksDirectory=t,this.showMaxWeeks=s,this.files=app.vault.getMarkdownFiles().filter(e=>e.path.startsWith(`${this.tasksDirectory}/`)&&e.key!==null)}formatDate(t,s){let e=m=>m.toString().padStart(2,"0"),i=t.getFullYear(),r=e(t.getMonth()+1),a=e(t.getDate()),n=e(t.getHours()),c=e(t.getMinutes());return s.replace("YYYY",i.toString()).replace("MM",r).replace("DD",a).replace("HH",n).replace("mm",c)}getWeekStartDate(t){let s=new Date(t),e=s.getDay(),i=s.getDate()-e+(e===0?-6:1);return s.setDate(i),new Date(s.setHours(0,0,0,0))}formatDuration(t){let s=[{label:"w",ms:6048e5},{label:"d",ms:864e5},{label:"h",ms:36e5},{label:"m",ms:6e4},{label:"s",ms:1e3}],e=t,i=[];for(let r of s){let a=Math.floor(e/r.ms);a>0&&(i.push(`${a}${r.label}`),e%=r.ms)}return i.length>0?i.join(" "):"0s"}trimOldEntries(t,s){let e=Object.keys(t).sort();for(;e.length>s;){let i=e.shift();i&&delete t[i]}}processEntries(t,s,e,i,r){if(!(!t||!Array.isArray(t)))for(let a of t){let n=i?`${i} > ${a.name}`:a.name;if(a.startTime){let c=new Date(a.startTime),l=this.getWeekStartDate(c).toISOString().split("T")[0];r[l]||(r[l]=[]);let d,p;a.endTime?(p=new Date(a.endTime),d=this.formatDuration(p.getTime()-c.getTime())):(p=new Date,d="ongoing"),r[l].push({file:s.replace(".md",""),issueKey:e,blockPath:n,startTime:this.formatDate(c,"DD-MM-YYYY HH:mm"),endTime:a.endTime?this.formatDate(p,"DD-MM-YYYY HH:mm"):"ongoing",duration:d})}a.subEntries&&Array.isArray(a.subEntries)&&this.processEntries(a.subEntries,s,e,n,r)}}async processFiles(){let t=[];await Promise.all(this.files.map(async e=>{var c;let i=await app.vault.read(e);if(!i.includes("```timekeep"))return;let r=app.metadataCache.getFileCache(e),a=(c=r==null?void 0:r.frontmatter)==null?void 0:c.key,n=i.match(/```timekeep([\s\S]*?)```/g);n==null||n.forEach(m=>{try{let l=m.slice(11,-3).trim(),d=JSON.parse(l);t.push({file:e.name,path:e.path,issueKey:a,data:d})}catch(l){console.error(`Error in ${e.name}:`,l)}})}));let s={};if(t.forEach(e=>{this.processEntries(e.data.entries,e.file,e.issueKey,"",s)}),this.trimOldEntries(s,this.showMaxWeeks),Object.keys(s).length>0){let e=Object.keys(s).sort();for(let r of e){let a=s[r];dv.header(3,`Week of ${r}`),dv.table(["Task","Block Path","Start Time","Duration"],a.map(n=>[n.file,n.blockPath,n.startTime,n.duration]))}let i=e.map(r=>`option('${JSON.stringify(s[r])}', ${"Week of "+r})`).join(`,
|
||||
`);dv.paragraph(`\`\`\`meta-bind
|
||||
INPUT[inlineSelect(option('[]', 'select week'), ${i}): jira_worklog_batch]
|
||||
\`\`\``),dv.paragraph(`\`\`\`meta-bind-button
|
||||
label: Send worklog to Jira
|
||||
hidden: false
|
||||
id: ""
|
||||
style: primary
|
||||
actions:
|
||||
- type: command
|
||||
command: jira-sync:update-work-log-jira-batch
|
||||
\`\`\``)}else dv.paragraph("No entries found.")}};function v(o="Jira Issues",t=3){new g(o,t).processFiles()}return D(T);})();
|
||||
|
||||
exports.runTimekeep();
|
||||
```
|
||||
Select a week to sent work log to Jira. Always reselect before pressing the button
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
interface Entry {
|
||||
name: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
subEntries?: Entry[];
|
||||
}
|
||||
|
||||
interface TimekeepData {
|
||||
entries: Entry[];
|
||||
}
|
||||
|
||||
interface TimekeepBlock {
|
||||
file: string;
|
||||
path: string;
|
||||
issueKey?: string;
|
||||
data: TimekeepData;
|
||||
}
|
||||
|
||||
interface ProcessedEntry {
|
||||
file: string;
|
||||
issueKey?: string;
|
||||
blockPath: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
interface GroupedEntries {
|
||||
[weekKey: string]: ProcessedEntry[];
|
||||
}
|
||||
|
||||
interface ObsidianFile {
|
||||
path: string;
|
||||
name: string;
|
||||
key?: string | null;
|
||||
}
|
||||
|
||||
interface ObsidianMetadata {
|
||||
frontmatter?: {
|
||||
key?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ObsidianApp {
|
||||
vault: {
|
||||
getMarkdownFiles: () => ObsidianFile[];
|
||||
read: (file: ObsidianFile) => Promise<string>;
|
||||
};
|
||||
metadataCache: {
|
||||
getFileCache: (file: ObsidianFile) => ObsidianMetadata | null;
|
||||
};
|
||||
}
|
||||
|
||||
declare const app: ObsidianApp;
|
||||
declare const dv: {
|
||||
header: (level: number, text: string) => void;
|
||||
table: (headers: string[], data: any[][]) => void;
|
||||
paragraph: (text: string) => void;
|
||||
};
|
||||
|
||||
export class TimekeepProcessor {
|
||||
private tasksDirectory: string;
|
||||
private showMaxWeeks: number;
|
||||
private files: ObsidianFile[];
|
||||
|
||||
constructor(tasksDirectory = "Kanban", showMaxWeeks = 3) {
|
||||
this.tasksDirectory = tasksDirectory;
|
||||
this.showMaxWeeks = showMaxWeeks;
|
||||
this.files = app.vault.getMarkdownFiles().filter(
|
||||
file => file.path.startsWith(`${this.tasksDirectory}/`) && file.key !== null
|
||||
);
|
||||
}
|
||||
|
||||
private formatDate(date: Date, format: string): string {
|
||||
const pad = (num: number): string => 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());
|
||||
|
||||
return format
|
||||
.replace('YYYY', year.toString())
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes);
|
||||
}
|
||||
|
||||
private getWeekStartDate(date: Date): 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
|
||||
}
|
||||
|
||||
private formatDuration(ms: number): string {
|
||||
const units = [
|
||||
{ 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: string[] = [];
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
private trimOldEntries(groupedByWeek: GroupedEntries, maxWeeks: number): void {
|
||||
const dates = Object.keys(groupedByWeek).sort();
|
||||
|
||||
while (dates.length > maxWeeks) {
|
||||
const oldestDate = dates.shift();
|
||||
if (oldestDate) {
|
||||
delete groupedByWeek[oldestDate];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processEntries(
|
||||
entries: Entry[] | undefined,
|
||||
fileName: string,
|
||||
issueKey: string | undefined,
|
||||
parentPath: string,
|
||||
groupedByWeek: GroupedEntries
|
||||
): void {
|
||||
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 = this.getWeekStartDate(startTime);
|
||||
const weekKey = weekStart.toISOString().split('T')[0];
|
||||
|
||||
// Initialize the week array if it doesn't exist
|
||||
if (!groupedByWeek[weekKey]) groupedByWeek[weekKey] = [];
|
||||
|
||||
// Calculate duration
|
||||
let duration: string;
|
||||
let endTime: Date;
|
||||
|
||||
if (entry.endTime) {
|
||||
endTime = new Date(entry.endTime);
|
||||
duration = this.formatDuration(endTime.getTime() - startTime.getTime());
|
||||
} else {
|
||||
endTime = new Date();
|
||||
duration = "ongoing";
|
||||
}
|
||||
|
||||
// Add to grouped data
|
||||
groupedByWeek[weekKey].push({
|
||||
file: fileName.replace(".md", ""),
|
||||
issueKey: issueKey,
|
||||
blockPath: currentPath,
|
||||
startTime: this.formatDate(startTime, 'DD-MM-YYYY HH:mm'),
|
||||
endTime: entry.endTime ? this.formatDate(endTime, 'DD-MM-YYYY HH:mm') : "ongoing",
|
||||
duration: duration
|
||||
});
|
||||
}
|
||||
|
||||
// Process subentries recursively
|
||||
if (entry.subEntries && Array.isArray(entry.subEntries)) {
|
||||
this.processEntries(entry.subEntries, fileName, issueKey, currentPath, groupedByWeek);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async processFiles(): Promise<void> {
|
||||
const timekeepBlocks: TimekeepBlock[] = [];
|
||||
|
||||
// Process all files in one go
|
||||
await Promise.all(this.files.map(async file => {
|
||||
const content = await app.vault.read(file);
|
||||
|
||||
if (!content.includes("```timekeep")) return;
|
||||
const metadata = app.metadataCache.getFileCache(file);
|
||||
const issueKey = metadata?.frontmatter?.key;
|
||||
|
||||
const timekeepMatches = content.match(/```timekeep([\s\S]*?)```/g);
|
||||
timekeepMatches?.forEach(block => {
|
||||
try {
|
||||
const jsonContent = block.slice(11, -3).trim();
|
||||
const data = JSON.parse(jsonContent);
|
||||
|
||||
timekeepBlocks.push({
|
||||
file: file.name,
|
||||
path: file.path,
|
||||
issueKey,
|
||||
data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error in ${file.name}:`, error);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Group entries by week
|
||||
const groupedByWeek: GroupedEntries = {};
|
||||
|
||||
// Process all timekeep blocks once
|
||||
timekeepBlocks.forEach(block => {
|
||||
this.processEntries(block.data.entries, block.file, block.issueKey, "", groupedByWeek);
|
||||
});
|
||||
|
||||
// Trim old entries to show only the most recent weeks
|
||||
this.trimOldEntries(groupedByWeek, this.showMaxWeeks);
|
||||
|
||||
// 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_worklog_batch]
|
||||
\`\`\``);
|
||||
|
||||
dv.paragraph(`\`\`\`meta-bind-button
|
||||
label: Send worklog to Jira
|
||||
hidden: false
|
||||
id: ""
|
||||
style: primary
|
||||
actions:
|
||||
- type: command
|
||||
command: jira-sync:update-work-log-jira-batch
|
||||
\`\`\``);
|
||||
} else {
|
||||
dv.paragraph("No entries found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This will be the minified entry point
|
||||
export function runTimekeep(tasksDir = "Jira Issues", maxWeeks = 3): void {
|
||||
const processor = new TimekeepProcessor(tasksDir, maxWeeks);
|
||||
processor.processFiles();
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "jira-sync",
|
||||
"name": "Jira Issue Manager",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"minAppVersion": "1.7.7",
|
||||
"description": "Get Jira issues, create and update them. Issue status and worklog management.",
|
||||
"author": "Alamion",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -16,6 +16,10 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {requestUrl} from "obsidian";
|
||||
import {authenticate, getAuthHeaders, getSessionCookieKey} from "./auth";
|
||||
import {authenticate, getAuthHeaders} from "./auth";
|
||||
import {debugLog} from "../tools/debugLogging";
|
||||
|
||||
export async function baseRequest(
|
||||
|
|
@ -8,13 +8,21 @@ export async function baseRequest(
|
|||
method: string,
|
||||
additional_url_path: string,
|
||||
body?: string,
|
||||
params?: Record<string, any>,
|
||||
retries: number = 1
|
||||
): Promise<any> {
|
||||
|
||||
const queryString = params && Object.keys(params).length > 0
|
||||
? "?" + new URLSearchParams(params).toString()
|
||||
: "";
|
||||
|
||||
const url = `${plugin.settings.jiraUrl}/rest/api/2${additional_url_path}${queryString}`;
|
||||
const requestParams = {
|
||||
url: `${plugin.settings.jiraUrl}/rest/api/2${additional_url_path}`,
|
||||
url,
|
||||
method: method,
|
||||
headers: await getAuthHeaders(plugin),
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
throw: false,
|
||||
body
|
||||
}
|
||||
|
|
@ -24,16 +32,29 @@ export async function baseRequest(
|
|||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401 && retries > 0) {
|
||||
await authenticate(plugin);
|
||||
return await baseRequest(plugin, method, additional_url_path, body, retries-1);
|
||||
return await baseRequest(plugin, method, additional_url_path, body, params, retries-1);
|
||||
}
|
||||
// console.error(error);
|
||||
throw new Error(`
|
||||
${response.text || "Unknown error"}
|
||||
${body && '\nbody:' + body || ""}`);
|
||||
}
|
||||
debugLog("Additional request info:\n", {"url": `${plugin.settings.jiraUrl}/rest/api/2${additional_url_path}`,
|
||||
"method": method, "body": body});
|
||||
debugLog("Additional request info:\n", {"url": url,
|
||||
"method": method, "body": body, params: params});
|
||||
return response.status === 204 ? null : response.json;
|
||||
|
||||
}
|
||||
|
||||
export function sanitizeObject(obj: any): any {
|
||||
if (Array.isArray(obj)) {
|
||||
obj = obj.map((item: any) => sanitizeObject(item));
|
||||
}
|
||||
else if (typeof obj === "object") {
|
||||
obj = Object.entries(obj).filter(([, v]) => v !== null && v !== undefined).reduce((acc, [k, v]) => {
|
||||
acc[k] = sanitizeObject(v)
|
||||
return acc
|
||||
}, {} as Record<string, any>);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {JiraIssue, JiraTransitionType} from "../interfaces";
|
||||
import {baseRequest} from "./base";
|
||||
import {baseRequest, sanitizeObject} from "./base";
|
||||
import {Notice} from "obsidian";
|
||||
|
||||
/**
|
||||
|
|
@ -14,6 +14,56 @@ export async function fetchIssue(plugin: JiraPlugin, issueKey: string): Promise<
|
|||
return await baseRequest(plugin, 'get', additional_url_path) as Promise<JiraIssue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch issues by JQL with optional field selection.
|
||||
* Returns only issues array for convenience.
|
||||
*/
|
||||
export async function fetchIssuesByJQL(
|
||||
plugin: JiraPlugin,
|
||||
jql: string,
|
||||
limit?: number,
|
||||
fields?: string[]
|
||||
): Promise<JiraIssue[]> {
|
||||
const test = await fetchIssuesByJQLRaw(plugin, jql, 1, fields);
|
||||
const totalAvailable = test.total;
|
||||
const actualLimit = Math.min(limit || totalAvailable, totalAvailable);
|
||||
|
||||
let startAt = 0;
|
||||
let issues: JiraIssue[] = [];
|
||||
|
||||
while (startAt < actualLimit) {
|
||||
const remaining = actualLimit - startAt;
|
||||
const maxResults = Math.min(remaining, 1000);
|
||||
|
||||
const result = await fetchIssuesByJQLRaw(plugin, jql, maxResults, fields, startAt);
|
||||
issues = [...issues, ...result.issues];
|
||||
startAt += result.issues.length;
|
||||
}
|
||||
|
||||
return issues as JiraIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch issues by JQL and return the raw search response (includes total, startAt, etc.).
|
||||
* Useful for previewing results and counts.
|
||||
*/
|
||||
export async function fetchIssuesByJQLRaw(
|
||||
plugin: JiraPlugin,
|
||||
jql: string,
|
||||
maxResults?: number,
|
||||
fields?: string[],
|
||||
startAt?: number
|
||||
): Promise<any> {
|
||||
const additional_url_path = `/search`;
|
||||
const body = JSON.stringify(sanitizeObject({
|
||||
jql,
|
||||
maxResults,
|
||||
startAt,
|
||||
fields: fields && fields.length > 0 ? fields : undefined,
|
||||
}));
|
||||
return await baseRequest(plugin, 'post', additional_url_path, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an issue from Jira by its key
|
||||
* @param plugin The plugin instance
|
||||
|
|
@ -30,7 +80,6 @@ export async function fetchIssueTransitions(plugin: JiraPlugin, issueKey: string
|
|||
})) as JiraTransitionType[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update an issue in Jira
|
||||
* @param plugin The plugin instance
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {baseRequest} from "./base";
|
||||
import {JiraIssueType, JiraProject} from "../interfaces";
|
||||
|
||||
export async function fetchProjects(plugin: JiraPlugin): Promise<JiraProject[]> {
|
||||
const result = await baseRequest(plugin, 'get', '/project');
|
||||
return result.map((project: any) => ({
|
||||
id: project.key,
|
||||
name: project.name,
|
||||
})) as JiraProject[];
|
||||
export async function fetchProjects(plugin: JiraPlugin): Promise<Record<string, any>[]> {
|
||||
return await baseRequest(plugin, 'get', '/project');
|
||||
|
||||
}
|
||||
|
||||
export async function fetchIssueTypes(plugin: JiraPlugin, projectKey: string): Promise<JiraIssueType[]> {
|
||||
const result = await baseRequest(plugin, 'get', `/issue/createmeta/${projectKey}/issuetypes`);
|
||||
return result.values.map((type: any) => ({
|
||||
name: type.name,
|
||||
})) as JiraIssueType[];
|
||||
export async function fetchIssueTypes(plugin: JiraPlugin, projectKey: string): Promise<Record<string, any>> {
|
||||
return await baseRequest(plugin, 'get', `/issue/createmeta/${projectKey}/issuetypes`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import {Notice, TFile} from "obsidian";
|
|||
import JiraPlugin from "../main";
|
||||
import {addWorkLog} from "../api";
|
||||
import {debugLog} from "../tools/debugLogging";
|
||||
import {checkCommandCallback} from "../tools/check_command_callback";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("commands.add_worklog.batch").t;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {TFile} from "obsidian";
|
|||
import JiraPlugin from "../main";
|
||||
import {IssueWorkLogModal} from "../modals";
|
||||
import {addWorkLog} from "../api";
|
||||
import {checkCommandCallback} from "../tools/check_command_callback";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("commands.add_worklog.manual").t;
|
||||
|
|
|
|||
78
src/commands/batchFetchIssues.ts
Normal file
78
src/commands/batchFetchIssues.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {JQLSearchModal} from "../modals";
|
||||
import {fetchIssuesByJQL, validateSettings} from "../api";
|
||||
import {createOrUpdateIssueNote} from "../file_operations/getIssue";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import {Notice} from "obsidian";
|
||||
|
||||
const t = useTranslations("commands.batch_fetch_issues").t;
|
||||
|
||||
/**
|
||||
* Register the batch fetch issues command
|
||||
*/
|
||||
export function registerBatchFetchIssuesCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "batch-fetch-issues-jira",
|
||||
name: t("name"),
|
||||
checkCallback: (checking: boolean) => {
|
||||
const settings_are_valid = validateSettings(plugin);
|
||||
if (settings_are_valid) {
|
||||
if (!checking) openJQLModal(plugin);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openJQLModal(plugin: JiraPlugin): void {
|
||||
new JQLSearchModal(plugin.app, plugin, async (jql: string) => {
|
||||
await batchFetchAndCreateIssues(plugin, jql);
|
||||
}).open();
|
||||
}
|
||||
|
||||
export async function batchFetchAndCreateIssues(plugin: JiraPlugin, jql: string): Promise<void> {
|
||||
try {
|
||||
new Notice(t("fetching_started"));
|
||||
|
||||
const issues = await fetchIssuesByJQL(plugin, jql, -1);
|
||||
|
||||
if (issues.length === 0) {
|
||||
new Notice(t("no_issues_found"));
|
||||
return;
|
||||
}
|
||||
|
||||
new Notice(t("fetching_completed", { count: issues.length.toString() }));
|
||||
|
||||
// Process each issue
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
await createOrUpdateIssueNote(plugin, issue);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
errors.push(`${issue.key}: ${error.message || "Unknown error"}`);
|
||||
console.error(`Error processing issue ${issue.key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Show results
|
||||
if (errorCount === 0) {
|
||||
new Notice(t("all_success", { count: successCount.toString() }));
|
||||
} else {
|
||||
new Notice(t("partial_success", {
|
||||
success: successCount.toString(),
|
||||
errors: errorCount.toString()
|
||||
}));
|
||||
console.error("Batch fetch errors:", errors);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
new Notice(t("fetch_error") + ": " + (error.message || "Unknown error"));
|
||||
console.error("Batch fetch error:", error);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@ import JiraPlugin from "../main";
|
|||
import { IssueTypeModal, ProjectModal } from "../modals";
|
||||
import {fetchIssueTypes, fetchProjects} from "../api";
|
||||
import {createIssueFromFile} from "../file_operations/createUpdateIssue";
|
||||
import {checkCommandCallback} from "../tools/check_command_callback";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import {readJiraFieldsFromFile} from "../file_operations/commonPrepareData";
|
||||
import {JiraIssueType, JiraProject} from "../interfaces";
|
||||
import {IssueAddSummaryModal} from "../modals/IssueAddSummaryModal";
|
||||
|
||||
const t = useTranslations("commands.create_issue").t;
|
||||
|
||||
|
|
@ -16,7 +19,7 @@ export function registerCreateIssueCommand(plugin: JiraPlugin): void {
|
|||
id: "create-issue-jira",
|
||||
name: t("name"),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, createIssue, ["summary"]);
|
||||
return checkCommandCallback(plugin, checking, checkProjects, []);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -24,31 +27,59 @@ export function registerCreateIssueCommand(plugin: JiraPlugin): void {
|
|||
/**
|
||||
* Create a new issue in Jira from the current note
|
||||
*/
|
||||
export async function createIssue(plugin: JiraPlugin, file: TFile): Promise<void> {
|
||||
export async function checkProjects(plugin: JiraPlugin, file: TFile): Promise<void> {
|
||||
try {
|
||||
const fields = await readJiraFieldsFromFile(plugin, file);
|
||||
|
||||
// Fetch projects to show in selection modal
|
||||
const projects = await fetchProjects(plugin);
|
||||
|
||||
// Open project selection modal
|
||||
new ProjectModal(plugin.app, projects, async (projectKey: string) => {
|
||||
// Fetch issue types for the selected project
|
||||
const issueTypes = await fetchIssueTypes(plugin, projectKey);
|
||||
|
||||
// Open issue type selection modal
|
||||
new IssueTypeModal(plugin.app, issueTypes, async (issueType: string) => {
|
||||
try {
|
||||
// Create the issue with selected project and issue type
|
||||
const issueKey = await createIssueFromFile(plugin, file, projectKey, issueType);
|
||||
new Notice(t('success', { issueKey }));
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"), 3000);
|
||||
console.error(error);
|
||||
}
|
||||
if (!fields.project || !projects.map((project: any) => project.key).includes(fields.project)) {
|
||||
new ProjectModal(plugin.app,
|
||||
projects.map((project: any) => ({
|
||||
id: project.key,
|
||||
name: project.name,
|
||||
})) as JiraProject[],
|
||||
async (projectKey: string) => {
|
||||
fields.project = projectKey;
|
||||
await checkIssueTypes(plugin, file, fields)
|
||||
}).open();
|
||||
}).open();
|
||||
} else {
|
||||
await checkIssueTypes(plugin, file, fields);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"));
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"), 3000);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIssueTypes(plugin: JiraPlugin, file: TFile, fields: any): Promise<void> {
|
||||
const issueTypes = await fetchIssueTypes(plugin, fields.project);
|
||||
|
||||
if (!fields.issuetype || !issueTypes.values.map((issueType: any) => issueType.name).includes(fields.issuetype)) {
|
||||
new IssueTypeModal(plugin.app,
|
||||
issueTypes.values.map((type: any) => ({
|
||||
name: type.name,
|
||||
})) as JiraIssueType[],
|
||||
async (issueType: string) => {
|
||||
fields.issuetype = issueType;
|
||||
await checkSummary(plugin, file, fields);
|
||||
}).open();
|
||||
} else {
|
||||
await checkSummary(plugin, file, fields);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function checkSummary(plugin: JiraPlugin, file: TFile, fields: any): Promise<void> {
|
||||
if (!fields.summary) {
|
||||
new IssueAddSummaryModal(plugin.app,
|
||||
async (summary: string) => {
|
||||
fields.summary = summary;
|
||||
const issueKey = await createIssueFromFile(plugin, file, fields);
|
||||
new Notice(t('success', {issueKey}));
|
||||
}).open();
|
||||
} else {
|
||||
await createIssueFromFile(plugin, file, fields);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import JiraPlugin from "../main";
|
|||
import {IssueSearchModal} from "../modals";
|
||||
import {fetchIssue, validateSettings} from "../api";
|
||||
import {createOrUpdateIssueNote} from "../file_operations/getIssue";
|
||||
import {checkCommandCallback} from "../tools/check_command_callback";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("commands.get_issue").t;
|
||||
|
||||
export function registerGetIssueCommandWithKey(plugin: JiraPlugin): void {
|
||||
export function registerGetIssueCommandWithCustomKey(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "get-issue-jira-key",
|
||||
name: t("with_key"),
|
||||
|
|
@ -23,7 +23,7 @@ export function registerGetIssueCommandWithKey(plugin: JiraPlugin): void {
|
|||
});
|
||||
}
|
||||
|
||||
export function registerGetIssueCommand(plugin: JiraPlugin): void {
|
||||
export function registerGetCurrentIssueCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "get-issue-jira",
|
||||
name: t("without_key"),
|
||||
|
|
@ -34,7 +34,7 @@ export function registerGetIssueCommand(plugin: JiraPlugin): void {
|
|||
}
|
||||
|
||||
function openIssueModal(plugin: JiraPlugin): void {
|
||||
new IssueSearchModal(plugin.app, async (issueKey: string) => {
|
||||
new IssueSearchModal(plugin.app, plugin, async (issueKey: string) => {
|
||||
await fetchAndOpenIssue(plugin, null, issueKey);
|
||||
}).open();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './addWorkLogBatch';
|
||||
export * from './addWorkLogManually';
|
||||
export * from './batchFetchIssues';
|
||||
export * from './createIssue';
|
||||
export * from './getIssue';
|
||||
export * from './updateIssue';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {Notice, TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {updateIssueFromFile} from "../file_operations/createUpdateIssue";
|
||||
import {checkCommandCallback} from "../tools/check_command_callback";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("commands.update_issue").t;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {fetchIssueTransitions} from "../api";
|
|||
import {updateStatusFromFile} from "../file_operations/createUpdateIssue";
|
||||
import {IssueStatusModal} from "../modals";
|
||||
import {JiraTransitionType} from "../interfaces";
|
||||
import {checkCommandCallback} from "../tools/check_command_callback";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("commands.update_status").t;
|
||||
|
|
|
|||
34
src/file_operations/commonPrepareData.ts
Normal file
34
src/file_operations/commonPrepareData.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {TFile} from "obsidian";
|
||||
import {extractAllJiraSyncValuesFromContent} from "../tools/sectionTools";
|
||||
|
||||
export async function prepareJiraFieldsFromFile(
|
||||
plugin: JiraPlugin,
|
||||
file: TFile
|
||||
): Promise<Record<string, any>> {
|
||||
const fileContent = await plugin.app.vault.read(file);
|
||||
|
||||
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
|
||||
|
||||
let fields: Record<string, any> = {};
|
||||
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
fields = {...syncSections, ...frontmatter};
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export async function readJiraFieldsFromFile(
|
||||
plugin: JiraPlugin,
|
||||
file: TFile
|
||||
): Promise<Record<string, any>> {
|
||||
const fileContent = await plugin.app.vault.cachedRead(file);
|
||||
|
||||
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
|
||||
|
||||
const cachedMetadata = plugin.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter || {};
|
||||
|
||||
return {...syncSections, ...frontmatter};
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {Notice, TFile} from "obsidian";
|
||||
import {extractAllJiraSyncValuesFromContent} from "../tools/sectionTools";
|
||||
import {localToJiraFields} from "../tools/mapObsidianJiraFields";
|
||||
import {debugLog} from "../tools/debugLogging";
|
||||
import {obsidianJiraFieldMappings} from "../default/obsidianJiraFieldsMapping";
|
||||
|
||||
/**
|
||||
* Prepares Jira fields from the content and frontmatter of a file
|
||||
* @param plugin The plugin instance
|
||||
* @param file The file to extract data from
|
||||
* @returns Object with fields for Jira API
|
||||
*/
|
||||
export async function prepareJiraFieldsFromFile(
|
||||
plugin: JiraPlugin,
|
||||
file: TFile
|
||||
): Promise<{fields: Record<string, any>, issueKey?: string}> {
|
||||
// Read file content
|
||||
const fileContent = await plugin.app.vault.read(file);
|
||||
|
||||
// Extract all sync sections
|
||||
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
|
||||
|
||||
// Initialize fields object
|
||||
let fields: Record<string, any> = {};
|
||||
let issueKey: string | undefined;
|
||||
|
||||
// Get frontmatter and prepare fields
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
debugLog(`Received frontmatter info: ${JSON.stringify(frontmatter)}`)
|
||||
// Get issue key if exists
|
||||
issueKey = frontmatter["key"];
|
||||
|
||||
// Convert frontmatter and sections to Jira fields
|
||||
fields = localToJiraFields(
|
||||
{...syncSections, ...frontmatter},
|
||||
{...obsidianJiraFieldMappings, ...plugin.settings.fieldMappings});
|
||||
});
|
||||
|
||||
return { fields, issueKey };
|
||||
}
|
||||
|
||||
export function getCurrentFileMainInfo(plugin: JiraPlugin):{issueKey?: string, filePath?: string} {
|
||||
const file = plugin.app.workspace.getActiveFile();
|
||||
if (!file) {
|
||||
new Notice("No active file");
|
||||
return {};
|
||||
}
|
||||
const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
let issueKey = frontmatter? frontmatter["key"] as string | undefined : undefined;
|
||||
return {issueKey, filePath: file.path};
|
||||
}
|
||||
|
|
@ -1,17 +1,20 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {TFile} from "obsidian";
|
||||
import {createJiraIssue, updateJiraIssue, updateJiraStatus} from "../api";
|
||||
import {prepareJiraFieldsFromFile} from "./common_prepareData";
|
||||
import {updateJiraToLocal} from "../tools/mapObsidianJiraFields";
|
||||
import {prepareJiraFieldsFromFile} from "./commonPrepareData";
|
||||
import {localToJiraFields, updateJiraToLocal} from "../tools/mapObsidianJiraFields";
|
||||
import {JiraIssue, JiraTransitionType} from "../interfaces";
|
||||
import {obsidianJiraFieldMappings} from "../default/obsidianJiraFieldsMapping";
|
||||
|
||||
export async function updateIssueFromFile(plugin: JiraPlugin, file: TFile): Promise<string> {
|
||||
const { fields, issueKey } = await prepareJiraFieldsFromFile(plugin, file);
|
||||
let fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
const issueKey = fields.key;
|
||||
|
||||
if (!issueKey) {
|
||||
throw new Error("No issue key found in frontmatter");
|
||||
}
|
||||
|
||||
fields = localToJiraFields(fields, {...obsidianJiraFieldMappings, ...plugin.settings.fieldMappings});
|
||||
await updateJiraIssue(plugin, issueKey, fields);
|
||||
return issueKey;
|
||||
}
|
||||
|
|
@ -19,33 +22,12 @@ export async function updateIssueFromFile(plugin: JiraPlugin, file: TFile): Prom
|
|||
export async function createIssueFromFile(
|
||||
plugin: JiraPlugin,
|
||||
file: TFile,
|
||||
projectKey?: string,
|
||||
issueType?: string
|
||||
fields?: Record<string, any>,
|
||||
): Promise<string> {
|
||||
const { fields } = await prepareJiraFieldsFromFile(plugin, file);
|
||||
|
||||
// Override project and issue type if provided
|
||||
if (projectKey) {
|
||||
fields.project = { key: projectKey };
|
||||
if (!fields) {
|
||||
fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
}
|
||||
|
||||
if (issueType) {
|
||||
fields.issuetype = { name: issueType };
|
||||
}
|
||||
|
||||
// Ensure required fields
|
||||
if (!fields.summary) {
|
||||
throw new Error("Summary is required in frontmatter");
|
||||
}
|
||||
|
||||
if (!fields.project) {
|
||||
throw new Error("Project is required in frontmatter or as parameter");
|
||||
}
|
||||
|
||||
if (!fields.issuetype) {
|
||||
throw new Error("Issue type is required in frontmatter or as parameter");
|
||||
}
|
||||
|
||||
fields = localToJiraFields(fields, {...obsidianJiraFieldMappings, ...plugin.settings.fieldMappings});
|
||||
// Create the issue
|
||||
const issueData = await createJiraIssue(plugin, fields);
|
||||
const issueKey = issueData.key;
|
||||
|
|
@ -59,13 +41,13 @@ export async function createIssueFromFile(
|
|||
}
|
||||
|
||||
export async function updateStatusFromFile(plugin: JiraPlugin, file: TFile, transition: JiraTransitionType): Promise<string> {
|
||||
const { issueKey } = await prepareJiraFieldsFromFile(plugin, file);
|
||||
const fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
|
||||
if (!issueKey) {
|
||||
if (!fields.key) {
|
||||
throw new Error("No issue key found in frontmatter");
|
||||
}
|
||||
|
||||
await updateJiraStatus(plugin, issueKey, transition.id);
|
||||
await updateJiraStatus(plugin, fields.key, transition.id);
|
||||
await updateJiraToLocal(plugin, file, {fields: {status: {name: transition.status}}} as JiraIssue);
|
||||
return issueKey;
|
||||
return fields.key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,32 +3,62 @@ import {JiraIssue} from "../interfaces";
|
|||
import {ensureIssuesFolder} from "../tools/filesUtils";
|
||||
import {sanitizeFileName} from "../tools/sanitizers";
|
||||
import {updateJiraToLocal} from "../tools/mapObsidianJiraFields";
|
||||
import {Notice, TFile} from "obsidian";
|
||||
import {defaultTemplate} from "../default/default_template";
|
||||
import {Notice, TFile, TFolder} from "obsidian";
|
||||
import {defaultTemplate} from "../default/defaultTemplate";
|
||||
import { debugLog } from "src/tools/debugLogging";
|
||||
|
||||
export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIssue, filePath?: string): Promise<void> {
|
||||
try {
|
||||
await ensureIssuesFolder(plugin);
|
||||
|
||||
const sanitizedSummary = sanitizeFileName(issue.fields.summary);
|
||||
filePath = filePath || `${plugin.settings.issuesFolder}/${sanitizedSummary}.md`;
|
||||
let targetFile: TFile | null = null;
|
||||
let targetPath: string = "";
|
||||
|
||||
const existingFile = plugin.app.vault.getFileByPath(filePath);
|
||||
|
||||
if (existingFile) {
|
||||
await updateJiraToLocal(plugin, existingFile, issue)
|
||||
|
||||
// Open the file
|
||||
await plugin.app.workspace.openLinkText(existingFile.path, "");
|
||||
if (filePath) {
|
||||
targetPath = filePath;
|
||||
targetFile = plugin.app.vault.getFileByPath(filePath);
|
||||
} else {
|
||||
// File doesn't exist, create it
|
||||
const newFile = await createNewIssueFile(plugin, filePath);
|
||||
// First, try to find the file using cache
|
||||
const cachedPath = plugin.getFilePathForIssueKey(issue.key);
|
||||
if (cachedPath) {
|
||||
targetFile = plugin.app.vault.getFileByPath(cachedPath);
|
||||
if (targetFile) {
|
||||
targetPath = cachedPath;
|
||||
debugLog(`Found issue ${issue.key} in cache: ${cachedPath}`);
|
||||
} else {
|
||||
debugLog(`Cached path for ${issue.key} no longer exists: ${cachedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update it
|
||||
// If not found in cache or file doesn't exist, fallback to search
|
||||
if (!targetFile) {
|
||||
debugLog(`Issue ${issue.key} not in cache, searching filesystem...`);
|
||||
targetFile = await findFileByIssueKey(plugin, issue.key);
|
||||
if (targetFile) {
|
||||
targetPath = targetFile.path;
|
||||
// Add to cache for future use
|
||||
plugin.setFilePathForIssueKey(issue.key, targetPath);
|
||||
debugLog(`Found issue ${issue.key} via search, added to cache: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, create new file
|
||||
if (!targetFile) {
|
||||
const sanitizedSummary = sanitizeFileName(issue.fields.summary);
|
||||
targetPath = `${plugin.settings.issuesFolder}/${sanitizedSummary} [${issue.key}].md`;
|
||||
debugLog(`Issue ${issue.key} not found in cache or filesystem, creating new file: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetFile) {
|
||||
await updateJiraToLocal(plugin, targetFile, issue)
|
||||
await plugin.app.workspace.openLinkText(targetFile.path, "");
|
||||
} else {
|
||||
const newFile = await createNewIssueFile(plugin, targetPath);
|
||||
await updateJiraToLocal(plugin, newFile, issue)
|
||||
|
||||
// Open the file
|
||||
await plugin.app.workspace.openLinkText(newFile.path, "");
|
||||
// Add new file to cache
|
||||
plugin.setFilePathForIssueKey(issue.key, newFile.path);
|
||||
}
|
||||
new Notice(`Issue ${issue.key} imported successfully`);
|
||||
} catch (error) {
|
||||
|
|
@ -37,6 +67,7 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
async function createNewIssueFile(
|
||||
plugin: JiraPlugin,
|
||||
filePath: string
|
||||
|
|
@ -70,4 +101,30 @@ async function createNewIssueFile(
|
|||
return newFile
|
||||
}
|
||||
|
||||
async function findFileByIssueKey(plugin: JiraPlugin, issueKey: string): Promise<TFile | null> {
|
||||
const issuesFolder = plugin.app.vault.getAbstractFileByPath(plugin.settings.issuesFolder);
|
||||
|
||||
if (!issuesFolder || !(issuesFolder instanceof TFolder)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await searchFolderForIssueKey(plugin, issuesFolder, issueKey);
|
||||
}
|
||||
|
||||
async function searchFolderForIssueKey(plugin: JiraPlugin, folder: TFolder, issueKey: string): Promise<TFile | null> {
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFile && child.extension === 'md') {
|
||||
const metadata = plugin.app.metadataCache.getFileCache(child);
|
||||
const foundIssueKey = metadata?.frontmatter?.key;
|
||||
if (foundIssueKey === issueKey) {
|
||||
return child;
|
||||
}
|
||||
} else if (child instanceof TFolder) {
|
||||
const found = await searchFolderForIssueKey(plugin, child, issueKey);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
name: Batch Fetch Issues by JQL
|
||||
|
||||
fetching_started: Starting to fetch issues...
|
||||
fetching_completed: Found {count} issues, starting to create/update notes...
|
||||
no_issues_found: No issues found matching the JQL query
|
||||
all_success: Successfully processed {count} issues
|
||||
partial_success: Processed {success} issues successfully, {errors} failed
|
||||
fetch_error: Error fetching issues
|
||||
8
src/localization/source/en/modals/add_summary.yaml
Normal file
8
src/localization/source/en/modals/add_summary.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
desc: Set the Jira issue summary
|
||||
|
||||
key:
|
||||
name: Summary
|
||||
placeholder: ""
|
||||
desc: Enter new task name (e.g., "My great issue")
|
||||
|
||||
submit: Submit
|
||||
20
src/localization/source/en/modals/jql_search.yaml
Normal file
20
src/localization/source/en/modals/jql_search.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
desc: Search issues by JQL query
|
||||
|
||||
jql:
|
||||
name: JQL Query
|
||||
desc: Enter a JQL (Jira Query Language) query to search for issues
|
||||
placeholder: project = "PROJECT" AND status = "To Do" ORDER BY created DESC
|
||||
|
||||
maxResults:
|
||||
name: Maximum Results
|
||||
desc: Maximum number of issues to fetch (default 50)
|
||||
placeholder: 50
|
||||
|
||||
submit: Search Issues
|
||||
|
||||
preview:
|
||||
total: Total {total} issues found
|
||||
showing: " (showing first {limit} issues)"
|
||||
error: "{error}"
|
||||
loading: Loading...
|
||||
empty_input: Enter a JQL query to see preview
|
||||
|
|
@ -14,10 +14,13 @@ from_jira: From Jira
|
|||
to_jira: To Jira
|
||||
field_name: Field name
|
||||
|
||||
add_mapping: Add mapping
|
||||
add_mapping_tooltip: Add new field mapping
|
||||
reset: Reset
|
||||
reset_tooltip: Reset
|
||||
reset_confirm_title: Reset field mapping
|
||||
reset_confirm_text: Field mapping will be cleaned up. Are you sure?
|
||||
reload_defaults_tooltip: Reload
|
||||
reload_confirm_title: Reload default field mapping
|
||||
reload_confirm_text: Current field mappings will be overwritten with the default mappings. Are you sure?
|
||||
reload_defaults: Reload
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ display:
|
|||
period_of: Period of {date}
|
||||
|
||||
table:
|
||||
total: Total
|
||||
task: Task
|
||||
block_path: Subtask
|
||||
start_time: Start Time
|
||||
|
|
|
|||
|
|
@ -7,5 +7,7 @@ invalid_exp: Invalid expression
|
|||
from_jira: From Jira
|
||||
value: Value
|
||||
|
||||
add_mapping: Add mapping
|
||||
add_mapping_tooltip: Add test mapping entry
|
||||
reset: Reset
|
||||
reset_tooltip: Reset
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
name: Массовое получение задач по JQL (Batch Fetch Issues by JQL)
|
||||
|
||||
fetching_started: Начинаем получение задач...
|
||||
fetching_completed: Найдено {count} задач, начинаем создание/обновление заметок...
|
||||
no_issues_found: Не найдено задач, соответствующих JQL запросу
|
||||
all_success: Успешно обработано {count} задач
|
||||
partial_success: Обработано {success} задач успешно, {errors} не удалось
|
||||
fetch_error: Ошибка получения задач
|
||||
8
src/localization/source/ru/modals/add_summary.yaml
Normal file
8
src/localization/source/ru/modals/add_summary.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
desc: Задать название задачи Jira
|
||||
|
||||
key:
|
||||
name: Название задачи
|
||||
placeholder: ""
|
||||
desc: Введите название задачи (например, "Моя замечательная задача")
|
||||
|
||||
submit: Подтвердить
|
||||
20
src/localization/source/ru/modals/jql_search.yaml
Normal file
20
src/localization/source/ru/modals/jql_search.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
desc: Поиск задач по JQL запросу
|
||||
|
||||
jql:
|
||||
name: JQL Запрос
|
||||
desc: Введите JQL (Jira Query Language) запрос для поиска задач
|
||||
placeholder: project = "PROJECT" AND status = "To Do" ORDER BY created DESC
|
||||
|
||||
maxResults:
|
||||
name: Максимум результатов
|
||||
desc: Максимальное количество задач для получения (по умолчанию 50)
|
||||
placeholder: 50
|
||||
|
||||
submit: Найти задачи
|
||||
|
||||
preview:
|
||||
total: "{total} задач найдено"
|
||||
showing: " (показаны первые {limit} задач)"
|
||||
error: "{error}"
|
||||
loading: Загрузка...
|
||||
empty_input: Введите JQL запрос для просмотра превью задач
|
||||
133
src/main.ts
133
src/main.ts
|
|
@ -1,75 +1,126 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import {Plugin } from "obsidian";
|
||||
import { JiraSettingTab } from "./settings/JiraSettingTab";
|
||||
import { JiraIssueType, JiraProject } from "./interfaces";
|
||||
import {DEFAULT_SETTINGS, JiraSettings} from "./settings/default";
|
||||
import {
|
||||
registerUpdateIssueCommand, registerUpdateWorkLogManuallyCommand,
|
||||
registerGetIssueCommand, registerUpdateWorkLogBatchCommand,
|
||||
registerCreateIssueCommand, registerGetIssueCommandWithKey, registerUpdateIssueStatusCommand
|
||||
registerGetCurrentIssueCommand, registerUpdateWorkLogBatchCommand,
|
||||
registerCreateIssueCommand, registerGetIssueCommandWithCustomKey, registerUpdateIssueStatusCommand,
|
||||
registerBatchFetchIssuesCommand
|
||||
} from "./commands";
|
||||
import {transform_string_to_functions_mappings} from "./tools/convertFunctionString";
|
||||
import {createJiraSyncExtension} from "./postprocessing/livePreview";
|
||||
import {hideJiraPointersReading} from "./postprocessing/reading";
|
||||
import { buildCacheFromFilesystem, validateCache } from "./tools/cacheUtils";
|
||||
|
||||
/**
|
||||
* Main plugin class
|
||||
*/
|
||||
export default class JiraPlugin extends Plugin {
|
||||
settings: JiraSettings;
|
||||
|
||||
// In-memory cache for instant access during synchronization
|
||||
private issueKeyToFilePathCache: Map<string, string> = new Map();
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// // Add ribbon icon to export issues
|
||||
// this.addRibbonIcon(
|
||||
// "book-up",
|
||||
// "Push current Kanban issues statuses to Jira",
|
||||
// () => {
|
||||
// // Use the getIssue command's functionality
|
||||
// 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
|
||||
// const getIssuesCommand = this.app.commands.commands["jira-plugin:pull-all-issues"];
|
||||
// if (getIssuesCommand) {
|
||||
// getIssuesCommand.callback();
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
|
||||
// validate cache from settings
|
||||
this.initializeCache();
|
||||
await validateCache(this);
|
||||
|
||||
// Register all commands
|
||||
registerUpdateIssueCommand(this);
|
||||
registerUpdateIssueStatusCommand(this);
|
||||
registerGetIssueCommand(this);
|
||||
registerGetIssueCommandWithKey(this);
|
||||
registerGetCurrentIssueCommand(this);
|
||||
registerGetIssueCommandWithCustomKey(this);
|
||||
registerCreateIssueCommand(this);
|
||||
registerBatchFetchIssuesCommand(this);
|
||||
|
||||
registerUpdateWorkLogManuallyCommand(this);
|
||||
registerUpdateWorkLogBatchCommand(this);
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new JiraSettingTab(this.app, this));
|
||||
|
||||
// Handle Reading mode (post-processor for rendered markdown)
|
||||
this.registerMarkdownPostProcessor(hideJiraPointersReading.bind(this));
|
||||
|
||||
// Handle Live Preview/Edit mode (CodeMirror extension)
|
||||
this.registerEditorExtension(createJiraSyncExtension(this));
|
||||
|
||||
// Register vault event listeners for cache maintenance
|
||||
this.registerVaultEventListeners();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin settings
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
this.settings.fieldMappings = await transform_string_to_functions_mappings(this.settings.fieldMappingsStrings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save plugin settings
|
||||
*/
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
private initializeCache() {
|
||||
this.issueKeyToFilePathCache.clear();
|
||||
Object.entries(this.settings.issueKeyToFilePathCache).forEach(([key, path]) => {
|
||||
this.issueKeyToFilePathCache.set(key, path);
|
||||
});
|
||||
}
|
||||
|
||||
getAllIssueKeysMap(): Map<string, string> {
|
||||
return this.issueKeyToFilePathCache
|
||||
}
|
||||
|
||||
getFilePathForIssueKey(issueKey: string): string | undefined {
|
||||
return this.issueKeyToFilePathCache.get(issueKey);
|
||||
}
|
||||
|
||||
setFilePathForIssueKey(issueKey: string, filePath: string) {
|
||||
this.issueKeyToFilePathCache.set(issueKey, filePath);
|
||||
this.settings.issueKeyToFilePathCache[issueKey] = filePath;
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
removeIssueKeyFromCache(issueKey: string) {
|
||||
this.issueKeyToFilePathCache.delete(issueKey);
|
||||
delete this.settings.issueKeyToFilePathCache[issueKey];
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.issueKeyToFilePathCache.clear();
|
||||
this.settings.issueKeyToFilePathCache = {};
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
async rebuildCache() {
|
||||
this.clearCache();
|
||||
await buildCacheFromFilesystem(this);
|
||||
}
|
||||
|
||||
private registerVaultEventListeners() {
|
||||
|
||||
// Handle file renames
|
||||
this.registerEvent(
|
||||
this.app.vault.on('rename', (file, oldPath) => {
|
||||
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
|
||||
if (cachedPath === oldPath) {
|
||||
this.setFilePathForIssueKey(issueKey, file.path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle file deletions
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
|
||||
if (cachedPath === file.path) {
|
||||
this.removeIssueKeyFromCache(issueKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/modals/IssueAddSummaryModal.ts
Normal file
51
src/modals/IssueAddSummaryModal.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import {App, Modal, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("modals.add_summary").t;
|
||||
/**
|
||||
* Modal for searching issues by key
|
||||
*/
|
||||
export class IssueAddSummaryModal extends Modal {
|
||||
private onSubmit: (result: string) => void;
|
||||
private summary: string = "";
|
||||
|
||||
constructor(app: App, onSubmit: (result: string) => void) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl("h2", {text: t("desc")});
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t("key.name"))
|
||||
.setDesc(t("key.desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("key.placeholder"))
|
||||
.onChange((value) => {
|
||||
this.summary = value;
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t("submit"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
this.onSubmit(this.summary);
|
||||
})
|
||||
);
|
||||
|
||||
// Handle Enter key
|
||||
this.contentEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.onSubmit(this.summary);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import {App, Modal, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import {JQLPreview} from "../settings/components/JQLPreview";
|
||||
import JiraPlugin from "../main";
|
||||
|
||||
const t = useTranslations("modals.search").t;
|
||||
/**
|
||||
|
|
@ -7,10 +9,15 @@ const t = useTranslations("modals.search").t;
|
|||
*/
|
||||
export class IssueSearchModal extends Modal {
|
||||
private onSubmit: (result: string) => void;
|
||||
private plugin: JiraPlugin;
|
||||
private issueKey: string = "";
|
||||
private previewEl?: HTMLElement;
|
||||
private preview?: JQLPreview;
|
||||
private debounceTimer?: number;
|
||||
|
||||
constructor(app: App, onSubmit: (result: string) => void) {
|
||||
constructor(app: App, plugin: JiraPlugin, onSubmit: (result: string) => void) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
|
|
@ -25,9 +32,14 @@ export class IssueSearchModal extends Modal {
|
|||
.setPlaceholder(t("key.placeholder"))
|
||||
.onChange((value) => {
|
||||
this.issueKey = value;
|
||||
this.schedulePreview();
|
||||
})
|
||||
);
|
||||
|
||||
this.previewEl = this.contentEl.createDiv();
|
||||
this.preview = new JQLPreview(this.plugin, this.previewEl, 1, false);
|
||||
this.preview.loadPreview("")
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
|
|
@ -48,4 +60,29 @@ export class IssueSearchModal extends Modal {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
private schedulePreview() {
|
||||
if (this.debounceTimer) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
// Show loading immediately if there's text
|
||||
if (this.issueKey.trim() && this.preview) {
|
||||
this.preview.showLoading();
|
||||
}
|
||||
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
if (this.preview) {
|
||||
this.preview.loadPreview(this.issueKey? "key = "+this.issueKey : "").catch(() => {
|
||||
// Error handling is done within the preview component
|
||||
});
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.debounceTimer) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
96
src/modals/JQLSearchModal.ts
Normal file
96
src/modals/JQLSearchModal.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import {App, Modal, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import JiraPlugin from "../main";
|
||||
import {JQLPreview} from "../settings/components/JQLPreview";
|
||||
|
||||
const t = useTranslations("modals.jql_search").t;
|
||||
|
||||
/**
|
||||
* Modal for searching issues by JQL query
|
||||
*/
|
||||
export class JQLSearchModal extends Modal {
|
||||
private onSubmit: (jql: string) => void;
|
||||
private plugin: JiraPlugin;
|
||||
private jql: string = "";
|
||||
private previewEl?: HTMLElement;
|
||||
private preview?: JQLPreview;
|
||||
private debounceTimer?: number;
|
||||
|
||||
constructor(app: App, plugin: JiraPlugin, onSubmit: (jql: string) => void) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl("h2", {text: t("desc")});
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t("jql.name"))
|
||||
.setDesc(t("jql.desc"))
|
||||
.addTextArea((text) => {
|
||||
text
|
||||
.setPlaceholder(t("jql.placeholder"))
|
||||
.setValue(this.jql)
|
||||
.onChange((value) => {
|
||||
this.jql = value;
|
||||
this.schedulePreview();
|
||||
});
|
||||
text.inputEl.style.height = "100px";
|
||||
});
|
||||
|
||||
// Create preview container and initialize preview component
|
||||
this.previewEl = this.contentEl.createDiv();
|
||||
this.preview = new JQLPreview(this.plugin, this.previewEl, 5);
|
||||
this.preview.loadPreview("")
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t("submit"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
if (this.jql.trim()) {
|
||||
this.close();
|
||||
this.onSubmit(this.jql.trim());
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle Enter key (Ctrl+Enter for textarea)
|
||||
this.contentEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
if (this.jql.trim()) {
|
||||
this.close();
|
||||
this.onSubmit(this.jql.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private schedulePreview() {
|
||||
if (this.debounceTimer) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
// Show loading immediately if there's text
|
||||
if (this.jql.trim() && this.preview) {
|
||||
this.preview.showLoading();
|
||||
}
|
||||
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
if (this.preview) {
|
||||
this.preview.loadPreview(this.jql).catch(() => {
|
||||
// Error handling is done within the preview component
|
||||
});
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.debounceTimer) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,3 +3,4 @@ export * from "./IssueStatusModal";
|
|||
export * from "./IssueTypeModal";
|
||||
export * from "./IssueWorkLogModal";
|
||||
export * from "./ProjectModal";
|
||||
export * from "./JQLSearchModal";
|
||||
|
|
|
|||
91
src/postprocessing/livePreview.ts
Normal file
91
src/postprocessing/livePreview.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {Extension, RangeSetBuilder} from "@codemirror/state";
|
||||
import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate} from "@codemirror/view";
|
||||
import {MarkdownView} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
|
||||
|
||||
export function createJiraSyncExtension(plugin: JiraPlugin): Extension {
|
||||
return ViewPlugin.fromClass(class {
|
||||
decorations: DecorationSet = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (this.passDecoration()) {
|
||||
this.decorations = Decoration.none;
|
||||
return;
|
||||
}
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
passDecoration() {
|
||||
const mdView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!mdView) return false;
|
||||
const current_state = mdView.getState()
|
||||
return current_state.mode === 'source' && current_state.source;
|
||||
}
|
||||
|
||||
buildDecorations(view: EditorView) {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
|
||||
for (let { from, to } of view.visibleRanges) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
const codeBlocks = this.findCodeBlocks(text, from);
|
||||
|
||||
for (const match of text.matchAll(/`(jira-sync-[^`]+)`/g)) {
|
||||
const start = from + match.index!;
|
||||
const end = start + match[0].length;
|
||||
const contentStart = start + 1; // без первой `
|
||||
const contentEnd = end - 1; // без последней `
|
||||
|
||||
// Find code blocks to ignore
|
||||
if (this.isInsideCodeBlock(contentStart, contentEnd, codeBlocks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let className = "jira-sync-hidden";
|
||||
|
||||
// Check if cursor is inside
|
||||
const sel = view.state.selection;
|
||||
if (sel.ranges.some(r => r.from <= contentEnd && r.to >= contentStart)) {
|
||||
className += " jira-sync-active";
|
||||
}
|
||||
|
||||
builder.add(start, end,
|
||||
Decoration.mark({ class: className })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
findCodeBlocks(text: string, offset: number) {
|
||||
const blocks = [];
|
||||
const regex = /```[\s\S]*?```/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
blocks.push({
|
||||
from: offset + match.index,
|
||||
to: offset + match.index + match[0].length
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
isInsideCodeBlock(start: number, end: number, codeBlocks: Array<{from: number, to: number}>) {
|
||||
return codeBlocks.some(block =>
|
||||
start >= block.from && end <= block.to
|
||||
);
|
||||
}
|
||||
|
||||
}, {
|
||||
decorations: v => v.decorations
|
||||
});
|
||||
}
|
||||
13
src/postprocessing/reading.ts
Normal file
13
src/postprocessing/reading.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// For Reading mode - targets <code> elements in <p> tags
|
||||
export function hideJiraPointersReading(element: HTMLElement, _: any) {
|
||||
// In Reading mode, inline code becomes <code> elements
|
||||
const codeElements = element.querySelectorAll(':not(pre) > code');
|
||||
|
||||
codeElements.forEach(codeEl => {
|
||||
const text = codeEl.textContent || '';
|
||||
|
||||
if (text.startsWith('jira-sync-')) {
|
||||
codeEl.addClass('jira-sync-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ButtonComponent } from "obsidian";
|
||||
import {setIcon} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import { SettingsComponent } from "../interfaces/settingsTypes";
|
||||
import { debugLog } from "../tools/debugLogging";
|
||||
|
|
@ -11,7 +11,6 @@ export class CollapsibleSection {
|
|||
private headerText: string;
|
||||
private saveStateKey?: string;
|
||||
private plugin: JiraPlugin;
|
||||
private toggleBtn: ButtonComponent;
|
||||
|
||||
constructor(
|
||||
plugin: JiraPlugin,
|
||||
|
|
@ -42,10 +41,10 @@ export class CollapsibleSection {
|
|||
await this.toggle();
|
||||
});
|
||||
|
||||
// Add toggle button to the header
|
||||
this.toggleBtn = new ButtonComponent(headerEl as any);
|
||||
this.toggleBtn.setIcon(this.isOpen ? "chevron-down" : "chevron-right")
|
||||
.setTooltip(this.isOpen ? "Collapse" : "Expand")
|
||||
const chevron = headerEl.createEl('span', {
|
||||
cls: 'jira-collapse-icon',
|
||||
});
|
||||
setIcon(chevron, "chevron-down");
|
||||
|
||||
// Content wrapper
|
||||
this.contentEl = this.containerEl.createDiv({
|
||||
|
|
@ -56,16 +55,9 @@ export class CollapsibleSection {
|
|||
|
||||
if (!this.isOpen) {
|
||||
this.contentEl.hide();
|
||||
this.containerEl.addClass("collapsed");
|
||||
}
|
||||
|
||||
// Optional: restore saved state
|
||||
// if (this.saveStateKey && this.plugin.settings.collapsedSections[this.saveStateKey] !== undefined) {
|
||||
// this.isOpen = !this.plugin.settings.collapsedSections[this.saveStateKey];
|
||||
// if (!this.isOpen) {
|
||||
// this.contentEl.hide();
|
||||
// }
|
||||
// }
|
||||
|
||||
debugLog("Rendered", this.saveStateKey || this.headerText, "as", this.isOpen);
|
||||
}
|
||||
|
||||
|
|
@ -77,18 +69,16 @@ export class CollapsibleSection {
|
|||
this.isOpen = !this.isOpen;
|
||||
debugLog("Toggled", this.saveStateKey || this.headerText, "to", this.isOpen);
|
||||
|
||||
const icon = this.isOpen ? "chevron-down" : "chevron-right";
|
||||
const tooltip = this.isOpen ? "Collapse" : "Expand";
|
||||
|
||||
// Rebuild or update the toggle button if needed
|
||||
// Or store a reference to the button and just update its properties
|
||||
|
||||
if (this.isOpen) {
|
||||
this.contentEl.show();
|
||||
this.containerEl.removeClass("collapsed");
|
||||
} else {
|
||||
this.contentEl.hide();
|
||||
this.containerEl.addClass("collapsed");
|
||||
}
|
||||
this.toggleBtn.setIcon(icon).setTooltip(tooltip);
|
||||
|
||||
if (this.saveStateKey && this.plugin) {
|
||||
this.plugin.settings.collapsedSections[this.saveStateKey] = this.isOpen;
|
||||
|
|
|
|||
335
src/settings/components/JQLPreview.ts
Normal file
335
src/settings/components/JQLPreview.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import {fetchIssuesByJQLRaw} from "../../api";
|
||||
import JiraPlugin from "../../main";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
|
||||
const t = useTranslations("modals.jql_search").t;
|
||||
|
||||
/**
|
||||
* Reusable JQL Preview component
|
||||
*/
|
||||
export class JQLPreview {
|
||||
private plugin: JiraPlugin;
|
||||
private containerEl: HTMLElement;
|
||||
private limit: number;
|
||||
private loadingEl?: HTMLElement;
|
||||
private contentEl?: HTMLElement;
|
||||
private show_header = true;
|
||||
|
||||
constructor(plugin: JiraPlugin, containerEl: HTMLElement, limit: number = 5, show_header = true) {
|
||||
this.plugin = plugin;
|
||||
this.containerEl = containerEl;
|
||||
this.limit = limit;
|
||||
this.setupContainer();
|
||||
this.show_header = show_header;
|
||||
}
|
||||
|
||||
private setupContainer() {
|
||||
this.containerEl.addClass("jql-preview-container");
|
||||
this.renderSkeleton();
|
||||
}
|
||||
|
||||
private renderSkeleton() {
|
||||
this.containerEl.empty();
|
||||
|
||||
this.loadingEl = this.containerEl.createDiv({ cls: "jql-preview-loading" });
|
||||
this.loadingEl.createDiv({
|
||||
cls: "jql-preview-loading-icon",
|
||||
text: "🔍"
|
||||
});
|
||||
this.loadingEl.createDiv({
|
||||
cls: "jql-preview-loading-text",
|
||||
text: t("preview.loading")
|
||||
});
|
||||
}
|
||||
|
||||
private renderEmpty() {
|
||||
this.containerEl.empty();
|
||||
|
||||
const emptyEl = this.containerEl.createDiv({ cls: "jql-preview-empty" });
|
||||
emptyEl.createDiv({
|
||||
cls: "jql-preview-empty-icon",
|
||||
text: "📝"
|
||||
});
|
||||
emptyEl.createDiv({
|
||||
cls: "jql-preview-empty-text",
|
||||
text: t("preview.empty_input")
|
||||
});
|
||||
}
|
||||
|
||||
private renderError(error: string) {
|
||||
this.containerEl.empty();
|
||||
|
||||
const errorEl = this.containerEl.createDiv({ cls: "jql-preview-error" });
|
||||
errorEl.createDiv({
|
||||
cls: "jql-preview-error-icon",
|
||||
text: "⚠️"
|
||||
});
|
||||
errorEl.createDiv({
|
||||
cls: "jql-preview-error-text",
|
||||
text: t("preview.error", { error })
|
||||
});
|
||||
}
|
||||
|
||||
private renderResults(total: number, issues: any[]) {
|
||||
this.containerEl.empty();
|
||||
|
||||
// Header with total count
|
||||
if (this.show_header) {
|
||||
const headerEl = this.containerEl.createDiv({ cls: "jql-preview-header" });
|
||||
const totalEl = headerEl.createDiv({ cls: "jql-preview-total" });
|
||||
totalEl.createSpan({ cls: "jql-preview-total-text", text: t("preview.total", { total }) });
|
||||
|
||||
if (total > this.limit) {
|
||||
totalEl.createSpan({
|
||||
cls: "jql-preview-showing",
|
||||
text: t("preview.showing", { limit: this.limit })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Issues table
|
||||
if (issues.length > 0) {
|
||||
const tableWrapperEl = this.containerEl.createDiv({ cls: "jql-preview-table-wrapper" });
|
||||
const tableEl = tableWrapperEl.createEl("table", { cls: "jql-preview-table" });
|
||||
|
||||
// Table header
|
||||
const theadEl = tableEl.createEl("thead");
|
||||
const headerRowEl = theadEl.createEl("tr");
|
||||
|
||||
// Header columns
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-key",
|
||||
text: "Key"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-summary",
|
||||
text: "Summary"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-icon",
|
||||
text: "T"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-date",
|
||||
text: "Created"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-date",
|
||||
text: "Updated"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-icon",
|
||||
text: "R"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-icon",
|
||||
text: "A"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-icon",
|
||||
text: "P"
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-status",
|
||||
text: "Status"
|
||||
});
|
||||
|
||||
// Table body
|
||||
const tbodyEl = tableEl.createEl("tbody");
|
||||
|
||||
issues.forEach((issue, index) => {
|
||||
const rowEl = tbodyEl.createEl("tr", { cls: "jql-preview-row" });
|
||||
|
||||
// Key column
|
||||
const keyCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-key" });
|
||||
const keyLinkEl = keyCellEl.createEl("a", {
|
||||
cls: "jql-preview-key-link",
|
||||
text: issue.key || "N/A",
|
||||
attr: {
|
||||
href: `${this.plugin.settings.jiraUrl}/browse/${issue.key}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
}
|
||||
});
|
||||
|
||||
// Summary column (S)
|
||||
const summaryCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-summary" });
|
||||
const summary = issue.fields?.summary || "No summary available";
|
||||
summaryCellEl.setAttr("title", summary); // Show full text on hover
|
||||
summaryCellEl.setText(summary.length > 80 ? summary.substring(0, 80) + "..." : summary);
|
||||
|
||||
// Type column (T)
|
||||
const typeCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const issueType = issue.fields?.issuetype;
|
||||
if (issueType && issueType.iconUrl) {
|
||||
const typeIconEl = typeCellEl.createEl("img", {
|
||||
attr: { src: issueType.iconUrl, alt: issueType.name },
|
||||
cls: "jql-preview-type-icon"
|
||||
});
|
||||
} else {
|
||||
// Fallback icon for unknown type
|
||||
const fallbackIconEl = typeCellEl.createEl("div", {
|
||||
cls: "jql-preview-status-indicator status-undefined"
|
||||
});
|
||||
}
|
||||
|
||||
// Created date column
|
||||
const createdCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-date" });
|
||||
const createdDate = issue.fields?.created;
|
||||
if (createdDate) {
|
||||
const date = new Date(createdDate);
|
||||
createdCellEl.setText(date.toLocaleDateString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
}));
|
||||
} else {
|
||||
createdCellEl.setText("N/A");
|
||||
}
|
||||
|
||||
// Updated date column
|
||||
const updatedCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-date" });
|
||||
const updatedDate = issue.fields?.updated;
|
||||
if (updatedDate) {
|
||||
const date = new Date(updatedDate);
|
||||
updatedCellEl.setText(date.toLocaleDateString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
}));
|
||||
} else {
|
||||
updatedCellEl.setText("N/A");
|
||||
}
|
||||
|
||||
// Reporter column (R)
|
||||
const reporterCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const reporter = issue.fields?.reporter;
|
||||
if (reporter) {
|
||||
const reporterLinkEl = reporterCellEl.createEl("a", {
|
||||
attr: {
|
||||
href: `${this.plugin.settings.jiraUrl}/secure/ViewProfile.jspa?name=${reporter.name}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
title: reporter.displayName || reporter.name
|
||||
}
|
||||
});
|
||||
if (reporter.avatarUrls && reporter.avatarUrls["16x16"]) {
|
||||
const avatarEl = reporterLinkEl.createEl("img", {
|
||||
attr: { src: reporter.avatarUrls["16x16"], alt: reporter.displayName },
|
||||
cls: "jql-preview-user-avatar"
|
||||
});
|
||||
} else {
|
||||
const initialEl = reporterLinkEl.createEl("div", {
|
||||
cls: "jql-preview-user-initial",
|
||||
text: reporter.displayName ? reporter.displayName.charAt(0).toUpperCase() : "?"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const unknownEl = reporterCellEl.createEl("div", {
|
||||
cls: "jql-preview-status-indicator status-undefined"
|
||||
});
|
||||
}
|
||||
|
||||
// Assignee column (A)
|
||||
const assigneeCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const assignee = issue.fields?.assignee;
|
||||
if (assignee) {
|
||||
const assigneeLinkEl = assigneeCellEl.createEl("a", {
|
||||
attr: {
|
||||
href: `${this.plugin.settings.jiraUrl}/secure/ViewProfile.jspa?name=${assignee.name}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
title: assignee.displayName || assignee.name
|
||||
}
|
||||
});
|
||||
if (assignee.avatarUrls && assignee.avatarUrls["16x16"]) {
|
||||
const avatarEl = assigneeLinkEl.createEl("img", {
|
||||
attr: { src: assignee.avatarUrls["16x16"], alt: assignee.displayName },
|
||||
cls: "jql-preview-user-avatar"
|
||||
});
|
||||
} else {
|
||||
const initialEl = assigneeLinkEl.createEl("div", {
|
||||
cls: "jql-preview-user-initial",
|
||||
text: assignee.displayName ? assignee.displayName.charAt(0).toUpperCase() : "?"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const unknownEl = assigneeCellEl.createEl("div", {
|
||||
cls: "jql-preview-status-indicator status-undefined"
|
||||
});
|
||||
}
|
||||
|
||||
// Priority column (P)
|
||||
const priorityCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const priority = issue.fields?.priority;
|
||||
if (priority && priority.iconUrl) {
|
||||
const priorityIconEl = priorityCellEl.createEl("img", {
|
||||
attr: { src: priority.iconUrl, alt: priority.name },
|
||||
cls: "jql-preview-priority-icon"
|
||||
});
|
||||
} else {
|
||||
const unknownEl = priorityCellEl.createEl("div", {
|
||||
cls: "jql-preview-status-indicator status-undefined"
|
||||
});
|
||||
}
|
||||
|
||||
// Status column
|
||||
const statusCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-status" });
|
||||
const status = issue.fields?.status;
|
||||
if (status) {
|
||||
const statusBadgeEl = statusCellEl.createEl("span", {
|
||||
cls: `jql-preview-status-badge status-${status.statusCategory?.key || 'default'}`,
|
||||
text: status.name
|
||||
});
|
||||
} else {
|
||||
const statusBadgeEl = statusCellEl.createEl("span", {
|
||||
cls: "jql-preview-status-badge status-default",
|
||||
text: "Unknown"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display preview for given JQL
|
||||
*/
|
||||
async loadPreview(jql: string): Promise<void> {
|
||||
const trimmedJql = jql.trim();
|
||||
|
||||
if (!trimmedJql) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderSkeleton();
|
||||
|
||||
try {
|
||||
// Enhanced fields for better preview (matching Jira table columns)
|
||||
const fields = ["summary", "issuetype", "key", "priority", "status", "created", "updated", "reporter", "assignee"];
|
||||
const res = await fetchIssuesByJQLRaw(this.plugin, trimmedJql, this.limit, fields);
|
||||
|
||||
const total = res.total ?? 0;
|
||||
const issues = Array.isArray(res.issues) ? res.issues.slice(0, this.limit) : [];
|
||||
|
||||
this.renderResults(total, issues);
|
||||
} catch (error) {
|
||||
this.renderError(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the preview
|
||||
*/
|
||||
clear(): void {
|
||||
this.renderEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading state
|
||||
*/
|
||||
showLoading(): void {
|
||||
this.renderSkeleton();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Setting, Notice } from 'obsidian';
|
||||
import {Setting, Notice, setIcon} from 'obsidian';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { processWorkLogBatch } from "../../commands";
|
||||
import { SuggestSelectComponent, SuggestSelectConfig } from './SuggestSelectComponent';
|
||||
|
|
@ -233,54 +233,158 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
sortedPeriods.forEach((period, index) => {
|
||||
const periodDiv = display.createDiv('period-section');
|
||||
// Добавляем небольшую задержку для каскадной анимации
|
||||
periodDiv.style.animationDelay = `${index * 50}ms`;
|
||||
|
||||
periodDiv.createEl('h4', {
|
||||
// Calculate total duration for this period
|
||||
const totalMs = this.groupedByPeriod[period].reduce((sum, e) => {
|
||||
return e.duration === 'ongoing' ? sum : sum + this.parseDurationToMs(e.duration);
|
||||
}, 0);
|
||||
|
||||
// Create period header with title and total
|
||||
const headerContainer = periodDiv.createDiv('timekeep-period-header');
|
||||
headerContainer.createEl('h4', {
|
||||
text: this.formatPeriodLabel(period),
|
||||
cls: 'timekeep-period-title'
|
||||
});
|
||||
headerContainer.createEl('span', {
|
||||
text: this.parseMsToDuration(totalMs),
|
||||
cls: 'timekeep-period-total'
|
||||
});
|
||||
|
||||
const entries = this.groupedByPeriod[period];
|
||||
this.createEntriesTable(periodDiv, entries);
|
||||
this.createEntriesTable(periodDiv, this.groupedByPeriod[period]);
|
||||
});
|
||||
}
|
||||
|
||||
private createEntriesTable(container: HTMLElement, entries: ProcessedEntry[]): void {
|
||||
const tableContainer = container.createDiv('timekeep-table-container');
|
||||
const table = tableContainer.createEl('table', { cls: 'timekeep-entries-table' });
|
||||
const thead = table.createEl('thead');
|
||||
const headerRow = thead.createEl('tr');
|
||||
private groupAndSummarizeEntries(entries: ProcessedEntry[]):
|
||||
Record<string, Record<string, { entries: ProcessedEntry[], totalMs: number }>>
|
||||
{
|
||||
const grouped: ReturnType<typeof this.groupAndSummarizeEntries> = {};
|
||||
|
||||
headerRow.createEl('th', { text: t("display.table.task") });
|
||||
headerRow.createEl('th', { text: t("display.table.issue_key") });
|
||||
headerRow.createEl('th', { text: t("display.table.block_path") });
|
||||
headerRow.createEl('th', { text: t("display.table.start_time") });
|
||||
headerRow.createEl('th', { text: t("display.table.duration") });
|
||||
entries.forEach(entry => {
|
||||
const file = entry.file;
|
||||
const issueKey = entry.issueKey || "no-issue";
|
||||
|
||||
const tbody = table.createEl('tbody');
|
||||
entries.forEach((entry, index) => {
|
||||
const row = tbody.createEl('tr');
|
||||
if (index % 2 === 1) row.addClass('timekeep-table-row-alt');
|
||||
|
||||
// Добавляем title для длинных текстов
|
||||
const taskCell = row.createEl('td', { text: entry.file });
|
||||
taskCell.title = entry.file;
|
||||
|
||||
const issueCell = row.createEl('td', { text: entry.issueKey || '-' });
|
||||
if (entry.issueKey) {
|
||||
issueCell.title = `Issue: ${entry.issueKey}`;
|
||||
if (!grouped[file]) grouped[file] = {};
|
||||
if (!grouped[file][issueKey]) {
|
||||
grouped[file][issueKey] = { entries: [], totalMs: 0 };
|
||||
}
|
||||
|
||||
const pathCell = row.createEl('td', { text: entry.blockPath });
|
||||
pathCell.title = entry.blockPath; // Полный путь в тултипе
|
||||
const group = grouped[file][issueKey];
|
||||
|
||||
const timeCell = row.createEl('td', { text: entry.startTime });
|
||||
timeCell.title = `Started: ${entry.startTime}`;
|
||||
if (entry.duration !== 'ongoing') {
|
||||
const ms = this.parseDurationToMs(entry.duration);
|
||||
group.totalMs += ms;
|
||||
}
|
||||
|
||||
const durationCell = row.createEl('td', { text: entry.duration });
|
||||
durationCell.title = `Duration: ${entry.duration}`;
|
||||
group.entries.push(entry);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private createEntriesTable(container: HTMLElement, entries: ProcessedEntry[]): void {
|
||||
if (!entries.length) return;
|
||||
|
||||
const grouped = this.groupAndSummarizeEntries(entries);
|
||||
const tableContainer = container.createDiv('timekeep-table-container');
|
||||
const groupCards: HTMLDivElement[] = [];
|
||||
|
||||
Object.entries(grouped).forEach(([file, issueMap]) => {
|
||||
Object.entries(issueMap).forEach(([issueKey, group]) => {
|
||||
// Create group card
|
||||
const groupCard = tableContainer.createDiv('timekeep-group-card collapsed');
|
||||
groupCards.push(groupCard);
|
||||
|
||||
// Create group header
|
||||
const groupHeader = groupCard.createDiv('timekeep-group-header');
|
||||
groupHeader.addEventListener('click', () => {
|
||||
groupCard.classList.toggle('collapsed');
|
||||
});
|
||||
|
||||
// Group title
|
||||
const groupTitle = groupHeader.createEl('div', { cls: 'timekeep-group-title' });
|
||||
groupTitle.createEl('span', { text: file });
|
||||
|
||||
// Group meta (issue key and duration)
|
||||
const groupMeta = groupHeader.createEl('div', { cls: 'timekeep-group-meta' });
|
||||
|
||||
if (issueKey && issueKey !== 'no-issue') {
|
||||
const issueKeySpan = groupMeta.createEl('a', {
|
||||
text: issueKey,
|
||||
cls: 'timekeep-group-issue-key',
|
||||
attr: {
|
||||
href: `${this.props.plugin.settings.jiraUrl}/browse/${issueKey}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
}
|
||||
});
|
||||
issueKeySpan.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
debugLog(`${this.props.plugin.settings.jiraUrl}/browse/${issueKey}`)
|
||||
}
|
||||
groupMeta.createEl('span', {
|
||||
text: this.parseMsToDuration(group.totalMs),
|
||||
cls: 'timekeep-group-duration'
|
||||
});
|
||||
|
||||
// Collapse icon
|
||||
const chevron = groupMeta.createEl('span', {
|
||||
cls: 'jira-collapse-icon',
|
||||
});
|
||||
setIcon(chevron, "chevron-down");
|
||||
|
||||
// Create entries container
|
||||
const entriesContainer = groupCard.createDiv('timekeep-entries-container');
|
||||
|
||||
// Add column labels if there are entries
|
||||
if (group.entries.length > 0) {
|
||||
const labelsDiv = entriesContainer.createDiv('timekeep-entry-labels');
|
||||
labelsDiv.createEl('div', {
|
||||
text: t("display.table.task"),
|
||||
cls: 'timekeep-entry-label-task'
|
||||
});
|
||||
labelsDiv.createEl('div', {
|
||||
text: t("display.table.start_time"),
|
||||
cls: 'timekeep-entry-label-start'
|
||||
});
|
||||
labelsDiv.createEl('div', {
|
||||
text: t("display.table.duration"),
|
||||
cls: 'timekeep-entry-label-duration'
|
||||
});
|
||||
}
|
||||
|
||||
// Individual entries
|
||||
group.entries.forEach((entry) => {
|
||||
const entryDiv = entriesContainer.createDiv('timekeep-entry');
|
||||
|
||||
// Block path
|
||||
entryDiv.createEl('div', {
|
||||
text: entry.blockPath,
|
||||
cls: 'timekeep-entry-block-path',
|
||||
title: entry.blockPath
|
||||
});
|
||||
|
||||
// Start time
|
||||
entryDiv.createEl('div', {
|
||||
text: entry.startTime,
|
||||
cls: 'timekeep-entry-start-time',
|
||||
title: entry.startTime
|
||||
});
|
||||
|
||||
// Duration
|
||||
entryDiv.createEl('div', {
|
||||
text: entry.duration,
|
||||
cls: 'timekeep-entry-duration',
|
||||
title: entry.duration
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
if (groupCards.length > 0) {
|
||||
const lastCard = groupCards[groupCards.length - 1];
|
||||
lastCard.classList.add('timekeep-last-group-card');
|
||||
}
|
||||
}
|
||||
|
||||
private async sendWorkLogToJira(): Promise<void> {
|
||||
|
|
@ -303,21 +407,28 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
}
|
||||
|
||||
private formatPeriodDate(periodKey: string): string {
|
||||
const type = this.props.plugin.settings.statisticsTimeType;
|
||||
|
||||
if (type === 'custom') {
|
||||
const from = this.props.plugin.settings.customDateRange.start;
|
||||
const to = this.props.plugin.settings.customDateRange.end;
|
||||
return `${from} – ${to}`;
|
||||
}
|
||||
|
||||
const date = new Date(periodKey);
|
||||
|
||||
switch (this.props.plugin.settings.statisticsTimeType) {
|
||||
switch (type) {
|
||||
case 'days':
|
||||
return date.toLocaleDateString();
|
||||
case 'weeks':
|
||||
return date.toISOString().split('T')[0];
|
||||
case 'weeks': {
|
||||
const weekEnd = new Date(date);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
return `${date.toLocaleDateString()} - ${weekEnd.toLocaleDateString()}`;
|
||||
return `${date.toISOString().split('T')[0]} – ${weekEnd.toISOString().split('T')[0]}`;
|
||||
}
|
||||
case 'months':
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||
case 'custom':
|
||||
return `${this.props.plugin.settings.customDateRange.start} - ${this.props.plugin.settings.customDateRange.end}`;
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
|
||||
default:
|
||||
return date.toLocaleDateString();
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -326,11 +437,12 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
case 'days':
|
||||
return date.toISOString().split('T')[0];
|
||||
case 'weeks':
|
||||
return this.getWeekStartDate(date).toISOString().split('T')[0];
|
||||
const weekStart = this.getWeekStartDate(date);
|
||||
return `week-${weekStart.toISOString().split('T')[0]}`;
|
||||
case 'months':
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1).toISOString().split('T')[0];
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
case 'custom':
|
||||
return `${this.props.plugin.settings.customDateRange.start}_${this.props.plugin.settings.customDateRange.end}`;
|
||||
return 'custom-range';
|
||||
default:
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
|
@ -343,8 +455,9 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
const from = new Date(this.props.plugin.settings.customDateRange.start);
|
||||
const to = new Date(this.props.plugin.settings.customDateRange.end);
|
||||
|
||||
return date >= from && date <= to;
|
||||
const toPlusOneDay = new Date(to);
|
||||
toPlusOneDay.setDate(to.getDate() + 1);
|
||||
return date >= from && date <= toPlusOneDay;
|
||||
}
|
||||
|
||||
private validateCustomRange(): boolean {
|
||||
|
|
@ -358,7 +471,7 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
const from = new Date(this.props.plugin.settings.customDateRange.start);
|
||||
const to = new Date(this.props.plugin.settings.customDateRange.end);
|
||||
|
||||
if (from >= to) {
|
||||
if (from > to) {
|
||||
new Notice(t("messages.invalid_date_range"));
|
||||
return false;
|
||||
}
|
||||
|
|
@ -375,8 +488,8 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
return new Date(result.setHours(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
private formatDuration(ms: number): string {
|
||||
const units = [
|
||||
private parseMsToDuration(ms: number): string {
|
||||
const units: { label: string; ms: number }[] = [
|
||||
{ label: "w", ms: 1000 * 60 * 60 * 24 * 7 },
|
||||
{ label: "d", ms: 1000 * 60 * 60 * 24 },
|
||||
{ label: "h", ms: 1000 * 60 * 60 },
|
||||
|
|
@ -398,6 +511,31 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
return result.length > 0 ? result.join(" ") : "0s";
|
||||
}
|
||||
|
||||
private parseDurationToMs(duration: string): number {
|
||||
if (duration === 'ongoing') return 0;
|
||||
|
||||
const units: { label: string; ms: number }[] = [
|
||||
{ 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 ms = 0;
|
||||
let temp = duration;
|
||||
|
||||
for (const unit of units) {
|
||||
const match = temp.match(new RegExp(`(\\d+)${unit.label}`));
|
||||
if (match) {
|
||||
ms += parseInt(match[1]) * unit.ms;
|
||||
temp = temp.replace(match[0], '');
|
||||
}
|
||||
}
|
||||
|
||||
return ms;
|
||||
}
|
||||
|
||||
private trimOldEntries(groupedEntries: GroupedEntries): void {
|
||||
if (this.props.plugin.settings.statisticsTimeType === 'custom') return;
|
||||
|
||||
|
|
@ -436,7 +574,7 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
if (entry.endTime) {
|
||||
endTime = new Date(entry.endTime);
|
||||
duration = this.formatDuration(endTime.getTime() - startTime.getTime());
|
||||
duration = this.parseMsToDuration(endTime.getTime() - startTime.getTime());
|
||||
} else {
|
||||
endTime = new Date();
|
||||
duration = "ongoing";
|
||||
|
|
@ -446,8 +584,8 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
file: fileName.replace(".md", ""),
|
||||
issueKey: issueKey,
|
||||
blockPath: currentPath,
|
||||
startTime: startTime.toLocaleString(),
|
||||
endTime: entry.endTime ? endTime.toLocaleString() : "ongoing",
|
||||
startTime: this.toLocalIso(startTime),
|
||||
endTime: entry.endTime ? this.toLocalIso(endTime) : "ongoing",
|
||||
duration: duration,
|
||||
timestamp: startTime.getTime()
|
||||
});
|
||||
|
|
@ -519,4 +657,14 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
this.periodSelector = null;
|
||||
}
|
||||
|
||||
private toUtcIso(date: Date): string {
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.replace(".000", ""); // Optional: trim millis if needed
|
||||
}
|
||||
|
||||
private toLocalIso(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export interface JiraSettings {
|
|||
statisticsTimeType: string;
|
||||
maxItemsToShow: number;
|
||||
customDateRange: { start: string; end: string };
|
||||
|
||||
// Cache for issue key to file path mapping
|
||||
issueKeyToFilePathCache: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: JiraSettings = {
|
||||
|
|
@ -47,6 +50,9 @@ export const DEFAULT_SETTINGS: JiraSettings = {
|
|||
|
||||
statisticsTimeType: "weeks",
|
||||
maxItemsToShow: 10,
|
||||
customDateRange: { start: "", end: "" }
|
||||
customDateRange: { start: "", end: "" },
|
||||
|
||||
// Initialize empty cache
|
||||
issueKeyToFilePathCache: {}
|
||||
};
|
||||
|
||||
|
|
|
|||
57
src/tools/cacheUtils.ts
Normal file
57
src/tools/cacheUtils.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import JiraPlugin from "../main";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
|
||||
/**
|
||||
* Utility functions for managing the issue key to file path cache
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build cache by scanning all markdown files in the issues folder
|
||||
* This is useful for initial cache population or cache rebuilding
|
||||
*/
|
||||
export async function buildCacheFromFilesystem(plugin: JiraPlugin): Promise<void> {
|
||||
const issuesFolder = plugin.app.vault.getAbstractFileByPath(plugin.settings.issuesFolder);
|
||||
|
||||
if (!issuesFolder || !(issuesFolder instanceof TFolder)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await scanFolderForIssueKeys(plugin, issuesFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan folder for markdown files with issue keys in frontmatter
|
||||
*/
|
||||
async function scanFolderForIssueKeys(plugin: JiraPlugin, folder: TFolder): Promise<void> {
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFile && child.extension === 'md') {
|
||||
const metadata = plugin.app.metadataCache.getFileCache(child);
|
||||
const issueKey = metadata?.frontmatter?.key;
|
||||
if (issueKey && typeof issueKey === 'string') {
|
||||
plugin.setFilePathForIssueKey(issueKey, child.path);
|
||||
}
|
||||
} else if (child instanceof TFolder) {
|
||||
await scanFolderForIssueKeys(plugin, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cache entries by checking if files still exist
|
||||
* Remove entries for files that no longer exist
|
||||
*/
|
||||
export async function validateCache(plugin: JiraPlugin): Promise<void> {
|
||||
const entriesToRemove: string[] = [];
|
||||
|
||||
for (const [issueKey, filePath] of plugin.getAllIssueKeysMap().entries()) {
|
||||
const file = plugin.app.vault.getFileByPath(filePath);
|
||||
if (!file) {
|
||||
entriesToRemove.push(issueKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove invalid entries
|
||||
entriesToRemove.forEach(issueKey => {
|
||||
plugin.removeIssueKeyFromCache(issueKey);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { JiraIssue } from "../interfaces";
|
||||
import {jiraToMarkdown} from "./markdown_html";
|
||||
import {jiraToMarkdown} from "./markdownHtml";
|
||||
import {Notice, TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {extractAllJiraSyncValuesFromContent, updateJiraSyncContent} from "./sectionTools";
|
||||
|
|
|
|||
|
|
@ -1,124 +1,114 @@
|
|||
import {debugLog} from "./debugLogging";
|
||||
import { debugLog } from "./debugLogging";
|
||||
|
||||
const getSectionRegex = (sectionName: string) => new RegExp(`\`jira-sync-section-${sectionName}\`([\\s\\S]*?)\\n?([\\s\\S]*?)(?=\\n#+ |\\n\`jira-sync-|$)`, 'g');
|
||||
const getLineRegex = (sectionName: string) => new RegExp(`\`jira-sync-line-${sectionName}\` *(.*?)(?=\\n|$)`, 'g');
|
||||
// ([w-]+)
|
||||
const regexCache = new Map<string, RegExp>();
|
||||
|
||||
function getRegex(type: "section" | "line" | "inline", name: string): RegExp {
|
||||
const key = `${type}:${name}`;
|
||||
if (regexCache.has(key)) return regexCache.get(key)!;
|
||||
|
||||
let regex: RegExp;
|
||||
switch (type) {
|
||||
case "section":
|
||||
regex = new RegExp(
|
||||
`\`jira-sync-section-${name}\`([\\s\\S]*?)\\n?([\\s\\S]*?)(?=\\n#+ |\\n[^\\n]*\`jira-sync-|$)`,
|
||||
"g"
|
||||
);
|
||||
break;
|
||||
case "line":
|
||||
regex = new RegExp(
|
||||
`\`jira-sync-line-${name}\` *(.*?)(?=\\n|$)`,
|
||||
"g"
|
||||
);
|
||||
break;
|
||||
case "inline":
|
||||
regex = new RegExp(
|
||||
`\`jira-sync-inline-start-${name}\`([\\s\\S]*?)\`jira-sync-inline-end\``,
|
||||
"g"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
regexCache.set(key, regex);
|
||||
return regex;
|
||||
}
|
||||
|
||||
function updateJiraSyncBlock(
|
||||
fileContent: string,
|
||||
type: "section" | "line" | "inline",
|
||||
name: string,
|
||||
content: string,
|
||||
force: boolean
|
||||
): string {
|
||||
const regex = getRegex(type, name);
|
||||
|
||||
let newBlock: string;
|
||||
switch (type) {
|
||||
case "section":
|
||||
newBlock = `\`jira-sync-section-${name}\`\n${content}`;
|
||||
break;
|
||||
case "line":
|
||||
newBlock = `\`jira-sync-line-${name}\`${content.split("\n")[0]}`;
|
||||
break;
|
||||
case "inline":
|
||||
newBlock = `\`jira-sync-inline-start-${name}\`${content}\`jira-sync-inline-end\``;
|
||||
break;
|
||||
}
|
||||
|
||||
const updated = fileContent.replace(regex, () => newBlock);
|
||||
if (updated !== fileContent) return updated;
|
||||
|
||||
if (force) {
|
||||
return fileContent + `\n${newBlock}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or adds a Jira sync section in the file content
|
||||
* @param fileContent - The current content of the file
|
||||
* @param sectionName - The name of the section (without jira-sync- prefix)
|
||||
* @param markdownContent - The content in markdown format
|
||||
* @param force - If true, the section will be created if it doesn't exist
|
||||
* @returns The updated file content
|
||||
*/
|
||||
export function updateJiraSyncContent(fileContent: string, sectionName: string, markdownContent: string, force: boolean = false): string {
|
||||
fileContent = updateJiraSyncSection(fileContent, sectionName, markdownContent, force);
|
||||
fileContent = updateJiraSyncLine(fileContent, sectionName, markdownContent, force);
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or adds a Jira sync section in the file content
|
||||
* @param fileContent - The current content of the file
|
||||
* @param sectionName - The name of the section (without jira-sync-section- prefix)
|
||||
* @param markdownContent - The content in markdown format
|
||||
* @param force - If true, the section will be created if it doesn't exist
|
||||
* @returns The updated file content
|
||||
*/
|
||||
function updateJiraSyncSection(fileContent: string, sectionName: string, markdownContent: string, force: boolean = false): string {
|
||||
const sectionRegex = getSectionRegex(sectionName);
|
||||
|
||||
// If section exists, update it, otherwise append it
|
||||
// debugLog(`Trying to find field ${sectionName} in section`)
|
||||
if (sectionRegex.test(fileContent)) {
|
||||
// Reset regex lastIndex
|
||||
sectionRegex.lastIndex = 0;
|
||||
return fileContent.replace(
|
||||
sectionRegex,
|
||||
(_, group1) => `\`jira-sync-section-${sectionName}\`${group1}\n${markdownContent}`
|
||||
);
|
||||
} else if (force) {
|
||||
// No section found, append it
|
||||
return fileContent + `\n\n\`jira-sync-section-${sectionName}\`\n${markdownContent}\n`;
|
||||
} else {
|
||||
return fileContent;
|
||||
}
|
||||
export function updateJiraSyncContent(
|
||||
fileContent: string,
|
||||
sectionName: string,
|
||||
markdownContent: string,
|
||||
force: boolean = false
|
||||
): string {
|
||||
fileContent = updateJiraSyncBlock(fileContent, "section", sectionName, markdownContent, force);
|
||||
fileContent = updateJiraSyncBlock(fileContent, "line", sectionName, markdownContent, force);
|
||||
fileContent = updateJiraSyncBlock(fileContent, "inline", sectionName, markdownContent, force);
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or adds a Jira sync line in the file content
|
||||
* @param fileContent - The current content of the file
|
||||
* @param lineName - The name of the line (without jira-sync-line- prefix)
|
||||
* @param lineContent - The content for the line
|
||||
* @param force - If true, the line will be created if it doesn't exist
|
||||
* @returns The updated file content
|
||||
*/
|
||||
function updateJiraSyncLine(fileContent: string, lineName: string, lineContent: string, force: boolean = false): string {
|
||||
const lineRegex = getLineRegex(lineName);
|
||||
const newLine = `\`jira-sync-line-${lineName}\` ${lineContent.split('\n')[0]}`;
|
||||
function extractValues(
|
||||
fileContent: string,
|
||||
type: "section" | "line" | "inline"
|
||||
): Record<string, string> {
|
||||
const regex = getRegex(type, "([\\w-]+)");
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
// If line exists, update it, otherwise append it
|
||||
// debugLog(`Trying to find field ${lineName} in line`)
|
||||
if (lineRegex.test(fileContent)) {
|
||||
// Reset regex lastIndex
|
||||
// debugLog(`Found a field ${lineName} in line`)
|
||||
lineRegex.lastIndex = 0;
|
||||
return fileContent.replace(lineRegex, newLine);
|
||||
} else if (force) {
|
||||
// No line found, append it
|
||||
return fileContent + `\n${newLine}`;
|
||||
} else {
|
||||
return fileContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all Jira sync sections and lines from file content
|
||||
* @param fileContent - The content of the file
|
||||
* @returns Object mapping section/line names to their content
|
||||
*/
|
||||
export function extractAllJiraSyncValuesFromContent(fileContent: string): Record<string, string> {
|
||||
const sections = extractAllJiraSyncValuesFromSections(fileContent);
|
||||
const lines = extractAllJiraSyncValuesFromLines(fileContent);
|
||||
debugLog(`extracted content from file: ${JSON.stringify({ ...sections, ...lines })}`);
|
||||
return { ...sections, ...lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all Jira sync sections from file content
|
||||
* @param fileContent - The content of the file
|
||||
* @returns Object mapping section names to their content
|
||||
*/
|
||||
function extractAllJiraSyncValuesFromSections(fileContent: string): Record<string, string> {
|
||||
const sectionRegex = getSectionRegex(`([\\w-]+)`);
|
||||
debugLog(sectionRegex);
|
||||
const sections: Record<string, string> = {};
|
||||
let match;
|
||||
while ((match = sectionRegex.exec(fileContent)) !== null) {
|
||||
// debugLog(`Found match ${JSON.stringify(match)} in section`)
|
||||
const sectionName = match[1];
|
||||
sections[sectionName] = match[3].trim();
|
||||
while ((match = regex.exec(fileContent)) !== null) {
|
||||
switch (type) {
|
||||
case "section":
|
||||
values[match[1]] = (match[3] || "").trim();
|
||||
break;
|
||||
case "line":
|
||||
values[match[1]] = (match[2] || "").trim();
|
||||
break;
|
||||
case "inline":
|
||||
values[match[1]] = (match[2] || "").trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all Jira sync lines from file content
|
||||
* @param fileContent - The content of the file
|
||||
* @returns Object mapping line names to their content
|
||||
*/
|
||||
function extractAllJiraSyncValuesFromLines(fileContent: string): Record<string, string> {
|
||||
const lineRegex = getLineRegex(`([\\w-]+)`);
|
||||
debugLog(lineRegex);
|
||||
const lines: Record<string, string> = {};
|
||||
let match;
|
||||
while ((match = lineRegex.exec(fileContent)) !== null) {
|
||||
// debugLog(`Found match ${JSON.stringify(match)} in line`)
|
||||
const lineName = match[1];
|
||||
lines[lineName] = match[2].trim();
|
||||
}
|
||||
export function extractAllJiraSyncValuesFromContent(
|
||||
fileContent: string
|
||||
): Record<string, string> {
|
||||
const sections = extractValues(fileContent, "section");
|
||||
const lines = extractValues(fileContent, "line");
|
||||
const inlines = extractValues(fileContent, "inline");
|
||||
|
||||
return lines;
|
||||
const result = { ...sections, ...lines, ...inlines };
|
||||
debugLog(`extracted content from file: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
835
styles.css
835
styles.css
|
|
@ -512,15 +512,7 @@ code.hljs {
|
|||
color: #b31d28;
|
||||
background-color: #ffeef0
|
||||
}
|
||||
.hljs-char.escape_,
|
||||
.hljs-link,
|
||||
.hljs-params,
|
||||
.hljs-property,
|
||||
.hljs-punctuation,
|
||||
.hljs-tag {
|
||||
/* purposely ignored */
|
||||
|
||||
}
|
||||
/* These highlight.js classes are intentionally left unstyled */
|
||||
|
||||
/* Period selector improvements */
|
||||
.period-selector .setting-item-control {
|
||||
|
|
@ -593,79 +585,245 @@ code.hljs {
|
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.timekeep-period-title {
|
||||
background: var(--background-secondary);
|
||||
padding: 0.875rem 1.25rem;
|
||||
.timekeep-period-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-secondary-alt) 100%);
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 0;
|
||||
color: var(--text-normal);
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
letter-spacing: 0.025em;
|
||||
border-bottom: 2px solid var(--interactive-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Table container and styling */
|
||||
.timekeep-period-title {
|
||||
color: var(--text-normal);
|
||||
/*font-weight: 700;*/
|
||||
font-size: 1.1em;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
/*text-transform: uppercase;*/
|
||||
}
|
||||
|
||||
.timekeep-period-total {
|
||||
font-family: var(--font-monospace);
|
||||
/*font-weight: 700;*/
|
||||
color: var(--text-on-accent);
|
||||
background: var(--text-selection);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Card-based container and styling */
|
||||
.timekeep-table-container {
|
||||
overflow-x: auto;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
.timekeep-entries-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875em;
|
||||
.timekeep-group-card {
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-color: transparent;
|
||||
border-radius: 8px;
|
||||
margin: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.timekeep-entries-table th {
|
||||
background: var(--background-secondary-alt);
|
||||
color: var(--text-normal);
|
||||
.timekeep-group-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.timekeep-group-header {
|
||||
background: var(--background-primary);
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
}
|
||||
|
||||
.timekeep-last-group-card .timekeep-group-header {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.timekeep-group-header:hover {
|
||||
background: var(--background-secondary);
|
||||
border-left-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.timekeep-group-title {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 2px solid var(--background-modifier-border);
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
font-size: 1em;
|
||||
color: var(--text-normal);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timekeep-entries-table td {
|
||||
padding: 0.875rem 1rem;
|
||||
.timekeep-group-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.timekeep-group-issue-key {
|
||||
font-family: var(--font-monospace);
|
||||
color: var(--interactive-accent);
|
||||
font-weight: 500;
|
||||
background: var(--background-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.timekeep-group-issue-key:hover {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.timekeep-group-duration {
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
background: var(--background-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.jira-collapse-icon {
|
||||
background-color: var(--background-secondary-alt);
|
||||
border-radius: 13px;
|
||||
transition: transform 0.2s ease;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.collapsed .jira-collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.timekeep-entries-container {
|
||||
background: var(--background-primary);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
/*transition: max-height 0.3s ease;*/
|
||||
}
|
||||
|
||||
.timekeep-group-card:not(.collapsed) .timekeep-entries-container {
|
||||
max-height: fit-content;
|
||||
}
|
||||
|
||||
.timekeep-entry {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
vertical-align: top;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.timekeep-entries-table tbody tr {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.timekeep-entries-table tbody tr:hover {
|
||||
.timekeep-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timekeep-entry:hover {
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.timekeep-table-row-alt {
|
||||
.timekeep-entry:nth-child(even) {
|
||||
background: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
.timekeep-table-row-alt:hover {
|
||||
.timekeep-entry:nth-child(even):hover {
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.timekeep-entry-block-path {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.8em;
|
||||
color: var(--text-normal);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.timekeep-entry-start-time {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-monospace);
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
text-align: right;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.timekeep-entry-duration {
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Entry labels */
|
||||
.timekeep-entry-labels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--background-secondary-alt);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.timekeep-entry-label-task {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.timekeep-entry-label-start {
|
||||
text-align: right;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.timekeep-entry-label-duration {
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Column-specific styling */
|
||||
.timekeep-entries-table td:nth-child(1) {
|
||||
.timekeep-issue-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.timekeep-entries-table td:nth-child(2) {
|
||||
.timekeep-issue-key {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85em;
|
||||
color: var(--interactive-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timekeep-entries-table td:nth-child(3) {
|
||||
.timekeep-datetime {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
max-width: 200px;
|
||||
|
|
@ -673,14 +831,15 @@ code.hljs {
|
|||
hyphens: auto;
|
||||
}
|
||||
|
||||
.timekeep-entries-table td:nth-child(4) {
|
||||
.timekeep-block-path {
|
||||
max-width: 33%;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timekeep-entries-table td:nth-child(5) {
|
||||
.timekeep-duration {
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
|
|
@ -715,21 +874,52 @@ code.hljs {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.timekeep-entries-table {
|
||||
font-size: 0.8em;
|
||||
.timekeep-group-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.timekeep-entries-table td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
.timekeep-group-meta {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timekeep-entries-table th {
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.75em;
|
||||
.timekeep-group-title {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.timekeep-entries-table td:nth-child(3) {
|
||||
max-width: 150px;
|
||||
.timekeep-entry {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.timekeep-entry-labels {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.timekeep-entry-start-time,
|
||||
.timekeep-entry-duration {
|
||||
text-align: left;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.timekeep-entry-label-start,
|
||||
.timekeep-entry-label-duration {
|
||||
text-align: left;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.timekeep-entry-block-path {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -754,3 +944,534 @@ code.hljs {
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.jira-sync-hidden:not(.jira-sync-active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-inline-code:has(> .jira-sync-hidden) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* JQL Preview Container */
|
||||
.jql-preview-container {
|
||||
margin-top: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
min-height: 120px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
/* Table wrapper for horizontal scrolling */
|
||||
.jql-preview-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.jql-preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: var(--background-primary-alt);
|
||||
color: var(--text-muted);
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.jql-preview-loading-icon {
|
||||
font-size: 2em;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.jql-preview-loading-text {
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.jql-preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: var(--background-secondary-alt);
|
||||
color: var(--text-muted);
|
||||
min-height: 120px;
|
||||
border: 1px dashed var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.jql-preview-empty-icon {
|
||||
font-size: 2em;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.jql-preview-empty-text {
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.jql-preview-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: var(--background-primary-alt);
|
||||
color: var(--text-error);
|
||||
min-height: 120px;
|
||||
border: 1px solid var(--text-error);
|
||||
border-radius: 8px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.jql-preview-error-icon {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.jql-preview-error-text {
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.jql-preview-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-secondary-alt) 100%);
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 2px solid var(--interactive-accent);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.jql-preview-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-normal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jql-preview-total-icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.jql-preview-total-text {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.jql-preview-showing {
|
||||
color: var(--text-muted);
|
||||
font-weight: normal;
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.jql-preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--background-primary);
|
||||
font-size: 0.85em;
|
||||
table-layout: auto;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.jql-preview-table tbody tr {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.jql-preview-table tbody td {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
.jql-preview-table thead {
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.jql-preview-table th {
|
||||
border-right: 1px solid var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.jql-preview-table th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Table Header Column Sizes */
|
||||
.jql-preview-th-key,
|
||||
.jql-preview-cell-key {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.jql-preview-th-icon,
|
||||
.jql-preview-cell-icon {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.jql-preview-th-summary,
|
||||
.jql-preview-cell-summary {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.jql-preview-th-date,
|
||||
.jql-preview-cell-date {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.jql-preview-th-status,
|
||||
.jql-preview-cell-status {
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Additional table styling for better appearance */
|
||||
.jql-preview-table {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jql-preview-table th {
|
||||
background: var(--background-secondary-alt);
|
||||
border-bottom: 2px solid var(--background-modifier-border);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.jql-preview-table td {
|
||||
border-right: 1px solid var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.jql-preview-table tr:hover {
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.jql-preview-table tr:nth-child(even) {
|
||||
background: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
.jql-preview-table tr:nth-child(even):hover {
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
/* Table Rows */
|
||||
.jql-preview-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Table Cells */
|
||||
.jql-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Key Cell */
|
||||
.jql-preview-cell-key {
|
||||
font-family: var(--font-monospace);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jql-preview-key-link {
|
||||
color: var(--interactive-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.jql-preview-key-link:hover {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
/* Icon Cells */
|
||||
.jql-preview-cell-icon {
|
||||
text-align: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
width: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Status Indicator */
|
||||
.jql-preview-status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.jql-preview-status-indicator.status-new {
|
||||
background: #4285f4; /* Blue */
|
||||
}
|
||||
|
||||
.jql-preview-status-indicator.status-indeterminate {
|
||||
background: #ff9800; /* Orange */
|
||||
}
|
||||
|
||||
.jql-preview-status-indicator.status-done {
|
||||
background: #4caf50; /* Green */
|
||||
}
|
||||
|
||||
.jql-preview-status-indicator.status-undefined {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Type and Priority Icons */
|
||||
.jql-preview-type-icon,
|
||||
.jql-preview-priority-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* User Avatars */
|
||||
.jql-preview-user-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.jql-preview-user-initial {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.jql-preview-cell-icon a:hover .jql-preview-user-avatar,
|
||||
.jql-preview-cell-icon a:hover .jql-preview-user-initial {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Summary Cell */
|
||||
.jql-preview-cell-summary {
|
||||
color: var(--text-normal);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Date Cells */
|
||||
.jql-preview-cell-date {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.8em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Status Cell */
|
||||
.jql-preview-cell-status {
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jql-preview-status-badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.jql-preview-status-badge.status-new {
|
||||
background: rgba(66, 133, 244, 0.1);
|
||||
color: #4285f4;
|
||||
border: 1px solid rgba(66, 133, 244, 0.3);
|
||||
}
|
||||
|
||||
.jql-preview-status-badge.status-indeterminate {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.jql-preview-status-badge.status-done {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.jql-preview-status-badge.status-default {
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.jql-preview-table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.jql-preview-table thead {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.jql-preview-table thead tr {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jql-preview-table tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jql-preview-table tr {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.jql-preview-table th,
|
||||
.jql-preview-table td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-right: 1px solid var(--background-modifier-border-hover);
|
||||
font-size: 0.8em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jql-preview-table th:last-child,
|
||||
.jql-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Column widths for mobile */
|
||||
.jql-preview-th-key,
|
||||
.jql-preview-cell-key {
|
||||
flex: 0 0 80px;
|
||||
min-width: 80px;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.jql-preview-th-summary,
|
||||
.jql-preview-cell-summary {
|
||||
flex: 1 1 120px;
|
||||
min-width: 120px;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.jql-preview-th-icon,
|
||||
.jql-preview-cell-icon {
|
||||
flex: 0 0 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jql-preview-th-date,
|
||||
.jql-preview-cell-date {
|
||||
flex: 0 0 80px;
|
||||
min-width: 80px;
|
||||
max-width: 80px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.jql-preview-th-status,
|
||||
.jql-preview-cell-status {
|
||||
flex: 0 0 120px;
|
||||
min-width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.jql-preview-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.jql-preview-total {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.5rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Ensure icons are properly centered in mobile */
|
||||
.jql-preview-cell-icon .jql-preview-user-avatar,
|
||||
.jql-preview-cell-icon .jql-preview-user-initial,
|
||||
.jql-preview-cell-icon .jql-preview-type-icon,
|
||||
.jql-preview-cell-icon .jql-preview-priority-icon,
|
||||
.jql-preview-cell-icon .jql-preview-status-indicator {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Make links more touch-friendly */
|
||||
.jql-preview-key-link,
|
||||
.jql-preview-cell-icon a {
|
||||
display: block;
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.jql-preview-key-link:hover,
|
||||
.jql-preview-cell-icon a:hover {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
yarn.lock
111
yarn.lock
|
|
@ -2,6 +2,67 @@
|
|||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@codemirror/autocomplete@^6.0.0":
|
||||
version "6.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz#de26e864a1ec8192a1b241eb86addbb612964ddb"
|
||||
integrity sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==
|
||||
dependencies:
|
||||
"@codemirror/language" "^6.0.0"
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.17.0"
|
||||
"@lezer/common" "^1.0.0"
|
||||
|
||||
"@codemirror/lang-javascript@^6.2.4":
|
||||
version "6.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz#eef2227d1892aae762f3a0f212f72bec868a02c5"
|
||||
integrity sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==
|
||||
dependencies:
|
||||
"@codemirror/autocomplete" "^6.0.0"
|
||||
"@codemirror/language" "^6.6.0"
|
||||
"@codemirror/lint" "^6.0.0"
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.17.0"
|
||||
"@lezer/common" "^1.0.0"
|
||||
"@lezer/javascript" "^1.0.0"
|
||||
|
||||
"@codemirror/language@^6.0.0", "@codemirror/language@^6.11.3", "@codemirror/language@^6.6.0":
|
||||
version "6.11.3"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.11.3.tgz#8e6632df566a7ed13a1bd307f9837765bb1abfdd"
|
||||
integrity sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.23.0"
|
||||
"@lezer/common" "^1.1.0"
|
||||
"@lezer/highlight" "^1.0.0"
|
||||
"@lezer/lr" "^1.0.0"
|
||||
style-mod "^4.0.0"
|
||||
|
||||
"@codemirror/lint@^6.0.0":
|
||||
version "6.8.5"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.5.tgz#9edaa808e764e28e07665b015951934c8ec3a418"
|
||||
integrity sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.35.0"
|
||||
crelt "^1.0.5"
|
||||
|
||||
"@codemirror/state@^6.0.0", "@codemirror/state@^6.5.0", "@codemirror/state@^6.5.2":
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.2.tgz#8eca3a64212a83367dc85475b7d78d5c9b7076c6"
|
||||
integrity sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==
|
||||
dependencies:
|
||||
"@marijn/find-cluster-break" "^1.0.0"
|
||||
|
||||
"@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.38.1":
|
||||
version "6.38.1"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.38.1.tgz#74214434351719ec0710431363a85f7a01e80a73"
|
||||
integrity sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.5.0"
|
||||
crelt "^1.0.6"
|
||||
style-mod "^4.1.0"
|
||||
w3c-keyname "^2.2.4"
|
||||
|
||||
"@esbuild/aix-ppc64@0.25.0":
|
||||
version "0.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64"
|
||||
|
|
@ -127,6 +188,39 @@
|
|||
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz"
|
||||
integrity sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==
|
||||
|
||||
"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.3.tgz#138fcddab157d83da557554851017c6c1e5667fd"
|
||||
integrity sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==
|
||||
|
||||
"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.1.tgz#596fa8f9aeb58a608be0a563e960c373cbf23f8b"
|
||||
integrity sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==
|
||||
dependencies:
|
||||
"@lezer/common" "^1.0.0"
|
||||
|
||||
"@lezer/javascript@^1.0.0":
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.5.1.tgz#2a424a6ec29f1d4ef3c34cbccc5447e373618ad8"
|
||||
integrity sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==
|
||||
dependencies:
|
||||
"@lezer/common" "^1.2.0"
|
||||
"@lezer/highlight" "^1.1.3"
|
||||
"@lezer/lr" "^1.3.0"
|
||||
|
||||
"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727"
|
||||
integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==
|
||||
dependencies:
|
||||
"@lezer/common" "^1.0.0"
|
||||
|
||||
"@marijn/find-cluster-break@^1.0.0":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
|
||||
integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
|
||||
|
||||
"@nodelib/fs.scandir@2.1.5":
|
||||
version "2.1.5"
|
||||
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|
||||
|
|
@ -291,6 +385,11 @@ chokidar@^4.0.3:
|
|||
dependencies:
|
||||
readdirp "^4.0.1"
|
||||
|
||||
crelt@^1.0.5, crelt@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
|
||||
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
|
||||
|
||||
debug@^4.3.4:
|
||||
version "4.3.7"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz"
|
||||
|
|
@ -507,7 +606,7 @@ ms@^2.1.3:
|
|||
|
||||
obsidian@latest:
|
||||
version "1.8.7"
|
||||
resolved "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz"
|
||||
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.8.7.tgz#601e9ea1724289effa4c9bb3b4e20d327263634f"
|
||||
integrity sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==
|
||||
dependencies:
|
||||
"@types/codemirror" "5.60.8"
|
||||
|
|
@ -560,6 +659,11 @@ slash@^3.0.0:
|
|||
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
|
||||
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
|
||||
|
||||
style-mod@^4.0.0, style-mod@^4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
|
||||
integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
|
||||
|
||||
to-regex-range@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
|
||||
|
|
@ -594,6 +698,11 @@ universalify@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
|
||||
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
||||
|
||||
w3c-keyname@^2.2.4:
|
||||
version "2.2.8"
|
||||
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
|
||||
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
|
||||
|
||||
yaml@^2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz"
|
||||
|
|
|
|||
Loading…
Reference in a new issue