mirror of
https://github.com/alamion/obsidian-jira-sync.git
synced 2026-07-22 12:20:29 +00:00
Compare commits
37 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294a18b37f | ||
|
|
3365e6ba5d | ||
|
|
a1deb0e95a | ||
|
|
59f863eb97 | ||
|
|
f77c6ea3f6 | ||
|
|
57fa0d1873 | ||
|
|
2aeb8c1bee | ||
|
|
3278de8748 | ||
|
|
ad06c8f4d8 | ||
|
|
e3a2a01270 | ||
|
|
d4d9746647 | ||
|
|
f259446b74 | ||
|
|
5229b2d1a5 | ||
|
|
a3211bcdb2 | ||
|
|
75dd9d75cd | ||
|
|
d45309743a | ||
|
|
6e35db6254 | ||
|
|
f50873d925 | ||
|
|
bca0cae215 | ||
|
|
0786db1535 | ||
|
|
a3d2c67da3 | ||
|
|
ed988fe101 | ||
|
|
0639461a08 | ||
|
|
4958fc6a3c | ||
|
|
5df851934d | ||
|
|
1b400e7e55 | ||
|
|
ad11b3e89a | ||
|
|
e99e14c043 | ||
|
|
aa6e36ba5d | ||
|
|
5199ad333f | ||
|
|
3169273f5e | ||
|
|
827a28f3ed | ||
|
|
1562f4ff52 | ||
|
|
f4732263f8 | ||
|
|
45f9c297ed | ||
|
|
75f9277387 | ||
|
|
1a14ac37c7 |
127 changed files with 6865 additions and 5777 deletions
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
23
.eslintrc
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
|
|
@ -1,34 +1,34 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
- name: Build plugin
|
||||
run: |
|
||||
yarn install --frozen-lockfile
|
||||
yarn run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -11,7 +11,10 @@ node_modules
|
|||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
*.js
|
||||
*.cjs
|
||||
!eslint.config.js
|
||||
!/src/localization/**/*.js
|
||||
/src/localization/translator.js
|
||||
!check-version.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
|
|
@ -28,3 +31,9 @@ tmp/
|
|||
|
||||
# localization compiled
|
||||
src/localization/compiled
|
||||
|
||||
# test configuration with credentials
|
||||
tests/test-config.local.json
|
||||
|
||||
# test configuration with credentials
|
||||
tests/test-config.local.json
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
node check-version.js
|
||||
yarn run format
|
||||
yarn run lint:fix
|
||||
yarn run test
|
||||
yarn run build
|
||||
yarn run validate_locale_once
|
||||
118
.opencode/skills/jira-sync/SKILL.md
Normal file
118
.opencode/skills/jira-sync/SKILL.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
name: jira-sync
|
||||
description: >-
|
||||
Project-specific knowledge for the Jira Issue Manager plugin.
|
||||
Covers architecture, known bugs, commands, cache, localization,
|
||||
field mapping, indicator system, build/test commands, and release process.
|
||||
Use this skill when implementing features, fixing bugs, or
|
||||
navigating the codebase.
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
plugin-id: jira-sync
|
||||
display-name: Jira Issue Manager
|
||||
entry: src/main.ts
|
||||
---
|
||||
|
||||
# Jira Sync — Project-specific knowledge
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Plugin ID**: `jira-sync`, display name: "Jira Issue Manager"
|
||||
- **Entry**: `src/main.ts` → `JiraPlugin extends Plugin`
|
||||
- **10 commands** registered via `register*Command` wrappers in `src/commands/`
|
||||
- **Settings**: `src/settings/default.ts` — `JiraSettingsInterface` with connections, field mappings, cache
|
||||
- **API layer**: `src/api/base.ts` — `baseRequest(plugin, method, path)` constructs `{jiraUrl}/rest/api/{version}{path}`
|
||||
- **Multi-connection**: `settings.connections[]`, `currentConnectionIndex`
|
||||
|
||||
## Known bugs
|
||||
|
||||
- `src/tools/debugLogging.ts:1` — `process.env.NODE_ENV` is always `undefined` in Obsidian (no Node.js runtime). Logging is **always on** in both dev and production. Prefix: `[DEBUG]`, `[DEBUG WARNING]`, `[DEBUG ERROR]`.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Plugin lifecycle, cache init, command/settings registration
|
||||
├── api/ # Jira REST API (base, auth, issues, projects, self)
|
||||
├── commands/ # 10 user-facing commands
|
||||
├── modals/ # UI dialogs (search, JQL, worklog, project/type/status selectors)
|
||||
├── settings/ # Settings tab + components (connection, field mapping, timekeep)
|
||||
├── file_operations/ # File read/write for issue notes
|
||||
├── tools/ # Utilities (logging, cache, files, field mapping, sanitizers, etc.)
|
||||
├── postprocessing/ # Live Preview (CM6) + Reading mode hide-jira-markers
|
||||
├── default/ # Default template, mock issue, built-in field mappings
|
||||
├── interfaces/ # TypeScript types
|
||||
└── localization/ # i18n (en/ru YAML sources → compiled JSON)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| ID | Name |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| `get-issue-jira` | Get current issue from Jira |
|
||||
| `get-issue-jira-key` | Get issue from Jira with custom key |
|
||||
| `create-issue-jira` | Create issue in Jira |
|
||||
| `update-issue-jira` | Update issue in Jira |
|
||||
| `update-issue-status-jira` | Update issue status in Jira |
|
||||
| `update-work-log-jira-manually` | Update work log manually |
|
||||
| `update-work-log-jira-batch` | Update work log by batch |
|
||||
| `batch-fetch-issues-jira` | Batch Fetch Issues by JQL |
|
||||
| `add-comment-jira` | Add comment to Jira |
|
||||
| `rebuild-issue-cache` | Rebuild issue file cache from filesystem |
|
||||
|
||||
## Cache system
|
||||
|
||||
- In-memory `Map<string, string>` in `JiraPlugin` instance
|
||||
- Persisted in `settings.issueKeyToFilePathCache`
|
||||
- Vault event listeners for maintenance: `rename` updates path, `delete` removes entry
|
||||
- `rebuildCache()` scans issues folder, reads frontmatter `key` fields
|
||||
|
||||
## Localization
|
||||
|
||||
- Sources: `src/localization/source/{lang}/*.yaml` → compiled to `src/localization/compiled/{lang}.json`
|
||||
- Locale auto-detected from `window.localStorage.getItem('language')`, falls back to `en`
|
||||
- Build commands: `yarn run compile_locale_once`, `yarn run validate_locale_once`
|
||||
|
||||
## Field mapping system
|
||||
|
||||
- User-defined JS arrow-functions stored as strings in `fieldMappingsStrings`
|
||||
- Validated via `acorn` parser, evaluated via `new Function()` with sandboxed context
|
||||
- Helpers available: `jiraToMarkdown`, `markdownToJira`, `markdownToAdf`, `adfToMarkdown`, `JSON`, `Math`, `Date`
|
||||
- Built-in mappings in `src/default/obsidianJiraFieldsMapping.ts`
|
||||
|
||||
## Indicator system
|
||||
|
||||
Three marker types in notes (hidden in both Live Preview and Reading mode):
|
||||
|
||||
- **Section**: `` `jira-sync-section-fieldname` `` — content until next heading
|
||||
- **Line**: `` `jira-sync-line-fieldname` `` — content on same line
|
||||
- **Inline**: `` `jira-sync-inline-start-fieldname` content `jira-sync-end` ``
|
||||
|
||||
## Build commands
|
||||
|
||||
```bash
|
||||
yarn install # Install
|
||||
yarn run dev # Watch mode
|
||||
yarn run build # Production (compile_locale → tsc check → esbuild)
|
||||
yarn run format # Prettier
|
||||
yarn run lint:fix # ESLint autofix
|
||||
yarn run test # Vitest unit tests
|
||||
yarn run test:int # API integration tests
|
||||
```
|
||||
|
||||
## Pre-commit hooks (husky)
|
||||
|
||||
Currently runs `node check-version.js` — validates version consistency across files.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `yarn run version` bumps via `version-bump.mjs` (syncs `package.json` → `manifest.json` + `versions.json`)
|
||||
- GitHub release tag = version string (no leading `v`)
|
||||
- Attach: `main.js`, `manifest.json`, `styles.css` (if present)
|
||||
|
||||
## Testing
|
||||
|
||||
- `yarn run test` — Vitest, config at `tests/vitest.config.ts`
|
||||
- `yarn run test:int` — API integration tests at `tests/__tests__/api/`
|
||||
- Manual: copy `main.js`, `manifest.json`, `styles.css` to `<vault>/.obsidian/plugins/jira-sync/`
|
||||
54
.opencode/skills/obsidian-plugin/SKILL.md
Normal file
54
.opencode/skills/obsidian-plugin/SKILL.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
name: obsidian-plugin
|
||||
description: >-
|
||||
General Obsidian plugin development conventions — bundling with esbuild,
|
||||
manifest structure, plugin lifecycle (onload/onunload), settings persistence,
|
||||
and coding patterns for community plugins. Use when writing or modifying
|
||||
any Obsidian plugin.
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
---
|
||||
|
||||
# Obsidian Plugin Development
|
||||
|
||||
## Plugin anatomy
|
||||
|
||||
- `Plugin` class from `obsidian` package with `onload()` / `onunload()` lifecycle
|
||||
- Entry: `src/main.ts`, output: `main.js` (bundled via esbuild)
|
||||
- Required artifacts: `main.js`, `manifest.json`, optional `styles.css`
|
||||
- Settings: `this.loadData()` / `this.saveData()` (JSON persisted by Obsidian)
|
||||
|
||||
## Bundling
|
||||
|
||||
- **esbuild**: bundles all source + runtime deps into single `main.js`
|
||||
- Externals: `obsidian`, `electron`, `@codemirror/*`, `@lezer/*`, built-in Node modules
|
||||
- Format: CJS, target: ES2018
|
||||
- Sourcemaps in dev only
|
||||
|
||||
## Manifest (`manifest.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "stable-id", // Never change after release
|
||||
"name": "Display Name",
|
||||
"version": "x.y.z", // SemVer
|
||||
"minAppVersion": "1.x.x",
|
||||
"description": "...",
|
||||
"author": "...",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
- Use `this.register*` helpers (`registerEvent`, `registerDomEvent`, `registerInterval`, `registerEditorExtension`) for automatic cleanup on unload
|
||||
- Never leak event listeners or intervals
|
||||
|
||||
## Code conventions
|
||||
|
||||
- TypeScript with `strict: true`
|
||||
- `src/main.ts` minimal — delegate to modules
|
||||
- `async/await` over promises
|
||||
- No comments unless explicitly requested
|
||||
- Bundle all deps — no unbundled runtime dependencies
|
||||
- Prefer `checkCallback` for commands (cleaner UX)
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"semi": true
|
||||
}
|
||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
||||
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Jira Issue Manager — Agent guide
|
||||
|
||||
## Project facts
|
||||
|
||||
- **Plugin ID**: `jira-sync`, display name: "Jira Issue Manager"
|
||||
- **Entry**: `src/main.ts` → `main.js` (bundled via esbuild)
|
||||
- **Package manager**: yarn (required)
|
||||
- **Author**: Alamion, min App Version: 1.10.1, mobile-compatible
|
||||
|
||||
## Known bugs
|
||||
|
||||
- `src/tools/debugLogging.ts:1` — `process.env.NODE_ENV` is always `undefined` in Obsidian (no Node.js). Logging is **always on**. Uses `console.log('[DEBUG]', ...)` etc.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Concern | Location |
|
||||
| -------------- | -------------------------------------------------------------------------------------- |
|
||||
| Commands | `src/commands/*.ts` — 10 commands, each in `register*Command(plugin)` |
|
||||
| API requests | `src/api/base.ts` — `baseRequest(plugin, method, path)` |
|
||||
| Settings | `src/settings/default.ts` — `JiraSettingsInterface`, persisted via `loadData/saveData` |
|
||||
| Modals | `src/modals/*.ts` — search, JQL, worklog, project/type/status selectors |
|
||||
| Cache | `Map<string, string>` in `JiraPlugin`, persisted in `settings.issueKeyToFilePathCache` |
|
||||
| i18n | `src/localization/` — YAML sources → compiled JSON, auto-detect locale |
|
||||
| Field mappings | JS arrow-function strings in `fieldMappingsStrings`, validated with `acorn` |
|
||||
| View markers | `` `jira-sync-section-*` ``, `` `jira-sync-line-*` ``, `` `jira-sync-inline-*` `` |
|
||||
|
||||
## Important patterns
|
||||
|
||||
- Commands use `checkCallback` (except `add-comment-jira` which uses `editorCheckCallback`, and `rebuild-issue-cache` which uses `callback`)
|
||||
- Always use `this.register*` helpers for listeners/events — never leak
|
||||
- Use `this.getCurrentConnection()` to access active Jira connection
|
||||
- Cache is maintained via vault `rename`/`delete` event listeners
|
||||
- Migration check runs in `loadSettings()` — `checkMigrateSettings()` handles old single-connection → multi-connection migration
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # lifecycle, cache, registration
|
||||
├── api/ # Jira REST API layer
|
||||
├── commands/ # user commands
|
||||
├── modals/ # UI dialogs
|
||||
├── settings/ # settings tab + components
|
||||
├── file_operations/ # note read/write
|
||||
├── tools/ # utilities
|
||||
├── postprocessing/ # view rendering (Live Preview + Reading)
|
||||
├── default/ # defaults (template, mock issue, field mappings)
|
||||
├── interfaces/ # TypeScript types
|
||||
└── localization/ # i18n
|
||||
```
|
||||
|
||||
For general Obsidian plugin development conventions and project-specific details, see `.opencode/skills/`.
|
||||
64
CONTRIBUTING.md
Normal file
64
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Contributing
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
yarn run dev # Watch mode (esbuild + localization)
|
||||
yarn run build # Production build
|
||||
yarn run format # Prettier formatting
|
||||
yarn run lint:fix # ESLint autofix
|
||||
yarn run test # Unit tests (Vitest)
|
||||
yarn run test:int # API integration tests
|
||||
yarn run validate_locale_once # Validate translation keys
|
||||
yarn run compile_locale_once # Compile YAML -> JSON
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Source in `src/`**, keep `main.ts` focused on lifecycle
|
||||
- **No comments** in code unless the logic is non-obvious
|
||||
- **Bundle everything** — no unbundled runtime dependencies
|
||||
- **Use `this.register*`** helpers for all listeners and intervals
|
||||
- **Stable command IDs** — never rename after release
|
||||
- **Follow existing patterns** — look at similar files first
|
||||
- **Prefer `checkCallback`** for commands (cleaner UX)
|
||||
- **Async/await** over promise chains
|
||||
- **Handle errors gracefully** — no silent failures
|
||||
|
||||
## Pre-commit checks
|
||||
|
||||
The repo uses husky. Currently runs `node check-version.js` to validate version consistency across `package.json`, `manifest.json`, and `versions.json`.
|
||||
|
||||
## Versioning
|
||||
|
||||
Run `yarn run version` to bump — this syncs `package.json` → `manifest.json` + `versions.json` via `version-bump.mjs`. Semantic versioning (x.y.z).
|
||||
|
||||
## Release
|
||||
|
||||
1. Bump version
|
||||
2. Build: `yarn run build`
|
||||
3. Create GitHub release with tag matching `manifest.json` version (no leading `v`)
|
||||
4. Attach: `main.js`, `manifest.json`, `styles.css` (if changed)
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `yarn run test`
|
||||
- Integration tests: `yarn run test:int` (requires a running Jira instance)
|
||||
- Manual test: copy `main.js`, `manifest.json`, `styles.css` to `<vault>/.obsidian/plugins/jira-sync/` and reload Obsidian
|
||||
|
||||
## Translation / i18n
|
||||
|
||||
- Source files: `src/localization/source/{lang}/*.yaml` (one YAML per component)
|
||||
- Compile: `yarn run compile_locale_once`
|
||||
- Validate: `yarn run validate_locale_once` (checks all keys exist and are referenced in code)
|
||||
- Locale is auto-detected from `window.localStorage.getItem('language')`, falls back to `en`
|
||||
|
||||
## Getting help
|
||||
|
||||
Open an issue on GitHub for bugs, feature requests, or questions.
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Alamion
|
||||
Copyright (c) 2025-2026 Alamion
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
166
README.md
166
README.md
|
|
@ -1,142 +1,52 @@
|
|||
# Jira Sync Plugin for Obsidian
|
||||
# Jira Issue Manager
|
||||
|
||||
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.
|
||||
A two-way Jira synchronization plugin for Obsidian. Templates, custom field mapping, time tracking, and batch operations — all without leaving your notes.
|
||||
|
||||
Originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira), but now packed with features you won't find elsewhere.
|
||||
https://github.com/user-attachments/assets/55cb2c99-34a9-47fc-85f1-f79dba5a27b1
|
||||
|
||||
## Why this plugin exists
|
||||
## Features
|
||||
|
||||
Other plugins treat Jira issues as read-only reference material. This one lets you:
|
||||
- **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
|
||||
- **Full two-way sync** — fetch issues from Jira, edit in Obsidian, push changes back
|
||||
- **Custom templates** — structure your issue notes however you want using `jira-sync-section`, `jira-sync-line`, and `jira-sync-inline` markers that are hidden from view
|
||||
- **Deep field mapping** — sync any Jira field, including custom fields from ScriptRunner, Insight, and other add-ons. Built-in mappings for 15+ standard fields, with support for custom JavaScript transform functions
|
||||
- **Time tracking** — built-in work log statistics and batch submission. Supports Timekeep and Super Simple Time Tracker formats
|
||||
- **Status management** — transition issues through workflows directly from Obsidian
|
||||
- **Multiple authentication methods** — Bearer Token (PAT), Basic Auth, or Session Cookie
|
||||
- **Multi-connection support** — work with multiple Jira instances
|
||||
- **JQL search and batch fetch** — find and pull in issues by query
|
||||
- **Inline comments** — add comments to Jira issues with text selection support
|
||||
|
||||
> **Pro Tip**: While TypeScript skills help for advanced mappings, we include ready-made mappings for 90% of basic use cases.
|
||||

|
||||
|
||||
## Killer features
|
||||
|
||||
### 🧩 Your Jira, your template
|
||||
Create Obsidian notes that look exactly like your team's Jira workflow. Pull in:
|
||||
- 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: ""
|
||||
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 -->
|
||||
|
||||
|
||||
### 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, 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 → Choose authentication method + add your Jira URL + credentials
|
||||
3. **Go**:
|
||||
- `Get issue from Jira with custom key` from command palette
|
||||
- Edit like any note using our flexible indicator system
|
||||
- When you are ready → `Update issue in Jira` - changes sync back
|
||||
|
||||
## 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
|
||||
1. Install from Community Plugins → search "Jira Issue Manager"
|
||||
2. Open Settings → configure Jira URL and authentication
|
||||
3. Use `Get issue from Jira with custom key` from the command palette
|
||||
4. Edit your note — changes sync back with `Update issue in Jira`
|
||||
|
||||
## 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)
|
||||
- [English guide](docs/how_to_en.md)
|
||||
- [Russian guide](docs/how_to_ru.md)
|
||||
- [Template examples](docs/template_example.md)
|
||||
|
||||
---
|
||||
## Why this one?
|
||||
|
||||
**Ready to transform your Jira workflow?** Install the plugin and start building your perfect issue management system in Obsidian today!
|
||||
Most Jira plugins for Obsidian treat issues as read-only. This one is built for people who want to work _in_ Obsidian and push changes back to Jira. Key differentiators:
|
||||
|
||||
- True two-way synchronization (not just display)
|
||||
- Support for arbitrary custom fields with programmable mapping
|
||||
- Integrated time tracking and batch work log submission
|
||||
- No external dependencies for statistics or time tracking
|
||||
- Markers are hidden in both Live Preview and Reading mode
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
MIT — originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira).
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: ['docs/statistics.ts'],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
globalName: 'exports',
|
||||
outfile: 'docs/statistics.min.js',
|
||||
write: false,
|
||||
target: 'es2018',
|
||||
});
|
||||
|
||||
const minifiedCode = new TextDecoder().decode(result.outputFiles[0].contents);
|
||||
|
||||
const dataviewBlock =`---
|
||||
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
|
||||
${minifiedCode}
|
||||
exports.runTimekeep();
|
||||
\`\`\`
|
||||
Select a week to sent work log to Jira. Always reselect before pressing the button`;
|
||||
|
||||
fs.writeFileSync('docs/statistics.md', dataviewBlock);
|
||||
console.log('Build completed successfully!');
|
||||
console.log(`Minified size: ${minifiedCode.length} bytes`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
build();
|
||||
|
|
@ -2,15 +2,14 @@
|
|||
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const stagedFiles = execSync('git diff --cached --name-only', { encoding: 'utf-8' })
|
||||
const stagedFiles = execSync('git diff --cached --name-only', {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
|
||||
const hasCodeChanges = stagedFiles.some(file =>
|
||||
file.endsWith('.ts') ||
|
||||
file.endsWith('.tsx')
|
||||
);
|
||||
const hasCodeChanges = stagedFiles.some((file) => file.endsWith('.ts') || file.endsWith('.tsx'));
|
||||
|
||||
const manifestChanged = stagedFiles.includes('manifest.json');
|
||||
|
||||
|
|
@ -26,7 +25,9 @@ if (hasCodeChanges && !manifestChanged) {
|
|||
|
||||
if (manifestChanged && hasCodeChanges) {
|
||||
try {
|
||||
const manifestDiff = execSync('git diff --cached manifest.json', { encoding: 'utf-8' });
|
||||
const manifestDiff = execSync('git diff --cached manifest.json', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
if (!manifestDiff.includes('"version"')) {
|
||||
console.error('\x1b[33m⚠️ manifest.json has been modified, but the version has not been updated!\x1b[0m');
|
||||
|
|
@ -36,8 +37,7 @@ if (manifestChanged && hasCodeChanges) {
|
|||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
console.log('\x1b[32m✓ Version check passed\x1b[0m');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
The functionality of the plugin can be divided into several parts.
|
||||
|
||||
### Basic Functionality
|
||||
|
||||
For basic functionality, it is enough to specify Jira credentials and the folder for created tasks.
|
||||
|
||||
**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
|
||||
|
|
@ -16,47 +18,60 @@ When running `Get issue from Jira with custom key`, the command will download th
|
|||
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:
|
||||
|
||||
##### Frontmatter
|
||||
|
||||
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.
|
||||
|
||||
##### Body
|
||||
The main content of the page. When using indicators like `jira-sync-"type"-*`, they will be filled with the corresponding fields from the response data.
|
||||
|
||||
The difference between these options is as follows:
|
||||
The main content of the page. When using indicators like `jira-sync-"type"-*`, they will be filled with the corresponding fields from the response data.
|
||||
|
||||
- `line` reads and writes values from the current line.
|
||||
The difference between these options is as follows:
|
||||
|
||||
- `line` reads and writes values from the current line.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
The responsible person for this task is `jira-sync-line-assignee` Bob
|
||||
The description is `jira-sync-line-description` Some description
|
||||
```
|
||||
|
||||
- `section` reads values from multiple lines after the indicator, stopping only at any other indicator or a heading.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
### Responsible `jira-sync-section-assignee`
|
||||
|
||||
Bob
|
||||
|
||||
### Description `jira-sync-section-description`
|
||||
|
||||
Some description
|
||||
`jira-sync-section-customfield_10842`
|
||||
...
|
||||
```
|
||||
|
||||
- `inline` allows indicator of start and end to be placed anywhere in the text. Must have an ending part of indicator (`jira-sync-end`).
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
The responsible person for this task is `jira-sync-inline-start-assignee`Bob`jira-sync-end`, and the description is `jira-sync-inline-start-description`Some description`jira-sync-end`.
|
||||
```
|
||||
|
||||
- `block` is basically the same as `inline`, but with line break before and after indicators.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
The responsible person for this task is `jira-sync-block-assignee`
|
||||
The responsible person for this task is `jira-sync-block-start-assignee`
|
||||
Bob
|
||||
`jira-sync-end`, and the description is `jira-sync-block-description`
|
||||
`jira-sync-end`, and the description is `jira-sync-block-start-description`
|
||||
Some description
|
||||
`jira-sync-end`.
|
||||
```
|
||||
|
|
@ -72,6 +87,7 @@ Not all fields are predefined, and some may need adjustments. To add any custom
|
|||
### Commands
|
||||
|
||||
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).
|
||||
|
|
@ -80,23 +96,30 @@ Currently, the plugin provides the following commands:
|
|||
- `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.
|
||||
- `Add comment to Jira` - allows adding a comment to the current task in Jira.
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
#### Field Mapping
|
||||
|
||||
In the settings, you can configure custom mapping for any additional fields received from Jira. To do this:
|
||||
|
||||
- Configure how information is sent to Jira (e.g., with the `null` function, the field will be ignored).
|
||||
- Define how it is received from Jira (e.g., `issue.fields.creator.name` will retrieve the creator's name instead of the entire object with related data).
|
||||
|
||||
Similarly, you can configure the `progressPercentage` shown in the example. This field does not exist in the response, but it can be "assembled" from the existing `progress` field: `issue.fields.progress.total ? 100 * issue.fields.progress.progress / issue.fields.progress.total : 0`. As seen in the syntax, mapping uses a simplified TypeScript format.
|
||||
|
||||
Additionally, you can use some of the built-in functions:
|
||||
- `jiraToMarkdown` - converts Jira markup to Markdown.
|
||||
- `markdownToJira` - converts Markdown to Jira markup.
|
||||
|
||||
- `jiraToMarkdown` - converts Jira markup to Markdown. (for Jira API v2)
|
||||
- `markdownToJira` - converts Markdown to Jira markup. (for Jira API v2)
|
||||
- `markdownToAdf` - converts Markdown to Atlassian Document Format (ADF). (for Jira API v3)
|
||||
- `adfToMarkdown` - converts ADF to Markdown. (for Jira API v3)
|
||||
- `JSON.parse` - converts a JSON string to an object.
|
||||
- `JSON.stringify` - converts an object to a JSON string.
|
||||
|
||||
and modules: (original Javascript syntax)
|
||||
and modules: (original JavaScript syntax)
|
||||
|
||||
- `Math` - provides access to mathematical functions, such as `Math.round()`.
|
||||
- `Date` - provides access to date functions, such as `Date.now()`.
|
||||
- `String` - provides access to string functions, such as `String.fromCharCode()`.
|
||||
|
|
@ -109,8 +132,10 @@ Field mapping can like this:
|
|||
|
||||

|
||||
|
||||
#### Statistics
|
||||
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.
|
||||
#### Time Tracking Statistics
|
||||
|
||||
Statistics 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.
|
||||
The statistics feature supports timekeep and simple-time-tracker time tracking formats.
|
||||
|
||||
The table looks like this:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
Функционал плагина можно разделить на несколько частей.
|
||||
|
||||
### Базовый функционал
|
||||
|
||||
Для него достаточно указать креды Jira и папку с создаваемыми задачами.
|
||||
|
||||
**Методы аутентификации**
|
||||
Плагин поддерживает три метода аутентификации:
|
||||
|
||||
- **Bearer Token (PAT)** - аутентификация по персональному токену доступа
|
||||
- **Basic Auth (Username + PAT)** - имя пользователя с персональным токеном доступа
|
||||
- **Session Cookie (Username + Password)** - традиционная аутентификация по имени пользователя и паролю
|
||||
|
|
@ -16,12 +18,15 @@
|
|||
Без шаблона такая страничка будет полностью пустой за исключением её названия, так что шаблон настоятельно рекомендуется к использованию. Шаблон используется только при создании новой страницы.
|
||||
|
||||
#### Шаблон
|
||||
|
||||
Шаблон можно составить из нескольких разных частей:
|
||||
|
||||
##### Frontmatter
|
||||
|
||||
Оно же метаинформация сверху экрана. При указании ключей для него и любом варианте `Get issues from Jira` они будут заполняться соответствующими полями из ответных данных.
|
||||
|
||||
##### Body
|
||||
|
||||
Основное содержание страницы. При использовании индикаторов типа `jira-sync-"type"-*` они будут заполняться соответствующими полями из сырых данных задачи.
|
||||
|
||||
Разница между этими параметрами заключается в следующем:
|
||||
|
|
@ -29,34 +34,43 @@
|
|||
- `line` считывает и записывает значения из текущей строки.
|
||||
|
||||
Пример:
|
||||
|
||||
```md
|
||||
Ответственным за эту задачу является `jira-sync-line-assignee` Боб.
|
||||
Описание: `jira-sync-line-description` Некоторое описание.
|
||||
```
|
||||
|
||||
- `section` считывает значения из нескольких строк после индикатора, останавливаясь только на любом другом индикаторе или заголовке.
|
||||
|
||||
Пример:
|
||||
|
||||
```md
|
||||
### Ответственный `jira-sync-section-assignee`
|
||||
|
||||
Боб
|
||||
|
||||
### Описание `jira-sync-section-description`
|
||||
|
||||
Некоторое описание
|
||||
`jira-sync-section-customfield_10842`...
|
||||
|
||||
```
|
||||
|
||||
- `inline` позволяет размещать индикатор начала и окончания в любом месте текста. Должна быть конечная часть индикатора (`jira-sync-end`).
|
||||
|
||||
Пример:
|
||||
|
||||
```md
|
||||
Ответственным за эту задачу является `jira-sync-inline-start-assignee`Боб`jira-sync-end`, а описание — `jira-sync-inline-start-description`Некоторое описание`jira-sync-end`.
|
||||
```
|
||||
|
||||
- `block` в основном аналогичен `inline`, но с разрывом строки перед и после индикаторов.
|
||||
|
||||
Пример:
|
||||
|
||||
```md
|
||||
Ответственным за эту задачу является `jira-sync-block-assignee`
|
||||
Ответственным за эту задачу является `jira-sync-block-start-assignee`
|
||||
Bob
|
||||
`jira-sync-end`, а описание — `jira-sync-block-description`
|
||||
`jira-sync-end`, а описание — `jira-sync-block-start-description`
|
||||
Некоторое описание
|
||||
`jira-sync-end`.
|
||||
```
|
||||
|
|
@ -72,6 +86,7 @@ Bob
|
|||
### Команды
|
||||
|
||||
На текущий момент плагин предоставляет следующие команды:
|
||||
|
||||
- `Get issue from Jira with custom key` - позволяет создать в папке, указанной в настройках, файл, импортирующий информацию из Jira по указанному вручную id.
|
||||
- `Batch Fetch Issues by JQL` - позволяет получить задачи по JQL и создать/обновить все соответствующие заметки.
|
||||
- `Get current issue from Jira` - позволяет обновить активный файл, если в его formatter указан key - id задачи Jira.
|
||||
|
|
@ -80,23 +95,30 @@ Bob
|
|||
- `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` - позволяет обновить статус задачи, выбрав один из возможных вариантов.
|
||||
- `Add comment to Jira` - позволяет добавить комментарий в выбранную задачу.
|
||||
|
||||
### Продвинутое использование
|
||||
|
||||
#### Маппинг полей
|
||||
|
||||
В настройках можно задать кастомный маппинг дл любых дополнительных полей, приходящих из запроса. Для этого нужно:
|
||||
|
||||
- Настроить в каком виде информация отправляется в Jira (например при функции `null` поле будет игнорироваться)
|
||||
- В каком виде получается из Jira (например `issue.fields.creator.name` позволит получать соответственное имя создателя запроса, а не весь объект с информацией о нём в целом.)
|
||||
|
||||
Таким же образом можно настроить показанный в примере `progressPercentage` - такого поля в запросе не существует, но его можно 'собрать' из существующего `progress`: `issue.fields.progress.total ? 100 * issue.fields.progress.progress / issue.fields.progress.total : 0`. Как понятно из синтаксиса, для маппинга используется обрезанный TypeScript
|
||||
|
||||
Кроме того, вы можете использовать некоторые встроенные функции:
|
||||
- `jiraToMarkdown` - преобразует разметку Jira в Markdown.
|
||||
- `markdownToJira` - преобразует Markdown в разметку Jira.
|
||||
|
||||
- `jiraToMarkdown` - преобразует разметку Jira в Markdown. (Jira API v2)
|
||||
- `markdownToJira` - преобразует Markdown в разметку Jira. (Jira API v2)
|
||||
- `markdownToAdf` - преобразует Markdown в ADF. (Jira API v3)
|
||||
- `adfToMarkdown` - преобразует ADF в Markdown. (Jira API v3)
|
||||
- `JSON.parse` - преобразует строку JSON в объект.
|
||||
- `JSON.stringify` - преобразует объект в строку JSON.
|
||||
|
||||
и модули: (исходный синтаксис Javascript)
|
||||
и модули: (исходный синтаксис JavaScript)
|
||||
|
||||
- `Math` - предоставляет доступ к математическим функциям, таким как `Math.round()`.
|
||||
- `Date` - предоставляет доступ к функциям даты, таким как `Date.now()`.
|
||||
- `String` - предоставляет доступ к функциям строки, таким как `String.fromCharCode()`.
|
||||
|
|
@ -107,11 +129,14 @@ Bob
|
|||
|
||||
Секция маппинга может выглядеть так:
|
||||
|
||||

|
||||

|
||||
|
||||
#### Статистика
|
||||
Статистика теперь интегрирована в плагин и доступна через настройки плагина в секции "Timekeep work log statistics" ("Статистика ведения журнала работы через Timekeep"). Эта функция предоставляет динамически генерируемую таблицу (или ряд таблиц), показывающую статистику работы и позволяющую отправлять журналы работ в Jira. Там же можно удобно выбирать временные промежутки для которых проводится расчёт и передавать информацию ведения журнала работы в Jira.
|
||||
#### Статистика ведения журнала работы
|
||||
|
||||
Выглядит табличка так:
|
||||
Статистика доступна через настройки плагина в секции "Timekeep work log statistics" ("Статистика ведения журнала работы через Timekeep"). Эта функция предоставляет динамически генерируемую таблицу (или ряд таблиц), показывающую статистику работы и позволяющую отправлять журналы работ в Jira. Там же можно удобно выбирать временные промежутки для которых проводится расчёт и передавать информацию ведения журнала работы в Jira.
|
||||
|
||||

|
||||
Поддерживаются форматы временных записей: timekeep и simple-time-tracker.
|
||||
|
||||
Выглядит табличка так:
|
||||
|
||||

|
||||
|
|
|
|||
BIN
docs/images/demo_image.png
Normal file
BIN
docs/images/demo_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 887 KiB |
BIN
docs/images/demo_video.mp4
Normal file
BIN
docs/images/demo_video.mp4
Normal file
Binary file not shown.
BIN
docs/images/field_mapping_demo.png
Normal file
BIN
docs/images/field_mapping_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
|
|
@ -12,6 +12,7 @@ link: http://jira.local:8000/browse/JIR-1234
|
|||
This is a comprehensive test file for the jira-sync parser.
|
||||
|
||||
## Summary `jira-sync-section-summary`
|
||||
|
||||
Complete API standardization and consolidation across all microservices to improve developer experience and reduce integration complexity.
|
||||
|
||||
This section continues here with more details.
|
||||
|
|
@ -20,7 +21,9 @@ Multiple paragraphs are supported.
|
|||
## Details Section
|
||||
|
||||
### Customer Impact `jira-sync-section-customfield_10842`
|
||||
|
||||
The current API structure is too fragmented and requires standardization. Customers are experiencing difficulties integrating with our services due to:
|
||||
|
||||
- Inconsistent authentication methods
|
||||
- Different response formats
|
||||
- Lack of versioning strategy
|
||||
|
|
@ -28,6 +31,7 @@ The current API structure is too fragmented and requires standardization. Custom
|
|||
This impacts approximately 2,300 enterprise customers.
|
||||
|
||||
### Next Heading Should Stop Section
|
||||
|
||||
This content should NOT be part of customfield_10842.
|
||||
|
||||
## Assignment Info
|
||||
|
|
@ -45,6 +49,7 @@ Actual time logged so far: `jira-sync-line-timeSpent`2d 4h
|
|||
Remaining estimate: `jira-sync-line-remainingEstimate`5d
|
||||
|
||||
## Acceptance Criteria `jira-sync-section-customfield_10100`
|
||||
|
||||
1. All endpoints follow RESTful conventions
|
||||
2. API versioning implemented via headers
|
||||
3. Consistent error handling across services
|
||||
|
|
@ -54,6 +59,7 @@ Remaining estimate: `jira-sync-line-remainingEstimate`5d
|
|||
All criteria must be met before moving to Done.
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
This is a separate section that shouldn't be included above.
|
||||
|
||||
## Code Block Examples
|
||||
|
|
@ -80,20 +86,21 @@ ssl: true
|
|||
## Edge Cases Testing
|
||||
|
||||
### Empty Inline
|
||||
|
||||
Status indicator: `jira-sync-inline-start-status_indicator``jira-sync-end`
|
||||
|
||||
### Whitespace Heavy Section `jira-sync-section-whitespace_test`
|
||||
|
||||
|
||||
This section has leading and trailing whitespace
|
||||
|
||||
This section has leading and trailing whitespace
|
||||
|
||||
Should be trimmed properly before sending to Jira.
|
||||
|
||||
### Single Line Section `jira-sync-section-oneliner`
|
||||
|
||||
Just one line here.
|
||||
|
||||
## Multi-line Inline Test
|
||||
Complex description: `jira-sync-inline-start-description` This is a description
|
||||
|
||||
Complex description: `jira-sync-inline-start-description` This is a description
|
||||
that spans multiple lines
|
||||
with various formatting `jira-sync-inline-end` and continues here.
|
||||
with various formatting `jira-sync-inline-end` and continues here.
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import esbuild from 'esbuild';
|
||||
import process from 'process';
|
||||
import builtins from 'builtin-modules';
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
const prod = process.argv[2] === 'production';
|
||||
|
||||
console.log("Esbuild context initialized:", prod);
|
||||
console.log('Esbuild context initialized:', prod);
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
entryPoints: ['src/main.ts'],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: prod ? "info" : "debug",
|
||||
sourcemap: prod ? false : "inline",
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
...builtins,
|
||||
],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: prod ? 'info' : 'debug',
|
||||
sourcemap: prod ? false : 'inline',
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
outfile: 'main.js',
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
|
|
|
|||
34
eslint.config.js
Normal file
34
eslint.config.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import js from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ['main.js', 'node_modules/**', 'src/**/*.js', 'tests/**'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json',
|
||||
ecmaVersion: 'latest',
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
{
|
||||
"id": "jira-sync",
|
||||
"name": "Jira Issue Manager",
|
||||
"version": "1.4.4",
|
||||
"version": "1.7.4",
|
||||
"minAppVersion": "1.10.1",
|
||||
"description": "Get Jira issues, create and update them. Issue status and worklog management.",
|
||||
"author": "Alamion",
|
||||
"authorUrl": "https://github.com/Alamion",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
2418
package-lock.json
generated
2418
package-lock.json
generated
File diff suppressed because it is too large
Load diff
29
package.json
29
package.json
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.4.4",
|
||||
"name": "obsidian-jira-sync",
|
||||
"version": "1.7.4",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -11,7 +12,13 @@
|
|||
"dev": "concurrently --kill-others-on-fail \"node esbuild.config.mjs\" \"yarn run compile_locale\"",
|
||||
"build": "yarn run compile_locale_once && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --config tests/vitest.config.ts",
|
||||
"test:watch": "vitest --config tests/vitest.config.ts",
|
||||
"test:int": "vitest run --config tests/vitest.config.ts tests/__tests__/api/",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
@ -19,20 +26,26 @@
|
|||
"devDependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"builtin-modules": "3.3.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"esbuild": "0.25.0",
|
||||
"eslint": "^10.2.1",
|
||||
"globals": "^17.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"obsidian": "latest",
|
||||
"prettier": "^3.8.3",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.1",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { Notice, requestUrl } from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
|
||||
export function getSessionCookieKey(plugin: JiraPlugin): string {
|
||||
return "jira-issue-managing-session-cookie-" + (plugin.app as any).appId;
|
||||
}
|
||||
import { Notice, requestUrl } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
|
||||
// Session cookie only
|
||||
export async function authenticate(plugin: JiraPlugin): Promise<boolean> {
|
||||
|
|
@ -12,38 +8,40 @@ export async function authenticate(plugin: JiraPlugin): Promise<boolean> {
|
|||
return false;
|
||||
}
|
||||
|
||||
const conn = plugin.getCurrentConnection();
|
||||
if (!conn) return false;
|
||||
|
||||
const response = await requestUrl({
|
||||
url: `${plugin.settings.connection.jiraUrl}/rest/auth/1/session`,
|
||||
method: "post",
|
||||
url: `${conn.jiraUrl}/rest/auth/1/session`,
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
username: plugin.settings.connection.username,
|
||||
password: plugin.settings.connection.password,
|
||||
username: conn.username,
|
||||
password: conn.password,
|
||||
}),
|
||||
contentType: "application/json",
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Origin: plugin.settings.connection.jiraUrl,
|
||||
'Content-type': 'application/json',
|
||||
Origin: conn.jiraUrl,
|
||||
},
|
||||
});
|
||||
|
||||
localStorage.setItem(
|
||||
getSessionCookieKey(plugin),
|
||||
response.json.session.value
|
||||
);
|
||||
conn.sessionCookie = response.json.session.value;
|
||||
await plugin.saveSettings();
|
||||
return true;
|
||||
} catch (error) {
|
||||
new Notice("Authentication failed: " + (error.message || "Unknown error"));
|
||||
} catch (error: unknown) {
|
||||
new Notice('Authentication failed: ' + ((error as Error).message || 'Unknown error'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSettings(plugin: JiraPlugin): boolean {
|
||||
if (!plugin.settings.connection.jiraUrl) {
|
||||
new Notice("Please configure Jira URL in plugin settings");
|
||||
const conn = plugin.getCurrentConnection();
|
||||
if (!conn || !conn.jiraUrl) {
|
||||
new Notice('Please configure Jira URL in plugin settings');
|
||||
return false;
|
||||
}
|
||||
if ((!plugin.settings.connection.username || !plugin.settings.connection.password) && !plugin.settings.connection.apiToken) {
|
||||
new Notice("Please configure Jira username and password or PAT API token in plugin settings");
|
||||
if ((!conn.username || !conn.password) && !conn.apiToken) {
|
||||
new Notice('Please configure Jira username and password or PAT API token in plugin settings');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -58,17 +56,20 @@ function createBasicAuthHeader(email: string, token: string): string {
|
|||
}
|
||||
|
||||
export async function getAuthHeaders(plugin: JiraPlugin): Promise<Record<string, string>> {
|
||||
const conn = plugin.getCurrentConnection();
|
||||
if (!conn) throw new Error('No connection configured');
|
||||
|
||||
// Common headers
|
||||
const commonHeaders = {
|
||||
"Content-type": "application/json",
|
||||
Origin: plugin.settings.connection.jiraUrl,
|
||||
'Content-type': 'application/json',
|
||||
Origin: conn.jiraUrl,
|
||||
};
|
||||
|
||||
// Personal Access Token
|
||||
const pat = plugin.settings.connection.apiToken?.trim() || "";
|
||||
const pat = conn.apiToken?.trim() || '';
|
||||
|
||||
// Option 1: Bearer token (PAT)
|
||||
if (plugin.settings.connection.authMethod === "bearer" || !plugin.settings.connection.authMethod) {
|
||||
if (conn.authMethod === 'bearer' || !conn.authMethod) {
|
||||
return {
|
||||
...commonHeaders,
|
||||
Authorization: `Bearer ${pat}`,
|
||||
|
|
@ -76,19 +77,17 @@ export async function getAuthHeaders(plugin: JiraPlugin): Promise<Record<string,
|
|||
}
|
||||
|
||||
// Option 2: Basic Auth with API token
|
||||
else if (plugin.settings.connection.authMethod === "basic") {
|
||||
else if (conn.authMethod === 'basic') {
|
||||
return {
|
||||
...commonHeaders,
|
||||
Authorization: createBasicAuthHeader(plugin.settings.connection.email || "", pat),
|
||||
Authorization: createBasicAuthHeader(conn.email || '', pat),
|
||||
};
|
||||
}
|
||||
|
||||
else if (plugin.settings.connection.authMethod === "session") {
|
||||
} else if (conn.authMethod === 'session') {
|
||||
// Option 3: Session cookie
|
||||
let cookie = localStorage.getItem(getSessionCookieKey(plugin));
|
||||
let cookie = conn.sessionCookie;
|
||||
if (!cookie) {
|
||||
await authenticate(plugin);
|
||||
cookie = localStorage.getItem(getSessionCookieKey(plugin));
|
||||
cookie = conn.sessionCookie;
|
||||
}
|
||||
|
||||
if (cookie) {
|
||||
|
|
@ -100,5 +99,5 @@ export async function getAuthHeaders(plugin: JiraPlugin): Promise<Record<string,
|
|||
}
|
||||
|
||||
// No valid auth method found
|
||||
throw new Error("No valid authentication method available");
|
||||
throw new Error('No valid authentication method available');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {requestUrl} from "obsidian";
|
||||
import {authenticate, getAuthHeaders} from "./auth";
|
||||
import {debugLog} from "../tools/debugLogging";
|
||||
import JiraPlugin from '../main';
|
||||
import { requestUrl } from 'obsidian';
|
||||
import { authenticate, getAuthHeaders } from './auth';
|
||||
import { debugLog } from '../tools/debugLogging';
|
||||
|
||||
export async function baseRequest(
|
||||
plugin: JiraPlugin,
|
||||
|
|
@ -9,53 +9,59 @@ export async function baseRequest(
|
|||
additional_url_path: string,
|
||||
body?: string,
|
||||
params?: Record<string, any>,
|
||||
retries: number = 1
|
||||
retries: number = 1,
|
||||
): Promise<any> {
|
||||
const queryString = params && Object.keys(params).length > 0 ? '?' + new URLSearchParams(params).toString() : '';
|
||||
|
||||
const queryString = params && Object.keys(params).length > 0
|
||||
? "?" + new URLSearchParams(params).toString()
|
||||
: "";
|
||||
const conn = plugin.getCurrentConnection();
|
||||
if (!conn) throw new Error('No connection configured');
|
||||
|
||||
const url = `${plugin.settings.connection.jiraUrl}/rest/api/${plugin.settings.connection.apiVersion}${additional_url_path}${queryString}`;
|
||||
const url = `${conn.jiraUrl}/rest/api/${conn.apiVersion}${additional_url_path}${queryString}`;
|
||||
const requestParams = {
|
||||
url,
|
||||
method: method,
|
||||
headers: await getAuthHeaders(plugin),
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
contentType: 'application/json',
|
||||
accept: 'application/json',
|
||||
throw: false,
|
||||
body
|
||||
}
|
||||
debugLog("Request:\n", requestParams)
|
||||
body,
|
||||
};
|
||||
debugLog('Request:\n', requestParams);
|
||||
const response = await requestUrl(requestParams);
|
||||
debugLog("Response:\n", response);
|
||||
debugLog('Response:\n', response);
|
||||
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, params, 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 || ""}`);
|
||||
${response.text || 'Unknown error'}
|
||||
${(body && '\nbody:' + body) || ''}`);
|
||||
}
|
||||
debugLog("Additional request info:\n", {"url": url,
|
||||
"method": method, "body": body, params: params});
|
||||
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>);
|
||||
} 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,9 +1,9 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {JiraIssue, JiraTransitionType} from "../interfaces";
|
||||
import {baseRequest, sanitizeObject} from "./base";
|
||||
import {Notice} from "obsidian";
|
||||
import {chunkArray, createLimiter} from "../tools/asyncLimiter";
|
||||
|
||||
import JiraPlugin from '../main';
|
||||
import { JiraIssue, JiraTransitionType } from '../interfaces';
|
||||
import { baseRequest, sanitizeObject } from './base';
|
||||
import { Notice } from 'obsidian';
|
||||
import { chunkArray, createLimiter } from '../tools/asyncLimiter';
|
||||
import { markdownToAdf } from '../tools/markdownToAdf';
|
||||
|
||||
/**
|
||||
* Fetch an issue from Jira by its key
|
||||
|
|
@ -12,10 +12,10 @@ import {chunkArray, createLimiter} from "../tools/asyncLimiter";
|
|||
* @returns The issue data
|
||||
*/
|
||||
export async function fetchIssue(plugin: JiraPlugin, issueKey: string): Promise<JiraIssue> {
|
||||
return await baseRequest(plugin, 'get', `/issue/${issueKey}`, undefined, {
|
||||
fields: plugin.settings.fetchIssue.fields || ["*all"],
|
||||
expand: plugin.settings.fetchIssue.expand.join(",") || "",
|
||||
}) as Promise<JiraIssue>;
|
||||
return (await baseRequest(plugin, 'get', `/issue/${issueKey}`, undefined, {
|
||||
fields: plugin.settings.fetchIssue.fields || ['*all'],
|
||||
expand: plugin.settings.fetchIssue.expand.join(',') || '',
|
||||
})) as Promise<JiraIssue>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -26,21 +26,22 @@ export async function fetchIssuesByJQL(
|
|||
plugin: JiraPlugin,
|
||||
jql: string,
|
||||
limit?: number,
|
||||
fields?: string[]
|
||||
fields?: string[],
|
||||
): Promise<JiraIssue[]> {
|
||||
let totalAvailable = 0;
|
||||
let useNextPageToken = false;
|
||||
|
||||
switch (plugin.settings.connection.apiVersion) {
|
||||
case "2":
|
||||
switch (plugin.getCurrentConnection()?.apiVersion) {
|
||||
case '2': {
|
||||
const result = await fetchIssuesByJQLRaw(plugin, jql, 1, fields);
|
||||
totalAvailable = result.total;
|
||||
break;
|
||||
case "3":
|
||||
}
|
||||
case '3':
|
||||
totalAvailable = await fetchCountIssuesByJQL(plugin, jql);
|
||||
useNextPageToken = true;
|
||||
if (!fields) {
|
||||
fields = ["*all"];
|
||||
fields = ['*all'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -101,7 +102,6 @@ export async function fetchIssuesByJQL(
|
|||
// return results.flat();
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* Fetch issues by JQL and return the raw search response (includes total, startAt, etc.).
|
||||
* Useful for previewing results and counts.
|
||||
|
|
@ -121,23 +121,27 @@ export async function fetchIssuesByJQLRaw(
|
|||
nextPageToken,
|
||||
fields: fields && fields.length > 0 ? fields : plugin.settings.fetchIssue.fields || [],
|
||||
};
|
||||
if (plugin.settings.connection.apiVersion === "3") {
|
||||
if (plugin.getCurrentConnection()?.apiVersion === '3') {
|
||||
requestBody.nextPageToken = nextPageToken;
|
||||
requestBody.expand = plugin.settings.fetchIssue.expand.join(",") || "";
|
||||
requestBody.expand = plugin.settings.fetchIssue.expand.join(',') || '';
|
||||
} else {
|
||||
requestBody.startAt = startAt;
|
||||
requestBody.expand = plugin.settings.fetchIssue.expand || [];
|
||||
}
|
||||
const body = JSON.stringify(sanitizeObject(requestBody));
|
||||
return await baseRequest(plugin, 'post', `/search${plugin.settings.connection.apiVersion === "3" ? "/jql" : ""}`, body);
|
||||
return await baseRequest(
|
||||
plugin,
|
||||
'post',
|
||||
`/search${plugin.getCurrentConnection()?.apiVersion === '3' ? '/jql' : ''}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCountIssuesByJQL(plugin: JiraPlugin, jql: string): Promise<number> {
|
||||
const body = JSON.stringify(sanitizeObject({ jql }));
|
||||
return (await baseRequest(plugin, 'post', "/search/approximate-count", body)).count;
|
||||
return (await baseRequest(plugin, 'post', '/search/approximate-count', body)).count;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch an issue from Jira by its key
|
||||
* @param plugin The plugin instance
|
||||
|
|
@ -146,10 +150,10 @@ export async function fetchCountIssuesByJQL(plugin: JiraPlugin, jql: string): Pr
|
|||
*/
|
||||
export async function fetchIssueTransitions(plugin: JiraPlugin, issueKey: string): Promise<JiraTransitionType[]> {
|
||||
const result = await baseRequest(plugin, 'get', `/issue/${issueKey}/transitions`);
|
||||
return result.transitions.map(({ id, name, to }: { id: string; name: string; to: { name: string }}) => ({
|
||||
return result.transitions.map(({ id, name, to }: { id: string; name: string; to: { name: string } }) => ({
|
||||
id,
|
||||
action: name,
|
||||
status: to.name
|
||||
status: to.name,
|
||||
})) as JiraTransitionType[];
|
||||
}
|
||||
|
||||
|
|
@ -162,48 +166,37 @@ export async function fetchIssueTransitions(plugin: JiraPlugin, issueKey: string
|
|||
export async function updateJiraIssue(
|
||||
plugin: JiraPlugin,
|
||||
issueKey: string,
|
||||
fields: Record<string, any>
|
||||
fields: Record<string, any>,
|
||||
): Promise<JiraIssue> {
|
||||
const body = JSON.stringify({ fields })
|
||||
return await baseRequest(plugin, 'put', `/issue/${issueKey}`, body) as Promise<JiraIssue>;
|
||||
const body = JSON.stringify({ fields });
|
||||
return (await baseRequest(plugin, 'put', `/issue/${issueKey}`, body)) as Promise<JiraIssue>;
|
||||
}
|
||||
|
||||
|
||||
export async function bulkUpdateJiraIssues(
|
||||
plugin: JiraPlugin,
|
||||
updates: { issueKey: string, fields: Record<string, any> }[]
|
||||
updates: { issueKey: string; fields: Record<string, any> }[],
|
||||
): Promise<any[]> {
|
||||
const limit = createLimiter(5);
|
||||
const promises = updates.map(update =>
|
||||
limit(() => updateJiraIssue(plugin, update.issueKey, update.fields))
|
||||
);
|
||||
const promises = updates.map((update) => limit(() => updateJiraIssue(plugin, update.issueKey, update.fields)));
|
||||
return await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new issue in Jira
|
||||
* @param plugin The plugin instance
|
||||
* @param fields The fields for the new issue
|
||||
* @returns The created issue key
|
||||
*/
|
||||
export async function createJiraIssue(
|
||||
plugin: JiraPlugin,
|
||||
fields: Record<string, any>
|
||||
): Promise<JiraIssue> {
|
||||
export async function createJiraIssue(plugin: JiraPlugin, fields: Record<string, any>): Promise<JiraIssue> {
|
||||
// Ensure required fields are present
|
||||
if (!fields.project || !fields.issuetype || !fields.summary) {
|
||||
throw new Error("Missing required fields: project, issuetype, and summary are required");
|
||||
throw new Error('Missing required fields: project, issuetype, and summary are required');
|
||||
}
|
||||
const body = JSON.stringify({ fields })
|
||||
return await baseRequest(plugin, 'post', `/issue/`, body) as Promise<JiraIssue>;
|
||||
const body = JSON.stringify({ fields });
|
||||
return (await baseRequest(plugin, 'post', `/issue/`, body)) as Promise<JiraIssue>;
|
||||
}
|
||||
|
||||
|
||||
export async function bulkCreateJiraIssues(
|
||||
plugin: JiraPlugin,
|
||||
issues: Record<string, any>[]
|
||||
): Promise<any[]> {
|
||||
export async function bulkCreateJiraIssues(plugin: JiraPlugin, issues: Record<string, any>[]): Promise<any[]> {
|
||||
const chunks = chunkArray(issues, 50);
|
||||
let results: any[] = [];
|
||||
for (const chunk of chunks) {
|
||||
|
|
@ -214,23 +207,17 @@ export async function bulkCreateJiraIssues(
|
|||
return results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update an issue status in Jira
|
||||
* @param plugin The plugin instance
|
||||
* @param issueKey The issue key to update
|
||||
* @param status The status to update the issue to
|
||||
*/
|
||||
export async function updateJiraStatus(
|
||||
plugin: JiraPlugin,
|
||||
issueKey: string,
|
||||
status: string
|
||||
): Promise<JiraIssue> {
|
||||
const body = JSON.stringify({ transition: { id: status } })
|
||||
return await baseRequest(plugin, 'post', `/issue/${issueKey}/transitions`, body) as Promise<JiraIssue>;
|
||||
export async function updateJiraStatus(plugin: JiraPlugin, issueKey: string, status: string): Promise<JiraIssue> {
|
||||
const body = JSON.stringify({ transition: { id: status } });
|
||||
return (await baseRequest(plugin, 'post', `/issue/${issueKey}/transitions`, body)) as Promise<JiraIssue>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a work log entry to a Jira issue
|
||||
*/
|
||||
|
|
@ -239,13 +226,13 @@ export async function addWorkLog(
|
|||
issueKey: string,
|
||||
timeSpent: string,
|
||||
startedAt: string,
|
||||
comment: string = "",
|
||||
showNotice: boolean = true
|
||||
comment: string = '',
|
||||
showNotice: boolean = true,
|
||||
): Promise<any> {
|
||||
const payload = {
|
||||
timeSpent,
|
||||
started: startedAt,
|
||||
comment
|
||||
comment,
|
||||
};
|
||||
const response = await baseRequest(plugin, 'post', `/issue/${issueKey}/worklog`, JSON.stringify(payload));
|
||||
|
||||
|
|
@ -256,15 +243,40 @@ export async function addWorkLog(
|
|||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to a Jira issue
|
||||
*/
|
||||
export async function addComment(plugin: JiraPlugin, issueKey: string, markdownText: string): Promise<any> {
|
||||
let body: any;
|
||||
const conn = plugin.getCurrentConnection();
|
||||
if (!conn) throw new Error('No connection configured');
|
||||
|
||||
if (conn.apiVersion === '3') {
|
||||
body = markdownToAdf(markdownText);
|
||||
} else {
|
||||
body = markdownText;
|
||||
}
|
||||
const payload = JSON.stringify({ body });
|
||||
const response = await baseRequest(plugin, 'post', `/issue/${issueKey}/comment`, payload);
|
||||
new Notice(`Comment added to ${issueKey}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function bulkAddWorkLog(
|
||||
plugin: JiraPlugin,
|
||||
worklogs: { issueKey: string, timeSpent: string, startedAt: string, comment: string }[],
|
||||
showNotice: boolean = true
|
||||
worklogs: {
|
||||
issueKey: string;
|
||||
timeSpent: string;
|
||||
startedAt: string;
|
||||
comment: string;
|
||||
}[],
|
||||
showNotice: boolean = true,
|
||||
): Promise<any[]> {
|
||||
const limit = createLimiter(5);
|
||||
const promises = worklogs.map(worklog =>
|
||||
limit(() => addWorkLog(plugin, worklog.issueKey, worklog.timeSpent, worklog.startedAt, worklog.comment, showNotice))
|
||||
const promises = worklogs.map((worklog) =>
|
||||
limit(() =>
|
||||
addWorkLog(plugin, worklog.issueKey, worklog.timeSpent, worklog.startedAt, worklog.comment, showNotice),
|
||||
),
|
||||
);
|
||||
return await Promise.allSettled(promises);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {baseRequest} from "./base";
|
||||
import JiraPlugin from '../main';
|
||||
import { baseRequest } from './base';
|
||||
|
||||
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<Record<string, any>> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {baseRequest} from "./base";
|
||||
import JiraPlugin from '../main';
|
||||
import { baseRequest } from './base';
|
||||
|
||||
export async function fetchSelf(plugin: JiraPlugin): Promise<Record<string, any>[]> {
|
||||
return await baseRequest(plugin, 'get', '/myself');
|
||||
|
||||
}
|
||||
|
|
|
|||
36
src/commands/addComment.ts
Normal file
36
src/commands/addComment.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { IssueCommentModal } from '../modals';
|
||||
import { addComment, validateSettings } from '../api';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations('commands.add_comment').t;
|
||||
|
||||
export function registerAddCommentCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'add-comment-jira',
|
||||
name: t('name'),
|
||||
editorCheckCallback: (checking: boolean, editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
|
||||
if (!validateSettings(plugin)) return false;
|
||||
if (!ctx.file) return false;
|
||||
|
||||
const frontmatter = plugin.app.metadataCache.getFileCache(ctx.file)?.frontmatter;
|
||||
const issueKey = frontmatter?.key;
|
||||
if (!issueKey) return false;
|
||||
|
||||
if (!checking) {
|
||||
const selectedText = editor.getSelection();
|
||||
new IssueCommentModal(plugin.app, selectedText, async (commentText: string) => {
|
||||
try {
|
||||
await addComment(plugin, issueKey, commentText);
|
||||
} catch (error: any) {
|
||||
new Notice(t('error') + ': ' + (error.message || 'Unknown error'));
|
||||
console.error(error);
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,18 +1,24 @@
|
|||
import {Notice, TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {addWorkLog} from "../api";
|
||||
import {debugLog} from "../tools/debugLogging";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { addWorkLog } from '../api';
|
||||
import { debugLog } from '../tools/debugLogging';
|
||||
import { checkCommandCallback } from '../tools/checkCommandCallback';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("commands.add_worklog.batch").t;
|
||||
const t = useTranslations('commands.add_worklog.batch').t;
|
||||
|
||||
export function registerUpdateWorkLogBatchCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "update-work-log-jira-batch",
|
||||
name: t("name"),
|
||||
id: 'update-work-log-jira-batch',
|
||||
name: t('name'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, processFrontmatterWorkLogs, ["jira_worklog_batch"], ["jira_worklog_batch"]);
|
||||
return checkCommandCallback(
|
||||
plugin,
|
||||
checking,
|
||||
processFrontmatterWorkLogs,
|
||||
['jira_worklog_batch'],
|
||||
['jira_worklog_batch'],
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -32,20 +38,20 @@ async function processFrontmatterWorkLogs(plugin: JiraPlugin, _: TFile, jira_wor
|
|||
} else {
|
||||
throw new TypeError(`jira_worklog_batch has an invalid type: ${typeof jira_worklog_batch}`);
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice("Failed to parse jira_worklog_batch");
|
||||
console.error("Failed to parse jira_worklog_batch:", error);
|
||||
} catch (error: unknown) {
|
||||
new Notice('Failed to parse jira_worklog_batch');
|
||||
console.error('Failed to parse jira_worklog_batch:', error);
|
||||
}
|
||||
|
||||
if (!foundData || workLogData.length === 0) {
|
||||
new Notice("No work log data to process");
|
||||
console.warn("No work log data to process");
|
||||
new Notice('No work log data to process');
|
||||
console.warn('No work log data to process');
|
||||
return;
|
||||
}
|
||||
await processWorkLogBatch(plugin, workLogData);
|
||||
} catch (error) {
|
||||
console.error("Error processing frontmatter work logs:", error);
|
||||
new Notice("Failed to process work log data from frontmatter");
|
||||
} catch (error: unknown) {
|
||||
console.error('Error processing frontmatter work logs:', error);
|
||||
new Notice('Failed to process work log data from frontmatter');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +59,7 @@ export async function processWorkLogBatch(plugin: JiraPlugin, workLogs: any[]):
|
|||
const results = {
|
||||
success: 0,
|
||||
total: workLogs.length,
|
||||
failures: [] as { reason: string; issueKeys: string[] }[]
|
||||
failures: [] as { reason: string; issueKeys: string[] }[],
|
||||
};
|
||||
|
||||
for (const workLog of workLogs) {
|
||||
|
|
@ -61,48 +67,56 @@ export async function processWorkLogBatch(plugin: JiraPlugin, workLogs: any[]):
|
|||
const { issueKey, startTime, duration, comment } = workLog;
|
||||
|
||||
if (!issueKey || !startTime || !duration) {
|
||||
addFailure(results, "Missing required fields", issueKey || "unknown");
|
||||
addFailure(results, 'Missing required fields', issueKey || 'unknown');
|
||||
continue;
|
||||
}
|
||||
|
||||
const startDate = convertToStandardDate(startTime);
|
||||
if (!startDate) {
|
||||
addFailure(results, "Invalid start time format", issueKey);
|
||||
addFailure(results, 'Invalid start time format', issueKey);
|
||||
continue;
|
||||
}
|
||||
const parsed_duration = parseDuration(duration);
|
||||
debugLog(`Duration: ${duration}, Parsed duration: ${parsed_duration}`);
|
||||
if (parsed_duration === '') {
|
||||
addFailure(results, "Duration must be at least 1 minute", issueKey);
|
||||
addFailure(results, 'Duration must be at least 1 minute', issueKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
await addWorkLog(plugin, issueKey, parsed_duration, startDate, comment || "", false);
|
||||
await addWorkLog(plugin, issueKey, parsed_duration, startDate, comment || '', false);
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
addFailure(results, error.message || "Unknown error", workLog.issueKey || "unknown");
|
||||
} catch (error: unknown) {
|
||||
addFailure(results, (error as Error).message || 'Unknown error', workLog.issueKey || 'unknown');
|
||||
}
|
||||
}
|
||||
|
||||
showResults(results);
|
||||
}
|
||||
|
||||
function showResults(results: { success: number, total: number, failures: { reason: string; issueKeys: string[] }[] }): void {
|
||||
function showResults(results: {
|
||||
success: number;
|
||||
total: number;
|
||||
failures: { reason: string; issueKeys: string[] }[];
|
||||
}): void {
|
||||
new Notice(`Work logs updated: ${results.success}/${results.total} succeeded`);
|
||||
|
||||
if (results.failures.length > 0) {
|
||||
console.error("Work log update failures:", results.failures);
|
||||
console.error('Work log update failures:', results.failures);
|
||||
|
||||
results.failures.forEach(failure => {
|
||||
results.failures.forEach((failure) => {
|
||||
if (failure.issueKeys.length > 0) {
|
||||
new Notice(`Failed: ${failure.reason} for issues: ${failure.issueKeys.join(", ")}`);
|
||||
new Notice(`Failed: ${failure.reason} for issues: ${failure.issueKeys.join(', ')}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addFailure(results: { failures: { reason: string; issueKeys: string[] }[] }, reason: string, issueKey: string): void {
|
||||
const existingFailure = results.failures.find(f => f.reason === reason);
|
||||
function addFailure(
|
||||
results: { failures: { reason: string; issueKeys: string[] }[] },
|
||||
reason: string,
|
||||
issueKey: string,
|
||||
): void {
|
||||
const existingFailure = results.failures.find((f) => f.reason === reason);
|
||||
if (existingFailure) {
|
||||
existingFailure.issueKeys.push(issueKey);
|
||||
} else {
|
||||
|
|
@ -116,20 +130,18 @@ function convertToStandardDate(dateString: string): string | null {
|
|||
const date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error("Invalid date format");
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
|
||||
// Format to ISO string and adjust to desired format
|
||||
const isoString = date.toISOString();
|
||||
return isoString.replace('Z', '+0000');
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error converting date:", error);
|
||||
console.error('Error converting date:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function parseDuration(input: string): string {
|
||||
const regex = /(\d+)([wdhms])/g;
|
||||
let match;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,23 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { IssueWorkLogModal } from '../modals';
|
||||
import { addWorkLog } from '../api';
|
||||
import { checkCommandCallback } from '../tools/checkCommandCallback';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
import {TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {IssueWorkLogModal} from "../modals";
|
||||
import {addWorkLog} from "../api";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
|
||||
const t = useTranslations("commands.add_worklog.manual").t;
|
||||
const t = useTranslations('commands.add_worklog.manual').t;
|
||||
|
||||
export function registerUpdateWorkLogManuallyCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "update-work-log-jira-manually",
|
||||
name: t("name"),
|
||||
id: 'update-work-log-jira-manually',
|
||||
name: t('name'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, processManualWorkLog, ["key"], ["key"]);
|
||||
return checkCommandCallback(plugin, checking, processManualWorkLog, ['key'], ['key']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function processManualWorkLog(plugin: JiraPlugin, _: TFile, issueKey: string): Promise<void> {
|
||||
|
||||
new IssueWorkLogModal(plugin.app, async (timeSpent: string, startDate: string, comment: string) => {
|
||||
await addWorkLog(plugin, issueKey, timeSpent, startDate, comment);
|
||||
}).open();
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
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";
|
||||
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;
|
||||
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"),
|
||||
id: 'batch-fetch-issues-jira',
|
||||
name: t('name'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
const settings_are_valid = validateSettings(plugin);
|
||||
if (settings_are_valid) {
|
||||
|
|
@ -33,16 +33,16 @@ function openJQLModal(plugin: JiraPlugin): void {
|
|||
|
||||
export async function batchFetchAndCreateIssues(plugin: JiraPlugin, jql: string): Promise<void> {
|
||||
try {
|
||||
new Notice(t("fetching_started"));
|
||||
new Notice(t('fetching_started'));
|
||||
|
||||
const issues = await fetchIssuesByJQL(plugin, jql);
|
||||
|
||||
|
||||
if (issues.length === 0) {
|
||||
new Notice(t("no_issues_found"));
|
||||
new Notice(t('no_issues_found'));
|
||||
return;
|
||||
}
|
||||
|
||||
new Notice(t("fetching_completed", { count: issues.length.toString() }));
|
||||
new Notice(t('fetching_completed', { count: issues.length.toString() }));
|
||||
|
||||
// Process each issue
|
||||
let successCount = 0;
|
||||
|
|
@ -53,26 +53,27 @@ export async function batchFetchAndCreateIssues(plugin: JiraPlugin, jql: string)
|
|||
try {
|
||||
await createOrUpdateIssueNote(plugin, issue);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
errorCount++;
|
||||
errors.push(`${issue.key}: ${error.message || "Unknown error"}`);
|
||||
errors.push(`${issue.key}: ${(error as 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() }));
|
||||
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);
|
||||
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);
|
||||
} catch (error: unknown) {
|
||||
new Notice(t('fetch_error') + ': ' + ((error as Error).message || 'Unknown error'));
|
||||
console.error('Batch fetch error:', error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import { Notice, TFile } from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import { IssueTypeModal, ProjectModal } from "../modals";
|
||||
import {fetchIssueTypes, fetchProjects} from "../api";
|
||||
import {createIssueFromFile} from "../file_operations/createUpdateIssue";
|
||||
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;
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { IssueTypeModal, ProjectModal } from '../modals';
|
||||
import { fetchIssueTypes, fetchProjects } from '../api';
|
||||
import { createIssueFromFile } from '../file_operations/createUpdateIssue';
|
||||
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;
|
||||
|
||||
export function registerCreateIssueCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "create-issue-jira",
|
||||
name: t("name"),
|
||||
id: 'create-issue-jira',
|
||||
name: t('name'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, createIssue, []);
|
||||
},
|
||||
|
|
@ -29,9 +28,9 @@ export async function createIssue(plugin: JiraPlugin, file: TFile): Promise<void
|
|||
await checkIssueTypes(plugin, fields);
|
||||
await checkSummary(plugin, fields);
|
||||
const issueKey = await createIssueFromFile(plugin, file, fields);
|
||||
new Notice(t('success', {issueKey}));
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"), 3000);
|
||||
new Notice(t('success', { issueKey }));
|
||||
} catch (error: unknown) {
|
||||
new Notice(t('error') + ': ' + ((error as Error).message || 'Unknown error'), 3000);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +39,8 @@ export async function checkProjects(plugin: JiraPlugin, fields: any): Promise<vo
|
|||
const projects = await fetchProjects(plugin);
|
||||
if (!fields.project || !projects.map((project: any) => project.key).includes(fields.project)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
new ProjectModal(plugin.app,
|
||||
new ProjectModal(
|
||||
plugin.app,
|
||||
projects.map((project: any) => ({
|
||||
id: project.key,
|
||||
name: project.name,
|
||||
|
|
@ -48,37 +48,39 @@ export async function checkProjects(plugin: JiraPlugin, fields: any): Promise<vo
|
|||
async (projectKey: string) => {
|
||||
fields.project = projectKey;
|
||||
resolve();
|
||||
}).open();
|
||||
},
|
||||
).open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIssueTypes(plugin: JiraPlugin, fields: any): Promise<void> {
|
||||
const issueTypesResponse = await fetchIssueTypes(plugin, fields.project);
|
||||
const issueTypes = plugin.settings.connection.apiVersion === "3" ? issueTypesResponse.issueTypes : issueTypesResponse.values;
|
||||
const issueTypes =
|
||||
plugin.getCurrentConnection()?.apiVersion === '3' ? issueTypesResponse.issueTypes : issueTypesResponse.values;
|
||||
if (!fields.issuetype || !issueTypes.map((issueType: any) => issueType.name).includes(fields.issuetype)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
new IssueTypeModal(plugin.app,
|
||||
new IssueTypeModal(
|
||||
plugin.app,
|
||||
issueTypes.map((type: any) => ({
|
||||
name: type.name,
|
||||
})) as JiraIssueType[],
|
||||
async (issueType: string) => {
|
||||
fields.issuetype = issueType;
|
||||
resolve();
|
||||
}).open();
|
||||
},
|
||||
).open();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function checkSummary(plugin: JiraPlugin, fields: any): Promise<void> {
|
||||
if (!fields.summary) {
|
||||
await new Promise<void>((resolve) => {
|
||||
new IssueAddSummaryModal(plugin.app,
|
||||
async (summary: string) => {
|
||||
fields.summary = summary;
|
||||
resolve();
|
||||
}).open();
|
||||
new IssueAddSummaryModal(plugin.app, async (summary: string) => {
|
||||
fields.summary = summary;
|
||||
resolve();
|
||||
}).open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import {TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {IssueSearchModal} from "../modals";
|
||||
import {fetchIssue, validateSettings} from "../api";
|
||||
import {createOrUpdateIssueNote} from "../file_operations/getIssue";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { IssueSearchModal } from '../modals';
|
||||
import { fetchIssue, validateSettings } from '../api';
|
||||
import { createOrUpdateIssueNote } from '../file_operations/getIssue';
|
||||
import { checkCommandCallback } from '../tools/checkCommandCallback';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("commands.get_issue").t;
|
||||
const t = useTranslations('commands.get_issue').t;
|
||||
|
||||
export function registerGetIssueCommandWithCustomKey(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "get-issue-jira-key",
|
||||
name: t("with_key"),
|
||||
id: 'get-issue-jira-key',
|
||||
name: t('with_key'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
const settings_are_valid = validateSettings(plugin);
|
||||
if (settings_are_valid) {
|
||||
|
|
@ -25,10 +25,10 @@ export function registerGetIssueCommandWithCustomKey(plugin: JiraPlugin): void {
|
|||
|
||||
export function registerGetCurrentIssueCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "get-issue-jira",
|
||||
name: t("without_key"),
|
||||
id: 'get-issue-jira',
|
||||
name: t('without_key'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, fetchAndOpenIssue, ["key"], ["key"]);
|
||||
return checkCommandCallback(plugin, checking, fetchAndOpenIssue, ['key'], ['key']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './addComment';
|
||||
export * from './addWorkLogBatch';
|
||||
export * from './addWorkLogManually';
|
||||
export * from './batchFetchIssues';
|
||||
|
|
@ -5,3 +6,5 @@ export * from './createIssue';
|
|||
export * from './getIssue';
|
||||
export * from './updateIssue';
|
||||
export * from './updateStatus';
|
||||
export * from './rebuildCache';
|
||||
export * from './rebuildCache';
|
||||
|
|
|
|||
13
src/commands/rebuildCache.ts
Normal file
13
src/commands/rebuildCache.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
|
||||
export function registerRebuildCacheCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'rebuild-issue-cache',
|
||||
name: 'Rebuild issue file cache from filesystem',
|
||||
callback: async () => {
|
||||
await plugin.rebuildCache();
|
||||
new Notice('Issue cache rebuilt successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import {Notice, TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {updateIssueFromFile} from "../file_operations/createUpdateIssue";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { updateIssueFromFile } from '../file_operations/createUpdateIssue';
|
||||
import { checkCommandCallback } from '../tools/checkCommandCallback';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("commands.update_issue").t;
|
||||
const t = useTranslations('commands.update_issue').t;
|
||||
|
||||
export function registerUpdateIssueCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "update-issue-jira",
|
||||
name: t("name"),
|
||||
id: 'update-issue-jira',
|
||||
name: t('name'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, updateIssue, ["key"]);
|
||||
return checkCommandCallback(plugin, checking, updateIssue, ['key']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -21,11 +21,11 @@ export async function updateIssue(plugin: JiraPlugin, file: TFile): Promise<void
|
|||
try {
|
||||
// Update the issue with all data from the file
|
||||
const issueKey = await updateIssueFromFile(plugin, file);
|
||||
new Notice(t('success', {issueKey}));
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"), 3000);
|
||||
new Notice(t('success', { issueKey }));
|
||||
} catch (error: unknown) {
|
||||
new Notice(t('error') + ': ' + ((error as Error).message || 'Unknown error'), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"));
|
||||
} catch (error: unknown) {
|
||||
new Notice(t('error') + ': ' + ((error as Error).message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import {Notice, TFile} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import {fetchIssueTransitions} from "../api";
|
||||
import {updateStatusFromFile} from "../file_operations/createUpdateIssue";
|
||||
import {IssueStatusModal} from "../modals";
|
||||
import {JiraTransitionType} from "../interfaces";
|
||||
import {checkCommandCallback} from "../tools/checkCommandCallback";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { fetchIssueTransitions } from '../api';
|
||||
import { updateStatusFromFile } from '../file_operations/createUpdateIssue';
|
||||
import { IssueStatusModal } from '../modals';
|
||||
import { JiraTransitionType } from '../interfaces';
|
||||
import { checkCommandCallback } from '../tools/checkCommandCallback';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("commands.update_status").t;
|
||||
const t = useTranslations('commands.update_status').t;
|
||||
|
||||
export function registerUpdateIssueStatusCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "update-issue-status-jira",
|
||||
id: 'update-issue-status-jira',
|
||||
name: t('name'),
|
||||
checkCallback: (checking: boolean) => {
|
||||
return checkCommandCallback(plugin, checking, updateIssueStatus, ["key"],["key"]);
|
||||
return checkCommandCallback(plugin, checking, updateIssueStatus, ['key'], ['key']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -26,15 +26,14 @@ export async function updateIssueStatus(plugin: JiraPlugin, file: TFile, issueKe
|
|||
new IssueStatusModal(plugin.app, issueTransitions, async (transition: JiraTransitionType) => {
|
||||
try {
|
||||
await updateStatusFromFile(plugin, file, transition);
|
||||
new Notice(t('success', {issueKey}));
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"), 3000);
|
||||
new Notice(t('success', { issueKey }));
|
||||
} catch (error: unknown) {
|
||||
new Notice(t('error') + ': ' + ((error as Error).message || 'Unknown error'), 3000);
|
||||
console.error(error);
|
||||
}
|
||||
}).open();
|
||||
|
||||
} catch (error) {
|
||||
new Notice(t('error') + ": " + (error.message || "Unknown error"));
|
||||
} catch (error: unknown) {
|
||||
new Notice(t('error') + ': ' + ((error as Error).message || 'Unknown error'));
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,405 +1,405 @@
|
|||
export const defaultIssue = {
|
||||
"expand": "",
|
||||
"id": "",
|
||||
"self": "",
|
||||
"key": "",
|
||||
"fields": {
|
||||
"issuetype": {
|
||||
"self": "",
|
||||
"id": "",
|
||||
"description": "",
|
||||
"iconUrl": "",
|
||||
"name": "",
|
||||
"subtask": 0,
|
||||
"avatarId": 0
|
||||
expand: '',
|
||||
id: '',
|
||||
self: '',
|
||||
key: '',
|
||||
fields: {
|
||||
issuetype: {
|
||||
self: '',
|
||||
id: '',
|
||||
description: '',
|
||||
iconUrl: '',
|
||||
name: '',
|
||||
subtask: 0,
|
||||
avatarId: 0,
|
||||
},
|
||||
"timespent": 0,
|
||||
"project": {
|
||||
"self": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"name": "",
|
||||
"projectTypeKey": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
}
|
||||
},
|
||||
"fixVersions": [],
|
||||
"aggregatetimespent": 0,
|
||||
"resolution": null,
|
||||
"resolutiondate": null,
|
||||
"workratio": 0,
|
||||
"lastViewed": "",
|
||||
"watches": {
|
||||
"self": "",
|
||||
"watchCount": 0,
|
||||
"isWatching": 0
|
||||
},
|
||||
"created": "",
|
||||
"priority": {
|
||||
"self": "",
|
||||
"iconUrl": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
},
|
||||
"labels": [],
|
||||
"timeestimate": 0,
|
||||
"aggregatetimeoriginalestimate": null,
|
||||
"versions": [],
|
||||
"issuelinks": [],
|
||||
"assignee": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
timespent: 0,
|
||||
project: {
|
||||
self: '',
|
||||
id: '',
|
||||
key: '',
|
||||
name: '',
|
||||
projectTypeKey: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
},
|
||||
"updated": "",
|
||||
"status": {
|
||||
"self": "",
|
||||
"description": "",
|
||||
"iconUrl": "",
|
||||
"name": "",
|
||||
"id": "",
|
||||
"statusCategory": {
|
||||
"self": "",
|
||||
"id": 0,
|
||||
"key": "",
|
||||
"colorName": "",
|
||||
"name": ""
|
||||
}
|
||||
fixVersions: [],
|
||||
aggregatetimespent: 0,
|
||||
resolution: null,
|
||||
resolutiondate: null,
|
||||
workratio: 0,
|
||||
lastViewed: '',
|
||||
watches: {
|
||||
self: '',
|
||||
watchCount: 0,
|
||||
isWatching: 0,
|
||||
},
|
||||
"components": [],
|
||||
"timeoriginalestimate": null,
|
||||
"description": "",
|
||||
"archiveddate": null,
|
||||
"timetracking": {
|
||||
"remainingEstimate": "",
|
||||
"timeSpent": "",
|
||||
"remainingEstimateSeconds": 0,
|
||||
"timeSpentSeconds": 0
|
||||
created: '',
|
||||
priority: {
|
||||
self: '',
|
||||
iconUrl: '',
|
||||
name: '',
|
||||
id: '',
|
||||
},
|
||||
"attachment": [],
|
||||
"aggregatetimeestimate": 0,
|
||||
"summary": "",
|
||||
"creator": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
labels: [],
|
||||
timeestimate: 0,
|
||||
aggregatetimeoriginalestimate: null,
|
||||
versions: [],
|
||||
issuelinks: [],
|
||||
assignee: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"subtasks": [],
|
||||
"reporter": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updated: '',
|
||||
status: {
|
||||
self: '',
|
||||
description: '',
|
||||
iconUrl: '',
|
||||
name: '',
|
||||
id: '',
|
||||
statusCategory: {
|
||||
self: '',
|
||||
id: 0,
|
||||
key: '',
|
||||
colorName: '',
|
||||
name: '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
},
|
||||
"aggregateprogress": {
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"percent": 0
|
||||
components: [],
|
||||
timeoriginalestimate: null,
|
||||
description: '',
|
||||
archiveddate: null,
|
||||
timetracking: {
|
||||
remainingEstimate: '',
|
||||
timeSpent: '',
|
||||
remainingEstimateSeconds: 0,
|
||||
timeSpentSeconds: 0,
|
||||
},
|
||||
"environment": null,
|
||||
"duedate": null,
|
||||
"progress": {
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"percent": 0
|
||||
attachment: [],
|
||||
aggregatetimeestimate: 0,
|
||||
summary: '',
|
||||
creator: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": {
|
||||
"comments": [],
|
||||
"maxResults": 0,
|
||||
"total": 0,
|
||||
"startAt": 0
|
||||
subtasks: [],
|
||||
reporter: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"votes": {
|
||||
"self": "",
|
||||
"votes": 0,
|
||||
"hasVoted": 0
|
||||
aggregateprogress: {
|
||||
progress: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
},
|
||||
"worklog": {
|
||||
"startAt": 0,
|
||||
"maxResults": 0,
|
||||
"total": 0,
|
||||
"worklogs": [
|
||||
environment: null,
|
||||
duedate: null,
|
||||
progress: {
|
||||
progress: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
},
|
||||
comment: {
|
||||
comments: [],
|
||||
maxResults: 0,
|
||||
total: 0,
|
||||
startAt: 0,
|
||||
},
|
||||
votes: {
|
||||
self: '',
|
||||
votes: 0,
|
||||
hasVoted: 0,
|
||||
},
|
||||
worklog: {
|
||||
startAt: 0,
|
||||
maxResults: 0,
|
||||
total: 0,
|
||||
worklogs: [
|
||||
{
|
||||
"self": "",
|
||||
"author": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
self: '',
|
||||
author: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"updateAuthor": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updateAuthor: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"started": "",
|
||||
"timeSpent": "",
|
||||
"timeSpentSeconds": 0,
|
||||
"id": "",
|
||||
"issueId": ""
|
||||
comment: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
started: '',
|
||||
timeSpent: '',
|
||||
timeSpentSeconds: 0,
|
||||
id: '',
|
||||
issueId: '',
|
||||
},
|
||||
{
|
||||
"self": "",
|
||||
"author": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
self: '',
|
||||
author: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"updateAuthor": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updateAuthor: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"started": "",
|
||||
"timeSpent": "",
|
||||
"timeSpentSeconds": 0,
|
||||
"id": "",
|
||||
"issueId": ""
|
||||
comment: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
started: '',
|
||||
timeSpent: '',
|
||||
timeSpentSeconds: 0,
|
||||
id: '',
|
||||
issueId: '',
|
||||
},
|
||||
{
|
||||
"self": "",
|
||||
"author": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
self: '',
|
||||
author: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"updateAuthor": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updateAuthor: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"started": "",
|
||||
"timeSpent": "",
|
||||
"timeSpentSeconds": 0,
|
||||
"id": "",
|
||||
"issueId": ""
|
||||
comment: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
started: '',
|
||||
timeSpent: '',
|
||||
timeSpentSeconds: 0,
|
||||
id: '',
|
||||
issueId: '',
|
||||
},
|
||||
{
|
||||
"self": "",
|
||||
"author": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
self: '',
|
||||
author: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"updateAuthor": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updateAuthor: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"started": "",
|
||||
"timeSpent": "",
|
||||
"timeSpentSeconds": 0,
|
||||
"id": "",
|
||||
"issueId": ""
|
||||
comment: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
started: '',
|
||||
timeSpent: '',
|
||||
timeSpentSeconds: 0,
|
||||
id: '',
|
||||
issueId: '',
|
||||
},
|
||||
{
|
||||
"self": "",
|
||||
"author": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
self: '',
|
||||
author: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"updateAuthor": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updateAuthor: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"started": "",
|
||||
"timeSpent": "",
|
||||
"timeSpentSeconds": 0,
|
||||
"id": "",
|
||||
"issueId": ""
|
||||
comment: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
started: '',
|
||||
timeSpent: '',
|
||||
timeSpentSeconds: 0,
|
||||
id: '',
|
||||
issueId: '',
|
||||
},
|
||||
{
|
||||
"self": "",
|
||||
"author": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
self: '',
|
||||
author: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"updateAuthor": {
|
||||
"self": "",
|
||||
"name": "",
|
||||
"key": "",
|
||||
"emailAddress": "",
|
||||
"avatarUrls": {
|
||||
"48x48": "",
|
||||
"24x24": "",
|
||||
"16x16": "",
|
||||
"32x32": ""
|
||||
updateAuthor: {
|
||||
self: '',
|
||||
name: '',
|
||||
key: '',
|
||||
emailAddress: '',
|
||||
avatarUrls: {
|
||||
'48x48': '',
|
||||
'24x24': '',
|
||||
'16x16': '',
|
||||
'32x32': '',
|
||||
},
|
||||
"displayName": "",
|
||||
"active": 0,
|
||||
"timeZone": ""
|
||||
displayName: '',
|
||||
active: 0,
|
||||
timeZone: '',
|
||||
},
|
||||
"comment": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"started": "",
|
||||
"timeSpent": "",
|
||||
"timeSpentSeconds": 0,
|
||||
"id": "",
|
||||
"issueId": ""
|
||||
}
|
||||
]
|
||||
comment: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
started: '',
|
||||
timeSpent: '',
|
||||
timeSpentSeconds: 0,
|
||||
id: '',
|
||||
issueId: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
"archivedby": null
|
||||
}
|
||||
}
|
||||
archivedby: null,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export const defaultTemplate =
|
||||
`---
|
||||
export const defaultTemplate = `---
|
||||
key:
|
||||
summary:
|
||||
status:
|
||||
|
|
|
|||
|
|
@ -1,79 +1,101 @@
|
|||
import {JiraIssue} from "../interfaces";
|
||||
import { JiraIssue } from '../interfaces';
|
||||
import { jiraToMarkdown, markdownToJira } from '../tools/markdownHtml';
|
||||
import { adfToMarkdown, markdownToAdf } from '../tools/markdownToAdf';
|
||||
|
||||
export interface FieldMapping {
|
||||
toJira: (value: any) => any;
|
||||
fromJira: (issue: JiraIssue, data_source: Record<string, any> | null) => any;
|
||||
toJira: (value: any, api_version?: '2' | '3') => any;
|
||||
fromJira: (issue: JiraIssue, api_version?: '2' | '3', data_source?: Record<string, any>) => any;
|
||||
}
|
||||
export const TO_JIRA_PARAMS = ['value', 'api_version'];
|
||||
export const FROM_JIRA_PARAMS = ['issue', 'api_version', 'data_source'];
|
||||
|
||||
export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
|
||||
"summary": {
|
||||
summary: {
|
||||
toJira: (value) => value,
|
||||
fromJira: (issue) => issue.fields.summary,
|
||||
},
|
||||
"description": {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.description,
|
||||
description: {
|
||||
toJira: (value, api_version) => (api_version === '3' ? markdownToAdf(value) : markdownToJira(value)),
|
||||
fromJira: (issue, api_version) =>
|
||||
api_version === '3' ? adfToMarkdown(issue.fields.description) : jiraToMarkdown(issue.fields.description),
|
||||
},
|
||||
"key": {
|
||||
key: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.key,
|
||||
},
|
||||
"self": {
|
||||
self: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.self,
|
||||
},
|
||||
"project": {
|
||||
project: {
|
||||
toJira: (value) => ({ key: value }),
|
||||
fromJira: (issue) =>
|
||||
issue.fields.project ?issue.fields.project.key :"",
|
||||
fromJira: (issue) => (issue.fields.project ? issue.fields.project.key : ''),
|
||||
},
|
||||
"issuetype": {
|
||||
issuetype: {
|
||||
toJira: (value) => ({ name: value }),
|
||||
fromJira: (issue) =>
|
||||
issue.fields.issuetype ?issue.fields.issuetype.name :"",
|
||||
fromJira: (issue) => (issue.fields.issuetype ? issue.fields.issuetype.name : ''),
|
||||
},
|
||||
"priority": {
|
||||
priority: {
|
||||
toJira: (value) => ({ name: value }),
|
||||
fromJira: (issue) =>issue.fields.priority ?issue.fields.priority.name :"",
|
||||
fromJira: (issue) => (issue.fields.priority ? issue.fields.priority.name : ''),
|
||||
},
|
||||
"status": {
|
||||
status: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) =>issue.fields.status ?issue.fields.status.name :"",
|
||||
fromJira: (issue) => (issue.fields.status ? issue.fields.status.name : ''),
|
||||
},
|
||||
"assignee": {
|
||||
assignee: {
|
||||
toJira: (value) => ({ name: value }),
|
||||
fromJira: (issue) =>issue.fields.assignee ?issue.fields.assignee.name :"",
|
||||
fromJira: (issue) => (issue.fields.assignee ? issue.fields.assignee.name : ''),
|
||||
},
|
||||
"reporter": {
|
||||
reporter: {
|
||||
toJira: (value) => ({ name: value }),
|
||||
fromJira: (issue) =>issue.fields.reporter ?issue.fields.reporter.name :"",
|
||||
fromJira: (issue) => (issue.fields.reporter ? issue.fields.reporter.name : ''),
|
||||
},
|
||||
"creator": {
|
||||
creator: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) =>issue.fields.creator ?issue.fields.creator.name :"",
|
||||
fromJira: (issue) => (issue.fields.creator ? issue.fields.creator.name : ''),
|
||||
},
|
||||
"lastViewed": {
|
||||
lastViewed: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.lastViewed,
|
||||
},
|
||||
"updated": {
|
||||
updated: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.updated,
|
||||
},
|
||||
"created": {
|
||||
created: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.created,
|
||||
},
|
||||
"link": {
|
||||
link: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.self.replace(/(\w+:\/\/\S+?)\/.*/, `$1/browse/${issue.key}`),
|
||||
},
|
||||
"openLink": {
|
||||
openLink: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.self.replace(/(\w+:\/\/\S+?)\/.*/, `[Open in Jira]($1/browse/${issue.key})`),
|
||||
fromJira: (issue) => issue.self.replace(/(\w+:\/\/\S+?)\/.*/, `[${issue.key}]($1/browse/${issue.key})`),
|
||||
},
|
||||
"progress": {
|
||||
progress: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.aggregateprogress.percent+'%',
|
||||
fromJira: (issue) => issue.fields.aggregateprogress.percent + '%',
|
||||
},
|
||||
comments: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue, api_version) => {
|
||||
const comments = issue.fields.comment?.comments;
|
||||
if (!comments?.length) return '';
|
||||
return comments
|
||||
.map((c: any) => {
|
||||
const author = c.author?.displayName ?? 'Unknown';
|
||||
const date = c.created ? c.created.replace('T', ' ').substring(0, 19) : '';
|
||||
const body = api_version === '3' ? adfToMarkdown(c.body) : c.body;
|
||||
const calloutBody = body
|
||||
.split('\n')
|
||||
.map((l: string) => (l === '' ? '>' : `> ${l}`))
|
||||
.join('\n');
|
||||
return `> [!note]+ ${author} — ${date}\n> \n${calloutBody}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {TFile} from "obsidian";
|
||||
import {extractAllJiraSyncValuesFromContent} from "../tools/sectionTools";
|
||||
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>> {
|
||||
export async function prepareJiraFieldsFromFile(plugin: JiraPlugin, file: TFile): Promise<Record<string, any>> {
|
||||
const fileContent = await plugin.app.vault.read(file);
|
||||
|
||||
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
|
||||
|
|
@ -13,16 +10,13 @@ export async function prepareJiraFieldsFromFile(
|
|||
let fields: Record<string, any> = {};
|
||||
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
fields = {...syncSections, ...frontmatter};
|
||||
fields = { ...syncSections, ...frontmatter };
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export async function readJiraFieldsFromFile(
|
||||
plugin: JiraPlugin,
|
||||
file: TFile
|
||||
): Promise<Record<string, any>> {
|
||||
export async function readJiraFieldsFromFile(plugin: JiraPlugin, file: TFile): Promise<Record<string, any>> {
|
||||
const fileContent = await plugin.app.vault.cachedRead(file);
|
||||
|
||||
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
|
||||
|
|
@ -30,5 +24,5 @@ export async function readJiraFieldsFromFile(
|
|||
const cachedMetadata = plugin.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter || {};
|
||||
|
||||
return {...syncSections, ...frontmatter};
|
||||
return { ...syncSections, ...frontmatter };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {TFile} from "obsidian";
|
||||
import {createJiraIssue, updateJiraIssue, updateJiraStatus} from "../api";
|
||||
import {prepareJiraFieldsFromFile} from "./commonPrepareData";
|
||||
import {localToJiraFields, updateJiraToLocal} from "../tools/mapObsidianJiraFields";
|
||||
import {JiraIssue, JiraTransitionType} from "../interfaces";
|
||||
import {obsidianJiraFieldMappings} from "../default/obsidianJiraFieldsMapping";
|
||||
import JiraPlugin from '../main';
|
||||
import { TFile } from 'obsidian';
|
||||
import { createJiraIssue, fetchIssue, updateJiraIssue, updateJiraStatus } from '../api';
|
||||
import { prepareJiraFieldsFromFile } from './commonPrepareData';
|
||||
import { localToJiraFields, updateJiraToLocal } from '../tools/mapObsidianJiraFields';
|
||||
import { JiraIssue, JiraTransitionType } from '../interfaces';
|
||||
import { obsidianJiraFieldMappings } from '../default/obsidianJiraFieldsMapping';
|
||||
import { updateJiraSyncContent } from '../tools/sectionTools';
|
||||
|
||||
export async function updateIssueFromFile(plugin: JiraPlugin, file: TFile): Promise<string> {
|
||||
let fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
const issueKey = fields.key;
|
||||
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
|
||||
|
||||
if (!issueKey) {
|
||||
throw new Error("No issue key found in frontmatter");
|
||||
throw new Error('No issue key found in frontmatter');
|
||||
}
|
||||
|
||||
fields = localToJiraFields(fields, {...obsidianJiraFieldMappings, ...plugin.settings.fieldMapping.fieldMappings});
|
||||
fields = localToJiraFields(
|
||||
fields,
|
||||
{
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
},
|
||||
apiVersion,
|
||||
);
|
||||
await updateJiraIssue(plugin, issueKey, fields);
|
||||
return issueKey;
|
||||
}
|
||||
|
|
@ -24,30 +33,64 @@ export async function createIssueFromFile(
|
|||
file: TFile,
|
||||
fields?: Record<string, any>,
|
||||
): Promise<string> {
|
||||
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
|
||||
if (!fields) {
|
||||
fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
}
|
||||
fields = localToJiraFields(fields, {...obsidianJiraFieldMappings, ...plugin.settings.fieldMapping.fieldMappings});
|
||||
fields = localToJiraFields(
|
||||
fields,
|
||||
{
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
},
|
||||
apiVersion,
|
||||
);
|
||||
// Create the issue
|
||||
const issueData = await createJiraIssue(plugin, fields);
|
||||
const issueKey = issueData.key;
|
||||
|
||||
// Update frontmatter with the new issue key
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
frontmatter["key"] = issueKey;
|
||||
frontmatter['key'] = issueKey;
|
||||
});
|
||||
|
||||
// Pull the created issue back from Jira to populate all remaining synced fields
|
||||
const createdIssue = await fetchIssue(plugin, issueKey);
|
||||
await updateJiraToLocal(plugin, file, createdIssue);
|
||||
|
||||
return issueKey;
|
||||
}
|
||||
|
||||
export async function updateStatusFromFile(plugin: JiraPlugin, file: TFile, transition: JiraTransitionType): Promise<string> {
|
||||
export async function updateStatusFromFile(
|
||||
plugin: JiraPlugin,
|
||||
file: TFile,
|
||||
transition: JiraTransitionType,
|
||||
): Promise<string> {
|
||||
const fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
|
||||
if (!fields.key) {
|
||||
throw new Error("No issue key found in frontmatter");
|
||||
throw new Error('No issue key found in frontmatter');
|
||||
}
|
||||
|
||||
await updateJiraStatus(plugin, fields.key, transition.id);
|
||||
await updateJiraToLocal(plugin, file, {fields: {status: {name: transition.status}}} as JiraIssue);
|
||||
|
||||
// Only update the status field. Calling updateJiraToLocal with a partial issue
|
||||
// causes all fromJira mappings to run with undefined inputs, producing empty strings
|
||||
// that overwrite description and comments.
|
||||
const allMappings = { ...obsidianJiraFieldMappings, ...plugin.settings.fieldMapping.fieldMappings };
|
||||
const statusMapping = allMappings['status'];
|
||||
const minimalIssue = { fields: { status: { name: transition.status } } } as JiraIssue;
|
||||
const localStatusValue = statusMapping
|
||||
? statusMapping.fromJira(minimalIssue, plugin.getCurrentConnection()?.apiVersion)
|
||||
: transition.status;
|
||||
|
||||
if (localStatusValue !== null && localStatusValue !== undefined) {
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter: any) => {
|
||||
frontmatter['status'] = localStatusValue;
|
||||
});
|
||||
await plugin.app.vault.process(file, (fileContent) => {
|
||||
return updateJiraSyncContent(fileContent, { status: String(localStatusValue) });
|
||||
});
|
||||
}
|
||||
return fields.key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,77 @@
|
|||
import JiraPlugin from "../main";
|
||||
import {JiraIssue} from "../interfaces";
|
||||
import {ensureIssuesFolder} from "../tools/filesUtils";
|
||||
import {sanitizeFileName} from "../tools/sanitizers";
|
||||
import {updateJiraToLocal} from "../tools/mapObsidianJiraFields";
|
||||
import {Notice, TFile, TFolder} from "obsidian";
|
||||
import {defaultTemplate} from "../default/defaultTemplate";
|
||||
import { debugLog } from "src/tools/debugLogging";
|
||||
import JiraPlugin from '../main';
|
||||
import { JiraIssue } from '../interfaces';
|
||||
import { ensureIssuesFolder } from '../tools/filesUtils';
|
||||
import { sanitizeFileName } from '../tools/sanitizers';
|
||||
import { updateJiraToLocal } from '../tools/mapObsidianJiraFields';
|
||||
import { Notice, TFile, TFolder } from 'obsidian';
|
||||
import { defaultTemplate } from '../default/defaultTemplate';
|
||||
import { debugLog } from '../tools/debugLogging';
|
||||
|
||||
function generateFilenameFromTemplate(template: string, issue: JiraIssue): string {
|
||||
export function generateFilenameFromTemplate(
|
||||
template: string,
|
||||
issue: { key: string; fields?: { summary?: string } },
|
||||
): string {
|
||||
let filename = template;
|
||||
|
||||
// Replace {summary} with sanitized summary
|
||||
const summary = issue.fields?.summary || "";
|
||||
const summary = issue.fields?.summary || '';
|
||||
const sanitizedSummary = sanitizeFileName(summary);
|
||||
filename = filename.replace(/\{summary\}/g, sanitizedSummary);
|
||||
|
||||
// Replace {key} with issue key
|
||||
const key = issue.key || "";
|
||||
const key = issue.key || '';
|
||||
filename = filename.replace(/\{key\}/g, key);
|
||||
|
||||
// Sanitize the entire filename to remove any prohibited characters
|
||||
filename = sanitizeFileName(filename);
|
||||
|
||||
// Fallback if the result is empty or only whitespace
|
||||
if (!filename || filename.trim() === "") {
|
||||
// Use key if available, otherwise use a default
|
||||
if (!filename || filename.trim() === '') {
|
||||
if (key) {
|
||||
filename = key;
|
||||
} else if (sanitizedSummary) {
|
||||
filename = sanitizedSummary;
|
||||
} else {
|
||||
filename = "jira-issue";
|
||||
filename = 'jira-issue';
|
||||
}
|
||||
}
|
||||
return filename.trim();
|
||||
}
|
||||
|
||||
export async function renameExistingIssueFiles(plugin: JiraPlugin): Promise<{ renamed: number; errors: string[] }> {
|
||||
const cacheMap = plugin.getAllIssueKeysMap();
|
||||
const template = plugin.settings.fetchIssue.filenameTemplate || '{summary} ({key})';
|
||||
const issuesFolder = plugin.settings.global.issuesFolder;
|
||||
let renamed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [issueKey, currentPath] of cacheMap.entries()) {
|
||||
try {
|
||||
const file = plugin.app.vault.getFileByPath(currentPath);
|
||||
if (!file) {
|
||||
errors.push(`${issueKey}: file not found at ${currentPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let summary = '';
|
||||
const metadata = plugin.app.metadataCache.getFileCache(file);
|
||||
const cachedSummary = metadata?.frontmatter?.summary;
|
||||
if (cachedSummary && typeof cachedSummary === 'string') {
|
||||
summary = cachedSummary;
|
||||
}
|
||||
|
||||
const newFilename = generateFilenameFromTemplate(template, { key: issueKey, fields: { summary } });
|
||||
const newPath = `${issuesFolder}/${newFilename}.md`;
|
||||
|
||||
if (newPath === currentPath) continue;
|
||||
|
||||
const existingFile = plugin.app.vault.getFileByPath(newPath);
|
||||
if (existingFile) {
|
||||
errors.push(`${issueKey}: target path already exists: ${newPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await plugin.app.vault.rename(file, newPath);
|
||||
await plugin.setFilePathForIssueKey(issueKey, newPath);
|
||||
renamed++;
|
||||
} catch (error) {
|
||||
errors.push(`${issueKey}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return filename.trim();
|
||||
return { renamed, errors };
|
||||
}
|
||||
|
||||
export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIssue, filePath?: string): Promise<void> {
|
||||
|
|
@ -42,13 +79,12 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
await ensureIssuesFolder(plugin);
|
||||
|
||||
let targetFile: TFile | null = null;
|
||||
let targetPath: string = "";
|
||||
let targetPath: string = '';
|
||||
|
||||
if (filePath) {
|
||||
targetPath = filePath;
|
||||
targetFile = plugin.app.vault.getFileByPath(filePath);
|
||||
} else {
|
||||
// First, try to find the file using cache
|
||||
const cachedPath = plugin.getFilePathForIssueKey(issue.key);
|
||||
if (cachedPath) {
|
||||
targetFile = plugin.app.vault.getFileByPath(cachedPath);
|
||||
|
|
@ -60,21 +96,18 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await 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 template = plugin.settings.fetchIssue.filenameTemplate || "{summary} ({key})";
|
||||
const template = plugin.settings.fetchIssue.filenameTemplate || '{summary} ({key})';
|
||||
const filename = generateFilenameFromTemplate(template, issue);
|
||||
targetPath = `${plugin.settings.global.issuesFolder}/${filename}.md`;
|
||||
debugLog(`Issue ${issue.key} not found in cache or filesystem, creating new file: ${targetPath}`);
|
||||
|
|
@ -82,63 +115,53 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
}
|
||||
|
||||
if (targetFile) {
|
||||
await updateJiraToLocal(plugin, targetFile, issue)
|
||||
await plugin.app.workspace.openLinkText(targetFile.path, "");
|
||||
await updateJiraToLocal(plugin, targetFile, issue);
|
||||
await plugin.app.workspace.openLinkText(targetFile.path, '');
|
||||
await plugin.setFilePathForIssueKey(issue.key, targetFile.path);
|
||||
} else {
|
||||
const newFile = await createNewIssueFile(plugin, targetPath);
|
||||
await updateJiraToLocal(plugin, newFile, issue)
|
||||
await plugin.app.workspace.openLinkText(newFile.path, "");
|
||||
// Add new file to cache
|
||||
plugin.setFilePathForIssueKey(issue.key, newFile.path);
|
||||
await updateJiraToLocal(plugin, newFile, issue);
|
||||
await plugin.app.workspace.openLinkText(newFile.path, '');
|
||||
await plugin.setFilePathForIssueKey(issue.key, newFile.path);
|
||||
}
|
||||
new Notice(`Issue ${issue.key} imported successfully`);
|
||||
} catch (error) {
|
||||
new Notice("Error creating issue note: " + (error.message || "Unknown error"));
|
||||
} catch (error: unknown) {
|
||||
new Notice('Error creating issue note: ' + ((error as Error).message || 'Unknown error'));
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise<TFile> {
|
||||
let initialContent = '';
|
||||
|
||||
async function createNewIssueFile(
|
||||
plugin: JiraPlugin,
|
||||
filePath: string
|
||||
): Promise<TFile> {
|
||||
let initialContent = "";
|
||||
|
||||
|
||||
let templatePath = plugin.settings.global.templatePath
|
||||
if (templatePath && !templatePath.endsWith(".md")) {
|
||||
templatePath += ".md";
|
||||
let templatePath = plugin.settings.global.templatePath;
|
||||
if (templatePath && !templatePath.endsWith('.md')) {
|
||||
templatePath += '.md';
|
||||
}
|
||||
if (templatePath && templatePath.trim() !== "") {
|
||||
if (templatePath && templatePath.trim() !== '') {
|
||||
const templateFile = plugin.app.vault.getFileByPath(templatePath);
|
||||
if (templateFile) {
|
||||
// Use the template as initial content
|
||||
initialContent = await plugin.app.vault.read(templateFile);
|
||||
} else {
|
||||
new Notice(`Template file not found: ${templatePath}, using default template`);
|
||||
}
|
||||
}
|
||||
if (initialContent === "") initialContent = defaultTemplate
|
||||
if (initialContent === '') initialContent = defaultTemplate;
|
||||
|
||||
// Create the file with initial content
|
||||
await plugin.app.vault.create(filePath, initialContent);
|
||||
|
||||
// Get file reference and update frontmatter
|
||||
const newFile = plugin.app.vault.getFileByPath(filePath);
|
||||
if (!newFile) {
|
||||
throw new Error("Could not create file");
|
||||
throw new Error('Could not create file');
|
||||
}
|
||||
return newFile
|
||||
return newFile;
|
||||
}
|
||||
|
||||
async function findFileByIssueKey(plugin: JiraPlugin, issueKey: string): Promise<TFile | null> {
|
||||
const issuesFolder = plugin.app.vault.getAbstractFileByPath(plugin.settings.global.issuesFolder);
|
||||
|
||||
if (!issuesFolder || !(issuesFolder instanceof TFolder)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await searchFolderForIssueKey(plugin, issuesFolder, issueKey);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App } from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import { App } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
|
||||
/**
|
||||
* Function type for field mappings
|
||||
|
|
@ -46,4 +46,3 @@ export interface ValidationResult {
|
|||
isValid: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,9 +53,8 @@ async function compileLocale(locale) {
|
|||
const data = yaml.parse(content);
|
||||
const fileNameWithoutExt = path.basename(item, '.yaml');
|
||||
|
||||
const targetKeys = fileNameWithoutExt === 'index'
|
||||
? parentKeys
|
||||
: [...parentKeys, fileNameWithoutExt];
|
||||
const targetKeys =
|
||||
fileNameWithoutExt === 'index' ? parentKeys : [...parentKeys, fileNameWithoutExt];
|
||||
|
||||
let target = result;
|
||||
for (const key of targetKeys.slice(0, -1)) {
|
||||
|
|
@ -90,7 +89,6 @@ function deepMerge(target, source) {
|
|||
return Object.assign(target || {}, source);
|
||||
}
|
||||
|
||||
|
||||
// Watch mode
|
||||
if (process.argv.includes('--watch')) {
|
||||
console.log('👀 Watching for changes...');
|
||||
|
|
@ -105,18 +103,20 @@ if (process.argv.includes('--watch')) {
|
|||
}, debounceDelay);
|
||||
|
||||
// Watch for changes in the compiled directory
|
||||
chokidar.watch(SOURCE_DIR, {
|
||||
ignoreInitial: true,
|
||||
ignored: /.*~$/, // Игнорировать скрытые файлы
|
||||
}).on('all', (event, path) => {
|
||||
if (event === 'change' || event === 'add' || event === 'unlink') {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
console.log(`🔁 Detected changes in ${path} (${event}), recompiling...`);
|
||||
main();
|
||||
}, debounceDelay);
|
||||
}
|
||||
});
|
||||
chokidar
|
||||
.watch(SOURCE_DIR, {
|
||||
ignoreInitial: true,
|
||||
ignored: /.*~$/, // Игнорировать скрытые файлы
|
||||
})
|
||||
.on('all', (event, path) => {
|
||||
if (event === 'change' || event === 'add' || event === 'unlink') {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
console.log(`🔁 Detected changes in ${path} (${event}), recompiling...`);
|
||||
main();
|
||||
}, debounceDelay);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Run once
|
||||
main();
|
||||
|
|
|
|||
2
src/localization/source/en/commands/add_comment.yaml
Normal file
2
src/localization/source/en/commands/add_comment.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name: Add comment to Jira
|
||||
error: Error adding comment
|
||||
|
|
@ -2,7 +2,7 @@ desc: Set the Jira issue summary
|
|||
|
||||
key:
|
||||
name: Summary
|
||||
placeholder: ""
|
||||
placeholder: 'Enter issue summary...'
|
||||
desc: Enter new task name (e.g., "My great issue")
|
||||
|
||||
submit: Submit
|
||||
|
|
|
|||
11
src/localization/source/en/modals/comment.yaml
Normal file
11
src/localization/source/en/modals/comment.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name: Add Jira comment
|
||||
|
||||
body:
|
||||
name: Comment
|
||||
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
|
||||
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
|
||||
|
||||
submit: Submit
|
||||
|
||||
warns:
|
||||
empty: Comment cannot be empty
|
||||
|
|
@ -12,9 +12,19 @@ maxResults:
|
|||
|
||||
submit: Search Issues
|
||||
|
||||
presets:
|
||||
load: JQL Preset
|
||||
load_desc: Load a saved JQL query preset
|
||||
select: Select preset
|
||||
save: Save as preset
|
||||
save_desc: Save current JQL query as a named preset
|
||||
save_hint: Preset name
|
||||
save_btn: Save
|
||||
delete: Delete preset
|
||||
|
||||
preview:
|
||||
total: Total {total} issues found
|
||||
showing: " (showing first {limit} issues)"
|
||||
error: "{error}"
|
||||
showing: ' (showing first {limit} issues)'
|
||||
error: '{error}'
|
||||
loading: Loading...
|
||||
empty_input: Enter a JQL query to see preview
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ name: Add work log
|
|||
|
||||
spent:
|
||||
name: Time spent
|
||||
desc: "Format: weeks days hours, minutes"
|
||||
placeholder: "3w 4d 12h 30m"
|
||||
desc: 'Format: weeks days hours, minutes'
|
||||
placeholder: '3w 4d 12h 30m'
|
||||
|
||||
start:
|
||||
name: Start datetime
|
||||
desc: "Format: DD/MMM/YY HH:MM"
|
||||
placeholder: "03/feb/25 15:22"
|
||||
desc: 'Format: DD/MMM/YY HH:MM'
|
||||
placeholder: '03/feb/25 15:22'
|
||||
|
||||
comment:
|
||||
name: Work description
|
||||
desc: Optional
|
||||
placeholder: ""
|
||||
placeholder: 'Enter work description...'
|
||||
|
||||
months: ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
|
||||
months: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
||||
|
||||
submit: Submit
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ auth:
|
|||
desc: Choose how to authenticate with Jira
|
||||
options:
|
||||
bearer: Bearer Token (PAT)
|
||||
basic: Basic Auth (Username + PAT)
|
||||
basic: Basic Auth (email + PAT)
|
||||
session: Session Cookie (Username + Password)
|
||||
|
||||
pat:
|
||||
|
|
@ -45,3 +45,12 @@ ping:
|
|||
title: Ping Jira
|
||||
desc: Check your connection to Jira
|
||||
button: Ping Jira
|
||||
|
||||
name:
|
||||
title: Connection name
|
||||
desc: A friendly name for this connection
|
||||
def: My Jira Connection
|
||||
|
||||
select_connection: Select connection
|
||||
add_connection: Add connection
|
||||
no_connection: No connection
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ title: Fetch issue settings
|
|||
note:
|
||||
desc0: Important!
|
||||
desc1: " The following 'fields' and 'expand' fields will be used across most of the Jira Manager requests related with issues. Make sure you know what you're doing."
|
||||
desc2: "For more info, go to official "
|
||||
link_label: "docs"
|
||||
desc3: ". While filling, list these params with ',' separator. Example: \"summary, description\""
|
||||
desc2: 'For more info, go to official '
|
||||
link_label: 'docs'
|
||||
desc3: '. While filling, list these params with '','' separator. Example: "summary, description"'
|
||||
|
||||
key:
|
||||
name: View raw issue data
|
||||
|
|
@ -13,17 +13,23 @@ key:
|
|||
|
||||
fields:
|
||||
name: Issue fields ('fields')
|
||||
desc: "Main Jira fields"
|
||||
def: "*all, -comment"
|
||||
desc: 'Main Jira fields'
|
||||
def: '*all, -comment'
|
||||
|
||||
expand:
|
||||
name: Expand fields ('expand')
|
||||
desc: "Extra Jira fields"
|
||||
def: "renderedFields, changelog"
|
||||
|
||||
desc: 'Extra Jira fields'
|
||||
def: 'renderedFields, changelog'
|
||||
|
||||
filenameTemplate:
|
||||
name: Filename template
|
||||
desc: |
|
||||
Template for created issue filenames. Use {summary} for issue summary and {key} for issue key
|
||||
placeholder: "{summary} ({key})"
|
||||
placeholder: '{summary} ({key})'
|
||||
|
||||
rename_files: Rename existing files
|
||||
no_files_to_rename: No files in cache to rename
|
||||
rename_confirm: 'This will rename {count} cached file(s). Continue?'
|
||||
rename_success: Successfully renamed {count} file(s)
|
||||
rename_errors: 'Failed to rename {count} file(s)'
|
||||
rename_no_changes: No files needed renaming
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
title: Field mappings
|
||||
desc: Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function.
|
||||
desc: |
|
||||
Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function.
|
||||
|
||||
To Jira:
|
||||
- value - the value of field in your .md file - can be a text string from file contents or a field value from frontmatter
|
||||
- api_version - Jira API version used in current connection (could be either '2' or '3')
|
||||
|
||||
From Jira:
|
||||
- issue - raw data from Jira (can be seen in lower `Fetch issue settings` section)
|
||||
- api_version - the same as `To Jira`
|
||||
- data_source - either parsed sections or frontmatter data in dictionary format, where keys are field names and values are field values
|
||||
fv:
|
||||
name: Enable field validation
|
||||
desc: Check fields before saving based on the standard API response (validation does not account for custom fields)
|
||||
secNote:
|
||||
name: "⚠️ Note: "
|
||||
name: '⚠️ Note: '
|
||||
desc: Field mapping uses JavaScript lambda functions.
|
||||
example:
|
||||
name: Example field mapping
|
||||
|
|
@ -13,6 +23,7 @@ example:
|
|||
from_jira: From Jira
|
||||
to_jira: To Jira
|
||||
field_name: Field name
|
||||
field_name_placeholder: Enter field name
|
||||
|
||||
add_mapping: Add mapping
|
||||
add_mapping_tooltip: Add new field mapping
|
||||
|
|
@ -20,6 +31,7 @@ reset: Reset
|
|||
reset_tooltip: Reset
|
||||
reset_confirm_title: Reset field mapping
|
||||
reset_confirm_text: Field mapping will be cleaned up. Are you sure?
|
||||
remove_field: Remove field
|
||||
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?
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
title: Timekeep work log statistics
|
||||
description: Manage and send work logs to Jira
|
||||
description: 'Manage and send work logs to Jira (supports Timekeep and Super Simple Time Tracker formats)'
|
||||
|
||||
settings:
|
||||
send_comments:
|
||||
|
|
@ -13,7 +13,7 @@ settings:
|
|||
max_weeks:
|
||||
name: Maximum Weeks to Show
|
||||
description: Number of recent weeks to display
|
||||
|
||||
|
||||
time_range:
|
||||
name: Time Range
|
||||
description: Select the time range for grouping entries
|
||||
|
|
@ -22,16 +22,16 @@ settings:
|
|||
weeks: Weeks
|
||||
months: Months
|
||||
custom: Custom Range
|
||||
|
||||
|
||||
max_items:
|
||||
name: Maximum Items to Show
|
||||
description: Number of recent time periods to display
|
||||
|
||||
|
||||
date_from:
|
||||
name: From Date
|
||||
description: Start date for custom range
|
||||
placeholder: YYYY-MM-DD
|
||||
|
||||
|
||||
date_to:
|
||||
name: To Date
|
||||
description: End date for custom range
|
||||
|
|
@ -40,11 +40,11 @@ settings:
|
|||
display:
|
||||
select_period: Select Period
|
||||
select_period_placeholder: Select a time period...
|
||||
selected_period: "Selected: {period}"
|
||||
selected_period: 'Selected: {period}'
|
||||
all_periods: All Recent Periods
|
||||
no_entries: No timekeep entries found.
|
||||
period_of: Period of {date}
|
||||
|
||||
|
||||
table:
|
||||
total: Total
|
||||
task: Task
|
||||
|
|
@ -60,8 +60,8 @@ actions:
|
|||
messages:
|
||||
select_period_first: Please select a time period first.
|
||||
refresh_success: Data refreshed successfully
|
||||
refresh_error: "Failed to refresh data: {error}"
|
||||
refresh_error: 'Failed to refresh data: {error}'
|
||||
send_success: Work log sent to Jira successfully
|
||||
send_error: "Failed to send work log to Jira: {error}"
|
||||
send_error: 'Failed to send work log to Jira: {error}'
|
||||
invalid_date_range: Invalid date range. End date must be after start date.
|
||||
custom_range_required: Please specify both start and end dates for custom range.
|
||||
|
|
|
|||
2
src/localization/source/ru/commands/add_comment.yaml
Normal file
2
src/localization/source/ru/commands/add_comment.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name: Add comment to Jira
|
||||
error: Error adding comment
|
||||
|
|
@ -2,7 +2,7 @@ desc: Задать название задачи Jira
|
|||
|
||||
key:
|
||||
name: Название задачи
|
||||
placeholder: ""
|
||||
placeholder: 'Введите название задачи...'
|
||||
desc: Введите название задачи (например, "Моя замечательная задача")
|
||||
|
||||
submit: Подтвердить
|
||||
|
|
|
|||
11
src/localization/source/ru/modals/comment.yaml
Normal file
11
src/localization/source/ru/modals/comment.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name: Add Jira comment
|
||||
|
||||
body:
|
||||
name: Comment
|
||||
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
|
||||
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
|
||||
|
||||
submit: Submit
|
||||
|
||||
warns:
|
||||
empty: Comment cannot be empty
|
||||
|
|
@ -12,9 +12,19 @@ maxResults:
|
|||
|
||||
submit: Найти задачи
|
||||
|
||||
presets:
|
||||
load: JQL пресет
|
||||
load_desc: Загрузить сохраненный JQL запрос
|
||||
select: Выберите пресет
|
||||
save: Сохранить как пресет
|
||||
save_desc: Сохранить текущий JQL запрос как именованный пресет
|
||||
save_hint: Имя пресета
|
||||
save_btn: Сохранить
|
||||
delete: Удалить пресет
|
||||
|
||||
preview:
|
||||
total: "{total} задач найдено"
|
||||
showing: " (показаны первые {limit} задач)"
|
||||
error: "{error}"
|
||||
total: '{total} задач найдено'
|
||||
showing: ' (показаны первые {limit} задач)'
|
||||
error: '{error}'
|
||||
loading: Загрузка...
|
||||
empty_input: Введите JQL запрос для просмотра превью задач
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ start:
|
|||
comment:
|
||||
name: Описание работы
|
||||
desc: Необязательно
|
||||
placeholder: ''
|
||||
placeholder: 'Введите описание работы...'
|
||||
|
||||
months:
|
||||
- янв
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ auth:
|
|||
desc: Выберите способ подключения к Jira
|
||||
options:
|
||||
bearer: Bearer Token (PAT)
|
||||
basic: Basic Auth (Имя пользователя + PAT)
|
||||
basic: Basic Auth (email + PAT)
|
||||
session: Session Cookie (Имя пользователя + Пароль)
|
||||
pat:
|
||||
title: Jira PAT
|
||||
|
|
@ -37,3 +37,12 @@ ping:
|
|||
title: Ping Jira
|
||||
desc: Проверить подключение к Jira
|
||||
button: Ping Jira
|
||||
|
||||
name:
|
||||
title: Название подключения
|
||||
desc: Понятное имя для этого подключения
|
||||
def: Мое подключение к Jira
|
||||
|
||||
select_connection: Выбрать подключение
|
||||
add_connection: Добавить подключение
|
||||
no_connection: Нет подключения
|
||||
|
|
|
|||
|
|
@ -2,22 +2,29 @@ title: Настройка получаемых полей
|
|||
note:
|
||||
desc0: Важно!
|
||||
desc1: " Следующие поля 'fields' и 'expand' будут использоваться в большинстве запросах Jira Manager. Убедитесь, что вы знаете, что делаете."
|
||||
desc2: "Для дополнительной информации можете посмотреть официальную "
|
||||
link_label: "документацию"
|
||||
desc3: ". Заполняя поля, перечислите параметры через разделитель ','. Пример: \"summary, description\""
|
||||
desc2: 'Для дополнительной информации можете посмотреть официальную '
|
||||
link_label: 'документацию'
|
||||
desc3: '. Заполняя поля, перечислите параметры через разделитель '',''. Пример: "summary, description"'
|
||||
key:
|
||||
name: Просмотр необработанных данных задачи
|
||||
desc: Введите номер задачи Jira, чтобы просмотреть необработанные данные, полученные из API
|
||||
fields:
|
||||
name: Поля задачи ('fields')
|
||||
desc: "Главные поля Jira"
|
||||
def: "*all, -comment"
|
||||
desc: 'Главные поля Jira'
|
||||
def: '*all, -comment'
|
||||
expand:
|
||||
name: Расширенные поля ('expand')
|
||||
desc: "Дополнительные поля Jira"
|
||||
def: "renderedFields, changelog"
|
||||
desc: 'Дополнительные поля Jira'
|
||||
def: 'renderedFields, changelog'
|
||||
filenameTemplate:
|
||||
name: Шаблон названия файла
|
||||
desc: |
|
||||
Шаблон для названий файлов созданных задач. Используйте {summary} для названия задачи и {key} для ключа задачи
|
||||
placeholder: "{summary} ({key})"
|
||||
placeholder: '{summary} ({key})'
|
||||
|
||||
rename_files: Переименовать существующие файлы
|
||||
no_files_to_rename: Нет файлов в кэше для переименования
|
||||
rename_confirm: 'Будет переименовано {count} файл(ов). Продолжить?'
|
||||
rename_success: 'Успешно переименовано {count} файл(ов)'
|
||||
rename_errors: 'Не удалось переименовать {count} файл(ов)'
|
||||
rename_no_changes: Файлы не нуждаются в переименовании
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
title: Сопоставление полей
|
||||
desc: Настройте, как поля будут сопоставляться между Obsidian и Jira. Для каждого
|
||||
поля требуется функция преобразования 'В Jira' и 'Из Jira'.
|
||||
desc: |
|
||||
Настройте сопоставление полей между Obsidian и Jira. Для каждого поля необходимо указать функции преобразования "В Jira" и "Из Jira".
|
||||
|
||||
В Jira:
|
||||
- value — значение поля в .md файле — это может быть текст из содержимого файла или значение поля из frontmatter
|
||||
- api_version - Jira API версия используемая в текущем коннекторе (может быть '2' или '3')
|
||||
|
||||
Из Jira:
|
||||
- issue — необработанные данные из Jira (можно увидеть в нижнем разделе "Настройка получаемых полей")
|
||||
- api_version - То же самое что и в "В Jira"
|
||||
- data_source — либо спаршенные секции данных из файла, либо данные frontmatter. Показывается в формате словаря, где ключи — это названия полей, а значения — данные из файла
|
||||
fv:
|
||||
name: Включить проверку полей
|
||||
desc: Проверять поля перед сохранением базируясь на стандартном ответе API
|
||||
desc: Проверять поля перед сохранением базируясь на стандартном ответе API
|
||||
(при проверке не учитываются пользовательские поля)
|
||||
secNote:
|
||||
name: "⚠️ Примечание: "
|
||||
name: '⚠️ Примечание: '
|
||||
desc: Сопоставление полей использует JavaScript lambda функции.
|
||||
example:
|
||||
name: Примеры сопоставления полей
|
||||
|
|
@ -14,11 +23,16 @@ example:
|
|||
from_jira: Из Jira
|
||||
to_jira: В Jira
|
||||
field_name: Название поля
|
||||
field_name_placeholder: Введите название поля
|
||||
|
||||
add_mapping: Добавить сопоставление
|
||||
add_mapping_tooltip: Добавить новое сопоставление полей
|
||||
reset: Убрать все
|
||||
reset_tooltip: Убрать все
|
||||
reset_confirm_title: Сбросить сопоставление полей
|
||||
reset_confirm_text: Сопоставление полей будет очищено. Вы уверены?
|
||||
remove_field: Удалить поле
|
||||
reload_defaults: Загрузить по умолчанию
|
||||
reload_defaults_tooltip: Обновить
|
||||
reload_confirm_title: Обновить сопоставления полей по умолчанию
|
||||
reload_confirm_text: Текущие сопоставления полей будут перезаписаны сопоставлениями по умолчанию. Вы уверены?
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
title: Статистика ведения журнала работы через Timekeep
|
||||
description: Управление и отправка журнала работы в Jira
|
||||
description: 'Управление и отправка журнала работы в Jira (поддерживает формат Timekeep и Super Simple Time Tracker)'
|
||||
settings:
|
||||
send_comments:
|
||||
name: Отправлять комментарии
|
||||
|
|
@ -38,6 +38,7 @@ display:
|
|||
no_entries: Записи рабочего лога не найдены.
|
||||
period_of: Период от {date}
|
||||
table:
|
||||
total: Всего
|
||||
task: Задача
|
||||
block_path: Подзадача
|
||||
start_time: Время начала
|
||||
|
|
|
|||
|
|
@ -7,5 +7,7 @@ invalid_exp: Некорректное выражение
|
|||
from_jira: Из Jira
|
||||
value: Значение
|
||||
|
||||
add_mapping: Добавить сопоставление
|
||||
add_mapping_tooltip: Добавить тестовое сопоставление полей
|
||||
reset: Сбросить
|
||||
reset_tooltip: Сбросить
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function replacePlaceholders(template: string, dict: Record<string, unknown>): s
|
|||
return template.replace(/{([^{}]+)}/g, (match, key) => {
|
||||
// Trim the key in case there's whitespace inside the braces
|
||||
const trimmedKey = key.trim();
|
||||
return dict.hasOwnProperty(trimmedKey) ? String(dict[trimmedKey]) : match;
|
||||
return Object.prototype.hasOwnProperty.call(dict, trimmedKey) ? String(dict[trimmedKey]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +63,6 @@ export const useTranslations = (prefix?: string) => {
|
|||
return {
|
||||
t: translate,
|
||||
locale: currentLocale,
|
||||
setPrefix: (newPrefix: string) => useTranslations(newPrefix)
|
||||
setPrefix: (newPrefix: string) => useTranslations(newPrefix),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,242 +3,263 @@ const path = require('path');
|
|||
const chokidar = require('chokidar');
|
||||
|
||||
const COMPILED_DIR = path.join(__dirname, 'compiled');
|
||||
const SRC_DIR = path.join(__dirname, '../../src');
|
||||
|
||||
// Function to get all keys from an object (including nested ones)
|
||||
function getAllKeys(obj, prefix = '', result = new Set()) {
|
||||
for (const key in obj) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
result.add(fullKey);
|
||||
|
||||
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||
getAllKeys(obj[key], fullKey, result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
for (const key in obj) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof obj[key] === 'string') {
|
||||
result.add(fullKey);
|
||||
} else if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||
getAllKeys(obj[key], fullKey, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if a key exists in an object
|
||||
function keyExists(obj, keyPath) {
|
||||
const parts = keyPath.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) {
|
||||
return false;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return true;
|
||||
const parts = keyPath.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) return false;
|
||||
current = current[part];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get value from nested object using dot notation
|
||||
function getValue(obj, keyPath) {
|
||||
const parts = keyPath.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
const parts = keyPath.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function findTranslationsInTsFiles() {
|
||||
const usedKeys = new Set();
|
||||
const files = [];
|
||||
|
||||
function walkDir(dir) {
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (item.endsWith('.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(SRC_DIR);
|
||||
|
||||
const useTranslationsRegex = /const\s+t\s*=\s*useTranslations\(["']([^"']+)["']\)\.t\s*;/g;
|
||||
const tCallRegex = /(?<!\w)t\(["']([^"']+)["']\)/g;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const relativePath = path.relative(SRC_DIR, file);
|
||||
|
||||
let match;
|
||||
let prefix = null;
|
||||
|
||||
useTranslationsRegex.lastIndex = 0;
|
||||
while ((match = useTranslationsRegex.exec(content)) !== null) {
|
||||
prefix = match[1];
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
tCallRegex.lastIndex = 0;
|
||||
while ((match = tCallRegex.exec(content)) !== null) {
|
||||
const key = match[1];
|
||||
const fullKey = `${prefix}.${key}`;
|
||||
usedKeys.add(fullKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error reading ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return usedKeys;
|
||||
}
|
||||
|
||||
// Function to validate translations
|
||||
function validateTranslations() {
|
||||
console.log('🔍 Validating translation files...');
|
||||
console.log('🔍 Validating translation files...');
|
||||
|
||||
// Read all compiled JSON files
|
||||
const files = fs.readdirSync(COMPILED_DIR).filter(file => file.endsWith('.json'));
|
||||
const files = fs.readdirSync(COMPILED_DIR).filter((file) => file.endsWith('.json'));
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('⚠️ No compiled translation files found');
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.log('⚠️ No compiled translation files found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all translation data
|
||||
const translations = {};
|
||||
for (const file of files) {
|
||||
const locale = path.basename(file, '.json');
|
||||
const filePath = path.join(COMPILED_DIR, file);
|
||||
translations[locale] = fs.readJsonSync(filePath);
|
||||
}
|
||||
console.log('\n📊 Scanning .ts files for used translation keys...');
|
||||
const usedKeys = findTranslationsInTsFiles();
|
||||
console.log(`📝 Found ${usedKeys.size} translation keys used in code`);
|
||||
|
||||
// Get all locales
|
||||
const locales = Object.keys(translations);
|
||||
console.log(`📁 Found ${locales.length} locales: ${locales.join(', ')}`);
|
||||
const translations = {};
|
||||
for (const file of files) {
|
||||
const locale = path.basename(file, '.json');
|
||||
const filePath = path.join(COMPILED_DIR, file);
|
||||
translations[locale] = fs.readJsonSync(filePath);
|
||||
}
|
||||
|
||||
if (locales.length < 2) {
|
||||
console.log('⚠️ Need at least 2 locales to compare');
|
||||
return;
|
||||
}
|
||||
const locales = Object.keys(translations);
|
||||
console.log(`📁 Found ${locales.length} locales: ${locales.join(', ')}`);
|
||||
|
||||
// Choose the first locale as reference
|
||||
const referenceLocale = locales[0];
|
||||
console.log(`🔑 Using ${referenceLocale} as reference locale`);
|
||||
if (locales.length < 2) {
|
||||
console.log('⚠️ Need at least 2 locales to compare');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all keys from reference locale
|
||||
const referenceKeys = getAllKeys(translations[referenceLocale]);
|
||||
console.log(`🔢 Reference locale has ${referenceKeys.size} unique keys`);
|
||||
const referenceLocale = locales[0];
|
||||
console.log(`🔑 Using ${referenceLocale} as reference locale`);
|
||||
|
||||
// Track statistics
|
||||
const stats = {
|
||||
missingKeys: {},
|
||||
extraKeys: {},
|
||||
typeErrors: {}
|
||||
};
|
||||
const referenceKeys = getAllKeys(translations[referenceLocale]);
|
||||
console.log(`🔢 Reference locale has ${referenceKeys.size} unique keys`);
|
||||
|
||||
// Initialize stats for each locale
|
||||
for (const locale of locales) {
|
||||
if (locale !== referenceLocale) {
|
||||
stats.missingKeys[locale] = [];
|
||||
stats.extraKeys[locale] = [];
|
||||
stats.typeErrors[locale] = [];
|
||||
}
|
||||
}
|
||||
const stats = {
|
||||
missingKeys: {},
|
||||
extraKeys: {},
|
||||
usedButNotInLocale: {},
|
||||
inLocaleButNotUsed: {},
|
||||
};
|
||||
|
||||
// Check each locale against the reference
|
||||
for (const locale of locales) {
|
||||
if (locale === referenceLocale) continue;
|
||||
for (const locale of locales) {
|
||||
if (locale !== referenceLocale) {
|
||||
stats.missingKeys[locale] = [];
|
||||
stats.extraKeys[locale] = [];
|
||||
stats.usedButNotInLocale[locale] = [];
|
||||
stats.inLocaleButNotUsed[locale] = [];
|
||||
}
|
||||
}
|
||||
stats.inLocaleButNotUsed[referenceLocale] = [];
|
||||
stats.usedButNotInLocale[referenceLocale] = [];
|
||||
|
||||
const localeKeys = getAllKeys(translations[locale]);
|
||||
for (const locale of locales) {
|
||||
const localeKeys = getAllKeys(translations[locale]);
|
||||
|
||||
// Check for missing keys
|
||||
for (const key of referenceKeys) {
|
||||
if (!keyExists(translations[locale], key)) {
|
||||
stats.missingKeys[locale].push(key);
|
||||
} else {
|
||||
// Check for type mismatches
|
||||
const refValue = getValue(translations[referenceLocale], key);
|
||||
const localeValue = getValue(translations[locale], key);
|
||||
for (const refKey of referenceKeys) {
|
||||
if (!keyExists(translations[locale], refKey)) {
|
||||
stats.missingKeys[locale].push(refKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof refValue !== typeof localeValue) {
|
||||
stats.typeErrors[locale].push({
|
||||
key,
|
||||
refType: typeof refValue,
|
||||
localeType: typeof localeValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const locKey of localeKeys) {
|
||||
if (!keyExists(translations[referenceLocale], locKey)) {
|
||||
stats.extraKeys[locale].push(locKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra keys
|
||||
for (const key of localeKeys) {
|
||||
if (!keyExists(translations[referenceLocale], key)) {
|
||||
stats.extraKeys[locale].push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const usedKey of usedKeys) {
|
||||
if (!keyExists(translations[locale], usedKey)) {
|
||||
stats.usedButNotInLocale[locale].push(usedKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Print results
|
||||
let hasIssues = false;
|
||||
for (const locKey of localeKeys) {
|
||||
if (!usedKeys.has(locKey)) {
|
||||
stats.inLocaleButNotUsed[locale].push(locKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print missing keys
|
||||
for (const locale in stats.missingKeys) {
|
||||
const missing = stats.missingKeys[locale];
|
||||
if (missing.length > 0) {
|
||||
hasIssues = true;
|
||||
console.log(`❌ ${locale} is missing ${missing.length} keys:`);
|
||||
missing.forEach(key => {
|
||||
console.log(` - ${key}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
let hasIssues = false;
|
||||
|
||||
// Print extra keys
|
||||
for (const locale in stats.extraKeys) {
|
||||
const extra = stats.extraKeys[locale];
|
||||
if (extra.length > 0) {
|
||||
hasIssues = true;
|
||||
console.log(`⚠️ ${locale} has ${extra.length} extra keys:`);
|
||||
extra.forEach(key => {
|
||||
console.log(` - ${key}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const locale in stats.missingKeys) {
|
||||
const missing = stats.missingKeys[locale];
|
||||
if (missing.length > 0) {
|
||||
hasIssues = true;
|
||||
console.log(`\n❌ ${locale} is missing ${missing.length} keys from reference:`);
|
||||
missing.forEach((key) => console.log(` - ${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Print type errors
|
||||
for (const locale in stats.typeErrors) {
|
||||
const typeErrors = stats.typeErrors[locale];
|
||||
if (typeErrors.length > 0) {
|
||||
hasIssues = true;
|
||||
console.log(`⚠️ ${locale} has ${typeErrors.length} type mismatches:`);
|
||||
typeErrors.forEach(err => {
|
||||
console.log(` - ${err.key}: expected ${err.refType}, got ${err.localeType}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const locale in stats.extraKeys) {
|
||||
const extra = stats.extraKeys[locale];
|
||||
if (extra.length > 0) {
|
||||
hasIssues = true;
|
||||
console.log(`\n⚠️ ${locale} has ${extra.length} extra keys not in reference:`);
|
||||
extra.forEach((key) => console.log(` - ${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Print empty values check if needed
|
||||
console.log('\n📊 Checking for empty values...');
|
||||
for (const locale of locales) {
|
||||
checkEmptyValues(translations[locale], locale);
|
||||
}
|
||||
for (const locale in stats.usedButNotInLocale) {
|
||||
const missing = stats.usedButNotInLocale[locale];
|
||||
if (missing.length > 0) {
|
||||
hasIssues = true;
|
||||
console.log(`\n🚨 ${locale}: ${missing.length} keys are used in code but NOT in localization:`);
|
||||
missing.forEach((key) => console.log(` - ${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log('✅ All locales have consistent structure!');
|
||||
}
|
||||
for (const locale in stats.inLocaleButNotUsed) {
|
||||
const unused = stats.inLocaleButNotUsed[locale];
|
||||
if (unused.length > 0) {
|
||||
console.log(`\n💡 ${locale}: ${unused.length} keys are in localization but NOT used in code:`);
|
||||
unused.forEach((key) => console.log(` - ${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
return hasIssues;
|
||||
console.log('\n📊 Checking for empty values...');
|
||||
for (const locale of locales) {
|
||||
checkEmptyValues(translations[locale], locale);
|
||||
}
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log('\n✅ All locales have consistent structure and all used keys are defined!');
|
||||
}
|
||||
|
||||
return hasIssues;
|
||||
}
|
||||
|
||||
// Function to check for empty values
|
||||
function checkEmptyValues(obj, locale, prefix = '') {
|
||||
for (const key in obj) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
for (const key in obj) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
|
||||
if (value === '') {
|
||||
console.log(`⚠️ ${locale} has empty string at ${fullKey}`);
|
||||
} else if (value === null) {
|
||||
console.log(`⚠️ ${locale} has null value at ${fullKey}`);
|
||||
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
checkEmptyValues(value, locale, fullKey);
|
||||
}
|
||||
}
|
||||
if (value === '') {
|
||||
console.log(`⚠️ ${locale} has empty string at ${fullKey}`);
|
||||
} else if (value === null) {
|
||||
console.log(`⚠️ ${locale} has null value at ${fullKey}`);
|
||||
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
checkEmptyValues(value, locale, fullKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
function main() {
|
||||
// Create compiled directory if it doesn't exist
|
||||
fs.ensureDirSync(COMPILED_DIR);
|
||||
|
||||
// Run validation
|
||||
validateTranslations();
|
||||
fs.ensureDirSync(COMPILED_DIR);
|
||||
validateTranslations();
|
||||
}
|
||||
|
||||
// Watch mode
|
||||
if (process.argv.includes('--watch')) {
|
||||
console.log('👀 Watching for changes...');
|
||||
console.log('👀 Watching for changes...');
|
||||
let debounceTimer;
|
||||
const debounceDelay = 100;
|
||||
|
||||
let debounceTimer;
|
||||
const debounceDelay = 100;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => main(), debounceDelay);
|
||||
|
||||
// Initial validation
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
main();
|
||||
}, debounceDelay);
|
||||
|
||||
// Watch for changes in the compiled directory
|
||||
chokidar.watch(COMPILED_DIR, {
|
||||
ignoreInitial: true,
|
||||
ignored: /.*~$/, // Игнорировать скрытые файлы
|
||||
}).on('all', (event, path) => {
|
||||
if (event === 'change' || event === 'add' || event === 'unlink') {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
console.log(`🔁 Detected changes in ${path} (${event}), revalidating...`);
|
||||
main();
|
||||
}, debounceDelay);
|
||||
}
|
||||
});
|
||||
chokidar
|
||||
.watch([COMPILED_DIR, SRC_DIR], {
|
||||
ignoreInitial: true,
|
||||
ignored: /.*~$/,
|
||||
})
|
||||
.on('all', (event, path) => {
|
||||
if (event === 'change' || event === 'add' || event === 'unlink') {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
console.log(`\n🔁 Detected changes in ${path} (${event}), revalidating...`);
|
||||
main();
|
||||
}, debounceDelay);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Run once
|
||||
main();
|
||||
main();
|
||||
}
|
||||
|
|
|
|||
101
src/main.ts
101
src/main.ts
|
|
@ -1,20 +1,26 @@
|
|||
import {Plugin } from "obsidian";
|
||||
import { JiraSettingTab } from "./settings/JiraSettingTab";
|
||||
import {DEFAULT_SETTINGS, JiraSettingsInterface} from "./settings/default";
|
||||
import { Plugin } from 'obsidian';
|
||||
import { JiraSettingTab } from './settings/JiraSettingTab';
|
||||
import { ConnectionSettingsInterface, DEFAULT_SETTINGS, JiraSettingsInterface } from './settings/default';
|
||||
import {
|
||||
registerUpdateIssueCommand, registerUpdateWorkLogManuallyCommand,
|
||||
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";
|
||||
import {checkMigrateSettings} from "./tools/migrateSettings";
|
||||
registerUpdateIssueCommand,
|
||||
registerUpdateWorkLogManuallyCommand,
|
||||
registerGetCurrentIssueCommand,
|
||||
registerUpdateWorkLogBatchCommand,
|
||||
registerCreateIssueCommand,
|
||||
registerGetIssueCommandWithCustomKey,
|
||||
registerUpdateIssueStatusCommand,
|
||||
registerBatchFetchIssuesCommand,
|
||||
registerAddCommentCommand,
|
||||
registerRebuildCacheCommand,
|
||||
} from './commands';
|
||||
import { transform_string_to_functions_mappings } from './tools/convertFunctionString';
|
||||
import { createJiraSyncExtension } from './postprocessing/livePreview';
|
||||
import { hideJiraPointersReading } from './postprocessing/reading';
|
||||
import { buildCacheFromFilesystem } from './tools/cacheUtils';
|
||||
import { checkMigrateSettings } from './tools/migrateSettings';
|
||||
|
||||
export default class JiraPlugin extends Plugin {
|
||||
settings: JiraSettingsInterface;
|
||||
settings!: JiraSettingsInterface;
|
||||
|
||||
// In-memory cache for instant access during synchronization
|
||||
private issueKeyToFilePathCache: Map<string, string> = new Map();
|
||||
|
|
@ -22,9 +28,8 @@ export default class JiraPlugin extends Plugin {
|
|||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// validate cache from settings
|
||||
// initialize cache from settings
|
||||
this.initializeCache();
|
||||
await validateCache(this);
|
||||
|
||||
// Register all commands
|
||||
registerUpdateIssueCommand(this);
|
||||
|
|
@ -36,28 +41,44 @@ export default class JiraPlugin extends Plugin {
|
|||
|
||||
registerUpdateWorkLogManuallyCommand(this);
|
||||
registerUpdateWorkLogBatchCommand(this);
|
||||
registerAddCommentCommand(this);
|
||||
registerRebuildCacheCommand(this);
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new JiraSettingTab(this.app, this));
|
||||
|
||||
// Handle Reading mode (post-processor for rendered markdown)
|
||||
this.registerMarkdownPostProcessor(hideJiraPointersReading.bind(this));
|
||||
this.registerMarkdownPostProcessor(hideJiraPointersReading());
|
||||
|
||||
// Handle Live Preview/Edit mode (CodeMirror extension)
|
||||
this.registerEditorExtension(createJiraSyncExtension(this));
|
||||
|
||||
// Register vault event listeners for cache maintenance
|
||||
this.registerVaultEventListeners();
|
||||
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
const old_data = await this.loadData();
|
||||
// TODO: Cancel and delete migration check in future (approximately 2026-2027)
|
||||
const new_data = checkMigrateSettings(old_data, this.saveSettings);
|
||||
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, new_data);
|
||||
this.settings.fieldMapping.fieldMappings = await transform_string_to_functions_mappings(this.settings.fieldMapping.fieldMappingsStrings);
|
||||
// TODO: Cancel and delete migration check in future (approximately 2026-2027)
|
||||
const { result: migratedData, changed: migrationChanged } = checkMigrateSettings(old_data);
|
||||
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...migratedData,
|
||||
fieldMapping: {
|
||||
...DEFAULT_SETTINGS.fieldMapping,
|
||||
...(migratedData?.fieldMapping || {}),
|
||||
},
|
||||
};
|
||||
|
||||
this.settings.fieldMapping.fieldMappings = await transform_string_to_functions_mappings(
|
||||
this.settings.fieldMapping.fieldMappingsStrings,
|
||||
);
|
||||
|
||||
if (migrationChanged) {
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -72,60 +93,66 @@ export default class JiraPlugin extends Plugin {
|
|||
}
|
||||
|
||||
getAllIssueKeysMap(): Map<string, string> {
|
||||
return this.issueKeyToFilePathCache
|
||||
return this.issueKeyToFilePathCache;
|
||||
}
|
||||
|
||||
getFilePathForIssueKey(issueKey: string): string | undefined {
|
||||
return this.issueKeyToFilePathCache.get(issueKey);
|
||||
}
|
||||
|
||||
setFilePathForIssueKey(issueKey: string, filePath: string) {
|
||||
getCurrentConnection(): ConnectionSettingsInterface | null {
|
||||
if (!this.settings.connections || this.settings.connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.settings.connections[this.settings.currentConnectionIndex];
|
||||
}
|
||||
|
||||
async setFilePathForIssueKey(issueKey: string, filePath: string) {
|
||||
this.issueKeyToFilePathCache.set(issueKey, filePath);
|
||||
this.settings.issueKeyToFilePathCache[issueKey] = filePath;
|
||||
this.saveSettings();
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
removeIssueKeyFromCache(issueKey: string) {
|
||||
async removeIssueKeyFromCache(issueKey: string) {
|
||||
this.issueKeyToFilePathCache.delete(issueKey);
|
||||
delete this.settings.issueKeyToFilePathCache[issueKey];
|
||||
this.saveSettings();
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
async clearCache() {
|
||||
this.issueKeyToFilePathCache.clear();
|
||||
this.settings.issueKeyToFilePathCache = {};
|
||||
this.saveSettings();
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async rebuildCache() {
|
||||
this.clearCache();
|
||||
await this.clearCache();
|
||||
await buildCacheFromFilesystem(this);
|
||||
}
|
||||
|
||||
private registerVaultEventListeners() {
|
||||
|
||||
// Handle file renames
|
||||
this.registerEvent(
|
||||
this.app.vault.on('rename', (file, oldPath) => {
|
||||
this.app.vault.on('rename', async (file, oldPath) => {
|
||||
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
|
||||
if (cachedPath === oldPath) {
|
||||
this.setFilePathForIssueKey(issueKey, file.path);
|
||||
await this.setFilePathForIssueKey(issueKey, file.path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Handle file deletions
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
this.app.vault.on('delete', async (file) => {
|
||||
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
|
||||
if (cachedPath === file.path) {
|
||||
this.removeIssueKeyFromCache(issueKey);
|
||||
await this.removeIssueKeyFromCache(issueKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/modals/ConfirmModal.ts
Normal file
39
src/modals/ConfirmModal.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
private message: string;
|
||||
private onConfirm: () => void;
|
||||
private onCancel: () => void;
|
||||
|
||||
constructor(app: App, message: string, onConfirm: () => void, onCancel: () => void) {
|
||||
super(app);
|
||||
this.message = message;
|
||||
this.onConfirm = onConfirm;
|
||||
this.onCancel = onCancel;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl('h2', { text: this.message });
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Yes')
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
this.onConfirm();
|
||||
}),
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('No').onClick(() => {
|
||||
this.close();
|
||||
this.onCancel();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import {App, Modal, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { App, Modal, Setting } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("modals.add_summary").t;
|
||||
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 = "";
|
||||
private summary: string = '';
|
||||
|
||||
constructor(app: App, onSubmit: (result: string) => void) {
|
||||
super(app);
|
||||
|
|
@ -15,33 +15,30 @@ export class IssueAddSummaryModal extends Modal {
|
|||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl("h2", {text: t("desc")});
|
||||
this.contentEl.createEl('h2', { text: t('desc') });
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t("key.name"))
|
||||
.setDesc(t("key.desc"))
|
||||
.setName(t('key.name'))
|
||||
.setDesc(t('key.desc'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("key.placeholder"))
|
||||
.onChange((value) => {
|
||||
this.summary = value;
|
||||
})
|
||||
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);
|
||||
})
|
||||
);
|
||||
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") {
|
||||
this.contentEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.onSubmit(this.summary);
|
||||
|
|
|
|||
55
src/modals/IssueCommentModal.ts
Normal file
55
src/modals/IssueCommentModal.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { App, Modal, Notice, Setting, TextAreaComponent } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations('modals.comment').t;
|
||||
|
||||
/**
|
||||
* Modal for adding a comment to a Jira issue.
|
||||
* Pre-populated with any text that was selected in the editor when the command was invoked.
|
||||
*/
|
||||
export class IssueCommentModal extends Modal {
|
||||
private onSubmit: (commentText: string) => void;
|
||||
private commentText: string;
|
||||
|
||||
constructor(app: App, initialText: string, onSubmit: (commentText: string) => void) {
|
||||
super(app);
|
||||
this.commentText = initialText;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
new Setting(this.contentEl).setName(t('name')).setHeading();
|
||||
|
||||
const textarea = new TextAreaComponent(this.contentEl)
|
||||
.setPlaceholder(t('body.placeholder'))
|
||||
.setValue(this.commentText)
|
||||
.onChange((value) => {
|
||||
this.commentText = value;
|
||||
});
|
||||
textarea.inputEl.rows = 10;
|
||||
textarea.inputEl.style.width = '100%';
|
||||
textarea.inputEl.style.marginBottom = '0.75em';
|
||||
textarea.inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(this.contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('submit'))
|
||||
.setCta()
|
||||
.onClick(() => this.submit()),
|
||||
);
|
||||
}
|
||||
|
||||
private submit() {
|
||||
if (!this.commentText.trim()) {
|
||||
new Notice(t('warns.empty'));
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSubmit(this.commentText);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
import {App, Modal, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import {JQLPreview} from "../settings/components/JQLPreview";
|
||||
import JiraPlugin from "../main";
|
||||
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;
|
||||
const t = useTranslations('modals.search').t;
|
||||
/**
|
||||
* Modal for searching issues by key
|
||||
*/
|
||||
export class IssueSearchModal extends Modal {
|
||||
private onSubmit: (result: string) => void;
|
||||
private plugin: JiraPlugin;
|
||||
private issueKey: string = "";
|
||||
private issueKey: string = '';
|
||||
private previewEl?: HTMLElement;
|
||||
private preview?: JQLPreview;
|
||||
private debounceTimer?: number;
|
||||
|
|
@ -22,38 +22,35 @@ export class IssueSearchModal extends Modal {
|
|||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl("h2", {text: t("desc")});
|
||||
this.contentEl.createEl('h2', { text: t('desc') });
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t("key.name"))
|
||||
.setDesc(t("key.desc"))
|
||||
.setName(t('key.name'))
|
||||
.setDesc(t('key.desc'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("key.placeholder"))
|
||||
.onChange((value) => {
|
||||
this.issueKey = value;
|
||||
this.schedulePreview();
|
||||
})
|
||||
text.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("")
|
||||
this.preview.loadPreview('');
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t("submit"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
this.onSubmit(this.issueKey);
|
||||
})
|
||||
);
|
||||
new Setting(this.contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('submit'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
this.onSubmit(this.issueKey);
|
||||
}),
|
||||
);
|
||||
|
||||
// Handle Enter key
|
||||
this.contentEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
this.contentEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.onSubmit(this.issueKey);
|
||||
|
|
@ -73,7 +70,7 @@ export class IssueSearchModal extends Modal {
|
|||
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
if (this.preview) {
|
||||
this.preview.loadPreview(this.issueKey? "key = "+this.issueKey : "").catch(() => {
|
||||
this.preview.loadPreview(this.issueKey ? 'key = ' + this.issueKey : '').catch(() => {
|
||||
// Error handling is done within the preview component
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {App, SuggestModal} from "obsidian";
|
||||
import {JiraTransitionType} from "../interfaces";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { App, SuggestModal } from 'obsidian';
|
||||
import { JiraTransitionType } from '../interfaces';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("modals.status").t;
|
||||
const t = useTranslations('modals.status').t;
|
||||
|
||||
/**
|
||||
* Modal for selecting an issue status
|
||||
|
|
@ -15,18 +15,19 @@ export class IssueStatusModal extends SuggestModal<JiraTransitionType> {
|
|||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.issueTransitions = issueTransitions;
|
||||
this.setPlaceholder(t("placeholder"));
|
||||
this.setPlaceholder(t('placeholder'));
|
||||
}
|
||||
|
||||
getSuggestions(query: string): JiraTransitionType[] {
|
||||
return this.issueTransitions.filter((transition) =>
|
||||
transition.status && transition.status.toLowerCase().includes(query.toLowerCase()) ||
|
||||
transition.action && transition.action.toLowerCase().includes(query.toLowerCase())
|
||||
return this.issueTransitions.filter(
|
||||
(transition) =>
|
||||
(transition.status && transition.status.toLowerCase().includes(query.toLowerCase())) ||
|
||||
(transition.action && transition.action.toLowerCase().includes(query.toLowerCase())),
|
||||
);
|
||||
}
|
||||
|
||||
renderSuggestion(type: JiraTransitionType, el: HTMLElement) {
|
||||
let result = "";
|
||||
let result = '';
|
||||
if (type.action && type.status) {
|
||||
if (type.action === type.status) {
|
||||
result = type.action;
|
||||
|
|
@ -38,7 +39,7 @@ export class IssueStatusModal extends SuggestModal<JiraTransitionType> {
|
|||
} else if (type.status) {
|
||||
result = type.status;
|
||||
}
|
||||
el.createEl("div", {text: result});
|
||||
el.createEl('div', { text: result });
|
||||
}
|
||||
|
||||
onChooseSuggestion(type: JiraTransitionType) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {App, SuggestModal} from "obsidian";
|
||||
import {JiraIssueType} from "../interfaces";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { App, SuggestModal } from 'obsidian';
|
||||
import { JiraIssueType } from '../interfaces';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("modals.type").t;
|
||||
const t = useTranslations('modals.type').t;
|
||||
|
||||
/**
|
||||
* Modal for selecting an issue type
|
||||
|
|
@ -15,17 +15,15 @@ export class IssueTypeModal extends SuggestModal<JiraIssueType> {
|
|||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.issueTypes = issueTypes;
|
||||
this.setPlaceholder(t("placeholder"));
|
||||
this.setPlaceholder(t('placeholder'));
|
||||
}
|
||||
|
||||
getSuggestions(query: string): JiraIssueType[] {
|
||||
return this.issueTypes.filter((type) =>
|
||||
type.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
return this.issueTypes.filter((type) => type.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
renderSuggestion(type: JiraIssueType, el: HTMLElement) {
|
||||
el.createEl("div", {text: type.name});
|
||||
el.createEl('div', { text: type.name });
|
||||
}
|
||||
|
||||
onChooseSuggestion(type: JiraIssueType) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import {App, Modal, Notice, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { App, Modal, Notice, Setting } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("modals.worklog").t;
|
||||
const t = useTranslations('modals.worklog').t;
|
||||
|
||||
/**
|
||||
* Modal for adding work log entries
|
||||
*/
|
||||
export class IssueWorkLogModal extends Modal {
|
||||
private onSubmit: (timeSpent: string, startDate: string, workDescription: string) => void;
|
||||
private timeSpent: string = "";
|
||||
private startDate: string = "";
|
||||
private workDescription: string = "";
|
||||
private displayDate: string = "";
|
||||
private timeSpentSetting: Setting;
|
||||
private dateSetting: Setting;
|
||||
private timeSpent: string = '';
|
||||
private startDate: string = '';
|
||||
private workDescription: string = '';
|
||||
private displayDate: string = '';
|
||||
private timeSpentSetting!: Setting;
|
||||
private dateSetting!: Setting;
|
||||
|
||||
constructor(app: App, onSubmit: (timeSpent: string, startDate: string, workDescription: string) => void) {
|
||||
super(app);
|
||||
|
|
@ -26,7 +26,7 @@ export class IssueWorkLogModal extends Modal {
|
|||
|
||||
private initializeDates(date: Date) {
|
||||
// Set the ISO format for API
|
||||
this.startDate = date.toISOString().split('.')[0] + ".000+0000";
|
||||
this.startDate = date.toISOString().split('.')[0] + '.000+0000';
|
||||
|
||||
// Set the display format (01/Jun/21 09:00 AM)
|
||||
const months = t('months') as any;
|
||||
|
|
@ -49,7 +49,7 @@ export class IssueWorkLogModal extends Modal {
|
|||
const [hours, minutes] = timePart.split(':');
|
||||
|
||||
const months = Object.fromEntries(
|
||||
(t('months') as any).map((month: string, index: number) => [month, index])
|
||||
(t('months') as any).map((month: string, index: number) => [month, index]),
|
||||
) as Record<string, number>;
|
||||
|
||||
// {'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||
|
|
@ -62,11 +62,17 @@ export class IssueWorkLogModal extends Modal {
|
|||
const fullYear = parseInt(year) + 2000;
|
||||
|
||||
// Create date object
|
||||
const date = new Date(fullYear, months[month as keyof typeof months], parseInt(day), hour, parseInt(minutes));
|
||||
if (isNaN(date.getTime())) throw new Error("Invalid date");
|
||||
const date = new Date(
|
||||
fullYear,
|
||||
months[month as keyof typeof months],
|
||||
parseInt(day),
|
||||
hour,
|
||||
parseInt(minutes),
|
||||
);
|
||||
if (isNaN(date.getTime())) throw new Error('Invalid date');
|
||||
|
||||
return date;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -83,7 +89,7 @@ export class IssueWorkLogModal extends Modal {
|
|||
.setValue(this.timeSpent)
|
||||
.onChange((value) => {
|
||||
this.timeSpent = value;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
this.dateSetting = new Setting(this.contentEl)
|
||||
|
|
@ -95,7 +101,7 @@ export class IssueWorkLogModal extends Modal {
|
|||
.setValue(this.displayDate)
|
||||
.onChange((value) => {
|
||||
this.displayDate = value;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Start Date field with date picker icon
|
||||
|
|
@ -164,37 +170,34 @@ export class IssueWorkLogModal extends Modal {
|
|||
|
||||
// Work Description field
|
||||
new Setting(this.contentEl)
|
||||
.setName(t("comment.name"))
|
||||
.setDesc(t("comment.name"))
|
||||
.setName(t('comment.name'))
|
||||
.setDesc(t('comment.name'))
|
||||
.addTextArea((text) =>
|
||||
text
|
||||
.setPlaceholder(t("comment.placeholder"))
|
||||
.onChange((value) => {
|
||||
this.workDescription = value;
|
||||
})
|
||||
text.setPlaceholder(t('comment.placeholder')).onChange((value) => {
|
||||
this.workDescription = value;
|
||||
}),
|
||||
)
|
||||
.setClass("work-description-container");
|
||||
.setClass('work-description-container');
|
||||
|
||||
// Make the textarea larger
|
||||
const textareaComponent = this.contentEl.querySelector(".work-description-container textarea");
|
||||
const textareaComponent = this.contentEl.querySelector('.work-description-container textarea');
|
||||
if (textareaComponent) {
|
||||
(textareaComponent as HTMLTextAreaElement).rows = 5;
|
||||
}
|
||||
|
||||
// Submit button
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t("submit"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
if (!this.validateInputs()) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSubmit(this.timeSpent, this.startDate, this.workDescription);
|
||||
})
|
||||
);
|
||||
new Setting(this.contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('submit'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
if (!this.validateInputs()) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSubmit(this.timeSpent, this.startDate, this.workDescription);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,7 +205,7 @@ export class IssueWorkLogModal extends Modal {
|
|||
*/
|
||||
private validateInputs(): boolean {
|
||||
if (!this.timeSpent.trim()) {
|
||||
new Notice(t("warns.no_time"));
|
||||
new Notice(t('warns.no_time'));
|
||||
this.timeSpentSetting.setClass('invalid');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -210,20 +213,20 @@ export class IssueWorkLogModal extends Modal {
|
|||
// Validate time spent format
|
||||
const timeSpentPattern = /^(\d+[wdhm]\s*)+$/;
|
||||
if (!timeSpentPattern.test(this.timeSpent)) {
|
||||
new Notice(t("warns.invalid_time"));
|
||||
new Notice(t('warns.invalid_time'));
|
||||
this.timeSpentSetting.setClass('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.displayDate.trim()) {
|
||||
new Notice(t("warns.no_start"));
|
||||
new Notice(t('warns.no_start'));
|
||||
this.dateSetting.setClass('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate date format by trying to parse it
|
||||
if (!this.parseDisplayDate(this.displayDate)) {
|
||||
new Notice(t("warns.invalid_start"));
|
||||
new Notice(t('warns.invalid_start'));
|
||||
this.dateSetting.setClass('invalid');
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import {App, Modal, Setting} from "obsidian";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import JiraPlugin from "../main";
|
||||
import {JQLPreview} from "../settings/components/JQLPreview";
|
||||
import { App, Modal, Setting, DropdownComponent } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
import JiraPlugin from '../main';
|
||||
import { JQLPreview } from '../settings/components/JQLPreview';
|
||||
import { JQLPreset } from '../settings/default';
|
||||
|
||||
const t = useTranslations("modals.jql_search").t;
|
||||
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 jql: string = '';
|
||||
private previewEl?: HTMLElement;
|
||||
private preview?: JQLPreview;
|
||||
private debounceTimer?: number;
|
||||
private presetName: string = '';
|
||||
private presetDropdown?: DropdownComponent;
|
||||
|
||||
constructor(app: App, plugin: JiraPlugin, onSubmit: (jql: string) => void) {
|
||||
super(app);
|
||||
|
|
@ -23,45 +23,103 @@ export class JQLSearchModal extends Modal {
|
|||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl("h2", {text: t("desc")});
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
if (conn?.lastJqlQuery) {
|
||||
this.jql = conn.lastJqlQuery;
|
||||
this.schedulePreview();
|
||||
}
|
||||
|
||||
this.contentEl.createEl('h2', { text: t('desc') });
|
||||
|
||||
const presetSetting = new Setting(this.contentEl).setName(t('presets.load')).setDesc(t('presets.load_desc'));
|
||||
|
||||
presetSetting.addDropdown((dropdown) => {
|
||||
this.presetDropdown = dropdown;
|
||||
this.populateDropdown(dropdown);
|
||||
dropdown.onChange((value) => {
|
||||
if (value) {
|
||||
const preset = this.getPresets().find((p) => p.name === value);
|
||||
if (preset) {
|
||||
this.jql = preset.query;
|
||||
const textarea = this.contentEl.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.value = preset.query;
|
||||
}
|
||||
this.schedulePreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
presetSetting.addButton((btn) =>
|
||||
btn.setButtonText(t('presets.delete')).onClick(() => {
|
||||
const selectedName = this.presetDropdown?.getValue();
|
||||
if (selectedName) {
|
||||
this.deletePreset(selectedName);
|
||||
if (this.presetDropdown) {
|
||||
this.populateDropdown(this.presetDropdown);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t("jql.name"))
|
||||
.setDesc(t("jql.desc"))
|
||||
.setName(t('presets.save'))
|
||||
.setDesc(t('presets.save_desc'))
|
||||
.addText((text) =>
|
||||
text.setPlaceholder(t('presets.save_hint')).onChange((value) => {
|
||||
this.presetName = value;
|
||||
}),
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText(t('presets.save_btn')).onClick(() => {
|
||||
if (this.jql.trim() && this.presetName.trim()) {
|
||||
this.savePreset(this.presetName.trim(), this.jql.trim());
|
||||
this.presetName = '';
|
||||
const nameInput = this.contentEl.querySelectorAll('input')[1];
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (this.presetDropdown) {
|
||||
this.populateDropdown(this.presetDropdown);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t('jql.name'))
|
||||
.setDesc(t('jql.desc'))
|
||||
.addTextArea((text) => {
|
||||
text
|
||||
.setPlaceholder(t("jql.placeholder"))
|
||||
text.setPlaceholder(t('jql.placeholder'))
|
||||
.setValue(this.jql)
|
||||
.onChange((value) => {
|
||||
this.jql = value;
|
||||
this.schedulePreview();
|
||||
});
|
||||
text.inputEl.style.height = "100px";
|
||||
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("")
|
||||
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());
|
||||
}
|
||||
})
|
||||
);
|
||||
new Setting(this.contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('submit'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
if (this.jql.trim()) {
|
||||
this.saveLastQuery(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)) {
|
||||
this.contentEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
if (this.jql.trim()) {
|
||||
this.saveLastQuery(this.jql.trim());
|
||||
this.close();
|
||||
this.onSubmit(this.jql.trim());
|
||||
}
|
||||
|
|
@ -69,21 +127,56 @@ export class JQLSearchModal extends Modal {
|
|||
});
|
||||
}
|
||||
|
||||
private populateDropdown(dropdown: DropdownComponent) {
|
||||
dropdown.selectEl.empty();
|
||||
dropdown.addOption('', '— ' + t('presets.select') + ' —');
|
||||
for (const preset of this.getPresets()) {
|
||||
dropdown.addOption(preset.name, preset.name);
|
||||
}
|
||||
}
|
||||
|
||||
private getPresets(): JQLPreset[] {
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
return conn?.jqlPresets || [];
|
||||
}
|
||||
|
||||
private savePreset(name: string, query: string) {
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
if (!conn) return;
|
||||
|
||||
const existingIndex = conn.jqlPresets.findIndex((p) => p.name === name);
|
||||
if (existingIndex >= 0) {
|
||||
conn.jqlPresets[existingIndex].query = query;
|
||||
} else {
|
||||
conn.jqlPresets.push({ name, query });
|
||||
}
|
||||
this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private deletePreset(name: string) {
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
if (!conn) return;
|
||||
conn.jqlPresets = conn.jqlPresets.filter((p) => p.name !== name);
|
||||
this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private saveLastQuery(jql: string) {
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
if (!conn) return;
|
||||
conn.lastJqlQuery = jql;
|
||||
this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
this.preview.loadPreview(this.jql).catch(() => {});
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {App, SuggestModal} from "obsidian";
|
||||
import {JiraProject} from "../interfaces";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import { App, SuggestModal } from 'obsidian';
|
||||
import { JiraProject } from '../interfaces';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations("modals.project").t;
|
||||
const t = useTranslations('modals.project').t;
|
||||
|
||||
/**
|
||||
* Modal for selecting a project
|
||||
|
|
@ -15,20 +15,20 @@ export class ProjectModal extends SuggestModal<JiraProject> {
|
|||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.projects = projects;
|
||||
this.setPlaceholder(t("placeholder"));
|
||||
this.setPlaceholder(t('placeholder'));
|
||||
}
|
||||
|
||||
getSuggestions(query: string): JiraProject[] {
|
||||
return this.projects.filter(
|
||||
(project) =>
|
||||
project.id.toLowerCase().includes(query.toLowerCase()) ||
|
||||
project.name.toLowerCase().includes(query.toLowerCase())
|
||||
project.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
renderSuggestion(project: JiraProject, el: HTMLElement) {
|
||||
el.createEl("div", {text: project.name});
|
||||
el.createEl("small", {text: project.id});
|
||||
el.createEl('div', { text: project.name });
|
||||
el.createEl('small', { text: project.id });
|
||||
}
|
||||
|
||||
onChooseSuggestion(project: JiraProject) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
export * from "./IssueSearchModal";
|
||||
export * from "./IssueStatusModal";
|
||||
export * from "./IssueTypeModal";
|
||||
export * from "./IssueWorkLogModal";
|
||||
export * from "./ProjectModal";
|
||||
export * from "./JQLSearchModal";
|
||||
export * from './IssueCommentModal';
|
||||
export * from './IssueSearchModal';
|
||||
export * from './IssueStatusModal';
|
||||
export * from './IssueTypeModal';
|
||||
export * from './IssueWorkLogModal';
|
||||
export * from './ProjectModal';
|
||||
export * from './JQLSearchModal';
|
||||
export * from './ConfirmModal';
|
||||
export * from './ConfirmModal';
|
||||
|
|
|
|||
|
|
@ -1,91 +1,92 @@
|
|||
import {Extension, RangeSetBuilder} from "@codemirror/state";
|
||||
import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate} from "@codemirror/view";
|
||||
import {MarkdownView} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
|
||||
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;
|
||||
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;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
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; // без первой `
|
||||
const contentEnd = end; // без последней `
|
||||
|
||||
// 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 })
|
||||
);
|
||||
update(update: ViewUpdate) {
|
||||
if (this.passDecoration()) {
|
||||
this.decorations = Decoration.none;
|
||||
return;
|
||||
}
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
buildDecorations(view: EditorView) {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const sel = view.state.selection;
|
||||
|
||||
isInsideCodeBlock(start: number, end: number, codeBlocks: Array<{from: number, to: number}>) {
|
||||
return codeBlocks.some(block =>
|
||||
start >= block.from && end <= block.to
|
||||
);
|
||||
}
|
||||
const allDecs: Array<{ from: number; to: number; dec: Decoration }> = [];
|
||||
|
||||
}, {
|
||||
decorations: v => v.decorations
|
||||
});
|
||||
for (let { from, to } of view.visibleRanges) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
const codeBlocks = this.findCodeBlocks(text, from);
|
||||
|
||||
// Collect all jira-sync marker positions
|
||||
const markers: Array<{ start: number; end: number; name: string }> = [];
|
||||
|
||||
for (const match of text.matchAll(/`(jira-sync-[^`]+)`/g)) {
|
||||
const start = from + match.index!;
|
||||
const end = start + match[0].length;
|
||||
if (this.isInsideCodeBlock(start, end, codeBlocks)) continue;
|
||||
markers.push({ start, end, name: match[1] });
|
||||
}
|
||||
|
||||
// Always hide the markers themselves, show when cursor is inside
|
||||
for (const { start, end } of markers) {
|
||||
const isActive = sel.ranges.some((r) => r.from <= end && r.to >= start);
|
||||
const cls = isActive ? 'jira-sync-hidden jira-sync-active' : 'jira-sync-hidden';
|
||||
allDecs.push({ from: start, to: end, dec: Decoration.mark({ class: cls }) });
|
||||
}
|
||||
}
|
||||
|
||||
// RangeSetBuilder requires ranges in ascending order of `from`, then `to`
|
||||
allDecs.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
for (const { from, to, dec } of allDecs) {
|
||||
builder.add(from, to, dec);
|
||||
}
|
||||
|
||||
return builder.finish() as DecorationSet;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// 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');
|
||||
export function hideJiraPointersReading() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return function (element: HTMLElement, _: any) {
|
||||
const codeElements = Array.from(element.querySelectorAll(':not(pre) > code')).filter((el) =>
|
||||
el.textContent?.startsWith('jira-sync-'),
|
||||
) as HTMLElement[];
|
||||
|
||||
codeElements.forEach(codeEl => {
|
||||
const text = codeEl.textContent || '';
|
||||
|
||||
if (text.startsWith('jira-sync-')) {
|
||||
for (const codeEl of codeElements) {
|
||||
codeEl.addClass('jira-sync-hidden');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {setIcon} from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import { SettingsComponent } from "../interfaces/settingsTypes";
|
||||
import { debugLog } from "../tools/debugLogging";
|
||||
import {CollapsedSections} from "./default";
|
||||
import { setIcon } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { SettingsComponent } from '../interfaces/settingsTypes';
|
||||
import { debugLog } from '../tools/debugLogging';
|
||||
import { CollapsedSections } from './default';
|
||||
|
||||
export class CollapsibleSection {
|
||||
private containerEl: HTMLDivElement;
|
||||
|
|
@ -19,7 +19,7 @@ export class CollapsibleSection {
|
|||
child: SettingsComponent,
|
||||
headerText: string,
|
||||
isOpenByDefault = false,
|
||||
saveStateKey?: string
|
||||
saveStateKey?: string,
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.child = child;
|
||||
|
|
@ -29,37 +29,37 @@ export class CollapsibleSection {
|
|||
|
||||
// Create the container div for the entire collapsible section
|
||||
this.containerEl = parentEl.createDiv({
|
||||
cls: "jira-collapsible-section",
|
||||
cls: 'jira-collapsible-section',
|
||||
});
|
||||
|
||||
// Create the header element
|
||||
const headerEl = this.containerEl.createDiv({
|
||||
cls: "jira-collapsible-header",
|
||||
cls: 'jira-collapsible-header',
|
||||
text: headerText,
|
||||
});
|
||||
headerEl.style.cursor = "pointer";
|
||||
headerEl.addEventListener("click", async () => {
|
||||
headerEl.style.cursor = 'pointer';
|
||||
headerEl.addEventListener('click', async () => {
|
||||
await this.toggle();
|
||||
});
|
||||
|
||||
const chevron = headerEl.createEl('span', {
|
||||
cls: 'jira-collapse-icon',
|
||||
});
|
||||
setIcon(chevron, "chevron-down");
|
||||
setIcon(chevron, 'chevron-down');
|
||||
|
||||
// Content wrapper
|
||||
this.contentEl = this.containerEl.createDiv({
|
||||
cls: "jira-collapsible-content",
|
||||
cls: 'jira-collapsible-content',
|
||||
});
|
||||
|
||||
this.child.render(this.contentEl);
|
||||
|
||||
if (!this.isOpen) {
|
||||
this.contentEl.hide();
|
||||
this.containerEl.addClass("collapsed");
|
||||
this.containerEl.addClass('collapsed');
|
||||
}
|
||||
|
||||
debugLog("Rendered", this.saveStateKey || this.headerText, "as", this.isOpen);
|
||||
debugLog('Rendered', this.saveStateKey || this.headerText, 'as', this.isOpen);
|
||||
}
|
||||
|
||||
public getContentContainer(): HTMLElement {
|
||||
|
|
@ -68,17 +68,17 @@ export class CollapsibleSection {
|
|||
|
||||
private async toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
debugLog("Toggled", this.saveStateKey || this.headerText, "to", this.isOpen);
|
||||
debugLog('Toggled', this.saveStateKey || this.headerText, 'to', this.isOpen);
|
||||
|
||||
// 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");
|
||||
this.containerEl.removeClass('collapsed');
|
||||
} else {
|
||||
this.contentEl.hide();
|
||||
this.containerEl.addClass("collapsed");
|
||||
this.containerEl.addClass('collapsed');
|
||||
}
|
||||
|
||||
if (this.saveStateKey && this.plugin) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { App, PluginSettingTab } from "obsidian";
|
||||
import JiraPlugin from "../main";
|
||||
import { SettingsComponentProps } from "../interfaces/settingsTypes";
|
||||
import { ConnectionSettingsComponent } from "./components/ConnectionSettingsComponent";
|
||||
import { GeneralSettingsComponent } from "./components/GeneralSettingsComponent";
|
||||
import { FieldMappingsComponent } from "./components/FieldMappingsComponent";
|
||||
import { FetchIssueComponent } from "./components/FetchIssueComponent";
|
||||
import { TestFieldMappingsComponent } from "./components/TestFieldMappingsComponent";
|
||||
import { debugLog } from "../tools/debugLogging";
|
||||
import { CollapsibleSection } from "./CollapsibleSection";
|
||||
import {useTranslations} from "../localization/translator";
|
||||
import {TimekeepSettingsComponent} from "./components/TimekeepSettingsComponent";
|
||||
import { App, PluginSettingTab } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { SettingsComponentProps } from '../interfaces/settingsTypes';
|
||||
import { ConnectionSettingsComponent } from './components/ConnectionSettingsComponent';
|
||||
import { GeneralSettingsComponent } from './components/GeneralSettingsComponent';
|
||||
import { FieldMappingsComponent } from './components/FieldMappingsComponent';
|
||||
import { FetchIssueComponent } from './components/FetchIssueComponent';
|
||||
import { TestFieldMappingsComponent } from './components/TestFieldMappingsComponent';
|
||||
import { debugLog } from '../tools/debugLogging';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
import { TimekeepSettingsComponent } from './components/TimekeepSettingsComponent';
|
||||
|
||||
const t = useTranslations("settings").t;
|
||||
const t = useTranslations('settings').t;
|
||||
/**
|
||||
* Settings tab for the Jira plugin
|
||||
* Orchestrates all components and manages the overall settings UI
|
||||
|
|
@ -33,7 +33,7 @@ export class JiraSettingTab extends PluginSettingTab {
|
|||
const componentProps: SettingsComponentProps = {
|
||||
app,
|
||||
plugin,
|
||||
onSettingsChange: this.handleSettingsChange.bind(this)
|
||||
onSettingsChange: this.handleSettingsChange.bind(this),
|
||||
};
|
||||
|
||||
// Initialize all settings components
|
||||
|
|
@ -41,8 +41,12 @@ export class JiraSettingTab extends PluginSettingTab {
|
|||
this.generalSettings = new GeneralSettingsComponent(componentProps);
|
||||
this.fieldMappingsSettings = new FieldMappingsComponent(componentProps);
|
||||
this.fetchIssue = new FetchIssueComponent(componentProps);
|
||||
this.testFieldMappings = new TestFieldMappingsComponent({ ...componentProps, getCurrentIssue: () => this.fetchIssue.getCurrentIssue() });
|
||||
this.fetchIssue.onIssueDataChange = () => this.testFieldMappings.setCurrentIssue(this.fetchIssue.getCurrentIssue());
|
||||
this.testFieldMappings = new TestFieldMappingsComponent({
|
||||
...componentProps,
|
||||
getCurrentIssue: () => this.fetchIssue.getCurrentIssue(),
|
||||
});
|
||||
this.fetchIssue.onIssueDataChange = () =>
|
||||
this.testFieldMappings.setCurrentIssue(this.fetchIssue.getCurrentIssue());
|
||||
this.timekeepSettings = new TimekeepSettingsComponent(componentProps);
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ export class JiraSettingTab extends PluginSettingTab {
|
|||
* This can be used for cross-component updates or validation
|
||||
*/
|
||||
private async handleSettingsChange(): Promise<void> {
|
||||
debugLog("Settings changed, handling updates");
|
||||
debugLog('Settings changed, handling updates');
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
|
|
@ -65,23 +69,59 @@ export class JiraSettingTab extends PluginSettingTab {
|
|||
|
||||
// remember to add collapsable section name ("connection", "general", etc.) in settings/default.ts in order to be able to save last state
|
||||
|
||||
new CollapsibleSection(this.plugin, containerEl, this.connectionSettings, t("connection.title"),
|
||||
this.plugin.settings.collapsedSections.connection, "connection").getContentContainer();
|
||||
new CollapsibleSection(
|
||||
this.plugin,
|
||||
containerEl,
|
||||
this.connectionSettings,
|
||||
t('connection.title'),
|
||||
this.plugin.settings.collapsedSections.connection,
|
||||
'connection',
|
||||
).getContentContainer();
|
||||
|
||||
new CollapsibleSection(this.plugin, containerEl, this.generalSettings, t("general.title"),
|
||||
this.plugin.settings.collapsedSections.general, "general").getContentContainer();
|
||||
new CollapsibleSection(
|
||||
this.plugin,
|
||||
containerEl,
|
||||
this.generalSettings,
|
||||
t('general.title'),
|
||||
this.plugin.settings.collapsedSections.general,
|
||||
'general',
|
||||
).getContentContainer();
|
||||
|
||||
new CollapsibleSection(this.plugin, containerEl, this.fieldMappingsSettings, t("fm.title"),
|
||||
this.plugin.settings.collapsedSections.fieldMappings, "fieldMappings").getContentContainer();
|
||||
new CollapsibleSection(
|
||||
this.plugin,
|
||||
containerEl,
|
||||
this.fieldMappingsSettings,
|
||||
t('fm.title'),
|
||||
this.plugin.settings.collapsedSections.fieldMappings,
|
||||
'fieldMappings',
|
||||
).getContentContainer();
|
||||
|
||||
new CollapsibleSection(this.plugin, containerEl, this.fetchIssue, t("fetch_issue.title"),
|
||||
this.plugin.settings.collapsedSections.fetchIssue, "fetchIssue").getContentContainer();
|
||||
new CollapsibleSection(
|
||||
this.plugin,
|
||||
containerEl,
|
||||
this.fetchIssue,
|
||||
t('fetch_issue.title'),
|
||||
this.plugin.settings.collapsedSections.fetchIssue,
|
||||
'fetchIssue',
|
||||
).getContentContainer();
|
||||
|
||||
new CollapsibleSection(this.plugin, containerEl, this.testFieldMappings, t("tfm.title"),
|
||||
this.plugin.settings.collapsedSections.testFieldMappings, "testFieldMappings").getContentContainer();
|
||||
new CollapsibleSection(
|
||||
this.plugin,
|
||||
containerEl,
|
||||
this.testFieldMappings,
|
||||
t('tfm.title'),
|
||||
this.plugin.settings.collapsedSections.testFieldMappings,
|
||||
'testFieldMappings',
|
||||
).getContentContainer();
|
||||
|
||||
new CollapsibleSection(this.plugin, containerEl, this.timekeepSettings, t("statistics.title"),
|
||||
this.plugin.settings.collapsedSections.statistics, "statistics").getContentContainer();
|
||||
new CollapsibleSection(
|
||||
this.plugin,
|
||||
containerEl,
|
||||
this.timekeepSettings,
|
||||
t('statistics.title'),
|
||||
this.plugin.settings.collapsedSections.statistics,
|
||||
'statistics',
|
||||
).getContentContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,187 +1,270 @@
|
|||
import {Notice, Setting} from "obsidian";
|
||||
import { useTranslations } from "src/localization/translator"
|
||||
import { SettingsComponent, SettingsComponentProps } from "../../interfaces/settingsTypes";
|
||||
import { fetchSelf } from "../../api";
|
||||
import {debugLog} from "../../tools/debugLogging";
|
||||
import { Notice, Setting } from 'obsidian';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { fetchSelf } from '../../api';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
|
||||
const t = useTranslations("settings.connection").t
|
||||
const t = useTranslations('settings.connection').t;
|
||||
|
||||
/**
|
||||
* Component for Jira connection settings
|
||||
*/
|
||||
export class ConnectionSettingsComponent implements SettingsComponent {
|
||||
private props: SettingsComponentProps;
|
||||
private currentAuthMethod: "bearer" | "basic" | "session";
|
||||
private currentAuthMethod: 'bearer' | 'basic' | 'session';
|
||||
private settingElements: Map<string, HTMLElement> = new Map();
|
||||
private connectionDropdown: any;
|
||||
private fieldsContainer!: HTMLElement;
|
||||
|
||||
constructor(props: SettingsComponentProps) {
|
||||
this.props = props;
|
||||
this.currentAuthMethod = props.plugin.settings.connection.authMethod || "bearer";
|
||||
const currentConn = this.getCurrentConnection();
|
||||
this.currentAuthMethod = currentConn?.authMethod || 'bearer';
|
||||
}
|
||||
|
||||
private getCurrentConnection() {
|
||||
const { plugin } = this.props;
|
||||
if (!plugin.settings.connections || plugin.settings.connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return plugin.settings.connections[plugin.settings.currentConnectionIndex];
|
||||
}
|
||||
|
||||
private getNextConnectionName(): string {
|
||||
const { plugin } = this.props;
|
||||
const count = plugin.settings.connections?.length || 0;
|
||||
return `Connection ${count + 1}`;
|
||||
}
|
||||
|
||||
render(containerEl: HTMLElement): void {
|
||||
const { plugin } = this.props;
|
||||
|
||||
// Always show: Jira URL
|
||||
// Connection selector dropdown and add button
|
||||
new Setting(containerEl)
|
||||
.setName(t("url.title"))
|
||||
.setDesc(t("url.desc"))
|
||||
.setName(t('select_connection'))
|
||||
.addDropdown((cb) => {
|
||||
this.connectionDropdown = cb;
|
||||
this.populateConnectionDropdown(cb);
|
||||
cb.onChange(async (value) => {
|
||||
const index = parseInt(value, 10);
|
||||
plugin.settings.currentConnectionIndex = index;
|
||||
await plugin.saveSettings();
|
||||
this.refreshFields();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText('+')
|
||||
.setTooltip(t('add_connection'))
|
||||
.onClick(async () => {
|
||||
const newConnection = {
|
||||
name: this.getNextConnectionName(),
|
||||
authMethod: 'bearer' as const,
|
||||
apiToken: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
jiraUrl: '',
|
||||
apiVersion: '2' as const,
|
||||
jqlPresets: [],
|
||||
lastJqlQuery: '',
|
||||
sessionCookie: '',
|
||||
};
|
||||
plugin.settings.connections.push(newConnection);
|
||||
plugin.settings.currentConnectionIndex = plugin.settings.connections.length - 1;
|
||||
await plugin.saveSettings();
|
||||
this.populateConnectionDropdown(this.connectionDropdown);
|
||||
this.refreshFields();
|
||||
});
|
||||
});
|
||||
|
||||
// Container for connection fields
|
||||
this.fieldsContainer = containerEl.createDiv();
|
||||
this.fieldsContainer.addClass('connection-fields-container');
|
||||
this.renderConnectionFields(this.fieldsContainer);
|
||||
}
|
||||
|
||||
private populateConnectionDropdown(cb: any): void {
|
||||
const { plugin } = this.props;
|
||||
cb.selectEl.empty();
|
||||
plugin.settings.connections.forEach((conn, index) => {
|
||||
cb.addOption(index.toString(), conn.name || `Connection ${index + 1}`);
|
||||
});
|
||||
cb.setValue(plugin.settings.currentConnectionIndex.toString());
|
||||
}
|
||||
|
||||
private renderConnectionFields(containerEl: HTMLElement): void {
|
||||
const { plugin } = this.props;
|
||||
containerEl.empty();
|
||||
this.settingElements.clear();
|
||||
|
||||
const currentConn = this.getCurrentConnection();
|
||||
if (!currentConn) {
|
||||
containerEl.createDiv({ text: t('no_connection') });
|
||||
return;
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('name.title'))
|
||||
.setDesc(t('name.desc'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("url.def"))
|
||||
.setValue(plugin.settings.connection.jiraUrl)
|
||||
.setPlaceholder(t('name.def'))
|
||||
.setValue(currentConn.name)
|
||||
.onChange(async (value) => {
|
||||
plugin.settings.connection.jiraUrl = value;
|
||||
currentConn.name = value;
|
||||
await plugin.saveSettings();
|
||||
})
|
||||
this.populateConnectionDropdown(this.connectionDropdown);
|
||||
}),
|
||||
);
|
||||
|
||||
// Always show: Jira URL
|
||||
new Setting(containerEl)
|
||||
.setName(t('url.title'))
|
||||
.setDesc(t('url.desc'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t('url.def'))
|
||||
.setValue(currentConn.jiraUrl)
|
||||
.onChange(async (value) => {
|
||||
currentConn.jiraUrl = value;
|
||||
await plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("api_version.title"))
|
||||
.setDesc(t("api_version.desc"))
|
||||
.setName(t('api_version.title'))
|
||||
.setDesc(t('api_version.desc'))
|
||||
.addDropdown((cb) =>
|
||||
cb
|
||||
.addOption("2", t("api_version.options.2"))
|
||||
.addOption("3", t("api_version.options.3"))
|
||||
.setValue(plugin.settings.connection.apiVersion)
|
||||
.onChange(async (value: "2" | "3") => {
|
||||
plugin.settings.connection.apiVersion = value;
|
||||
.addOption('2', t('api_version.options.2'))
|
||||
.addOption('3', t('api_version.options.3'))
|
||||
.setValue(currentConn.apiVersion)
|
||||
.onChange(async (value: string) => {
|
||||
currentConn.apiVersion = value as '2' | '3';
|
||||
await plugin.saveSettings();
|
||||
})
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
// Auth Method Select
|
||||
new Setting(containerEl)
|
||||
.setName(t("auth.title"))
|
||||
.setDesc(t("auth.desc"))
|
||||
.setName(t('auth.title'))
|
||||
.setDesc(t('auth.desc'))
|
||||
.addDropdown((cb) =>
|
||||
cb
|
||||
.addOption("bearer", t("auth.options.bearer"))
|
||||
.addOption("basic", t("auth.options.basic"))
|
||||
.addOption("session", t("auth.options.session"))
|
||||
.addOption('bearer', t('auth.options.bearer'))
|
||||
.addOption('basic', t('auth.options.basic'))
|
||||
.addOption('session', t('auth.options.session'))
|
||||
.setValue(this.currentAuthMethod)
|
||||
.onChange(async (value: "bearer" | "basic" | "session") => {
|
||||
plugin.settings.connection.authMethod = value;
|
||||
.onChange(async (value: string) => {
|
||||
currentConn.authMethod = value as 'bearer' | 'basic' | 'session';
|
||||
await plugin.saveSettings();
|
||||
this.currentAuthMethod = value;
|
||||
this.currentAuthMethod = value as 'bearer' | 'basic' | 'session';
|
||||
this.updateVisibility();
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// // Store reference for dynamic visibility control
|
||||
// this.settingElements.set("authMethod", authMethodSetting.settingEl);
|
||||
|
||||
// Jira PAT
|
||||
const patSetting = new Setting(containerEl)
|
||||
.setName(t("pat.title"))
|
||||
.setDesc(t("pat.desc"))
|
||||
.setName(t('pat.title'))
|
||||
.setDesc(t('pat.desc'))
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "password";
|
||||
text
|
||||
.setPlaceholder(t("pat.def"))
|
||||
.setValue(plugin.settings.connection.apiToken)
|
||||
text.inputEl.type = 'password';
|
||||
text.setPlaceholder(t('pat.def'))
|
||||
.setValue(currentConn.apiToken)
|
||||
.onChange(async (value) => {
|
||||
plugin.settings.connection.apiToken = value;
|
||||
currentConn.apiToken = value;
|
||||
await plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
this.settingElements.set("pat", patSetting.settingEl);
|
||||
this.settingElements.set('pat', patSetting.settingEl);
|
||||
|
||||
// Jira username
|
||||
const usernameSetting = new Setting(containerEl)
|
||||
.setName(t("username.title"))
|
||||
.setDesc(t("username.desc"))
|
||||
.setName(t('username.title'))
|
||||
.setDesc(t('username.desc'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("username.def"))
|
||||
.setValue(plugin.settings.connection.username)
|
||||
.setPlaceholder(t('username.def'))
|
||||
.setValue(currentConn.username)
|
||||
.onChange(async (value) => {
|
||||
plugin.settings.connection.username = value;
|
||||
currentConn.username = value;
|
||||
await plugin.saveSettings();
|
||||
})
|
||||
}),
|
||||
);
|
||||
this.settingElements.set("username", usernameSetting.settingEl);
|
||||
this.settingElements.set('username', usernameSetting.settingEl);
|
||||
|
||||
// Jira username
|
||||
// Jira email
|
||||
const emailSetting = new Setting(containerEl)
|
||||
.setName(t("username.title"))
|
||||
.setDesc(t("username.desc"))
|
||||
.setName(t('email.title'))
|
||||
.setDesc(t('email.desc'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("username.def"))
|
||||
.setValue(plugin.settings.connection.email)
|
||||
.setPlaceholder(t('email.def'))
|
||||
.setValue(currentConn.email)
|
||||
.onChange(async (value) => {
|
||||
plugin.settings.connection.email = value;
|
||||
currentConn.email = value;
|
||||
await plugin.saveSettings();
|
||||
})
|
||||
}),
|
||||
);
|
||||
this.settingElements.set("email", emailSetting.settingEl);
|
||||
this.settingElements.set('email', emailSetting.settingEl);
|
||||
|
||||
// Jira password
|
||||
const passwordSetting = new Setting(containerEl)
|
||||
.setName(t("password.title"))
|
||||
.setDesc(t("password.desc"))
|
||||
.setName(t('password.title'))
|
||||
.setDesc(t('password.desc'))
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "password";
|
||||
text
|
||||
.setPlaceholder(t("password.def"))
|
||||
.setValue(plugin.settings.connection.password)
|
||||
text.inputEl.type = 'password';
|
||||
text.setPlaceholder(t('password.def'))
|
||||
.setValue(currentConn.password)
|
||||
.onChange(async (value) => {
|
||||
plugin.settings.connection.password = value;
|
||||
currentConn.password = value;
|
||||
await plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
this.settingElements.set("password", passwordSetting.settingEl);
|
||||
|
||||
// // Session cookie name
|
||||
// const sessionCookieSetting = new Setting(containerEl)
|
||||
// .setName("Session cookie name")
|
||||
// .setDesc("Only needed for username+password authentication")
|
||||
// .addText((text) =>
|
||||
// text
|
||||
// .setPlaceholder("JSESSIONID")
|
||||
// .setValue(plugin.settings.sessionCookieName)
|
||||
// .onChange(async (value) => {
|
||||
// plugin.settings.sessionCookieName = value;
|
||||
// await plugin.saveSettings();
|
||||
// })
|
||||
// );
|
||||
// this.settingElements.set("sessionCookie", sessionCookieSetting.settingEl);
|
||||
this.settingElements.set('password', passwordSetting.settingEl);
|
||||
|
||||
// Ping button to test connection
|
||||
new Setting(containerEl)
|
||||
.setName(t("ping.title"))
|
||||
.setDesc(t("ping.desc"))
|
||||
.setName(t('ping.title'))
|
||||
.setDesc(t('ping.desc'))
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText(t("ping.button"))
|
||||
.setButtonText(t('ping.button'))
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
await this.testConnection();
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Initial visibility
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
private refreshFields(): void {
|
||||
const currentConn = this.getCurrentConnection();
|
||||
if (currentConn) {
|
||||
this.currentAuthMethod = currentConn.authMethod;
|
||||
}
|
||||
this.renderConnectionFields(this.fieldsContainer);
|
||||
}
|
||||
|
||||
private async testConnection(): Promise<void> {
|
||||
const { plugin } = this.props;
|
||||
|
||||
try {
|
||||
const notice = new Notice("Testing connection to Jira...", 2);
|
||||
const notice = new Notice('Testing connection to Jira...', 2);
|
||||
|
||||
debugLog("Testing Jira connection...");
|
||||
debugLog('Testing Jira connection...');
|
||||
|
||||
const result = await fetchSelf(plugin);
|
||||
notice.hide();
|
||||
|
||||
const successMessage = "Successfully connected to Jira";
|
||||
const successMessage = 'Successfully connected to Jira';
|
||||
new Notice(successMessage);
|
||||
debugLog(successMessage, result);
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = `Failed to connect to Jira: ${error.message}`;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = `Failed to connect to Jira: ${(error as Error).message}`;
|
||||
new Notice(errorMessage);
|
||||
console.error(errorMessage, error);
|
||||
}
|
||||
|
|
@ -190,52 +273,38 @@ export class ConnectionSettingsComponent implements SettingsComponent {
|
|||
private updateVisibility(): void {
|
||||
const method = this.currentAuthMethod;
|
||||
|
||||
// // Show always
|
||||
// this.showElement("authMethod");
|
||||
|
||||
// Show PAT if bearer or basic
|
||||
if (method === "bearer" || method === "basic") {
|
||||
this.showElement("pat");
|
||||
if (method === 'bearer' || method === 'basic') {
|
||||
this.showElement('pat');
|
||||
} else {
|
||||
this.hideElement("pat");
|
||||
this.hideElement('pat');
|
||||
}
|
||||
|
||||
// Show username if basic or session
|
||||
if (method === "session") {
|
||||
this.showElement("username");
|
||||
if (method === 'session') {
|
||||
this.showElement('username');
|
||||
} else {
|
||||
this.hideElement("username");
|
||||
this.hideElement('username');
|
||||
}
|
||||
|
||||
// Show email if basic or session
|
||||
if (method === "basic") {
|
||||
this.showElement("email");
|
||||
if (method === 'basic') {
|
||||
this.showElement('email');
|
||||
} else {
|
||||
this.hideElement("email");
|
||||
this.hideElement('email');
|
||||
}
|
||||
|
||||
// Show password only if session
|
||||
if (method === "session") {
|
||||
this.showElement("password");
|
||||
if (method === 'session') {
|
||||
this.showElement('password');
|
||||
} else {
|
||||
this.hideElement("password");
|
||||
this.hideElement('password');
|
||||
}
|
||||
|
||||
// // Show session cookie only if session
|
||||
// if (method === "session") {
|
||||
// this.showElement("sessionCookie");
|
||||
// } else {
|
||||
// this.hideElement("sessionCookie");
|
||||
// }
|
||||
}
|
||||
|
||||
private showElement(key: string): void {
|
||||
const el = this.settingElements.get(key);
|
||||
if (el) el.style.display = "";
|
||||
if (el) el.style.display = '';
|
||||
}
|
||||
|
||||
private hideElement(key: string): void {
|
||||
const el = this.settingElements.get(key);
|
||||
if (el) el.style.display = "none";
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,141 @@
|
|||
import {Notice, Setting} from "obsidian";
|
||||
import {SettingsComponent, SettingsComponentProps} from "../../interfaces/settingsTypes";
|
||||
import {fetchIssue} from "../../api";
|
||||
import debounce from "lodash/debounce";
|
||||
import hljs from "highlight.js";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import {setupArrayTextSetting} from "../tools/setupArrayTextString";
|
||||
import { Notice, Setting } from 'obsidian';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { fetchIssue, validateSettings } from '../../api';
|
||||
import { renameExistingIssueFiles } from '../../file_operations/getIssue';
|
||||
import { ConfirmModal } from '../../modals/ConfirmModal';
|
||||
import debounce from 'lodash/debounce';
|
||||
import hljs from 'highlight.js';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
import { setupArrayTextSetting } from '../tools/setupArrayTextString';
|
||||
|
||||
|
||||
const t = useTranslations("settings.fetch_issue").t;
|
||||
const t = useTranslations('settings.fetch_issue').t;
|
||||
export class FetchIssueComponent implements SettingsComponent {
|
||||
private props: SettingsComponentProps;
|
||||
private issueDataContainer: HTMLElement | null = null;
|
||||
private debouncedFetch: ReturnType<typeof debounce>;
|
||||
private currentIssueData: any = null;
|
||||
private currentIssueKey: string = "";
|
||||
private props: SettingsComponentProps;
|
||||
private issueDataContainer: HTMLElement | null = null;
|
||||
private debouncedFetch: ReturnType<typeof debounce>;
|
||||
private currentIssueData: any = null;
|
||||
private currentIssueKey: string = '';
|
||||
public onIssueDataChange?: () => void;
|
||||
|
||||
constructor(props: SettingsComponentProps) {
|
||||
this.props = props;
|
||||
this.debouncedFetch = debounce(this.fetchIssueData.bind(this), 500);
|
||||
}
|
||||
constructor(props: SettingsComponentProps) {
|
||||
this.props = props;
|
||||
this.debouncedFetch = debounce(this.fetchIssueData.bind(this), 500);
|
||||
}
|
||||
|
||||
|
||||
render(containerEl: HTMLElement): void {
|
||||
render(containerEl: HTMLElement): void {
|
||||
const { plugin } = this.props;
|
||||
|
||||
// Filename template setting
|
||||
const filenameTemplateSetting = new Setting(containerEl)
|
||||
.setName(t("filenameTemplate.name"))
|
||||
.setDesc(t("filenameTemplate.desc"));
|
||||
.setName(t('filenameTemplate.name'))
|
||||
.setDesc(t('filenameTemplate.desc'));
|
||||
|
||||
filenameTemplateSetting.addText((text) => {
|
||||
text
|
||||
.setPlaceholder(t("filenameTemplate.placeholder"))
|
||||
.setValue(plugin.settings.fetchIssue.filenameTemplate || "")
|
||||
text.setPlaceholder(t('filenameTemplate.placeholder'))
|
||||
.setValue(plugin.settings.fetchIssue.filenameTemplate || '')
|
||||
.onChange(async (value) => {
|
||||
plugin.settings.fetchIssue.filenameTemplate = value;
|
||||
await plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
filenameTemplateSetting.addButton((btn) =>
|
||||
btn.setButtonText(t('rename_files')).onClick(async () => {
|
||||
if (!validateSettings(plugin)) return;
|
||||
|
||||
const link = plugin.settings.connection.apiVersion === "3" ? "https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post" : "https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-jql-post"
|
||||
const cacheSize = plugin.getAllIssueKeysMap().size;
|
||||
if (cacheSize === 0) {
|
||||
new Notice(t('no_files_to_rename'));
|
||||
return;
|
||||
}
|
||||
|
||||
const securityNote = containerEl.createEl("p");
|
||||
securityNote.createEl("strong", {
|
||||
text: t("note.desc0")
|
||||
new ConfirmModal(
|
||||
plugin.app,
|
||||
t('rename_confirm', { count: cacheSize.toString() }),
|
||||
async () => {
|
||||
const result = await renameExistingIssueFiles(plugin);
|
||||
if (result.renamed > 0) {
|
||||
new Notice(t('rename_success', { count: result.renamed.toString() }));
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
new Notice(
|
||||
t('rename_errors', { count: result.errors.length.toString() }) +
|
||||
': ' +
|
||||
result.errors.slice(0, 3).join(', '),
|
||||
);
|
||||
}
|
||||
if (result.renamed === 0 && result.errors.length === 0) {
|
||||
new Notice(t('rename_no_changes'));
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
).open();
|
||||
}),
|
||||
);
|
||||
|
||||
const link =
|
||||
plugin.getCurrentConnection()?.apiVersion === '3'
|
||||
? 'https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post'
|
||||
: 'https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-jql-post';
|
||||
|
||||
const securityNote = containerEl.createEl('p');
|
||||
securityNote.createEl('strong', {
|
||||
text: t('note.desc0'),
|
||||
});
|
||||
securityNote.createSpan({
|
||||
text: t("note.desc1")
|
||||
text: t('note.desc1'),
|
||||
});
|
||||
securityNote.createEl("br");
|
||||
securityNote.createEl("br");
|
||||
securityNote.createEl('br');
|
||||
securityNote.createEl('br');
|
||||
securityNote.createSpan({
|
||||
text: t("note.desc2")
|
||||
})
|
||||
securityNote.createEl("a", {
|
||||
text: t("note.link_label"),
|
||||
text: t('note.desc2'),
|
||||
});
|
||||
securityNote.createEl('a', {
|
||||
text: t('note.link_label'),
|
||||
href: link,
|
||||
cls: "external-link"
|
||||
})
|
||||
cls: 'external-link',
|
||||
});
|
||||
securityNote.createSpan({
|
||||
text: t("note.desc3")
|
||||
text: t('note.desc3'),
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("fields.name"))
|
||||
.setDesc(t("fields.desc"))
|
||||
.setName(t('fields.name'))
|
||||
.setDesc(t('fields.desc'))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t("fields.def"))
|
||||
setupArrayTextSetting(
|
||||
text,
|
||||
plugin.settings.fetchIssue.fields,
|
||||
async (array) => {
|
||||
plugin.settings.fetchIssue.fields = array;
|
||||
this.rerenderIssueData();
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
);
|
||||
text.setPlaceholder(t('fields.def'));
|
||||
setupArrayTextSetting(text, plugin.settings.fetchIssue.fields, async (array) => {
|
||||
plugin.settings.fetchIssue.fields = array;
|
||||
this.rerenderIssueData();
|
||||
await plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("expand.name"))
|
||||
.setDesc(t("expand.desc"))
|
||||
.setName(t('expand.name'))
|
||||
.setDesc(t('expand.desc'))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t("expand.def"))
|
||||
setupArrayTextSetting(
|
||||
text,
|
||||
plugin.settings.fetchIssue.expand,
|
||||
async (array) => {
|
||||
plugin.settings.fetchIssue.expand = array;
|
||||
this.rerenderIssueData();
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
);
|
||||
text.setPlaceholder(t('expand.def'));
|
||||
setupArrayTextSetting(text, plugin.settings.fetchIssue.expand, async (array) => {
|
||||
plugin.settings.fetchIssue.expand = array;
|
||||
this.rerenderIssueData();
|
||||
await plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
// Add input field for issue key
|
||||
new Setting(containerEl)
|
||||
.setName(t("key.name"))
|
||||
.setDesc(t("key.desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("PROJ-123")
|
||||
.onChange((value) => {
|
||||
this.currentIssueKey = value;
|
||||
this.rerenderIssueData();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl)
|
||||
.setName(t('key.name'))
|
||||
.setDesc(t('key.desc'))
|
||||
.addText((text) =>
|
||||
text.setPlaceholder('PROJ-123').onChange((value) => {
|
||||
this.currentIssueKey = value;
|
||||
this.rerenderIssueData();
|
||||
}),
|
||||
);
|
||||
|
||||
// Create container for issue data
|
||||
this.issueDataContainer = containerEl.createDiv({
|
||||
cls: "jira-raw-issue-container"
|
||||
});
|
||||
}
|
||||
this.issueDataContainer = containerEl.createDiv({
|
||||
cls: 'jira-raw-issue-container',
|
||||
});
|
||||
}
|
||||
|
||||
private rerenderIssueData() {
|
||||
if (this.currentIssueKey) {
|
||||
|
|
@ -123,51 +145,51 @@ export class FetchIssueComponent implements SettingsComponent {
|
|||
}
|
||||
}
|
||||
|
||||
public getCurrentIssue(): any {
|
||||
return this.currentIssueData;
|
||||
}
|
||||
public getCurrentIssue(): any {
|
||||
return this.currentIssueData;
|
||||
}
|
||||
|
||||
private async fetchIssueData(value: string): Promise<void> {
|
||||
try {
|
||||
const issueData = await fetchIssue(this.props.plugin, value);
|
||||
this.currentIssueData = issueData;
|
||||
this.displayIssueData(issueData);
|
||||
} catch (error) {
|
||||
new Notice(`Failed to fetch issue: ${error.message}`);
|
||||
this.clearIssueData();
|
||||
}
|
||||
private async fetchIssueData(value: string): Promise<void> {
|
||||
try {
|
||||
const issueData = await fetchIssue(this.props.plugin, value);
|
||||
this.currentIssueData = issueData;
|
||||
this.displayIssueData(issueData);
|
||||
} catch (error: unknown) {
|
||||
new Notice(`Failed to fetch issue: ${(error as Error).message}`);
|
||||
this.clearIssueData();
|
||||
}
|
||||
if (this.onIssueDataChange) {
|
||||
this.onIssueDataChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private displayIssueData(issueData: any): void {
|
||||
if (!this.issueDataContainer) return;
|
||||
private displayIssueData(issueData: any): void {
|
||||
if (!this.issueDataContainer) return;
|
||||
|
||||
this.issueDataContainer.empty();
|
||||
this.issueDataContainer.empty();
|
||||
|
||||
const pre = this.issueDataContainer.createEl("pre", {
|
||||
cls: "jira-copyable",
|
||||
});
|
||||
const pre = this.issueDataContainer.createEl('pre', {
|
||||
cls: 'jira-copyable',
|
||||
});
|
||||
|
||||
const code = pre.createEl("code", {
|
||||
cls: "language-json"
|
||||
});
|
||||
const code = pre.createEl('code', {
|
||||
cls: 'language-json',
|
||||
});
|
||||
|
||||
const jsonString = JSON.stringify(issueData, null, 2);
|
||||
code.setText(jsonString);
|
||||
hljs.highlightElement(code);
|
||||
}
|
||||
const jsonString = JSON.stringify(issueData, null, 2);
|
||||
code.setText(jsonString);
|
||||
hljs.highlightElement(code);
|
||||
}
|
||||
|
||||
private clearIssueData(): void {
|
||||
if (this.issueDataContainer) {
|
||||
this.issueDataContainer.empty();
|
||||
}
|
||||
this.currentIssueData = null;
|
||||
}
|
||||
private clearIssueData(): void {
|
||||
if (this.issueDataContainer) {
|
||||
this.issueDataContainer.empty();
|
||||
}
|
||||
this.currentIssueData = null;
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.clearIssueData();
|
||||
this.debouncedFetch.cancel();
|
||||
}
|
||||
hide(): void {
|
||||
this.clearIssueData();
|
||||
this.debouncedFetch.cancel();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { validateField } from "../tools/fieldValidation";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import { validateField } from '../tools/fieldValidation';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
import { FROM_JIRA_PARAMS, TO_JIRA_PARAMS } from '../../default/obsidianJiraFieldsMapping';
|
||||
|
||||
const t = useTranslations("settings.fm").t;
|
||||
const t = useTranslations('settings.fm').t;
|
||||
/**
|
||||
* Interface for field mapping item props
|
||||
*/
|
||||
|
|
@ -19,10 +20,10 @@ export interface FieldMappingItemProps {
|
|||
*/
|
||||
export class FieldMappingItem {
|
||||
private props: FieldMappingItemProps;
|
||||
private fieldContainer: HTMLDivElement;
|
||||
private fieldNameInput: HTMLInputElement;
|
||||
private toJiraInput: HTMLTextAreaElement;
|
||||
private fromJiraInput: HTMLTextAreaElement;
|
||||
private fieldContainer!: HTMLDivElement;
|
||||
private fieldNameInput!: HTMLInputElement;
|
||||
private toJiraInput!: HTMLTextAreaElement;
|
||||
private fromJiraInput!: HTMLTextAreaElement;
|
||||
|
||||
constructor(props: FieldMappingItemProps) {
|
||||
this.props = props;
|
||||
|
|
@ -36,7 +37,7 @@ export class FieldMappingItem {
|
|||
return {
|
||||
fieldName: this.fieldNameInput.value.trim(),
|
||||
toJira: this.toJiraInput.value.trim(),
|
||||
fromJira: this.fromJiraInput.value.trim()
|
||||
fromJira: this.fromJiraInput.value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -44,51 +45,72 @@ export class FieldMappingItem {
|
|||
* Render the field mapping item
|
||||
*/
|
||||
private render() {
|
||||
const { container, fieldName = "", toJira = "", fromJira = "", enableValidation } = this.props;
|
||||
const { container, fieldName = '', toJira = '', fromJira = '', enableValidation } = this.props;
|
||||
|
||||
// Create the field container
|
||||
this.fieldContainer = container.createDiv({ cls: "field-mapping-item" });
|
||||
this.fieldContainer = container.createDiv({
|
||||
cls: 'field-mapping-item',
|
||||
});
|
||||
|
||||
// Create field name input
|
||||
const fieldNameContainer = this.fieldContainer.createDiv({ cls: "field-name-container" });
|
||||
fieldNameContainer.createEl("span", { text: t("field_name"), cls: "field-mapping-label" });
|
||||
this.fieldNameInput = fieldNameContainer.createEl("input", {
|
||||
type: "text",
|
||||
const fieldNameContainer = this.fieldContainer.createDiv({
|
||||
cls: 'field-name-container',
|
||||
});
|
||||
fieldNameContainer.createEl('span', {
|
||||
text: t('field_name'),
|
||||
cls: 'field-mapping-label',
|
||||
});
|
||||
this.fieldNameInput = fieldNameContainer.createEl('input', {
|
||||
type: 'text',
|
||||
value: fieldName,
|
||||
cls: "field-name-input",
|
||||
placeholder: t("field_name_placeholder") || "e.g., summary, assignee, priority"
|
||||
cls: 'field-name-input',
|
||||
placeholder: t('field_name_placeholder') || 'e.g., summary, assignee, priority',
|
||||
});
|
||||
|
||||
// Create functions container (grid layout)
|
||||
const functionsContainer = this.fieldContainer.createDiv({ cls: "functions-container" });
|
||||
const functionsContainer = this.fieldContainer.createDiv({
|
||||
cls: 'functions-container',
|
||||
});
|
||||
|
||||
// Create toJira function input
|
||||
const toJiraContainer = functionsContainer.createDiv({ cls: "to-jira-container" });
|
||||
toJiraContainer.createEl("span", { text: t("to_jira"), cls: "field-mapping-label" });
|
||||
this.toJiraInput = toJiraContainer.createEl("textarea", {
|
||||
cls: "to-jira-input"
|
||||
const toJiraContainer = functionsContainer.createDiv({
|
||||
cls: 'to-jira-container',
|
||||
});
|
||||
this.toJiraInput.placeholder = "(value) => {\n // Transform Obsidian value to Jira\n return value;\n}";
|
||||
toJiraContainer.createEl('span', {
|
||||
text: t('to_jira'),
|
||||
cls: 'field-mapping-label',
|
||||
});
|
||||
this.toJiraInput = toJiraContainer.createEl('textarea', {
|
||||
cls: 'to-jira-input',
|
||||
});
|
||||
this.toJiraInput.placeholder =
|
||||
'(value, api_version) => {\n // Transform Obsidian value to Jira\n return value;\n}';
|
||||
this.toJiraInput.value = toJira;
|
||||
|
||||
// Create fromJira function input
|
||||
const fromJiraContainer = functionsContainer.createDiv({ cls: "from-jira-container" });
|
||||
fromJiraContainer.createEl("span", { text: t("from_jira"), cls: "field-mapping-label" });
|
||||
this.fromJiraInput = fromJiraContainer.createEl("textarea", {
|
||||
cls: "from-jira-input"
|
||||
const fromJiraContainer = functionsContainer.createDiv({
|
||||
cls: 'from-jira-container',
|
||||
});
|
||||
fromJiraContainer.createEl('span', {
|
||||
text: t('from_jira'),
|
||||
cls: 'field-mapping-label',
|
||||
});
|
||||
this.fromJiraInput = fromJiraContainer.createEl('textarea', {
|
||||
cls: 'from-jira-input',
|
||||
});
|
||||
this.fromJiraInput.value = fromJira;
|
||||
this.fromJiraInput.placeholder = "(issue, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}";
|
||||
this.fromJiraInput.placeholder =
|
||||
'(issue, api_version, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}';
|
||||
|
||||
// Add remove button
|
||||
const removeBtn = this.fieldContainer.createEl("button", {
|
||||
text: "✕",
|
||||
cls: "remove-field-btn",
|
||||
attr: { 'aria-label': t("remove_field") || 'Remove field mapping' }
|
||||
const removeBtn = this.fieldContainer.createEl('button', {
|
||||
text: '✕',
|
||||
cls: 'remove-field-btn',
|
||||
attr: { 'aria-label': t('remove_field') || 'Remove field mapping' },
|
||||
});
|
||||
|
||||
// Handle field removal
|
||||
removeBtn.addEventListener("click", () => {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
this.fieldContainer.remove();
|
||||
this.props.onRemove();
|
||||
});
|
||||
|
|
@ -111,7 +133,7 @@ export class FieldMappingItem {
|
|||
textarea.addEventListener('focus', resize);
|
||||
|
||||
// Initial resize
|
||||
setTimeout(resize, 0);
|
||||
window.setTimeout(resize, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -119,8 +141,8 @@ export class FieldMappingItem {
|
|||
*/
|
||||
async setupValidation(enableValidation: boolean) {
|
||||
await validateField(this.fieldNameInput, enableValidation, 'string');
|
||||
await validateField(this.toJiraInput, enableValidation, 'function', ['value']);
|
||||
await validateField(this.fromJiraInput, enableValidation, 'function', ['issue', 'data_source']);
|
||||
await validateField(this.toJiraInput, enableValidation, 'function', TO_JIRA_PARAMS);
|
||||
await validateField(this.fromJiraInput, enableValidation, 'function', FROM_JIRA_PARAMS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { Setting, setIcon } from "obsidian";
|
||||
import { SettingsComponent, SettingsComponentProps } from "../../interfaces/settingsTypes";
|
||||
import { FieldMappingItem } from "./FieldMappingItem";
|
||||
import { collectFieldMappingsFromUI, processMappings } from "../tools/mappingTransformers";
|
||||
import {jiraFunctionToString, transform_string_to_functions_mappings} from "../../tools/convertFunctionString";
|
||||
import { obsidianJiraFieldMappings } from "../../default/obsidianJiraFieldsMapping";
|
||||
import { debugLog } from "../../tools/debugLogging";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import { Setting, setIcon } from 'obsidian';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { FieldMappingItem } from './FieldMappingItem';
|
||||
import { collectFieldMappingsFromUI, processMappings } from '../tools/mappingTransformers';
|
||||
import { jiraFunctionToString, transform_string_to_functions_mappings } from '../../tools/convertFunctionString';
|
||||
import { obsidianJiraFieldMappings } from '../../default/obsidianJiraFieldsMapping';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
|
||||
const t = useTranslations("settings.fm").t;
|
||||
const t = useTranslations('settings.fm').t;
|
||||
|
||||
/**
|
||||
* Component for field mappings section
|
||||
*/
|
||||
export class FieldMappingsComponent implements SettingsComponent {
|
||||
private props: SettingsComponentProps;
|
||||
private fieldsList: HTMLDivElement;
|
||||
private fieldsList!: HTMLDivElement;
|
||||
private mappingItems: FieldMappingItem[] = [];
|
||||
|
||||
constructor(props: SettingsComponentProps) {
|
||||
|
|
@ -28,60 +28,70 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
const { plugin } = this.props;
|
||||
|
||||
// Create the mapping section
|
||||
const mappingSection = containerEl.createDiv({ cls: "jira-field-mappings" });
|
||||
const mappingSection = containerEl.createDiv({
|
||||
cls: 'jira-field-mappings',
|
||||
});
|
||||
|
||||
// Add explanation
|
||||
mappingSection.createEl("p", {
|
||||
text: t("desc")
|
||||
mappingSection.createEl('p', {
|
||||
text: t('desc'),
|
||||
cls: 'break-line',
|
||||
});
|
||||
|
||||
// Add validation toggle
|
||||
new Setting(mappingSection)
|
||||
.setName(t("fv.name"))
|
||||
.setDesc(t("fv.desc"))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(plugin.settings.fieldMapping.enableFieldValidation)
|
||||
.onChange(async (value) => {
|
||||
.setName(t('fv.name'))
|
||||
.setDesc(t('fv.desc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(plugin.settings.fieldMapping.enableFieldValidation).onChange(async (value) => {
|
||||
plugin.settings.fieldMapping.enableFieldValidation = value;
|
||||
await plugin.saveSettings();
|
||||
|
||||
// Update validation for all mapping items
|
||||
this.mappingItems.forEach(item => {
|
||||
this.mappingItems.forEach((item) => {
|
||||
item.updateValidation(value);
|
||||
});
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
// Create list container for field mappings
|
||||
this.fieldsList = mappingSection.createDiv({ cls: "field-mappings-list" });
|
||||
this.fieldsList = mappingSection.createDiv({
|
||||
cls: 'field-mappings-list',
|
||||
});
|
||||
|
||||
// Create button container
|
||||
const buttonView = mappingSection.createDiv({ cls: "button-view" });
|
||||
const buttonView = mappingSection.createDiv({ cls: 'button-view' });
|
||||
|
||||
// Add button to add new field mapping
|
||||
const addFieldBtn = buttonView.createEl("button", {
|
||||
text: t("add_mapping") || "Add Mapping",
|
||||
cls: "add-field-mapping-btn",
|
||||
attr: { 'data-tooltip': t("add_mapping_tooltip") || "Add new field mapping" }
|
||||
const addFieldBtn = buttonView.createEl('button', {
|
||||
text: t('add_mapping') || 'Add Mapping',
|
||||
cls: 'add-field-mapping-btn',
|
||||
attr: {
|
||||
'data-tooltip': t('add_mapping_tooltip') || 'Add new field mapping',
|
||||
},
|
||||
});
|
||||
setIcon(addFieldBtn, "circle-plus");
|
||||
setIcon(addFieldBtn, 'circle-plus');
|
||||
|
||||
addFieldBtn.addEventListener("click", () => {
|
||||
addFieldBtn.addEventListener('click', () => {
|
||||
this.addFieldMapping();
|
||||
});
|
||||
|
||||
// Reset button
|
||||
const resetBtn = buttonView.createEl("button", {
|
||||
text: t("reset") || "Reset All",
|
||||
cls: "reset-field-mappings-btn",
|
||||
attr: { 'data-tooltip': t("reset_tooltip") || "Clear all field mappings" }
|
||||
const resetBtn = buttonView.createEl('button', {
|
||||
text: t('reset') || 'Reset All',
|
||||
cls: 'reset-field-mappings-btn',
|
||||
attr: {
|
||||
'data-tooltip': t('reset_tooltip') || 'Clear all field mappings',
|
||||
},
|
||||
});
|
||||
setIcon(resetBtn, "refresh-cw");
|
||||
setIcon(resetBtn, 'refresh-cw');
|
||||
|
||||
resetBtn.addEventListener("click", async () => {
|
||||
resetBtn.addEventListener('click', async () => {
|
||||
// Add confirmation dialog
|
||||
const confirmed = await this.showConfirmDialog(
|
||||
t("reset_confirm_title") || "Reset Field Mappings",
|
||||
t("reset_confirm_text") || "Are you sure you want to clear all field mappings? This action cannot be undone."
|
||||
t('reset_confirm_title') || 'Reset Field Mappings',
|
||||
t('reset_confirm_text') ||
|
||||
'Are you sure you want to clear all field mappings? This action cannot be undone.',
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
|
|
@ -93,17 +103,19 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
});
|
||||
|
||||
// Reload default mappings button
|
||||
const reloadBtn = buttonView.createEl("button", {
|
||||
text: t("reload_defaults") || "Load Defaults",
|
||||
cls: "reload-field-mappings-btn",
|
||||
attr: { 'data-tooltip': t("reload_defaults_tooltip") || "Restore default field mappings" }
|
||||
const reloadBtn = buttonView.createEl('button', {
|
||||
text: t('reload_defaults') || 'Load Defaults',
|
||||
cls: 'reload-field-mappings-btn',
|
||||
attr: {
|
||||
'data-tooltip': t('reload_defaults_tooltip') || 'Restore default field mappings',
|
||||
},
|
||||
});
|
||||
setIcon(reloadBtn, "list-restart");
|
||||
setIcon(reloadBtn, 'list-restart');
|
||||
|
||||
reloadBtn.addEventListener("click", async () => {
|
||||
reloadBtn.addEventListener('click', async () => {
|
||||
const confirmed = await this.showConfirmDialog(
|
||||
t("reload_confirm_title") || "Load Default Mappings",
|
||||
t("reload_confirm_text") || "This will replace all current mappings with defaults. Continue?"
|
||||
t('reload_confirm_title') || 'Load Default Mappings',
|
||||
t('reload_confirm_text') || 'This will replace all current mappings with defaults. Continue?',
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
|
|
@ -114,7 +126,7 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
for (const [fieldName, mapping] of Object.entries(obsidianJiraFieldMappings)) {
|
||||
defaultMappingsStrings[fieldName] = {
|
||||
toJira: jiraFunctionToString(mapping.toJira, false),
|
||||
fromJira: jiraFunctionToString(mapping.fromJira, true)
|
||||
fromJira: jiraFunctionToString(mapping.fromJira, true),
|
||||
};
|
||||
}
|
||||
plugin.settings.fieldMapping.fieldMappingsStrings = defaultMappingsStrings;
|
||||
|
|
@ -131,10 +143,12 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
this.renderExamples(mappingSection);
|
||||
|
||||
// Add security notice
|
||||
const securityNote = containerEl.createEl("div", { cls: "setting-item-description" });
|
||||
securityNote.createEl("strong", { text: t("secNote.name") });
|
||||
const securityNote = containerEl.createEl('div', {
|
||||
cls: 'setting-item-description',
|
||||
});
|
||||
securityNote.createEl('strong', { text: t('secNote.name') });
|
||||
securityNote.createSpan({
|
||||
text: t("secNote.desc")
|
||||
text: t('secNote.desc'),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -142,28 +156,42 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-container';
|
||||
modal.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-title">${title}</div>
|
||||
<div class="modal-content">${message}</div>
|
||||
<div class="modal-button-container">
|
||||
<button class="mod-cta" id="confirm-btn">Confirm</button>
|
||||
<button id="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.className = 'modal';
|
||||
modal.appendChild(modalContent);
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'modal-title';
|
||||
titleEl.textContent = title;
|
||||
modalContent.appendChild(titleEl);
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'modal-content';
|
||||
contentEl.textContent = message;
|
||||
modalContent.appendChild(contentEl);
|
||||
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className = 'modal-button-container';
|
||||
modalContent.appendChild(buttonContainer);
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'mod-cta';
|
||||
confirmBtn.textContent = 'Confirm';
|
||||
buttonContainer.appendChild(confirmBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
buttonContainer.appendChild(cancelBtn);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const confirmBtn = modal.querySelector('#confirm-btn') as HTMLButtonElement;
|
||||
const cancelBtn = modal.querySelector('#cancel-btn') as HTMLButtonElement;
|
||||
|
||||
confirmBtn?.addEventListener('click', () => {
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(false);
|
||||
});
|
||||
|
|
@ -173,7 +201,7 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
/**
|
||||
* Add a new field mapping item
|
||||
*/
|
||||
addFieldMapping(fieldName = "", toJira = "", fromJira = ""): FieldMappingItem {
|
||||
addFieldMapping(fieldName = '', toJira = '', fromJira = ''): FieldMappingItem {
|
||||
const item = new FieldMappingItem({
|
||||
container: this.fieldsList,
|
||||
fieldName,
|
||||
|
|
@ -186,7 +214,7 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
if (index !== -1) {
|
||||
this.mappingItems.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.mappingItems.push(item);
|
||||
|
|
@ -206,28 +234,28 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
debugLog(`Loading mapping settings`);
|
||||
|
||||
// If we have string representations stored, use those
|
||||
if (plugin.settings.fieldMapping.fieldMappingsStrings &&
|
||||
Object.keys(plugin.settings.fieldMapping.fieldMappingsStrings).length > 0) {
|
||||
if (
|
||||
plugin.settings.fieldMapping.fieldMappingsStrings &&
|
||||
Object.keys(plugin.settings.fieldMapping.fieldMappingsStrings).length > 0
|
||||
) {
|
||||
debugLog('Current mapping (string) is: ', plugin.settings.fieldMapping.fieldMappingsStrings);
|
||||
|
||||
const savedMappings = plugin.settings.fieldMapping.fieldMappingsStrings;
|
||||
for (const [fieldName, mapping] of Object.entries(savedMappings)) {
|
||||
if (mapping && typeof mapping === 'object' && 'toJira' in mapping && 'fromJira' in mapping) {
|
||||
this.addFieldMapping(
|
||||
fieldName,
|
||||
mapping.toJira,
|
||||
mapping.fromJira
|
||||
);
|
||||
this.addFieldMapping(fieldName, mapping.toJira, mapping.fromJira);
|
||||
}
|
||||
}
|
||||
plugin.settings.fieldMapping.fieldMappings = await transform_string_to_functions_mappings(
|
||||
plugin.settings.fieldMapping.fieldMappingsStrings,
|
||||
plugin.settings.fieldMapping.enableFieldValidation
|
||||
plugin.settings.fieldMapping.enableFieldValidation,
|
||||
);
|
||||
}
|
||||
// Otherwise, try to use the function mappings and convert them to strings
|
||||
else if (plugin.settings.fieldMapping.fieldMappings &&
|
||||
Object.keys(plugin.settings.fieldMapping.fieldMappings).length > 0) {
|
||||
else if (
|
||||
plugin.settings.fieldMapping.fieldMappings &&
|
||||
Object.keys(plugin.settings.fieldMapping.fieldMappings).length > 0
|
||||
) {
|
||||
debugLog('Current mapping is: ', plugin.settings.fieldMapping.fieldMappings);
|
||||
|
||||
const existingMappings = plugin.settings.fieldMapping.fieldMappings;
|
||||
|
|
@ -236,7 +264,7 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
this.addFieldMapping(
|
||||
fieldName,
|
||||
jiraFunctionToString(mapping.toJira, false),
|
||||
jiraFunctionToString(mapping.fromJira, true)
|
||||
jiraFunctionToString(mapping.fromJira, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -252,7 +280,9 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
async saveFieldMappings(): Promise<void> {
|
||||
const { plugin } = this.props;
|
||||
|
||||
debugLog(`From strings ${JSON.stringify(plugin.settings.fieldMapping.fieldMappingsStrings)}\nand funcs ${JSON.stringify(plugin.settings.fieldMapping.fieldMappings)}`);
|
||||
debugLog(
|
||||
`From strings ${JSON.stringify(plugin.settings.fieldMapping.fieldMappingsStrings)}\nand funcs ${JSON.stringify(plugin.settings.fieldMapping.fieldMappings)}`,
|
||||
);
|
||||
|
||||
// Collect mappings from UI
|
||||
const stringMappings = await collectFieldMappingsFromUI(this.fieldsList);
|
||||
|
|
@ -260,7 +290,7 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
// Process mappings
|
||||
const { stringMappings: newStringMappings, functionMappings } = await processMappings(
|
||||
stringMappings,
|
||||
plugin.settings.fieldMapping.enableFieldValidation
|
||||
plugin.settings.fieldMapping.enableFieldValidation,
|
||||
);
|
||||
|
||||
// Update settings
|
||||
|
|
@ -276,45 +306,53 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
* Render example mappings
|
||||
*/
|
||||
private renderExamples(containerEl: HTMLElement): void {
|
||||
const examplesSection = containerEl.createDiv({ cls: "mapping-examples" });
|
||||
examplesSection.createEl("h4", { text: t("example.name") });
|
||||
const examplesSection = containerEl.createDiv({
|
||||
cls: 'mapping-examples',
|
||||
});
|
||||
examplesSection.createEl('h4', { text: t('example.name') });
|
||||
|
||||
const examplesList = examplesSection.createEl("ul");
|
||||
const examplesList = examplesSection.createEl('ul');
|
||||
|
||||
const examples = [
|
||||
{
|
||||
name: "summary",
|
||||
toJira: "value",
|
||||
fromJira: "issue.fields.summary"
|
||||
name: 'summary',
|
||||
toJira: 'value',
|
||||
fromJira: 'issue.fields.summary',
|
||||
},
|
||||
{
|
||||
name: "reporter",
|
||||
toJira: "null",
|
||||
fromJira: "issue.fields.reporter.name"
|
||||
name: 'reporter',
|
||||
toJira: 'null',
|
||||
fromJira: 'issue.fields.reporter.name',
|
||||
},
|
||||
{
|
||||
name: "project",
|
||||
toJira: "({ key: value })",
|
||||
fromJira: "issue.fields.project ? issue.fields.project.key : \"\""
|
||||
}
|
||||
name: 'project',
|
||||
toJira: '({ key: value })',
|
||||
fromJira: 'issue.fields.project ? issue.fields.project.key : ""',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
toJira: 'api_version === "3" ? markdownToAdf(value) : markdownToJira(value)',
|
||||
fromJira:
|
||||
'api_version === "3" ? adfToMarkdown(issue.fields.description) : jiraToMarkdown(issue.fields.description)',
|
||||
},
|
||||
];
|
||||
|
||||
examples.forEach(example => {
|
||||
const item = examplesList.createEl("li");
|
||||
item.createEl("strong", { text: example.name });
|
||||
examples.forEach((example) => {
|
||||
const item = examplesList.createEl('li');
|
||||
item.createEl('strong', { text: example.name });
|
||||
|
||||
// Add button to use this example
|
||||
const useBtn = item.createEl("button", {
|
||||
text: t("example.use"),
|
||||
cls: "use-example-btn"
|
||||
const useBtn = item.createEl('button', {
|
||||
text: t('example.use'),
|
||||
cls: 'use-example-btn',
|
||||
});
|
||||
|
||||
item.createEl("br");
|
||||
item.createEl('br');
|
||||
item.createSpan({ text: `toJira: ${example.toJira}` });
|
||||
item.createEl("br");
|
||||
item.createEl('br');
|
||||
item.createSpan({ text: `fromJira: ${example.fromJira}` });
|
||||
|
||||
useBtn.addEventListener("click", () => {
|
||||
useBtn.addEventListener('click', () => {
|
||||
this.addFieldMapping(example.name, example.toJira, example.fromJira);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, AbstractInputSuggest, TFolder } from "obsidian";
|
||||
import {debugLog} from "../../tools/debugLogging";
|
||||
import { App, AbstractInputSuggest, TFolder } from 'obsidian';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
|
||||
interface FolderOption {
|
||||
path: string;
|
||||
|
|
@ -27,7 +27,7 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOption> {
|
|||
if (this.cacheValid) return;
|
||||
|
||||
this.allFolders = [];
|
||||
this.scanFolderForFolders(this.app.vault.getRoot(), "");
|
||||
this.scanFolderForFolders(this.app.vault.getRoot(), '');
|
||||
|
||||
// Sort by display name
|
||||
this.allFolders.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
|
@ -39,10 +39,10 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOption> {
|
|||
*/
|
||||
private scanFolderForFolders(folder: TFolder, prefix: string): void {
|
||||
// Add root folder
|
||||
if (prefix === "") {
|
||||
if (prefix === '') {
|
||||
this.allFolders.push({
|
||||
path: "",
|
||||
displayName: "/ (root)"
|
||||
path: '',
|
||||
displayName: '/ (root)',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOption> {
|
|||
const displayName = prefix ? `${prefix}/${child.name}` : child.name;
|
||||
this.allFolders.push({
|
||||
path: child.path,
|
||||
displayName: displayName
|
||||
displayName: displayName,
|
||||
});
|
||||
// Recursively scan subfolders
|
||||
this.scanFolderForFolders(child, displayName);
|
||||
|
|
@ -82,22 +82,22 @@ export class FolderSuggest extends AbstractInputSuggest<FolderOption> {
|
|||
}
|
||||
|
||||
// Fast filtering - match both display name and path
|
||||
return this.allFolders.filter(option =>
|
||||
option.displayName.toLowerCase().includes(query) ||
|
||||
option.path.toLowerCase().includes(query)
|
||||
return this.allFolders.filter(
|
||||
(option) => option.displayName.toLowerCase().includes(query) || option.path.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
renderSuggestion(folder: FolderOption, el: HTMLElement): void {
|
||||
el.setText(folder.displayName);
|
||||
el.setAttr("title", folder.path || "Root folder");
|
||||
el.setAttr('title', folder.path || 'Root folder');
|
||||
}
|
||||
|
||||
selectSuggestion(folder: FolderOption): void {
|
||||
debugLog("selected Issues Folder", folder);
|
||||
debugLog('selected Issues Folder', folder);
|
||||
this.setValue(folder.path);
|
||||
this.onChange && this.onChange(folder.path);
|
||||
if (this.onChange) {
|
||||
this.onChange(folder.path);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { App, Setting, normalizePath } from "obsidian";
|
||||
import { SettingsComponent, SettingsComponentProps } from "../../interfaces/settingsTypes";
|
||||
import { TemplateSuggest } from "./TemplateSuggest";
|
||||
import { FolderSuggest } from "./FolderSuggest";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import { App, Setting, normalizePath } from 'obsidian';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { TemplateSuggest } from './TemplateSuggest';
|
||||
import { FolderSuggest } from './FolderSuggest';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
|
||||
const t = useTranslations("settings.general").t;
|
||||
const t = useTranslations('settings.general').t;
|
||||
|
||||
interface TemplatePluginInfo {
|
||||
coreTemplatesEnabled: boolean;
|
||||
|
|
@ -24,17 +24,15 @@ export class GeneralSettingsComponent implements SettingsComponent {
|
|||
const { plugin } = this.props;
|
||||
|
||||
// Issues folder setting with native search
|
||||
const folderSetting = new Setting(containerEl)
|
||||
.setName(t("folder.name"))
|
||||
.setDesc(t("folder.desc"));
|
||||
const folderSetting = new Setting(containerEl).setName(t('folder.name')).setDesc(t('folder.desc'));
|
||||
|
||||
folderSetting.addSearch((search) => {
|
||||
const onChange = async (value: string) => {
|
||||
plugin.settings.global.issuesFolder = value;
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
};
|
||||
search
|
||||
.setPlaceholder(t("folder.placeholder"))
|
||||
.setPlaceholder(t('folder.placeholder'))
|
||||
.setValue(plugin.settings.global.issuesFolder)
|
||||
.onChange(onChange);
|
||||
new FolderSuggest(plugin.app, search.inputEl, onChange);
|
||||
|
|
@ -43,16 +41,14 @@ export class GeneralSettingsComponent implements SettingsComponent {
|
|||
// Template path setting with native search
|
||||
const templateInfo = this.detectTemplatePlugins(plugin.app);
|
||||
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(t("template.name"))
|
||||
.setDesc(t("template.desc"));
|
||||
const setting = new Setting(containerEl).setName(t('template.name')).setDesc(t('template.desc'));
|
||||
|
||||
// Add warning if no template plugins are enabled
|
||||
if (templateInfo.warningMessage) {
|
||||
setting.descEl.createDiv({}, (div) => {
|
||||
div.createEl("small", {
|
||||
div.createEl('small', {
|
||||
text: templateInfo.warningMessage,
|
||||
cls: "mod-warning"
|
||||
cls: 'mod-warning',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -62,16 +58,15 @@ export class GeneralSettingsComponent implements SettingsComponent {
|
|||
const onChange = async (value: string) => {
|
||||
plugin.settings.global.templatePath = value ? normalizePath(value) : '';
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
};
|
||||
search
|
||||
.setPlaceholder(t("template.selector.placeholder"))
|
||||
.setValue(plugin.settings.global.templatePath || "")
|
||||
.setPlaceholder(t('template.selector.placeholder'))
|
||||
.setValue(plugin.settings.global.templatePath || '')
|
||||
.onChange(onChange);
|
||||
new TemplateSuggest(plugin.app, search.inputEl, templateInfo.templateDirectory, onChange);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private detectTemplatePlugins(app: App): TemplatePluginInfo {
|
||||
const coreTemplates = (app as any).internalPlugins?.plugins?.templates;
|
||||
const templaterPlugin = (app as any).plugins?.plugins?.['templater-obsidian'];
|
||||
|
|
@ -80,7 +75,7 @@ export class GeneralSettingsComponent implements SettingsComponent {
|
|||
const templaterEnabled = (app as any).plugins?.enabledPlugins?.has('templater-obsidian') || false;
|
||||
|
||||
let templateDirectory: string | null = null;
|
||||
let warningMessage = "";
|
||||
let warningMessage = '';
|
||||
|
||||
if (coreTemplatesEnabled && coreTemplates.instance?.options?.folder) {
|
||||
templateDirectory = coreTemplates.instance.options.folder;
|
||||
|
|
@ -89,14 +84,14 @@ export class GeneralSettingsComponent implements SettingsComponent {
|
|||
}
|
||||
|
||||
if (!coreTemplatesEnabled && !templaterEnabled) {
|
||||
warningMessage = t("template.warning");
|
||||
warningMessage = t('template.warning');
|
||||
}
|
||||
|
||||
return {
|
||||
coreTemplatesEnabled,
|
||||
templaterEnabled,
|
||||
templateDirectory,
|
||||
warningMessage
|
||||
warningMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {fetchCountIssuesByJQL, fetchIssuesByJQLRaw} from "../../api";
|
||||
import JiraPlugin from "../../main";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import { fetchCountIssuesByJQL, fetchIssuesByJQLRaw } from '../../api';
|
||||
import JiraPlugin from '../../main';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
|
||||
const t = useTranslations("modals.jql_search").t;
|
||||
const t = useTranslations('modals.jql_search').t;
|
||||
|
||||
/**
|
||||
* Reusable JQL Preview component
|
||||
|
|
@ -23,49 +23,55 @@ export class JQLPreview {
|
|||
}
|
||||
|
||||
private setupContainer() {
|
||||
this.containerEl.addClass("jql-preview-container");
|
||||
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 = this.containerEl.createDiv({
|
||||
cls: 'jql-preview-loading',
|
||||
});
|
||||
this.loadingEl.createDiv({
|
||||
cls: "jql-preview-loading-text",
|
||||
text: t("preview.loading")
|
||||
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: "📝"
|
||||
const emptyEl = this.containerEl.createDiv({
|
||||
cls: 'jql-preview-empty',
|
||||
});
|
||||
emptyEl.createDiv({
|
||||
cls: "jql-preview-empty-text",
|
||||
text: t("preview.empty_input")
|
||||
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: "⚠️"
|
||||
const errorEl = this.containerEl.createDiv({
|
||||
cls: 'jql-preview-error',
|
||||
});
|
||||
errorEl.createDiv({
|
||||
cls: "jql-preview-error-text",
|
||||
text: t("preview.error", { error })
|
||||
cls: 'jql-preview-error-icon',
|
||||
text: '⚠️',
|
||||
});
|
||||
errorEl.createDiv({
|
||||
cls: 'jql-preview-error-text',
|
||||
text: t('preview.error', { error }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -74,217 +80,257 @@ export class JQLPreview {
|
|||
|
||||
// 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 }) });
|
||||
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 })
|
||||
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" });
|
||||
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");
|
||||
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-key',
|
||||
text: 'Key',
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-summary",
|
||||
text: "Summary"
|
||||
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-icon',
|
||||
text: 'T',
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-date",
|
||||
text: "Created"
|
||||
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-date',
|
||||
text: 'Updated',
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-icon",
|
||||
text: "R"
|
||||
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: 'A',
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-icon",
|
||||
text: "P"
|
||||
headerRowEl.createEl('th', {
|
||||
cls: 'jql-preview-th-icon',
|
||||
text: 'P',
|
||||
});
|
||||
headerRowEl.createEl("th", {
|
||||
cls: "jql-preview-th-status",
|
||||
text: "Status"
|
||||
headerRowEl.createEl('th', {
|
||||
cls: 'jql-preview-th-status',
|
||||
text: 'Status',
|
||||
});
|
||||
|
||||
// Table body
|
||||
const tbodyEl = tableEl.createEl("tbody");
|
||||
const tbodyEl = tableEl.createEl('tbody');
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
const jiraUrl = conn?.jiraUrl || '';
|
||||
|
||||
issues.forEach((issue, _) => {
|
||||
const rowEl = tbodyEl.createEl("tr", { cls: "jql-preview-row" });
|
||||
issues.forEach((issue) => {
|
||||
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",
|
||||
const keyCellEl = rowEl.createEl('td', {
|
||||
cls: 'jql-preview-cell-key',
|
||||
});
|
||||
keyCellEl.createEl('a', {
|
||||
cls: 'jql-preview-key-link',
|
||||
text: issue.key || 'N/A',
|
||||
attr: {
|
||||
href: `${this.plugin.settings.connection.jiraUrl}/browse/${issue.key}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
}
|
||||
href: `${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);
|
||||
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 typeCellEl = rowEl.createEl('td', {
|
||||
cls: 'jql-preview-cell-icon',
|
||||
});
|
||||
const issueType = issue.fields?.issuetype;
|
||||
if (issueType && issueType.iconUrl) {
|
||||
const typeIconEl = typeCellEl.createEl("img", {
|
||||
typeCellEl.createEl('img', {
|
||||
attr: { src: issueType.iconUrl, alt: issueType.name },
|
||||
cls: "jql-preview-type-icon"
|
||||
cls: 'jql-preview-type-icon',
|
||||
});
|
||||
} else {
|
||||
// Fallback icon for unknown type
|
||||
const fallbackIconEl = typeCellEl.createEl("div", {
|
||||
cls: "jql-preview-status-indicator status-undefined"
|
||||
typeCellEl.createEl('div', {
|
||||
cls: 'jql-preview-status-indicator status-undefined',
|
||||
});
|
||||
}
|
||||
|
||||
// Created date column
|
||||
const createdCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-date" });
|
||||
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"
|
||||
}));
|
||||
createdCellEl.setText(
|
||||
date.toLocaleDateString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
createdCellEl.setText("N/A");
|
||||
createdCellEl.setText('N/A');
|
||||
}
|
||||
|
||||
// Updated date column
|
||||
const updatedCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-date" });
|
||||
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"
|
||||
}));
|
||||
updatedCellEl.setText(
|
||||
date.toLocaleDateString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
updatedCellEl.setText("N/A");
|
||||
updatedCellEl.setText('N/A');
|
||||
}
|
||||
|
||||
// Reporter column (R)
|
||||
const reporterCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const reporterCellEl = rowEl.createEl('td', {
|
||||
cls: 'jql-preview-cell-icon',
|
||||
});
|
||||
const reporter = issue.fields?.reporter;
|
||||
if (reporter) {
|
||||
const reporterLinkEl = reporterCellEl.createEl("a", {
|
||||
const reporterLinkEl = reporterCellEl.createEl('a', {
|
||||
attr: {
|
||||
href: `${this.plugin.settings.connection.jiraUrl}/secure/ViewProfile.jspa?name=${reporter.name}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
title: reporter.displayName || reporter.name
|
||||
}
|
||||
href: `${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"
|
||||
if (reporter.avatarUrls && reporter.avatarUrls['16x16']) {
|
||||
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() : "?"
|
||||
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"
|
||||
reporterCellEl.createEl('div', {
|
||||
cls: 'jql-preview-status-indicator status-undefined',
|
||||
});
|
||||
}
|
||||
|
||||
// Assignee column (A)
|
||||
const assigneeCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const assigneeCellEl = rowEl.createEl('td', {
|
||||
cls: 'jql-preview-cell-icon',
|
||||
});
|
||||
const assignee = issue.fields?.assignee;
|
||||
if (assignee) {
|
||||
const assigneeLinkEl = assigneeCellEl.createEl("a", {
|
||||
const assigneeLinkEl = assigneeCellEl.createEl('a', {
|
||||
attr: {
|
||||
href: `${this.plugin.settings.connection.jiraUrl}/secure/ViewProfile.jspa?name=${assignee.name}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
title: assignee.displayName || assignee.name
|
||||
}
|
||||
href: `${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"
|
||||
if (assignee.avatarUrls && assignee.avatarUrls['16x16']) {
|
||||
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() : "?"
|
||||
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"
|
||||
assigneeCellEl.createEl('div', {
|
||||
cls: 'jql-preview-status-indicator status-undefined',
|
||||
});
|
||||
}
|
||||
|
||||
// Priority column (P)
|
||||
const priorityCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-icon" });
|
||||
const priorityCellEl = rowEl.createEl('td', {
|
||||
cls: 'jql-preview-cell-icon',
|
||||
});
|
||||
const priority = issue.fields?.priority;
|
||||
if (priority && priority.iconUrl) {
|
||||
const priorityIconEl = priorityCellEl.createEl("img", {
|
||||
priorityCellEl.createEl('img', {
|
||||
attr: { src: priority.iconUrl, alt: priority.name },
|
||||
cls: "jql-preview-priority-icon"
|
||||
cls: 'jql-preview-priority-icon',
|
||||
});
|
||||
} else {
|
||||
const unknownEl = priorityCellEl.createEl("div", {
|
||||
cls: "jql-preview-status-indicator status-undefined"
|
||||
priorityCellEl.createEl('div', {
|
||||
cls: 'jql-preview-status-indicator status-undefined',
|
||||
});
|
||||
}
|
||||
|
||||
// Status column
|
||||
const statusCellEl = rowEl.createEl("td", { cls: "jql-preview-cell-status" });
|
||||
const statusCellEl = rowEl.createEl('td', {
|
||||
cls: 'jql-preview-cell-status',
|
||||
});
|
||||
const status = issue.fields?.status;
|
||||
if (status) {
|
||||
const statusBadgeEl = statusCellEl.createEl("span", {
|
||||
statusCellEl.createEl('span', {
|
||||
cls: `jql-preview-status-badge status-${status.statusCategory?.key || 'default'}`,
|
||||
text: status.name
|
||||
text: status.name,
|
||||
});
|
||||
} else {
|
||||
const statusBadgeEl = statusCellEl.createEl("span", {
|
||||
cls: "jql-preview-status-badge status-default",
|
||||
text: "Unknown"
|
||||
statusCellEl.createEl('span', {
|
||||
cls: 'jql-preview-status-badge status-default',
|
||||
text: 'Unknown',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -306,15 +352,25 @@ export class JQLPreview {
|
|||
|
||||
try {
|
||||
// Enhanced fields for better preview (matching Jira table columns)
|
||||
const fields = ["summary", "issuetype", "key", "priority", "status", "created", "updated", "reporter", "assignee"];
|
||||
const fields = [
|
||||
'summary',
|
||||
'issuetype',
|
||||
'key',
|
||||
'priority',
|
||||
'status',
|
||||
'created',
|
||||
'updated',
|
||||
'reporter',
|
||||
'assignee',
|
||||
];
|
||||
const res = await fetchIssuesByJQLRaw(this.plugin, trimmedJql, this.limit, fields);
|
||||
|
||||
let total = 0;
|
||||
switch (this.plugin.settings.connection.apiVersion) {
|
||||
case "2":
|
||||
switch (this.plugin.getCurrentConnection()?.apiVersion) {
|
||||
case '2':
|
||||
total = res.total;
|
||||
break;
|
||||
case "3":
|
||||
case '3':
|
||||
total = await fetchCountIssuesByJQL(this.plugin, trimmedJql);
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, AbstractInputSuggest, TFile, TFolder } from "obsidian";
|
||||
import {debugLog} from "../../tools/debugLogging";
|
||||
import { App, AbstractInputSuggest, TFile, TFolder } from 'obsidian';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
|
||||
interface TemplateOption {
|
||||
path: string;
|
||||
|
|
@ -15,7 +15,12 @@ export class TemplateSuggest extends AbstractInputSuggest<TemplateOption> {
|
|||
private cacheValid: boolean = false;
|
||||
private onChange?: (value: string) => void;
|
||||
|
||||
constructor(app: App, inputEl: HTMLInputElement, templateDirectory: string | null = null, onChange?: (value: string) => void) {
|
||||
constructor(
|
||||
app: App,
|
||||
inputEl: HTMLInputElement,
|
||||
templateDirectory: string | null = null,
|
||||
onChange?: (value: string) => void,
|
||||
) {
|
||||
super(app, inputEl);
|
||||
this.templateDirectory = templateDirectory;
|
||||
this.onChange = onChange;
|
||||
|
|
@ -34,9 +39,9 @@ export class TemplateSuggest extends AbstractInputSuggest<TemplateOption> {
|
|||
: this.app.vault.getRoot();
|
||||
|
||||
if (searchFolder instanceof TFolder) {
|
||||
this.scanFolderForTemplates(searchFolder, "");
|
||||
this.scanFolderForTemplates(searchFolder, '');
|
||||
} else {
|
||||
this.scanFolderForTemplates(this.app.vault.getRoot(), "");
|
||||
this.scanFolderForTemplates(this.app.vault.getRoot(), '');
|
||||
}
|
||||
|
||||
this.allTemplates.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
|
@ -52,7 +57,7 @@ export class TemplateSuggest extends AbstractInputSuggest<TemplateOption> {
|
|||
const displayName = prefix ? `${prefix}/${child.basename}` : child.basename;
|
||||
this.allTemplates.push({
|
||||
path: child.path,
|
||||
displayName: displayName
|
||||
displayName: displayName,
|
||||
});
|
||||
} else if (child instanceof TFolder) {
|
||||
const newPrefix = prefix ? `${prefix}/${child.name}` : child.name;
|
||||
|
|
@ -85,22 +90,22 @@ export class TemplateSuggest extends AbstractInputSuggest<TemplateOption> {
|
|||
}
|
||||
|
||||
// Fast filtering - only filter if we have a query
|
||||
return this.allTemplates.filter(option =>
|
||||
option.displayName.toLowerCase().includes(query) ||
|
||||
option.path.toLowerCase().includes(query)
|
||||
return this.allTemplates.filter(
|
||||
(option) => option.displayName.toLowerCase().includes(query) || option.path.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
renderSuggestion(template: TemplateOption, el: HTMLElement): void {
|
||||
el.setText(template.displayName);
|
||||
el.setAttr("title", template.path);
|
||||
el.setAttr('title', template.path);
|
||||
}
|
||||
|
||||
selectSuggestion(template: TemplateOption): void {
|
||||
debugLog("selected Templates Folder", template);
|
||||
debugLog('selected Templates Folder', template);
|
||||
this.setValue(template.path);
|
||||
this.onChange && this.onChange(template.path);
|
||||
if (this.onChange) {
|
||||
this.onChange(template.path);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,139 +1,157 @@
|
|||
import { SettingsComponent, SettingsComponentProps } from "../../interfaces/settingsTypes";
|
||||
import debounce from "lodash/debounce";
|
||||
import { safeStringToFunction } from "../../tools/convertFunctionString";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import {setIcon} from "obsidian";
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { safeStringToFunction } from '../../tools/convertFunctionString';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
import { setIcon } from 'obsidian';
|
||||
|
||||
const t = useTranslations("settings.tfm").t;
|
||||
const t = useTranslations('settings.tfm').t;
|
||||
|
||||
export class TestFieldMappingsComponent implements SettingsComponent {
|
||||
private props: SettingsComponentProps;
|
||||
private testMappingsContainer: HTMLElement | null = null;
|
||||
private currentIssueData: any = null;
|
||||
private getCurrentIssue: (() => any) | null = null;
|
||||
private props: SettingsComponentProps;
|
||||
private testMappingsContainer: HTMLElement | null = null;
|
||||
private currentIssueData: any = null;
|
||||
private getCurrentIssue: (() => any) | null = null;
|
||||
private _testMappingItemsContainer: HTMLElement | null = null;
|
||||
|
||||
constructor(props: SettingsComponentProps & { getCurrentIssue?: () => any }) {
|
||||
this.props = props;
|
||||
if (props.getCurrentIssue) {
|
||||
this.getCurrentIssue = props.getCurrentIssue;
|
||||
}
|
||||
}
|
||||
constructor(props: SettingsComponentProps & { getCurrentIssue?: () => any }) {
|
||||
this.props = props;
|
||||
if (props.getCurrentIssue) {
|
||||
this.getCurrentIssue = props.getCurrentIssue;
|
||||
}
|
||||
}
|
||||
|
||||
render(containerEl: HTMLElement): void {
|
||||
// Add section header
|
||||
this.testMappingsContainer = containerEl.createDiv({
|
||||
cls: "jira-test-mappings-container"
|
||||
});
|
||||
|
||||
this.testMappingsContainer.createEl("p", {
|
||||
text: t("desc"),
|
||||
cls: "test-mappings-desc"
|
||||
render(containerEl: HTMLElement): void {
|
||||
// Add section header
|
||||
this.testMappingsContainer = containerEl.createDiv({
|
||||
cls: 'jira-test-mappings-container',
|
||||
});
|
||||
|
||||
// Container for all test mapping items
|
||||
this._testMappingItemsContainer = this.testMappingsContainer.createDiv({ cls: "test-mapping-items-list" });
|
||||
|
||||
// Add first test mapping item
|
||||
this.addTestMappingItem();
|
||||
|
||||
const buttonView = this.testMappingsContainer.createDiv({ cls: "button-view" });
|
||||
|
||||
// Add new mapping button (only once, at the bottom)
|
||||
const addBtn = buttonView.createEl("button", {
|
||||
text: t("add_mapping") || "Add Mapping",
|
||||
cls: "add-field-mapping-btn",
|
||||
attr: { 'data-tooltip': t("add_mapping_tooltip") || "Add new field mapping" }
|
||||
this.testMappingsContainer.createEl('p', {
|
||||
text: t('desc'),
|
||||
cls: 'test-mappings-desc',
|
||||
});
|
||||
setIcon(addBtn, "circle-plus");
|
||||
addBtn.addEventListener("click", () => {
|
||||
this.addTestMappingItem();
|
||||
});
|
||||
|
||||
const resetBtn = buttonView.createEl("button", {
|
||||
text: t("reset") || "Reset All",
|
||||
cls: "reset-field-mappings-btn",
|
||||
attr: { 'data-tooltip': t("reset_tooltip") || "Clear all field mappings" }
|
||||
// Container for all test mapping items
|
||||
this._testMappingItemsContainer = this.testMappingsContainer.createDiv({
|
||||
cls: 'test-mapping-items-list',
|
||||
});
|
||||
setIcon(resetBtn, "refresh-cw");
|
||||
resetBtn.addEventListener("click", () => {
|
||||
|
||||
// Add first test mapping item
|
||||
this.addTestMappingItem();
|
||||
|
||||
const buttonView = this.testMappingsContainer.createDiv({
|
||||
cls: 'button-view',
|
||||
});
|
||||
|
||||
// Add new mapping button (only once, at the bottom)
|
||||
const addBtn = buttonView.createEl('button', {
|
||||
text: t('add_mapping') || 'Add Mapping',
|
||||
cls: 'add-field-mapping-btn',
|
||||
attr: {
|
||||
'data-tooltip': t('add_mapping_tooltip') || 'Add new field mapping',
|
||||
},
|
||||
});
|
||||
setIcon(addBtn, 'circle-plus');
|
||||
addBtn.addEventListener('click', () => {
|
||||
this.addTestMappingItem();
|
||||
});
|
||||
|
||||
const resetBtn = buttonView.createEl('button', {
|
||||
text: t('reset') || 'Reset All',
|
||||
cls: 'reset-field-mappings-btn',
|
||||
attr: {
|
||||
'data-tooltip': t('reset_tooltip') || 'Clear all field mappings',
|
||||
},
|
||||
});
|
||||
setIcon(resetBtn, 'refresh-cw');
|
||||
resetBtn.addEventListener('click', () => {
|
||||
this._testMappingItemsContainer?.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private addTestMappingItem(): void {
|
||||
// Use the dedicated items container
|
||||
const itemsContainer = this._testMappingItemsContainer as HTMLElement;
|
||||
if (!itemsContainer) return;
|
||||
private addTestMappingItem(): void {
|
||||
// Use the dedicated items container
|
||||
const itemsContainer = this._testMappingItemsContainer as HTMLElement;
|
||||
if (!itemsContainer) return;
|
||||
|
||||
const itemContainer = itemsContainer.createDiv({
|
||||
cls: "test-mapping-item"
|
||||
});
|
||||
const itemContainer = itemsContainer.createDiv({
|
||||
cls: 'test-mapping-item',
|
||||
});
|
||||
|
||||
// From Jira expression input
|
||||
const fromJiraContainer = itemContainer.createDiv({ cls: "from-jira-container" });
|
||||
fromJiraContainer.createEl("span", { text: t("from_jira")+":", cls: "field-mapping-label" });
|
||||
// From Jira expression input
|
||||
const fromJiraContainer = itemContainer.createDiv({
|
||||
cls: 'from-jira-container',
|
||||
});
|
||||
fromJiraContainer.createEl('span', {
|
||||
text: t('from_jira') + ':',
|
||||
cls: 'field-mapping-label',
|
||||
});
|
||||
|
||||
const fromJiraInput = fromJiraContainer.createEl("textarea", {
|
||||
cls: "from-jira-input"
|
||||
});
|
||||
fromJiraInput.rows = 1;
|
||||
fromJiraInput.placeholder = "(issue) => null";
|
||||
const fromJiraInput = fromJiraContainer.createEl('textarea', {
|
||||
cls: 'from-jira-input',
|
||||
});
|
||||
fromJiraInput.rows = 1;
|
||||
fromJiraInput.placeholder = '(issue) => null';
|
||||
|
||||
// Value display
|
||||
const valueContainer = itemContainer.createDiv({ cls: "value-container" });
|
||||
valueContainer.createEl("span", { text: t("value")+":", cls: "field-mapping-label" });
|
||||
const valueDisplay = valueContainer.createEl("div", {
|
||||
cls: "value-display",
|
||||
attr: {
|
||||
style: "user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;"
|
||||
}
|
||||
});
|
||||
// Value display
|
||||
const valueContainer = itemContainer.createDiv({
|
||||
cls: 'value-container',
|
||||
});
|
||||
valueContainer.createEl('span', {
|
||||
text: t('value') + ':',
|
||||
cls: 'field-mapping-label',
|
||||
});
|
||||
const valueDisplay = valueContainer.createEl('div', {
|
||||
cls: 'value-display',
|
||||
attr: {
|
||||
style: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
|
||||
},
|
||||
});
|
||||
|
||||
// Debounced evaluation function
|
||||
const debouncedEvaluate = debounce(async () => {
|
||||
const issueData = this.getCurrentIssue ? this.getCurrentIssue() : this.currentIssueData;
|
||||
if (!issueData) {
|
||||
valueDisplay.setText(t("no_data"));
|
||||
return;
|
||||
}
|
||||
// Debounced evaluation function
|
||||
const debouncedEvaluate = debounce(async () => {
|
||||
const issueData = this.getCurrentIssue ? this.getCurrentIssue() : this.currentIssueData;
|
||||
if (!issueData) {
|
||||
valueDisplay.setText(t('no_data'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fromJiraFn = await safeStringToFunction(fromJiraInput.value, 'fromJira', false);
|
||||
if (fromJiraFn) {
|
||||
const result = fromJiraFn(issueData, null);
|
||||
valueDisplay.setText(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
valueDisplay.setText(t("invalid_exp"));
|
||||
}
|
||||
} catch (error) {
|
||||
valueDisplay.setText(`Error: ${error.message}`);
|
||||
}
|
||||
}, 500);
|
||||
try {
|
||||
const fromJiraFn = await safeStringToFunction(fromJiraInput.value, 'fromJira', false);
|
||||
if (fromJiraFn) {
|
||||
const result = fromJiraFn(issueData, null);
|
||||
valueDisplay.setText(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
valueDisplay.setText(t('invalid_exp'));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
valueDisplay.setText(`Error: ${(error as Error).message}`);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Add event listeners
|
||||
fromJiraInput.addEventListener("input", debouncedEvaluate);
|
||||
// Add event listeners
|
||||
fromJiraInput.addEventListener('input', debouncedEvaluate);
|
||||
|
||||
// Add remove button
|
||||
const removeBtn = itemContainer.createEl("button", {
|
||||
text: "✕",
|
||||
cls: "remove-field-btn"
|
||||
});
|
||||
removeBtn.addEventListener("click", () => {
|
||||
itemContainer.remove();
|
||||
});
|
||||
}
|
||||
// Add remove button
|
||||
const removeBtn = itemContainer.createEl('button', {
|
||||
text: '✕',
|
||||
cls: 'remove-field-btn',
|
||||
});
|
||||
removeBtn.addEventListener('click', () => {
|
||||
itemContainer.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally, allow setting the current issue externally
|
||||
setCurrentIssue(issue: any) {
|
||||
this.currentIssueData = issue;
|
||||
// Trigger re-evaluation of all test mappings
|
||||
this.testMappingsContainer?.querySelectorAll(".from-jira-input").forEach((input: HTMLTextAreaElement) => {
|
||||
input.dispatchEvent(new Event("input"));
|
||||
});
|
||||
}
|
||||
// Optionally, allow setting the current issue externally
|
||||
setCurrentIssue(issue: any) {
|
||||
this.currentIssueData = issue;
|
||||
// Trigger re-evaluation of all test mappings
|
||||
this.testMappingsContainer?.querySelectorAll('.from-jira-input').forEach((input) => {
|
||||
(input as HTMLTextAreaElement).dispatchEvent(new Event('input'));
|
||||
});
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
// No persistent state to clear
|
||||
}
|
||||
}
|
||||
hide(): void {
|
||||
// No persistent state to clear
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import {Setting, Notice, setIcon, DropdownComponent, ButtonComponent} from 'obsidian';
|
||||
import { Setting, Notice, setIcon, DropdownComponent, ButtonComponent } from 'obsidian';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { processWorkLogBatch } from "../../commands";
|
||||
import {useTranslations} from "../../localization/translator";
|
||||
import {debugLog} from "../../tools/debugLogging";
|
||||
import { processWorkLogBatch } from '../../commands';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
import {
|
||||
parseTimeBlocks,
|
||||
processTimeEntries,
|
||||
ProcessedEntry,
|
||||
GroupedEntries,
|
||||
TimeTrackerEntry,
|
||||
} from '../../tools/timeTracker';
|
||||
|
||||
type TimeRangeType = 'days' | 'weeks' | 'months' | 'custom';
|
||||
type SendComment = 'no' | 'last_block' | 'block_path';
|
||||
|
|
@ -12,40 +19,7 @@ interface Option {
|
|||
label: string;
|
||||
}
|
||||
|
||||
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;
|
||||
lastBlockName: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: string;
|
||||
timestamp: number; // For easier sorting and filtering
|
||||
}
|
||||
|
||||
interface GroupedEntries {
|
||||
[periodKey: string]: ProcessedEntry[];
|
||||
}
|
||||
|
||||
const t = useTranslations("settings.statistics").t;
|
||||
const t = useTranslations('settings.statistics').t;
|
||||
|
||||
export class TimekeepSettingsComponent implements SettingsComponent {
|
||||
private props: SettingsComponentProps;
|
||||
|
|
@ -81,30 +55,30 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
const settingsContainer = containerEl.createDiv('timekeep-settings');
|
||||
|
||||
new Setting(settingsContainer)
|
||||
.setName(t("settings.send_comments.name"))
|
||||
.setDesc(t("settings.send_comments.description"))
|
||||
.addDropdown(dropdown => {
|
||||
dropdown.addOption('no', t("settings.send_comments.options.no"));
|
||||
dropdown.addOption('last_block', t("settings.send_comments.options.last_block"));
|
||||
dropdown.addOption('block_path', t("settings.send_comments.options.block_path"));
|
||||
.setName(t('settings.send_comments.name'))
|
||||
.setDesc(t('settings.send_comments.description'))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown.addOption('no', t('settings.send_comments.options.no'));
|
||||
dropdown.addOption('last_block', t('settings.send_comments.options.last_block'));
|
||||
dropdown.addOption('block_path', t('settings.send_comments.options.block_path'));
|
||||
dropdown.setValue(this.props.plugin.settings.timekeep.sendComments);
|
||||
dropdown.onChange(async (value: SendComment) => {
|
||||
this.props.plugin.settings.timekeep.sendComments = value;
|
||||
dropdown.onChange(async (value: string) => {
|
||||
this.props.plugin.settings.timekeep.sendComments = value as SendComment;
|
||||
});
|
||||
});
|
||||
|
||||
// Time range selector
|
||||
new Setting(settingsContainer)
|
||||
.setName(t("settings.time_range.name"))
|
||||
.setDesc(t("settings.time_range.description"))
|
||||
.addDropdown(dropdown => {
|
||||
dropdown.addOption('days', t("settings.time_range.options.days"));
|
||||
dropdown.addOption('weeks', t("settings.time_range.options.weeks"));
|
||||
dropdown.addOption('months', t("settings.time_range.options.months"));
|
||||
dropdown.addOption('custom', t("settings.time_range.options.custom"));
|
||||
.setName(t('settings.time_range.name'))
|
||||
.setDesc(t('settings.time_range.description'))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown.addOption('days', t('settings.time_range.options.days'));
|
||||
dropdown.addOption('weeks', t('settings.time_range.options.weeks'));
|
||||
dropdown.addOption('months', t('settings.time_range.options.months'));
|
||||
dropdown.addOption('custom', t('settings.time_range.options.custom'));
|
||||
dropdown.setValue(this.props.plugin.settings.timekeep.statisticsTimeType);
|
||||
dropdown.onChange(async (value: TimeRangeType) => {
|
||||
this.props.plugin.settings.timekeep.statisticsTimeType = value;
|
||||
dropdown.onChange(async (value: string) => {
|
||||
this.props.plugin.settings.timekeep.statisticsTimeType = value as TimeRangeType;
|
||||
this.toggleCustomDateInputs();
|
||||
await this.refreshData();
|
||||
});
|
||||
|
|
@ -112,10 +86,11 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
// Max items slider (hidden for custom range)
|
||||
this.maxItemsSetting = new Setting(settingsContainer)
|
||||
.setName(t("settings.max_items.name"))
|
||||
.setDesc(t("settings.max_items.description"))
|
||||
.addSlider(slider => {
|
||||
slider.setLimits(1, 20, 1)
|
||||
.setName(t('settings.max_items.name'))
|
||||
.setDesc(t('settings.max_items.description'))
|
||||
.addSlider((slider) => {
|
||||
slider
|
||||
.setLimits(1, 20, 1)
|
||||
.setValue(this.props.plugin.settings.timekeep.maxItemsToShow)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
|
|
@ -128,56 +103,59 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
this.customDatesContainer = settingsContainer.createDiv('custom-dates-container');
|
||||
|
||||
new Setting(this.customDatesContainer)
|
||||
.setName(t("settings.date_from.name"))
|
||||
.setDesc(t("settings.date_from.description"))
|
||||
.addText(text => {
|
||||
text.setPlaceholder(t("settings.date_from.placeholder"));
|
||||
.setName(t('settings.date_from.name'))
|
||||
.setDesc(t('settings.date_from.description'))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t('settings.date_from.placeholder'));
|
||||
text.setValue(this.props.plugin.settings.timekeep.customDateRange.start);
|
||||
text.onChange(async (value) => {
|
||||
this.props.plugin.settings.timekeep.customDateRange.start = value;
|
||||
if (this.props.plugin.settings.timekeep.statisticsTimeType === 'custom' && this.props.plugin.settings.timekeep.customDateRange.end) {
|
||||
if (
|
||||
this.props.plugin.settings.timekeep.statisticsTimeType === 'custom' &&
|
||||
this.props.plugin.settings.timekeep.customDateRange.end
|
||||
) {
|
||||
await this.refreshData();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this.customDatesContainer)
|
||||
.setName(t("settings.date_to.name"))
|
||||
.setDesc(t("settings.date_to.description"))
|
||||
.addText(text => {
|
||||
text.setPlaceholder(t("settings.date_to.placeholder"));
|
||||
.setName(t('settings.date_to.name'))
|
||||
.setDesc(t('settings.date_to.description'))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t('settings.date_to.placeholder'));
|
||||
text.setValue(this.props.plugin.settings.timekeep.customDateRange.end);
|
||||
text.onChange(async (value) => {
|
||||
this.props.plugin.settings.timekeep.customDateRange.end = value;
|
||||
if (this.props.plugin.settings.timekeep.statisticsTimeType === 'custom' && this.props.plugin.settings.timekeep.customDateRange.start) {
|
||||
if (
|
||||
this.props.plugin.settings.timekeep.statisticsTimeType === 'custom' &&
|
||||
this.props.plugin.settings.timekeep.customDateRange.start
|
||||
) {
|
||||
await this.refreshData();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
new Setting(settingsContainer)
|
||||
.setName(t("display.select_period"))
|
||||
.setName(t('display.select_period'))
|
||||
.setClass('period-selector')
|
||||
.addDropdown(dropdown => {
|
||||
.addDropdown((dropdown) => {
|
||||
this.periodDropdown = dropdown;
|
||||
this.populateTimeRangeOptions(dropdown, []);
|
||||
dropdown
|
||||
.setValue(this.props.plugin.settings.timekeep.sendComments)
|
||||
.onChange(async (selectedPeriod) => {
|
||||
this.selectedPeriodData = this.groupedByPeriod[selectedPeriod] || [];
|
||||
this.sendButton? this.sendButton.setDisabled(false) : null;
|
||||
});
|
||||
dropdown.setValue(this.props.plugin.settings.timekeep.sendComments).onChange(async (selectedPeriod) => {
|
||||
this.selectedPeriodData = this.groupedByPeriod[selectedPeriod] || [];
|
||||
if (this.sendButton) {
|
||||
this.sendButton.setDisabled(false);
|
||||
}
|
||||
});
|
||||
})
|
||||
.addButton(
|
||||
button => {
|
||||
this.sendButton = button;
|
||||
button.setDisabled(true);
|
||||
button.setButtonText(t("actions.send_to_jira"));
|
||||
button.onClick(() => this.sendWorkLogToJira());
|
||||
button.setClass('timekeep-btn');
|
||||
}
|
||||
);
|
||||
.addButton((button) => {
|
||||
this.sendButton = button;
|
||||
button.setDisabled(true);
|
||||
button.setButtonText(t('actions.send_to_jira'));
|
||||
button.onClick(() => this.sendWorkLogToJira());
|
||||
button.setClass('timekeep-btn');
|
||||
});
|
||||
}
|
||||
|
||||
private toggleCustomDateInputs(): void {
|
||||
|
|
@ -186,7 +164,7 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
const isCustom = this.props.plugin.settings.timekeep.statisticsTimeType === 'custom';
|
||||
|
||||
if (isCustom) {
|
||||
this.maxItemsSetting.settingEl.hide()
|
||||
this.maxItemsSetting.settingEl.hide();
|
||||
this.customDatesContainer.show();
|
||||
} else {
|
||||
this.maxItemsSetting.settingEl.show();
|
||||
|
|
@ -203,10 +181,14 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
try {
|
||||
await this.processFiles();
|
||||
this.updateDisplay();
|
||||
if (!disableNotification) new Notice(t("messages.refresh_success"));
|
||||
} catch (error) {
|
||||
if (!disableNotification) new Notice(t('messages.refresh_success'));
|
||||
} catch (error: unknown) {
|
||||
console.error('Error refreshing timekeep data:', error);
|
||||
new Notice(t("messages.refresh_error", { error: error.message }));
|
||||
new Notice(
|
||||
t('messages.refresh_error', {
|
||||
error: (error as Error).message,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
// Убираем индикатор загрузки
|
||||
this.containerEl.removeClass('timekeep-loading');
|
||||
|
|
@ -222,8 +204,8 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
if (Object.keys(this.groupedByPeriod).length === 0) {
|
||||
this.containerEl.createEl('div', {
|
||||
text: t("display.no_entries"),
|
||||
cls: 'timekeep-no-data'
|
||||
text: t('display.no_entries'),
|
||||
cls: 'timekeep-no-data',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -234,8 +216,8 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
private renderAllPeriodsDisplay(container: HTMLElement): void {
|
||||
const display = container.createDiv('all-periods-display jira-copyable timekeep-fade-in');
|
||||
display.createEl('h3', {
|
||||
text: t("display.all_periods"),
|
||||
cls: 'timekeep-section-title'
|
||||
text: t('display.all_periods'),
|
||||
cls: 'timekeep-section-title',
|
||||
});
|
||||
|
||||
const sortedPeriods = Object.keys(this.groupedByPeriod).sort().reverse();
|
||||
|
|
@ -253,25 +235,25 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
const headerContainer = periodDiv.createDiv('timekeep-period-header');
|
||||
headerContainer.createEl('h4', {
|
||||
text: this.formatPeriodLabel(period),
|
||||
cls: 'timekeep-period-title'
|
||||
cls: 'timekeep-period-title',
|
||||
});
|
||||
headerContainer.createEl('span', {
|
||||
text: this.parseMsToDuration(totalMs),
|
||||
cls: 'timekeep-period-total'
|
||||
cls: 'timekeep-period-total',
|
||||
});
|
||||
|
||||
this.createEntriesTable(periodDiv, this.groupedByPeriod[period]);
|
||||
});
|
||||
}
|
||||
|
||||
private groupAndSummarizeEntries(entries: ProcessedEntry[]):
|
||||
Record<string, Record<string, { entries: ProcessedEntry[], totalMs: number }>>
|
||||
{
|
||||
private groupAndSummarizeEntries(
|
||||
entries: ProcessedEntry[],
|
||||
): Record<string, Record<string, { entries: ProcessedEntry[]; totalMs: number }>> {
|
||||
const grouped: ReturnType<typeof this.groupAndSummarizeEntries> = {};
|
||||
|
||||
entries.forEach(entry => {
|
||||
entries.forEach((entry) => {
|
||||
const file = entry.file;
|
||||
const issueKey = entry.issueKey || "no-issue";
|
||||
const issueKey = entry.issueKey || 'no-issue';
|
||||
|
||||
if (!grouped[file]) grouped[file] = {};
|
||||
if (!grouped[file][issueKey]) {
|
||||
|
|
@ -311,37 +293,43 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
});
|
||||
|
||||
// Group title
|
||||
const groupTitle = groupHeader.createEl('div', { cls: 'timekeep-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' });
|
||||
const groupMeta = groupHeader.createEl('div', {
|
||||
cls: 'timekeep-group-meta',
|
||||
});
|
||||
|
||||
const conn = this.props.plugin.getCurrentConnection();
|
||||
const jiraUrl = conn?.jiraUrl || '';
|
||||
if (issueKey && issueKey !== 'no-issue') {
|
||||
const issueKeySpan = groupMeta.createEl('a', {
|
||||
text: issueKey,
|
||||
cls: 'timekeep-group-issue-key',
|
||||
attr: {
|
||||
href: `${this.props.plugin.settings.connection.jiraUrl}/browse/${issueKey}`,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
}
|
||||
href: `${jiraUrl}/browse/${issueKey}`,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
});
|
||||
issueKeySpan.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
debugLog(`${this.props.plugin.settings.connection.jiraUrl}/browse/${issueKey}`)
|
||||
debugLog(`${jiraUrl}/browse/${issueKey}`);
|
||||
}
|
||||
groupMeta.createEl('span', {
|
||||
text: this.parseMsToDuration(group.totalMs),
|
||||
cls: 'timekeep-group-duration'
|
||||
cls: 'timekeep-group-duration',
|
||||
});
|
||||
|
||||
// Collapse icon
|
||||
const chevron = groupMeta.createEl('span', {
|
||||
cls: 'jira-collapse-icon',
|
||||
});
|
||||
setIcon(chevron, "chevron-down");
|
||||
setIcon(chevron, 'chevron-down');
|
||||
|
||||
// Create entries container
|
||||
const entriesContainer = groupCard.createDiv('timekeep-entries-container');
|
||||
|
|
@ -350,16 +338,16 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
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'
|
||||
text: t('display.table.task'),
|
||||
cls: 'timekeep-entry-label-task',
|
||||
});
|
||||
labelsDiv.createEl('div', {
|
||||
text: t("display.table.start_time"),
|
||||
cls: 'timekeep-entry-label-start'
|
||||
text: t('display.table.start_time'),
|
||||
cls: 'timekeep-entry-label-start',
|
||||
});
|
||||
labelsDiv.createEl('div', {
|
||||
text: t("display.table.duration"),
|
||||
cls: 'timekeep-entry-label-duration'
|
||||
text: t('display.table.duration'),
|
||||
cls: 'timekeep-entry-label-duration',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -371,21 +359,21 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
entryDiv.createEl('div', {
|
||||
text: entry.blockPath,
|
||||
cls: 'timekeep-entry-block-path',
|
||||
title: entry.blockPath
|
||||
title: entry.blockPath,
|
||||
});
|
||||
|
||||
// Start time
|
||||
entryDiv.createEl('div', {
|
||||
text: entry.startTime,
|
||||
cls: 'timekeep-entry-start-time',
|
||||
title: entry.startTime
|
||||
title: entry.startTime,
|
||||
});
|
||||
|
||||
// Duration
|
||||
entryDiv.createEl('div', {
|
||||
text: entry.duration,
|
||||
cls: 'timekeep-entry-duration',
|
||||
title: entry.duration
|
||||
title: entry.duration,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -398,7 +386,7 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
private async sendWorkLogToJira(): Promise<void> {
|
||||
if (this.selectedPeriodData.length === 0) {
|
||||
new Notice(t("messages.select_period_first"));
|
||||
new Notice(t('messages.select_period_first'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -408,13 +396,13 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
case 'last_block':
|
||||
data_to_send = this.selectedPeriodData.map((entry) => ({
|
||||
...entry,
|
||||
comment: entry.lastBlockName
|
||||
comment: entry.lastBlockName,
|
||||
}));
|
||||
break;
|
||||
case 'block_path':
|
||||
data_to_send = this.selectedPeriodData.map((entry) => ({
|
||||
...entry,
|
||||
comment: entry.blockPath
|
||||
comment: entry.blockPath,
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
|
|
@ -422,15 +410,17 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
break;
|
||||
}
|
||||
await processWorkLogBatch(this.props.plugin, data_to_send);
|
||||
new Notice(t("messages.send_success"));
|
||||
} catch (error) {
|
||||
new Notice(t('messages.send_success'));
|
||||
} catch (error: unknown) {
|
||||
console.error('Error sending work log to Jira:', error);
|
||||
new Notice(t("messages.send_error", { error: error.message }));
|
||||
new Notice(t('messages.send_error', { error: (error as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
private formatPeriodLabel(periodKey: string): string {
|
||||
return t("display.period_of", { date: this.formatPeriodDate(periodKey) });
|
||||
return t('display.period_of', {
|
||||
date: this.formatPeriodDate(periodKey),
|
||||
});
|
||||
}
|
||||
|
||||
private formatPeriodDate(periodKey: string): string {
|
||||
|
|
@ -453,7 +443,10 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
return `${date.toISOString().split('T')[0]} – ${weekEnd.toISOString().split('T')[0]}`;
|
||||
}
|
||||
case 'months':
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
default:
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
|
@ -463,9 +456,10 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
switch (this.props.plugin.settings.timekeep.statisticsTimeType) {
|
||||
case 'days':
|
||||
return date.toISOString().split('T')[0];
|
||||
case 'weeks':
|
||||
case 'weeks': {
|
||||
const weekStart = this.getWeekStartDate(date);
|
||||
return `week-${weekStart.toISOString().split('T')[0]}`;
|
||||
}
|
||||
case 'months':
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
case 'custom':
|
||||
|
|
@ -478,7 +472,11 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
private isDateInRange(date: Date): boolean {
|
||||
if (this.props.plugin.settings.timekeep.statisticsTimeType !== 'custom') return true;
|
||||
|
||||
if (!this.props.plugin.settings.timekeep.customDateRange.start || !this.props.plugin.settings.timekeep.customDateRange.end) return false;
|
||||
if (
|
||||
!this.props.plugin.settings.timekeep.customDateRange.start ||
|
||||
!this.props.plugin.settings.timekeep.customDateRange.end
|
||||
)
|
||||
return false;
|
||||
|
||||
const from = new Date(this.props.plugin.settings.timekeep.customDateRange.start);
|
||||
const to = new Date(this.props.plugin.settings.timekeep.customDateRange.end);
|
||||
|
|
@ -490,8 +488,11 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
private validateCustomRange(): boolean {
|
||||
if (this.props.plugin.settings.timekeep.statisticsTimeType !== 'custom') return true;
|
||||
|
||||
if (!this.props.plugin.settings.timekeep.customDateRange.start || !this.props.plugin.settings.timekeep.customDateRange.end) {
|
||||
new Notice(t("messages.custom_range_required"));
|
||||
if (
|
||||
!this.props.plugin.settings.timekeep.customDateRange.start ||
|
||||
!this.props.plugin.settings.timekeep.customDateRange.end
|
||||
) {
|
||||
new Notice(t('messages.custom_range_required'));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -499,7 +500,7 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
const to = new Date(this.props.plugin.settings.timekeep.customDateRange.end);
|
||||
|
||||
if (from > to) {
|
||||
new Notice(t("messages.invalid_date_range"));
|
||||
new Notice(t('messages.invalid_date_range'));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -517,11 +518,11 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
|
||||
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 },
|
||||
{ label: "m", ms: 1000 * 60 },
|
||||
{ label: "s", ms: 1000 }
|
||||
{ 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;
|
||||
|
|
@ -535,18 +536,18 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result.join(" ") : "0s";
|
||||
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 }
|
||||
{ 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;
|
||||
|
|
@ -576,108 +577,65 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
}
|
||||
|
||||
private processEntries(
|
||||
entries: Entry[] | undefined,
|
||||
entries: TimeTrackerEntry[] | undefined,
|
||||
fileName: string,
|
||||
issueKey: string | undefined,
|
||||
parentPath: string,
|
||||
groupedEntries: GroupedEntries
|
||||
groupedEntries: GroupedEntries,
|
||||
): void {
|
||||
if (!entries || !Array.isArray(entries)) return;
|
||||
|
||||
for (const entry of entries) {
|
||||
const currentPath = parentPath ? `${parentPath} > ${entry.name}` : entry.name;
|
||||
|
||||
if (entry.startTime) {
|
||||
const startTime = new Date(entry.startTime);
|
||||
|
||||
// Check if date is in range for custom range
|
||||
if (!this.isDateInRange(startTime)) continue;
|
||||
|
||||
const periodKey = this.getPeriodKey(startTime);
|
||||
if (!groupedEntries[periodKey]) groupedEntries[periodKey] = [];
|
||||
|
||||
let duration: string;
|
||||
let endTime: Date;
|
||||
|
||||
if (entry.endTime) {
|
||||
endTime = new Date(entry.endTime);
|
||||
duration = this.parseMsToDuration(endTime.getTime() - startTime.getTime());
|
||||
} else {
|
||||
endTime = new Date();
|
||||
duration = "ongoing";
|
||||
}
|
||||
|
||||
groupedEntries[periodKey].push({
|
||||
file: fileName.replace(".md", ""),
|
||||
issueKey: issueKey,
|
||||
blockPath: currentPath,
|
||||
lastBlockName: entry.name,
|
||||
startTime: this.toLocalIso(startTime),
|
||||
endTime: entry.endTime ? this.toLocalIso(endTime) : "ongoing",
|
||||
duration: duration,
|
||||
timestamp: startTime.getTime()
|
||||
});
|
||||
}
|
||||
|
||||
// Process sub-entries recursively
|
||||
if (entry.subEntries && Array.isArray(entry.subEntries)) {
|
||||
this.processEntries(entry.subEntries, fileName, issueKey, currentPath, groupedEntries);
|
||||
}
|
||||
}
|
||||
processTimeEntries(
|
||||
entries,
|
||||
fileName,
|
||||
issueKey,
|
||||
parentPath,
|
||||
groupedEntries,
|
||||
(date) => this.isDateInRange(date),
|
||||
(date) => this.getPeriodKey(date),
|
||||
);
|
||||
}
|
||||
|
||||
private async processFiles(): Promise<void> {
|
||||
if (!this.validateCustomRange()) return;
|
||||
|
||||
const timekeepBlocks: TimekeepBlock[] = [];
|
||||
const files = this.props.app.vault.getMarkdownFiles().filter(
|
||||
file => file.path.startsWith(`${this.props.plugin.settings.global.issuesFolder}/`)
|
||||
const allBlocks: ReturnType<typeof parseTimeBlocks> = [];
|
||||
const files = this.props.app.vault
|
||||
.getMarkdownFiles()
|
||||
.filter((file) => file.path.startsWith(`${this.props.plugin.settings.global.issuesFolder}/`));
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const content = await this.props.app.vault.read(file);
|
||||
if (!content.includes('```timekeep') && !content.includes('```simple-time-tracker')) return;
|
||||
|
||||
const metadata = this.props.app.metadataCache.getFileCache(file);
|
||||
const issueKey = metadata?.frontmatter?.key;
|
||||
|
||||
const blocks = parseTimeBlocks(content, file, issueKey);
|
||||
allBlocks.push(...blocks);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(files.map(async file => {
|
||||
const content = await this.props.app.vault.read(file);
|
||||
if (!content.includes("```timekeep")) return;
|
||||
|
||||
const metadata = this.props.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 parsing timekeep block in ${file.name}:`, error);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.groupedByPeriod = {};
|
||||
|
||||
timekeepBlocks.forEach(block => {
|
||||
this.processEntries(block.data.entries, block.file, block.issueKey, "", this.groupedByPeriod);
|
||||
allBlocks.forEach((block) => {
|
||||
this.processEntries(block.data.entries, block.file, block.issueKey, '', this.groupedByPeriod);
|
||||
});
|
||||
|
||||
debugLog("grouped entries", this.groupedByPeriod);
|
||||
debugLog('grouped entries', this.groupedByPeriod);
|
||||
|
||||
this.trimOldEntries(this.groupedByPeriod);
|
||||
|
||||
const sortedPeriods = Object.keys(this.groupedByPeriod).sort().reverse(); // Most recent first
|
||||
const options = sortedPeriods.map(period => ({
|
||||
const options = sortedPeriods.map((period) => ({
|
||||
value: period,
|
||||
label: this.formatPeriodLabel(period)
|
||||
label: this.formatPeriodLabel(period),
|
||||
}));
|
||||
|
||||
this.populateTimeRangeOptions(this.periodDropdown, options);
|
||||
this.periodDropdown?.setValue("");
|
||||
this.sendButton ? this.sendButton.disabled = true : null;
|
||||
this.periodDropdown?.setValue('');
|
||||
if (this.sendButton) {
|
||||
this.sendButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private populateTimeRangeOptions(dropdown: DropdownComponent | null, options: Option[]): void {
|
||||
|
|
@ -687,16 +645,17 @@ export class TimekeepSettingsComponent implements SettingsComponent {
|
|||
dropdown.selectEl.empty();
|
||||
|
||||
// Add new options based on current state
|
||||
const record_options = options.reduce((acc, curr) => {
|
||||
acc[curr.value] = curr.label;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
const record_options = options.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.value] = curr.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
dropdown.addOptions(record_options);
|
||||
}
|
||||
|
||||
|
||||
private toLocalIso(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import {FieldMapping} from "../default/obsidianJiraFieldsMapping";
|
||||
import { FieldMapping } from '../default/obsidianJiraFieldsMapping';
|
||||
|
||||
export interface JQLPreset {
|
||||
name: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface ConnectionSettingsInterface {
|
||||
name: string;
|
||||
jiraUrl: string;
|
||||
apiVersion: "2" | "3";
|
||||
authMethod: "bearer" | "basic" | "session";
|
||||
apiVersion: '2' | '3';
|
||||
authMethod: 'bearer' | 'basic' | 'session';
|
||||
apiToken: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
jqlPresets: JQLPreset[];
|
||||
lastJqlQuery: string;
|
||||
sessionCookie: string;
|
||||
}
|
||||
|
||||
export interface GlobalSettingsInterface {
|
||||
|
|
@ -29,7 +37,7 @@ export interface FetchIssueInterface {
|
|||
}
|
||||
|
||||
export interface TimekeepSettingsInterface {
|
||||
sendComments: "no" | "last_block" | "block_path";
|
||||
sendComments: 'no' | 'last_block' | 'block_path';
|
||||
statisticsTimeType: string;
|
||||
maxItemsToShow: number;
|
||||
customDateRange: { start: string; end: string };
|
||||
|
|
@ -47,7 +55,8 @@ export interface CollapsedSections {
|
|||
export interface JiraSettingsInterface {
|
||||
collapsedSections: CollapsedSections;
|
||||
|
||||
connection: ConnectionSettingsInterface;
|
||||
connections: ConnectionSettingsInterface[];
|
||||
currentConnectionIndex: number;
|
||||
global: GlobalSettingsInterface;
|
||||
fieldMapping: FieldMappingSettingsInterface;
|
||||
fetchIssue: FetchIssueInterface;
|
||||
|
|
@ -67,19 +76,26 @@ export const DEFAULT_SETTINGS: JiraSettingsInterface = {
|
|||
statistics: false,
|
||||
},
|
||||
|
||||
connection: {
|
||||
authMethod: "bearer",
|
||||
apiToken: "",
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
jiraUrl: "",
|
||||
apiVersion: "2",
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
name: 'Connection 1',
|
||||
authMethod: 'bearer',
|
||||
apiToken: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
jiraUrl: '',
|
||||
apiVersion: '2',
|
||||
jqlPresets: [],
|
||||
lastJqlQuery: '',
|
||||
sessionCookie: '',
|
||||
},
|
||||
],
|
||||
currentConnectionIndex: 0,
|
||||
|
||||
global: {
|
||||
issuesFolder: "jira-issues",
|
||||
templatePath: "",
|
||||
issuesFolder: 'jira-issues',
|
||||
templatePath: '',
|
||||
},
|
||||
|
||||
fieldMapping: {
|
||||
|
|
@ -89,19 +105,18 @@ export const DEFAULT_SETTINGS: JiraSettingsInterface = {
|
|||
},
|
||||
|
||||
fetchIssue: {
|
||||
filenameTemplate: "{summary} ({key})",
|
||||
fields: ["*all"],
|
||||
filenameTemplate: '{summary} ({key})',
|
||||
fields: ['*all'],
|
||||
expand: [],
|
||||
},
|
||||
|
||||
timekeep: {
|
||||
sendComments: "no",
|
||||
statisticsTimeType: "weeks",
|
||||
sendComments: 'no',
|
||||
statisticsTimeType: 'weeks',
|
||||
maxItemsToShow: 10,
|
||||
customDateRange: { start: "", end: "" },
|
||||
customDateRange: { start: '', end: '' },
|
||||
},
|
||||
|
||||
sessionCookieName: "JSESSIONID",
|
||||
issueKeyToFilePathCache: {}
|
||||
sessionCookieName: 'JSESSIONID',
|
||||
issueKeyToFilePathCache: {},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Notice } from "obsidian";
|
||||
import { ValidationResult } from "../../interfaces/settingsTypes";
|
||||
import { validateFunctionString } from "../../tools/convertFunctionString";
|
||||
import { debugLog } from "../../tools/debugLogging";
|
||||
import { Notice } from 'obsidian';
|
||||
import { ValidationResult } from '../../interfaces/settingsTypes';
|
||||
import { validateFunctionString } from '../../tools/convertFunctionString';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
|
||||
/**
|
||||
* Validate a field and update its visual state
|
||||
|
|
@ -10,7 +10,7 @@ export async function validateField(
|
|||
input: HTMLInputElement | HTMLTextAreaElement,
|
||||
requireValidating: boolean = true,
|
||||
type: string = 'string',
|
||||
validateParams: Array<string> = []
|
||||
validateParams: Array<string> = [],
|
||||
): Promise<void> {
|
||||
const validatorFunction = async (event?: Event) => {
|
||||
const consoleOutput = !!event;
|
||||
|
|
@ -22,10 +22,10 @@ export async function validateField(
|
|||
setupValidatorProperty(input, validatorFunction);
|
||||
|
||||
if (requireValidating) {
|
||||
input.addEventListener("change", (input as any)._validatorFunction);
|
||||
input.addEventListener('change', (input as any)._validatorFunction);
|
||||
await validatorFunction(); // Run initial validation
|
||||
} else {
|
||||
input.removeEventListener("change", (input as any)._validatorFunction);
|
||||
input.removeEventListener('change', (input as any)._validatorFunction);
|
||||
updateFieldAppearance({ isValid: true }, input, false); // Clear any validation errors
|
||||
}
|
||||
}
|
||||
|
|
@ -36,12 +36,12 @@ export async function validateField(
|
|||
async function getValidationResult(
|
||||
value: string,
|
||||
type: string = 'string',
|
||||
validateParams: Array<string> = []
|
||||
validateParams: Array<string> = [],
|
||||
): Promise<ValidationResult> {
|
||||
switch(type) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return value.length === 0
|
||||
? { isValid: false, errorMessage: "Field name cannot be empty" }
|
||||
? { isValid: false, errorMessage: 'Field name cannot be empty' }
|
||||
: { isValid: true };
|
||||
case 'function':
|
||||
return await validateFunctionString(value, validateParams);
|
||||
|
|
@ -56,16 +56,16 @@ async function getValidationResult(
|
|||
export function updateFieldAppearance(
|
||||
validation: ValidationResult,
|
||||
element: HTMLInputElement | HTMLTextAreaElement,
|
||||
consoleOutput: boolean
|
||||
consoleOutput: boolean,
|
||||
): void {
|
||||
if (!validation.isValid) {
|
||||
element.classList.add("invalid");
|
||||
element.classList.add('invalid');
|
||||
if (consoleOutput && validation.errorMessage) {
|
||||
debugLog(validation.errorMessage);
|
||||
new Notice(validation.errorMessage);
|
||||
}
|
||||
} else {
|
||||
element.classList.remove("invalid");
|
||||
element.classList.remove('invalid');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,16 +74,16 @@ export function updateFieldAppearance(
|
|||
*/
|
||||
function setupValidatorProperty(
|
||||
input: HTMLInputElement | HTMLTextAreaElement,
|
||||
validatorFunction: (event?: Event) => Promise<void>
|
||||
validatorFunction: (event?: Event) => Promise<void>,
|
||||
): void {
|
||||
if (!input.hasOwnProperty('_validatorFunction')) {
|
||||
if (!Object.prototype.hasOwnProperty.call(input, '_validatorFunction')) {
|
||||
Object.defineProperty(input, '_validatorFunction', {
|
||||
value: validatorFunction,
|
||||
writable: true,
|
||||
configurable: true
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
input.removeEventListener("change", (input as any)._validatorFunction);
|
||||
input.removeEventListener('change', (input as any)._validatorFunction);
|
||||
(input as any)._validatorFunction = validatorFunction;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { JiraFieldMapping, JiraFieldMappingString } from "../../interfaces/settingsTypes";
|
||||
import { JiraFieldMapping, JiraFieldMappingString } from '../../interfaces/settingsTypes';
|
||||
import {
|
||||
jiraFunctionToString,
|
||||
transform_string_to_functions_mappings,
|
||||
// validateFunctionString
|
||||
} from "../../tools/convertFunctionString";
|
||||
import { debugLog } from "../../tools/debugLogging";
|
||||
} from '../../tools/convertFunctionString';
|
||||
import { debugLog } from '../../tools/debugLogging';
|
||||
|
||||
/**
|
||||
* Convert function mappings to their string representation
|
||||
*/
|
||||
export function convertFunctionMappingsToStrings(
|
||||
mappings: Record<string, JiraFieldMapping>
|
||||
mappings: Record<string, JiraFieldMapping>,
|
||||
): Record<string, JiraFieldMappingString> {
|
||||
const result: Record<string, JiraFieldMappingString> = {};
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export function convertFunctionMappingsToStrings(
|
|||
if (mapping && typeof mapping === 'object' && 'toJira' in mapping && 'fromJira' in mapping) {
|
||||
result[fieldName] = {
|
||||
toJira: jiraFunctionToString(mapping.toJira, false),
|
||||
fromJira: jiraFunctionToString(mapping.fromJira, true)
|
||||
fromJira: jiraFunctionToString(mapping.fromJira, true),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -34,12 +34,12 @@ export async function collectFieldMappingsFromUI(
|
|||
// enableValidation: boolean
|
||||
): Promise<Record<string, JiraFieldMappingString>> {
|
||||
const mappings: Record<string, JiraFieldMappingString> = {};
|
||||
const fieldItems = element.querySelectorAll(".field-mapping-item");
|
||||
const fieldItems = element.querySelectorAll('.field-mapping-item');
|
||||
|
||||
fieldItems.forEach(item => {
|
||||
const fieldNameInput = item.querySelector(".field-name-input");
|
||||
const toJiraInput = item.querySelector(".to-jira-input");
|
||||
const fromJiraInput = item.querySelector(".from-jira-input");
|
||||
fieldItems.forEach((item) => {
|
||||
const fieldNameInput = item.querySelector('.field-name-input');
|
||||
const toJiraInput = item.querySelector('.to-jira-input');
|
||||
const fromJiraInput = item.querySelector('.from-jira-input');
|
||||
|
||||
if (!fieldNameInput || !toJiraInput || !fromJiraInput) {
|
||||
return;
|
||||
|
|
@ -53,7 +53,7 @@ export async function collectFieldMappingsFromUI(
|
|||
if (fieldName) {
|
||||
mappings[fieldName] = {
|
||||
toJira: toJira,
|
||||
fromJira: fromJira
|
||||
fromJira: fromJira,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -67,19 +67,16 @@ export async function collectFieldMappingsFromUI(
|
|||
*/
|
||||
export async function processMappings(
|
||||
stringMappings: Record<string, JiraFieldMappingString>,
|
||||
enableValidation: boolean
|
||||
enableValidation: boolean,
|
||||
): Promise<{
|
||||
stringMappings: Record<string, JiraFieldMappingString>,
|
||||
functionMappings: Record<string, JiraFieldMapping>
|
||||
stringMappings: Record<string, JiraFieldMappingString>;
|
||||
functionMappings: Record<string, JiraFieldMapping>;
|
||||
}> {
|
||||
// Convert string mappings to function mappings
|
||||
const functionMappings = await transform_string_to_functions_mappings(
|
||||
stringMappings,
|
||||
enableValidation
|
||||
);
|
||||
const functionMappings = await transform_string_to_functions_mappings(stringMappings, enableValidation);
|
||||
|
||||
return {
|
||||
stringMappings,
|
||||
functionMappings
|
||||
functionMappings,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
import {TextComponent} from "obsidian";
|
||||
import { TextComponent } from 'obsidian';
|
||||
|
||||
export function setupArrayTextSetting(
|
||||
text: TextComponent,
|
||||
initialArray: string[],
|
||||
onChange: (array: string[]) => Promise<void>
|
||||
onChange: (array: string[]) => Promise<void>,
|
||||
) {
|
||||
text
|
||||
.setValue(initialArray.join(", "))
|
||||
.onChange(async (value) => {
|
||||
const array = value
|
||||
.split(",")
|
||||
.map(field => field.trim())
|
||||
.filter(field => field.length > 0);
|
||||
text.setValue(initialArray.join(', ')).onChange(async (value) => {
|
||||
const array = value
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => field.length > 0);
|
||||
|
||||
await onChange(array);
|
||||
});
|
||||
await onChange(array);
|
||||
});
|
||||
|
||||
text.inputEl.addEventListener("blur", () => {
|
||||
text.inputEl.addEventListener('blur', () => {
|
||||
const currentValue = text.inputEl.value;
|
||||
const cleaned = currentValue
|
||||
.split(",")
|
||||
.map(field => field.trim())
|
||||
.filter(field => field.length > 0)
|
||||
.join(", ");
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => field.length > 0)
|
||||
.join(', ');
|
||||
|
||||
if (currentValue !== cleaned) {
|
||||
text.setValue(cleaned);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
export function createLimiter(concurrency: number) {
|
||||
let activeCount = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
|
|
@ -13,7 +12,7 @@ export function createLimiter(concurrency: number) {
|
|||
|
||||
return async function limit<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (activeCount >= concurrency) {
|
||||
await new Promise<void>(resolve => queue.push(resolve));
|
||||
await new Promise<void>((resolve) => queue.push(resolve));
|
||||
}
|
||||
activeCount++;
|
||||
try {
|
||||
|
|
@ -25,7 +24,5 @@ export function createLimiter(concurrency: number) {
|
|||
}
|
||||
|
||||
export function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
return Array.from({length: Math.ceil(arr.length / size)}, (_, i) =>
|
||||
arr.slice(i * size, i * size + size)
|
||||
);
|
||||
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size));
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue