Compare commits

..

36 commits

Author SHA1 Message Date
Alamion
294a18b37f
Update README.md 2026-07-22 14:17:29 +03:00
Alamion
3365e6ba5d
Update README.md 2026-07-22 14:15:44 +03:00
Alamion
a1deb0e95a
fixes:
- changed video format
2026-07-22 14:05:52 +03:00
Alamion
59f863eb97
fixes:
- markdownToAdf refactor
  - readme refactor
  - minor obsidian plugin dev best practices issues fixed
2026-07-22 13:38:15 +03:00
Alamion
f77c6ea3f6
fixes:
- another node dependencies fix
2026-06-25 18:14:48 +03:00
Alamion
57fa0d1873
fixes:
- node dependencies fix
2026-06-25 18:11:01 +03:00
Alamion
2aeb8c1bee
feats:
- presets for JQL
  - rename all cached issues
  - rebuild cache command
  - bunch of vitests
fixes:
  - incorrect migration save/load settings
  - incorrect cache validation before obsidian init
2026-06-25 17:47:22 +03:00
Alamion
3278de8748
Merge pull request #28 from Alamion/testing
A bunch of minor fixes
2026-06-03 18:38:32 +03:00
Alamion
ad06c8f4d8
Fix:
- `jira-sync-block-start-` docs fix
- custom fields functions correct load on startup
- jira values highlight rollback due to contributor dropped feature full realization. (In current state it's too buggy to release)
2026-06-03 18:35:22 +03:00
Alamion
e3a2a01270
Merge remote-tracking branch 'slipstyle/feature/highlight-sync-sections' into testing
# Conflicts:
#	src/postprocessing/livePreview.ts
#	src/postprocessing/reading.ts
#	src/settings/default.ts
2026-05-09 14:59:06 +03:00
slips
d4d9746647 fix: resolve TypeScript errors in createUpdateIssue and markdownToAdf
- Fixed processFrontmatter typo (processFrontMatter)
- Fixed minimalIssue type assertion (as any as JiraIssue)
- Fixed AdfTaskListNode interface duplicates
- Fixed AdfBlockNode type union to use AdfTaskListNode
- Fixed statusMapping.fromJira second parameter (api_version)
2026-05-05 18:01:08 -04:00
slips
f259446b74 fix: return null for undefined inputs in markdown converters
- adfToMarkdown(undefined) now returns null instead of ''
- jiraToMarkdown(undefined) now returns null instead of ''
- Prevents empty strings from passing guard clause and clearing note fields
- Proper semantics: undefined = field not in issue, null = field cleared in Jira
2026-05-05 17:03:36 -04:00
slips
5229b2d1a5 fix: update status only — don't call updateJiraToLocal with partial issue
Passing {fields: {status: {name: ...}}} to updateJiraToLocal caused all
fromJira mappings to run with undefined inputs, producing empty strings
that overwrote description and comments. Now updates only the status
field in frontmatter and sync content markers directly.
2026-05-05 16:52:45 -04:00
Alamion
a3211bcdb2
Fix:
- multiple fixes and code refactors for last feats to work correctly
- added Jira api_version variable to field mapping context
2026-05-04 21:05:29 +03:00
Alamion
75dd9d75cd
Merge remote-tracking branch 'slipstyle/feature/add-jira-comments' into testing
# Conflicts:
#	src/api/issues.ts
#	src/main.ts
#	src/modals/index.ts
2026-05-02 17:59:20 +03:00
Alamion
d45309743a
Merge remote-tracking branch 'slipstyle/feature/pull-after-create' into testing
# Conflicts:
#	src/file_operations/createUpdateIssue.ts
2026-04-30 02:01:56 +03:00
Alamion
6e35db6254
Feat:
- @slipstyle's ADF converter (tweaked)
- More broad description of Field Mapping settings section
- a few minor fixes of how lambda js functions convert into strings
2026-04-30 01:44:22 +03:00
Alamoin
f50873d925
Fix
- bump version
2026-04-29 18:05:14 +03:00
Alamoin
bca0cae215
Fix
- codemirror version fix
2026-04-29 17:55:29 +03:00
slips
a3d2c67da3 fix: indent taskItem continuation lines and filter empty nested blocks in adfToMarkdown 2026-04-28 17:31:49 -04:00
slips
ed988fe101 feat: handle links, wikilinks, checkboxes, and nested lists in ADF conversion
- parseInline: [text](url) → ADF link mark; [[wikilink]] preserved as plain text for round-trip fidelity
- adfInlineToMarkdown: inlineCard renders as [url](url) to preserve link text
- markdownToAdf: - [ ]/- [x] → taskList/taskItem nodes; sub-items flattened with hardBreak (taskItem only allows inline content)
- markdownToAdf: indented sub-bullets under bullet/ordered lists → nested bulletList in listItem
- adfBlockToMarkdown: taskList, bulletList, orderedList all render nested content with 2-space indentation
2026-04-28 16:14:36 -04:00
slips
0639461a08 feat: polish comment modal — full-width textarea, Ctrl+Enter to submit 2026-04-27 17:30:33 -04:00
slips
4958fc6a3c feat: add "Add comment to Jira" command with editor selection pre-fill
Opens a modal pre-populated with any selected editor text, allowing
users to highlight notes in a non-synced section and post them as a
Jira comment in one step. Supports API v2 (plain text) and v3 (ADF).
2026-04-27 17:11:59 -04:00
slips
5df851934d revert: restore broad marker regex for custom field support 2026-04-27 11:05:29 -04:00
slips
1b400e7e55 fix: tighten marker regex to avoid matching CSS class names in docs 2026-04-27 11:02:18 -04:00
slips
ad11b3e89a feat: render comments as expanded callouts instead of heading separators 2026-04-27 10:42:03 -04:00
slips
e99e14c043 fix: handle mention, emoji, link, strike, blockquote, table in adfToMarkdown 2026-04-27 10:31:36 -04:00
slips
aa6e36ba5d Fix block highlight viewport bug: scan full doc for block ranges 2026-04-27 09:05:58 -04:00
slips
5199ad333f feat: pull full issue from Jira after creation
Writes key immediately after create so it is never lost, then fetches
the full issue to populate all synced fields (status, assignee, dates,
comments, etc.) in one step.
2026-04-22 16:44:25 -04:00
slips
3169273f5e fix: use inset box-shadow for block border to preserve list indentation
border-left + padding-left was overriding Obsidian's list item padding,
causing bullet and ordered list markers to overlap the accent bar. An
inset box-shadow achieves the same visual effect without touching layout.
2026-04-22 14:29:35 -04:00
slips
827a28f3ed feat: add comment field as default mapping using adfToMarkdown
Pulls Jira comments into Obsidian notes with author, timestamp (date
and time to second precision), and body converted from ADF to markdown.
Returns empty string when no comments exist.
2026-04-22 13:58:51 -04:00
slips
1562f4ff52 refactor: highlight synced content instead of markers
When highlight mode is on, hide markers as before but wrap the synced
content itself in a highlight decoration:
- Inline sections: background + outline on text between start/end markers
- Line sections: same treatment on text after the line marker
- Block sections: left accent border + tinted background on each content line

Reading mode wraps the same content in a jira-sync-content span.
Uses color-mix() for theme-aware accent tinting.
2026-04-22 11:20:32 -04:00
slips
f4732263f8 feat: add highlight sync sections setting
Add a toggle in General settings to make jira-sync markers visible as
styled labels instead of hiding them. When enabled:
- Markers render with tag-style pill appearance (theme-aware via --tag-* vars)
- Block content between jira-sync-block-start and jira-sync-end gets a
  left border accent line in live preview
- Works in both live preview (CM6) and reading mode
- Cursor inside a marker adds an accent outline (jira-sync-active)

Default is off, preserving existing hide-on-blur behaviour.
2026-04-22 11:09:08 -04:00
slips
45f9c297ed fix: move description conversion into fromJira mapping for v2/v3 compatibility
Remove the blanket jiraToMarkdown() pass on all body sync sections — it was
corrupting ADF-sourced markdown (headings becoming ordered list items). Instead,
the default description fromJira now calls jiraToMarkdown directly, preserving
v2 wiki-markup behaviour. v3 users override description.fromJira with
adfToMarkdown() via their field mapping settings.
2026-04-22 10:55:18 -04:00
slips
75f9277387 feat: add adfToMarkdown for bidirectional description sync
Implement adfToMarkdown, adfInlineToMarkdown, and adfBlockToMarkdown so
that pulling a Jira issue back into Obsidian converts the ADF description
to clean markdown rather than clobbering the note with raw HTML or wiki
markup. Export adfToMarkdown and register it in SAFE_GLOBALS / context so
field mapping expressions can call it directly.
2026-04-22 10:35:00 -04:00
slips
1a14ac37c7 Add markdownToAdf helper for Jira API v3 ADF description support
Converts Obsidian markdown to Atlassian Document Format (ADF) so
descriptions can be sent to the Jira Cloud API v3, which rejects
plain text and wiki markup in the description field.

Supports headings, paragraphs, bullet/ordered lists, fenced code
blocks, horizontal rules, and inline bold/italic/inline code.

Exposes markdownToAdf as a first-class helper in the expression
evaluation context alongside markdownToJira and jiraToMarkdown,
so it can be used in field mapping expressions as markdownToAdf(value).
2026-04-22 09:31:11 -04:00
67 changed files with 4275 additions and 628 deletions

View file

@ -15,12 +15,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
node-version: '20.x'
- name: Build plugin
run: |
npm install
npm run build
yarn install --frozen-lockfile
yarn run build
- name: Create release
env:

7
.gitignore vendored
View file

@ -7,7 +7,6 @@
# npm
node_modules
yarn.lock
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
@ -32,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

View file

@ -1,5 +1,6 @@
node check-version.js
yarn run format
yarn run lint:fix
yarn run test
yarn run build
yarn run validate_locale_once

View 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/`

View 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)

301
AGENTS.md
View file

@ -1,265 +1,52 @@
# Obsidian Jira Sync - Community plugin for Obsidian
# Jira Issue Manager — Agent guide
## Project overview
## Project facts
- **Plugin ID**: `jira-sync`
- **Name**: Jira Issue Manager
- **Target**: Obsidian Community Plugin (TypeScript → bundled JavaScript)
- **Entry point**: `main.ts` compiled to `main.js` and loaded by Obsidian
- **Required release artifacts**: `main.js`, `manifest.json`, and optional `styles.css`
- **Min App Version**: 1.10.1
- **Author**: Alamion
- **Mobile compatible**: Yes (`isDesktopOnly: false`)
- **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
## Environment & tooling
## Known bugs
- Node.js: use current LTS (Node 18+ recommended)
- **Package manager: yarn** (required - `package.json` defines yarn scripts)
- **Bundler: esbuild** (required - `esbuild.config.mjs` and build scripts depend on it)
- Types: `obsidian` type definitions
- `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.
### Install
## Quick reference
```bash
yarn install
| 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
```
### Dev (watch)
```bash
yarn run dev
```
### Production build
```bash
yarn run build
```
### Localization
```bash
yarn run validate_locale_once # Validate YAML source files
yarn run compile_locale_once # Compile YAML to JSON
```
## Linting
All three commands must pass without errors:
1. `yarn run format` - No Prettier errors
2. `yarn run lint:fix` - No ESLint errors
3. `yarn run build` - No TypeScript/build errors
4. `yarn run validate_locale_once` - No localization errors
## File & folder conventions
- **Source lives in `src/`**. Keep `main.ts` small and focused on plugin lifecycle.
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files.
- Generated output should be placed at the plugin root. Release artifacts must end up at the top level of the plugin folder in the vault.
### Directory structure (`src/`)
| Directory | Purpose | Key Files |
| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `commands/` | User commands | `getIssue.ts`, `createIssue.ts`, `updateIssue.ts`, `updateStatus.ts`, `addWorkLogBatch.ts`, `addWorkLogManually.ts`, `batchFetchIssues.ts` |
| `api/` | Jira REST API | `base.ts` (core HTTP), `auth.ts`, `issues.ts`, `projects.ts`, `self.ts` |
| `settings/` | Settings tab & components | `default.ts` (defaults), `JiraSettingTab.ts`, `components/*` |
| `modals/` | UI dialogs | `IssueSearchModal.ts`, `JQLSearchModal.ts`, `IssueWorkLogModal.ts`, `ProjectModal.ts`, `IssueTypeModal.ts`, `IssueStatusModal.ts` |
| `file_operations/` | File read/write | `getIssue.ts`, `createUpdateIssue.ts`, `commonPrepareData.ts` |
| `tools/` | Utilities | `debugLogging.ts`, `cacheUtils.ts`, `filesUtils.ts`, `convertFunctionString.ts`, `sanitizers.ts`, `asyncLimiter.ts` |
| `postprocessing/` | Live Preview/Reading | `livePreview.ts`, `reading.ts` |
| `default/` | Defaults | `defaultTemplate.ts`, `defaultIssue.ts`, `obsidianJiraFieldsMapping.ts` |
| `interfaces/` | TypeScript types | `settingsTypes.ts`, `index.ts` |
| `localization/` | i18n | `translator.ts`, `compiled/*.json`, `source/*.yaml` |
## Packages used
| Package | Purpose | Usage |
| -------------- | ----------------- | ------------------------------------------------------ |
| `obsidian` | Obsidian API | Core plugin, `requestUrl`, `TFile`, `Plugin` etc. |
| `esbuild` | Bundler | `esbuild.config.mjs` - bundles all deps into `main.js` |
| `typescript` | Type safety | TypeScript 4.7.4 |
| `fs-extra` | File operations | `src/tools/filesUtils.ts` |
| `yaml` | YAML parsing | Settings validation, field mapping config |
| `highlight.js` | Code highlighting | `src/tools/markdownHtml.ts` |
| `chokidar` | File watching | Localization compiler watch mode |
## Logging
- Location: `src/tools/debugLogging.ts:1-19`
- **IMPORTANT BUG**: Uses `process.env.NODE_ENV` which is always `undefined` in Obsidian (no Node.js runtime) - logging is **always on** in both dev and production
- Exports: `debugLog`, `debugWarn`, `debugError`
- Used in: `src/api/base.ts` for API request/response logging
- Prefix format: `[DEBUG]`, `[DEBUG WARNING]`, `[DEBUG ERROR]`
```typescript
import { debugLog, debugWarn, debugError } from './tools/debugLogging';
debugLog('Request:', { url, method });
debugWarn('Missing field:', fieldName);
debugError('Failed to fetch:', error);
```
## Cache system
- Maps Jira issue keys to local file paths
- In-memory cache: `issueKeyToFilePathCache` (Map in plugin instance)
- Persisted in settings: `settings.issueKeyToFilePathCache`
- Vault event listeners for maintenance (`main.ts:105-130`):
- `rename`: updates cache when file is moved
- `delete`: removes from cache when file is deleted
## Localization (i18n)
- Source files: `src/localization/source/{lang}/*.yaml`
- Compiled to: `src/localization/compiled/{lang}.json`
- Auto-detects locale from `window.localStorage.getItem('language')`
- Falls back to `en` if not found
- Build-time compilation: `src/localization/compiler.js`
## Pre-commit hooks
- Uses husky (`.husky/pre-commit`)
- Current check: `node check-version.js`
- `version-bump.mjs` - syncs version from `package.json` to `manifest.json` and `versions.json`
```bash
# Runs on every commit:
node check-version.js
```
- **Note**: Future pre-commit upgrades may include linting and type checking
## Manifest rules
- Must include:
- `id` (stable - never change after release)
- `name`
- `version` (Semantic Versioning x.y.z)
- `minAppVersion`
- `description`
- `isDesktopOnly` (boolean)
- Keep `minAppVersion` accurate when using newer APIs
## Testing
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` to:
```
<Vault>/.obsidian/plugins/<plugin-id>/
```
- Reload Obsidian and enable in **Settings → Community plugins**
## Commands & settings
- Commands added via `this.addCommand(...)` in `src/commands/`
- Settings persisted with `this.loadData()` / `this.saveData()`
- Use stable command IDs - avoid renaming after release
- All commands wrapped in `register*Command` functions
## Versioning & releases
- Bump version in `manifest.json` (SemVer) and update `versions.json`
- Run `yarn version` to auto-bump via `version-bump.mjs`
- Create GitHub release with tag matching `manifest.json` version (no leading `v`)
- Attach: `manifest.json`, `main.js`, `styles.css` (if present)
## Security, privacy, and compliance
- Default to local/offline operation. Only make network requests when essential.
- No hidden telemetry.
- Never execute remote code, fetch and eval scripts.
- Minimize scope: read/write only what's necessary inside the vault.
- Use `register*` helpers for all listeners to ensure proper cleanup on unload.
## Performance
- Keep startup light - defer heavy work until needed
- Batch disk access, avoid excessive vault scans
- Debounce/throttle operations in response to file system events
## Coding conventions
- TypeScript with `"strict": true` preferred
- Keep `main.ts` minimal - delegate feature logic to separate modules
- Split large files (~200-300+ lines) into smaller focused modules
- Bundle everything into `main.js` (no unbundled runtime deps)
- Prefer `async/await` over promise chains
- Handle errors gracefully
- **Do not add comments unless explicitly requested**
## Agent do/don't
**Do**
- Add commands with stable IDs (don't rename after release)
- Provide defaults and validation in settings
- Write idempotent code paths
- Use `this.register*` helpers for cleanup
**Don't**
- Introduce network calls without user-facing reason and documentation
- Ship features requiring cloud services without opt-in
- Store or transmit vault contents unless essential
## Common tasks
### Add a command
```typescript
export function registerMyCommand(plugin: JiraPlugin): void {
plugin.addCommand({
id: 'my-command',
name: t('name'),
checkCallback: (checking: boolean) => {
const valid = validateSettings(plugin);
if (!checking && valid) doSomething(plugin);
return valid;
},
});
}
```
### Make API request
```typescript
import { baseRequest } from './api/base';
const issue = await baseRequest(plugin, 'GET', `/issue/${issueKey}`);
```
### Persist settings
```typescript
await this.saveData(this.settings);
```
### Register listeners safely
```typescript
this.registerEvent(
this.app.workspace.on('file-open', (f) => {
/* ... */
}),
);
this.registerDomEvent(window, 'resize', () => {
/* ... */
});
this.registerInterval(
window.setInterval(() => {
/* ... */
}, 1000),
);
```
## Troubleshooting
- Plugin doesn't load: ensure `main.js` and `manifest.json` at top level of plugin folder
- Build issues: run `yarn run dev` or `yarn run build` to compile
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique
- Settings not persisting: ensure `loadData`/`saveData` are awaited
## References
- Obsidian plugin docs: https://docs.obsidian.md
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide
For general Obsidian plugin development conventions and project-specific details, see `.opencode/skills/`.

64
CONTRIBUTING.md Normal file
View 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.

182
README.md
View file

@ -1,160 +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:
- **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
- **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
![demo_image.png](docs/images/demo_image.png)
> **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
- **Support for multiple formats** - works with Timekeep and Super Simple Time Tracker time tracking formats
- **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
![field_mapping_demo.png](docs/images/field_mapping_demo.png)
## 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?
---
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:
**Ready to transform your Jira workflow?** Install the plugin and start building your perfect issue management system in Obsidian today!
- 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
![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&label=downloads&query=$[%27jira-sync%27].downloads&url=https://raw.githubusercontent.com/obsidianmd/obsidian-releases/refs/heads/master/community-plugin-stats.json)
![GitHub release](https://img.shields.io/github/v/release/Alamion/obsidian-jira-sync)
![Platform](https://img.shields.io/badge/platform-desktop%20%7C%20mobile-blue)
## License
MIT — originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira).

View file

@ -69,9 +69,9 @@ The responsible person for this task is `jira-sync-inline-start-assignee`Bob`jir
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`.
```
@ -96,6 +96,7 @@ 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
@ -110,12 +111,14 @@ Similarly, you can configure the `progressPercentage` shown in the example. This
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()`.

View file

@ -68,9 +68,9 @@
Пример:
```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`.
```
@ -95,6 +95,7 @@ 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` - позволяет добавить комментарий в выбранную задачу.
### Продвинутое использование
@ -109,12 +110,14 @@ Bob
Кроме того, вы можете использовать некоторые встроенные функции:
- `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()`.

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View file

@ -91,7 +91,7 @@ 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.

View file

@ -6,7 +6,7 @@ import tsPlugin from '@typescript-eslint/eslint-plugin';
export default defineConfig([
{
ignores: ['main.js', 'node_modules/**', 'src/**/*.js'],
ignores: ['main.js', 'node_modules/**', 'src/**/*.js', 'tests/**'],
},
{
files: ['**/*.ts'],

View file

@ -1,7 +1,7 @@
{
"id": "jira-sync",
"name": "Jira Issue Manager",
"version": "1.5.0",
"version": "1.7.4",
"minAppVersion": "1.10.1",
"description": "Get Jira issues, create and update them. Issue status and worklog management.",
"author": "Alamion",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-sample-plugin",
"version": "1.5.0",
"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",
@ -15,6 +15,9 @@
"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": [],
@ -23,11 +26,11 @@
"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",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"builtin-modules": "3.3.0",
@ -36,11 +39,13 @@
"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": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
"dependencies": {
"acorn": "^8.14.1",

View file

@ -1,10 +1,6 @@
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;
}
// Session cookie only
export async function authenticate(plugin: JiraPlugin): Promise<boolean> {
try {
@ -29,7 +25,8 @@ export async function authenticate(plugin: JiraPlugin): Promise<boolean> {
},
});
localStorage.setItem(getSessionCookieKey(plugin), response.json.session.value);
conn.sessionCookie = response.json.session.value;
await plugin.saveSettings();
return true;
} catch (error: unknown) {
new Notice('Authentication failed: ' + ((error as Error).message || 'Unknown error'));
@ -87,10 +84,10 @@ export async function getAuthHeaders(plugin: JiraPlugin): Promise<Record<string,
};
} 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) {

View file

@ -3,6 +3,7 @@ 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
@ -242,6 +243,25 @@ 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: {

View 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;
},
});
}

View file

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

View 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');
},
});
}

View file

@ -1,9 +1,13 @@
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: {
@ -11,8 +15,9 @@ export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
fromJira: (issue) => issue.fields.summary,
},
description: {
toJira: () => null,
fromJira: (issue) => issue.fields.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: {
toJira: () => null,
@ -74,4 +79,23 @@ export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
toJira: () => null,
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');
},
},
};

View file

@ -1,23 +1,29 @@
import JiraPlugin from '../main';
import { TFile } from 'obsidian';
import { createJiraIssue, updateJiraIssue, updateJiraStatus } from '../api';
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');
}
fields = localToJiraFields(fields, {
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
});
fields = localToJiraFields(
fields,
{
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
},
apiVersion,
);
await updateJiraIssue(plugin, issueKey, fields);
return issueKey;
}
@ -27,13 +33,18 @@ 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;
@ -43,6 +54,10 @@ export async function createIssueFromFile(
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;
}
@ -58,8 +73,24 @@ export async function updateStatusFromFile(
}
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;
}

View file

@ -7,24 +7,18 @@ 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 sanitizedSummary = sanitizeFileName(summary);
filename = filename.replace(/\{summary\}/g, sanitizedSummary);
// Replace {key} with 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 (key) {
filename = key;
} else if (sanitizedSummary) {
@ -33,10 +27,53 @@ function generateFilenameFromTemplate(template: string, issue: JiraIssue): strin
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 { renamed, errors };
}
export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIssue, filePath?: string): Promise<void> {
try {
await ensureIssuesFolder(plugin);
@ -48,7 +85,6 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
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,19 +96,16 @@ 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 filename = generateFilenameFromTemplate(template, issue);
@ -84,12 +117,12 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
if (targetFile) {
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 plugin.setFilePathForIssueKey(issue.key, newFile.path);
}
new Notice(`Issue ${issue.key} imported successfully`);
} catch (error: unknown) {
@ -108,7 +141,6 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
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`);
@ -116,10 +148,8 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
}
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');
@ -129,11 +159,9 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
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);
}

View file

@ -0,0 +1,2 @@
name: Add comment to Jira
error: Error adding comment

View 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

View file

@ -12,6 +12,16 @@ 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)'

View file

@ -26,3 +26,10 @@ filenameTemplate:
desc: |
Template for created issue filenames. Use {summary} for issue summary and {key} for issue 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

View file

@ -1,5 +1,15 @@
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)

View file

@ -0,0 +1,2 @@
name: Add comment to Jira
error: Error adding comment

View 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

View file

@ -12,8 +12,18 @@ maxResults:
submit: Найти задачи
presets:
load: JQL пресет
load_desc: Загрузить сохраненный JQL запрос
select: Выберите пресет
save: Сохранить как пресет
save_desc: Сохранить текущий JQL запрос как именованный пресет
save_hint: Имя пресета
save_btn: Сохранить
delete: Удалить пресет
preview:
total: '{total} задач найдено'
total: '{total} задач найдено'
showing: ' (показаны первые {limit} задач)'
error: '{error}'
loading: Загрузка...

View file

@ -21,3 +21,10 @@ filenameTemplate:
desc: |
Шаблон для названий файлов созданных задач. Используйте {summary} для названия задачи и {key} для ключа задачи
placeholder: '{summary} ({key})'
rename_files: Переименовать существующие файлы
no_files_to_rename: Нет файлов в кэше для переименования
rename_confirm: 'Будет переименовано {count} файл(ов). Продолжить?'
rename_success: 'Успешно переименовано {count} файл(ов)'
rename_errors: 'Не удалось переименовать {count} файл(ов)'
rename_no_changes: Файлы не нуждаются в переименовании

View file

@ -1,6 +1,15 @@
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

View file

@ -10,11 +10,13 @@ import {
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, validateCache } from './tools/cacheUtils';
import { buildCacheFromFilesystem } from './tools/cacheUtils';
import { checkMigrateSettings } from './tools/migrateSettings';
export default class JiraPlugin extends Plugin {
@ -26,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);
@ -40,12 +41,14 @@ 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));
@ -56,13 +59,26 @@ export default class JiraPlugin extends Plugin {
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);
// 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() {
@ -91,36 +107,36 @@ export default class JiraPlugin extends Plugin {
return this.settings.connections[this.settings.currentConnectionIndex];
}
setFilePathForIssueKey(issueKey: string, filePath: string) {
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;
}
}
@ -129,10 +145,10 @@ export default class JiraPlugin extends Plugin {
// 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;
}
}

View 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();
}
}

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

View file

@ -1,13 +1,11 @@
import { App, Modal, Setting } from 'obsidian';
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;
/**
* Modal for searching issues by JQL query
*/
export class JQLSearchModal extends Modal {
private onSubmit: (jql: string) => void;
private plugin: JiraPlugin;
@ -15,6 +13,8 @@ export class JQLSearchModal extends Modal {
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,8 +23,68 @@ export class JQLSearchModal extends Modal {
}
onOpen() {
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('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'))
@ -38,7 +98,6 @@ export class JQLSearchModal extends Modal {
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('');
@ -49,17 +108,18 @@ export class JQLSearchModal extends Modal {
.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)) {
event.preventDefault();
if (this.jql.trim()) {
this.saveLastQuery(this.jql.trim());
this.close();
this.onSubmit(this.jql.trim());
}
@ -67,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);
}

View file

@ -1,6 +1,9 @@
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';

View file

@ -31,35 +31,39 @@ export function createJiraSyncExtension(plugin: JiraPlugin): Extension {
buildDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>();
const sel = view.state.selection;
const allDecs: Array<{ from: number; to: number; dec: Decoration }> = [];
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;
const contentStart = start; // без первой `
const contentEnd = end; // без последней `
if (this.isInsideCodeBlock(start, end, codeBlocks)) continue;
markers.push({ start, end, name: match[1] });
}
// Find code blocks to ignore
if (this.isInsideCodeBlock(contentStart, contentEnd, codeBlocks)) {
continue;
}
let className = 'jira-sync-hidden';
// Check if cursor is inside
const sel = view.state.selection;
if (sel.ranges.some((r) => r.from <= contentEnd && r.to >= contentStart)) {
className += ' jira-sync-active';
}
builder.add(start, end, Decoration.mark({ class: className }));
// 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 }) });
}
}
return builder.finish();
// 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) {

View file

@ -1,13 +1,12 @@
// For Reading mode - targets <code> elements in <p> tags
export function hideJiraPointersReading(element: HTMLElement) {
// 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');
}
});
};
}

View file

@ -66,6 +66,9 @@ export class ConnectionSettingsComponent implements SettingsComponent {
password: '',
jiraUrl: '',
apiVersion: '2' as const,
jqlPresets: [],
lastJqlQuery: '',
sessionCookie: '',
};
plugin.settings.connections.push(newConnection);
plugin.settings.currentConnectionIndex = plugin.settings.connections.length - 1;
@ -83,7 +86,7 @@ export class ConnectionSettingsComponent implements SettingsComponent {
private populateConnectionDropdown(cb: any): void {
const { plugin } = this.props;
cb.selectEl.innerHTML = '';
cb.selectEl.empty();
plugin.settings.connections.forEach((conn, index) => {
cb.addOption(index.toString(), conn.name || `Connection ${index + 1}`);
});

View file

@ -1,6 +1,8 @@
import { Notice, Setting } from 'obsidian';
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
import { fetchIssue } from '../../api';
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';
@ -23,7 +25,6 @@ export class FetchIssueComponent implements SettingsComponent {
render(containerEl: HTMLElement): void {
const { plugin } = this.props;
// Filename template setting
const filenameTemplateSetting = new Setting(containerEl)
.setName(t('filenameTemplate.name'))
.setDesc(t('filenameTemplate.desc'));
@ -37,6 +38,40 @@ export class FetchIssueComponent implements SettingsComponent {
});
});
filenameTemplateSetting.addButton((btn) =>
btn.setButtonText(t('rename_files')).onClick(async () => {
if (!validateSettings(plugin)) return;
const cacheSize = plugin.getAllIssueKeysMap().size;
if (cacheSize === 0) {
new Notice(t('no_files_to_rename'));
return;
}
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'
@ -87,7 +122,6 @@ export class FetchIssueComponent implements SettingsComponent {
});
});
// Add input field for issue key
new Setting(containerEl)
.setName(t('key.name'))
.setDesc(t('key.desc'))
@ -98,7 +132,6 @@ export class FetchIssueComponent implements SettingsComponent {
}),
);
// Create container for issue data
this.issueDataContainer = containerEl.createDiv({
cls: 'jira-raw-issue-container',
});

View file

@ -1,5 +1,6 @@
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;
/**
@ -82,7 +83,8 @@ export class FieldMappingItem {
this.toJiraInput = toJiraContainer.createEl('textarea', {
cls: 'to-jira-input',
});
this.toJiraInput.placeholder = '(value) => {\n // Transform Obsidian value to Jira\n return value;\n}';
this.toJiraInput.placeholder =
'(value, api_version) => {\n // Transform Obsidian value to Jira\n return value;\n}';
this.toJiraInput.value = toJira;
// Create fromJira function input
@ -98,7 +100,7 @@ export class FieldMappingItem {
});
this.fromJiraInput.value = fromJira;
this.fromJiraInput.placeholder =
'(issue, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}';
'(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', {
@ -131,7 +133,7 @@ export class FieldMappingItem {
textarea.addEventListener('focus', resize);
// Initial resize
setTimeout(resize, 0);
window.setTimeout(resize, 0);
}
/**
@ -139,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);
}
/**

View file

@ -35,6 +35,7 @@ export class FieldMappingsComponent implements SettingsComponent {
// Add explanation
mappingSection.createEl('p', {
text: t('desc'),
cls: 'break-line',
});
// Add validation toggle
@ -155,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);
});
@ -314,6 +329,12 @@ export class FieldMappingsComponent implements SettingsComponent {
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) => {

View file

@ -1,5 +1,10 @@
import { FieldMapping } from '../default/obsidianJiraFieldsMapping';
export interface JQLPreset {
name: string;
query: string;
}
export interface ConnectionSettingsInterface {
name: string;
jiraUrl: string;
@ -9,6 +14,9 @@ export interface ConnectionSettingsInterface {
username: string;
email: string;
password: string;
jqlPresets: JQLPreset[];
lastJqlQuery: string;
sessionCookie: string;
}
export interface GlobalSettingsInterface {
@ -78,6 +86,9 @@ export const DEFAULT_SETTINGS: JiraSettingsInterface = {
password: '',
jiraUrl: '',
apiVersion: '2',
jqlPresets: [],
lastJqlQuery: '',
sessionCookie: '',
},
],
currentConnectionIndex: 0,

View file

@ -28,7 +28,7 @@ async function scanFolderForIssueKeys(plugin: JiraPlugin, folder: TFolder): Prom
const metadata = plugin.app.metadataCache.getFileCache(child);
const issueKey = metadata?.frontmatter?.key;
if (issueKey && typeof issueKey === 'string') {
plugin.setFilePathForIssueKey(issueKey, child.path);
await plugin.setFilePathForIssueKey(issueKey, child.path);
}
} else if (child instanceof TFolder) {
await scanFolderForIssueKeys(plugin, child);
@ -51,7 +51,7 @@ export async function validateCache(plugin: JiraPlugin): Promise<void> {
}
// Remove invalid entries
entriesToRemove.forEach((issueKey) => {
plugin.removeIssueKeyFromCache(issueKey);
});
for (const issueKey of entriesToRemove) {
await plugin.removeIssueKeyFromCache(issueKey);
}
}

View file

@ -2,9 +2,10 @@ import { Notice } from 'obsidian';
import { JiraIssue } from '../interfaces';
import { parse } from 'acorn';
import { debugLog } from './debugLogging';
import { FieldMapping } from '../default/obsidianJiraFieldsMapping';
import { FieldMapping, FROM_JIRA_PARAMS, TO_JIRA_PARAMS } from '../default/obsidianJiraFieldsMapping';
import { defaultIssue } from '../default/defaultIssue';
import { jiraToMarkdown, markdownToJira } from './markdownHtml';
import { markdownToAdf, adfToMarkdown } from './markdownToAdf';
// Constants for validation and error messages
const FORBIDDEN_PATTERNS = ['document', 'window', 'eval', 'Function', 'fetch', 'setTimeout', 'globalThis'];
@ -12,6 +13,8 @@ const SYNTAX_KEYWORDS = ['return', 'if', 'else', 'for', 'while', 'switch', 'try'
const SAFE_GLOBALS = {
jiraToMarkdown,
markdownToJira,
markdownToAdf,
adfToMarkdown,
JSON: {
parse: JSON.parse,
stringify: JSON.stringify,
@ -54,6 +57,7 @@ export function validateFunctionStringBrowser(
const varDeclarations = approved_vars
.map((varName) => {
if (varName === 'issue') return `${varName} = ${JSON.stringify(defaultIssue)}`;
if (varName === 'api_version') return `${varName} = "2"`;
if (varName === 'value') return `${varName} = "test value"`;
if (varName === 'data_source') return `${varName} = {}`;
return `${varName} = {}`;
@ -88,6 +92,7 @@ export function validateFunctionStringBrowser(
const testArgs = approved_vars.map((varName) => {
if (varName === 'issue') return defaultIssue;
if (varName === 'api_version') return '2';
if (varName === 'value') return 'test value';
if (varName === 'data_source') return {};
return {};
@ -177,7 +182,7 @@ export async function safeStringToFunction(
if (extraValidate) {
const validation = await validateFunctionString(
exprString,
type === 'fromJira' ? ['issue', 'data_source'] : ['value'],
type === 'fromJira' ? FROM_JIRA_PARAMS : TO_JIRA_PARAMS,
);
if (!validation.isValid) {
console.warn(`Invalid function: ${validation.errorMessage}`);
@ -200,6 +205,8 @@ export async function safeStringToFunction(
const context = {
jiraToMarkdown,
markdownToJira,
markdownToAdf,
adfToMarkdown,
JSON,
Math,
Date,
@ -211,9 +218,9 @@ export async function safeStringToFunction(
};
if (type === 'toJira') {
return function (value: any) {
return function (value: any, api_version?: '2' | '3') {
const fn = new Function(
'value',
...TO_JIRA_PARAMS,
'context',
`
with (context) {
@ -227,14 +234,13 @@ export async function safeStringToFunction(
`,
);
return fn.call(context, value, context);
return fn.call(context, value, api_version, context);
};
} else {
// fromJira
return function (issue: JiraIssue, data_source: Record<string, any> | null) {
return function (issue: JiraIssue, api_version?: '2' | '3', data_source?: Record<string, any>) {
const fn = new Function(
'issue',
'data_source',
...FROM_JIRA_PARAMS,
'context',
`
with (context) {
@ -248,7 +254,7 @@ export async function safeStringToFunction(
`,
);
return fn.call(context, issue, data_source, context);
return fn.call(context, issue, api_version, data_source, context);
};
}
} catch (error) {
@ -263,38 +269,26 @@ export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: bo
if (!baseStr) return baseStr;
if (isFromJira) {
const fnStr = fn.toString().trim();
const fnStr = fn.toString().trim();
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
if (paramMatch) {
const params = (paramMatch[1] || paramMatch[2] || '').split(',').map((p) => p.trim());
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
if (paramMatch) {
const params = (paramMatch[1] || paramMatch[2] || '').split(',').map((p) => p.trim());
let resultStr = baseStr;
let resultStr = baseStr;
if (params.length >= 1 && params[0]) {
const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g');
resultStr = resultStr.replace(regex1, 'issue');
}
if (params.length >= 2 && params[1]) {
const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g');
resultStr = resultStr.replace(regex2, 'data_source');
}
return resultStr;
if (params.length >= 1 && params[0]) {
const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g');
resultStr = isFromJira ? resultStr.replace(regex1, 'issue') : resultStr.replace(regex1, 'value');
}
} else {
const fnStr = fn.toString().trim();
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
if (paramMatch) {
const param = (paramMatch[1] || paramMatch[2] || '').trim();
if (param) {
const regex = new RegExp(`\\b${param}\\b`, 'g');
return baseStr.replace(regex, 'value');
}
if (params.length >= 2 && params[1]) {
const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g');
resultStr = resultStr.replace(regex2, 'api_version');
}
if (params.length >= 3 && params[2]) {
const regex3 = new RegExp(`\\b${params[2]}\\b`, 'g');
resultStr = resultStr.replace(regex3, 'data_source');
}
return resultStr;
}
return baseStr;
@ -303,6 +297,13 @@ export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: bo
export function functionToExpressionString(fn: (...args: any[]) => any): string {
try {
const fnStr = fn.toString().trim();
if (fnStr.contains('\n')) {
// return everything for multiline funcs
return fnStr
.split('\n')
.map((s: string) => s.trim())
.join('\n');
}
// Handle arrow functions
const arrowMatch = fnStr.match(/^\s*\(?([^)]*)\)?\s*=>\s*(.+)$/s);
@ -339,13 +340,17 @@ export async function transform_string_to_functions_mappings(
// Also convert to functions for runtime use
const transformedMappings: Record<string, FieldMapping> = {};
for (const [fieldName, { toJira, fromJira }] of Object.entries(mappings)) {
const toJiraFn = safeStringToFunction(toJira, 'toJira', extraValidate);
const fromJiraFn = safeStringToFunction(fromJira, 'fromJira', extraValidate);
const toJiraFn = await safeStringToFunction(toJira, 'toJira', extraValidate);
const fromJiraFn = await safeStringToFunction(fromJira, 'fromJira', extraValidate);
if (toJiraFn && fromJiraFn) {
transformedMappings[fieldName] = {
toJira: (await toJiraFn) as (value: any) => any,
fromJira: (await fromJiraFn) as (issue: JiraIssue, data_source: Record<string, any> | null) => any,
toJira: toJiraFn as (value: any, api_version?: '2' | '3') => any,
fromJira: fromJiraFn as (
issue: JiraIssue,
api_version?: '2' | '3',
data_source?: Record<string, any>,
) => any,
};
} else {
console.warn(`Invalid function in field: ${fieldName}`);

View file

@ -1,5 +1,4 @@
import { JiraIssue } from '../interfaces';
import { jiraToMarkdown } from './markdownHtml';
import { Notice, TFile } from 'obsidian';
import JiraPlugin from '../main';
import { extractAllJiraSyncValuesFromContent, updateJiraSyncContent } from './sectionTools';
@ -9,6 +8,7 @@ import { debugLog } from './debugLogging';
export function localToJiraFields(
data_source: Record<string, any>,
customFieldMappings: Record<string, FieldMapping>,
apiVersion?: '2' | '3',
): Record<string, any> {
const jiraFields: Record<string, any> = {};
@ -24,10 +24,12 @@ export function localToJiraFields(
const mapping = customFieldMappings[key];
try {
// Skip fields that shouldn't be sent to Jira
if (mapping.toJira(value) === null) continue;
const result = mapping.toJira(value, apiVersion);
jiraFields[key] = mapping.toJira(value);
// Skip fields that shouldn't be sent to Jira
if (result === null) continue;
jiraFields[key] = result;
} catch (e) {
console.error(`Error mapping for ${key}: ${e}`);
new Notice(`Error mapping for ${key}: ${e}`);
@ -43,13 +45,19 @@ export function localToJiraFields(
}
export async function updateJiraToLocal(plugin: JiraPlugin, file: TFile, issue: JiraIssue): Promise<void> {
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
// First, update the frontmatter using processFrontMatter
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
// Update frontmatter with Jira data
applyJiraDataToLocal(frontmatter, issue, {
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
});
applyJiraDataToLocal(
frontmatter,
issue,
{
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
},
apiVersion,
);
});
// Then, process the file content to update sync sections
@ -58,16 +66,21 @@ export async function updateJiraToLocal(plugin: JiraPlugin, file: TFile, issue:
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
// Update sync sections with Jira data
applyJiraDataToLocal(syncSections, issue, {
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
});
applyJiraDataToLocal(
syncSections,
issue,
{
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
},
apiVersion,
);
// Update content sections from Jira fields
let updatedContent = fileContent;
let updatesDict: Record<string, string> = {};
for (const [fieldName, fieldValue] of Object.entries(syncSections)) {
updatesDict[fieldName] = jiraToMarkdown(fieldValue);
updatesDict[fieldName] = fieldValue;
}
debugLog(`Updating sync sections: ${JSON.stringify(updatesDict)}`);
@ -81,10 +94,11 @@ export function applyJiraDataToLocal(
localData: Record<string, any>,
issue: JiraIssue,
fieldMappings: Record<string, FieldMapping>,
apiVersion?: '2' | '3',
): void {
// Process existing fields in local data
for (const key of Object.keys(localData)) {
updateFieldFromJira(key, localData, issue, fieldMappings);
updateFieldFromJira(key, localData, issue, fieldMappings, apiVersion);
}
// Ensure required fields are always processed
@ -101,13 +115,14 @@ function updateFieldFromJira(
targetObject: Record<string, any>,
issue: JiraIssue,
fieldMappings: Record<string, FieldMapping>,
apiVersion?: '2' | '3',
): void {
try {
let value = issue.fields[key];
// Apply custom mapping if available
if (key in fieldMappings) {
value = fieldMappings[key].fromJira(issue, targetObject);
value = fieldMappings[key].fromJira(issue, apiVersion, targetObject);
}
// debugLog(`Updating field: ${key}: ${issue.fields[key]}`);
@ -129,14 +144,14 @@ function logMappingDebugInfo(key: string, error: Error, fieldMappings: Record<st
new Notice(`Error mapping for ${key}: ${error}`);
// Create debug info about available mappings
const mappingInfo: Record<string, { hasToJira: string; hasFromJira: string }> = {};
const mappingInfo: Record<string, { toJira: string; fromJira: string }> = {};
for (const mappingKey of Object.keys(fieldMappings)) {
mappingInfo[mappingKey] = {
hasToJira: typeof fieldMappings[mappingKey].toJira,
hasFromJira: typeof fieldMappings[mappingKey].fromJira,
toJira: fieldMappings[mappingKey].toJira.toString().trim(),
fromJira: fieldMappings[mappingKey].fromJira.toString().trim(),
};
}
console.debug(`Available mappings: ${JSON.stringify(mappingInfo)}`);
console.debug(`Available mappings:`, mappingInfo);
}

View file

@ -3,9 +3,10 @@
* @param str The Jira markup string
* @returns The Markdown string
*/
export function jiraToMarkdown(str: any): string {
export function jiraToMarkdown(str: any): string | null {
try {
if (str === null || str === undefined) return '';
if (str === undefined) return null;
if (str === null) return '';
// Initial normalization to string
let content: string = '';

510
src/tools/markdownToAdf.ts Normal file
View file

@ -0,0 +1,510 @@
interface AdfTextMark {
type: 'strong' | 'em' | 'code' | 'link' | 'underline' | 'strike';
attrs?: { href: string };
}
interface AdfTextNode {
type: 'text';
text: string;
marks?: AdfTextMark[];
}
interface AdfInlineNode {
type: 'hardBreak';
}
type AdfInlineContent = AdfTextNode | AdfInlineNode;
interface AdfParagraphNode {
type: 'paragraph';
content: AdfInlineContent[];
}
interface AdfHeadingNode {
type: 'heading';
attrs: { level: number };
content: AdfInlineContent[];
}
interface AdfCodeBlockNode {
type: 'codeBlock';
attrs: { language: string };
content: [{ type: 'text'; text: string }];
}
interface AdfListItemNode {
type: 'listItem';
content: [AdfParagraphNode];
}
interface AdfBulletListNode {
type: 'bulletList';
content: AdfListItemNode[];
}
interface AdfOrderedListNode {
type: 'orderedList';
content: AdfListItemNode[];
}
interface AdfTaskItemNode {
type: 'taskItem';
attrs: { localId: string; state: 'TODO' | 'DONE' };
content: AdfInlineContent[];
}
interface AdfTaskListNode {
type: 'taskList';
content: AdfTaskItemNode[];
attrs?: { localId: string };
}
interface AdfRuleNode {
type: 'rule';
}
type AdfBlockNode =
| AdfParagraphNode
| AdfHeadingNode
| AdfCodeBlockNode
| AdfBulletListNode
| AdfOrderedListNode
| AdfTaskListNode
| AdfRuleNode;
interface AdfDoc {
version: 1;
type: 'doc';
content: AdfBlockNode[];
}
// --- Inline formatting rules -------------------------------------------------
interface FormatRule {
open: string;
close: string;
marks: AdfTextMark[];
}
const formatRules: FormatRule[] = [
{ open: '<b>', close: '</b>', marks: [{ type: 'strong' }] },
{ open: '<strong>', close: '</strong>', marks: [{ type: 'strong' }] },
{ open: '<i>', close: '</i>', marks: [{ type: 'em' }] },
{ open: '<em>', close: '</em>', marks: [{ type: 'em' }] },
{ open: '<u>', close: '</u>', marks: [{ type: 'underline' }] },
{ open: '<ins>', close: '</ins>', marks: [{ type: 'underline' }] },
{ open: '<s>', close: '</s>', marks: [{ type: 'strike' }] },
{ open: '<strike>', close: '</strike>', marks: [{ type: 'strike' }] },
{ open: '<del>', close: '</del>', marks: [{ type: 'strike' }] },
{ open: '***', close: '***', marks: [{ type: 'strong' }, { type: 'em' }] },
{ open: '**', close: '**', marks: [{ type: 'strong' }] },
{ open: '*', close: '*', marks: [{ type: 'em' }] },
{ open: '`', close: '`', marks: [{ type: 'code' }] },
];
const linkRe = /^\[([^\]]+)\]\(([^)]+)\)/;
const wikiRe = /^\[\[[^\]]+\]\]/;
// --- Inline parser -----------------------------------------------------------
function mergeTextNodes(nodes: AdfInlineContent[]): AdfInlineContent[] {
const out: AdfInlineContent[] = [];
for (const n of nodes) {
if (n.type !== 'text' || !out.length || out[out.length - 1].type !== 'text') {
out.push(n);
continue;
}
const last = out[out.length - 1] as AdfTextNode;
if (JSON.stringify(last.marks) !== JSON.stringify(n.marks)) {
out.push(n);
continue;
}
last.text += n.text;
}
return out;
}
function parseInlineContent(text: string): AdfInlineContent[] {
const nodes: AdfInlineContent[] = [];
let pos = 0;
let textStart = 0;
const flush = () => {
if (textStart < pos) nodes.push({ type: 'text', text: text.slice(textStart, pos) });
};
while (pos < text.length) {
const remaining = text.slice(pos);
const linkMatch = remaining.match(linkRe);
if (linkMatch) {
flush();
const linkMark: AdfTextMark = { type: 'link', attrs: { href: linkMatch[2] } };
for (const n of parseInlineContent(linkMatch[1])) {
if (n.type === 'text') {
nodes.push({ type: 'text', text: n.text, marks: n.marks ? [...n.marks, linkMark] : [linkMark] });
}
}
pos += linkMatch[0].length;
textStart = pos;
continue;
}
const wikiMatch = remaining.match(wikiRe);
if (wikiMatch) {
flush();
nodes.push({ type: 'text', text: wikiMatch[0] });
pos += wikiMatch[0].length;
textStart = pos;
continue;
}
let matched = false;
for (const rule of formatRules) {
if (!remaining.startsWith(rule.open)) continue;
const afterOpen = remaining.slice(rule.open.length);
const closeIdx = afterOpen.indexOf(rule.close);
if (closeIdx === -1) continue;
if (rule.marks[0].type !== 'code' && closeIdx === 0) continue;
const content = afterOpen.slice(0, closeIdx);
flush();
if (rule.marks[0].type === 'code') {
nodes.push({ type: 'text', text: content, marks: [...rule.marks] });
} else {
const inner = parseInlineContent(content);
for (const n of inner) {
if (n.type === 'text') {
nodes.push({ type: 'text', text: n.text, marks: [...rule.marks, ...(n.marks || [])] });
}
}
}
pos += rule.open.length + content.length + rule.close.length;
textStart = pos;
matched = true;
break;
}
if (!matched) pos++;
}
flush();
return mergeTextNodes(nodes);
}
function parseInline(text: string): AdfInlineContent[] {
return parseInlineContent(text);
}
// --- Block-level parsing helpers ---------------------------------------------
function parseFencedCode(lines: string[], i: number): { node: AdfCodeBlockNode; next: number } | null {
if (!lines[i].startsWith('```')) return null;
const lang = lines[i].slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
return {
node: { type: 'codeBlock', attrs: { language: lang }, content: [{ type: 'text', text: codeLines.join('\n') }] },
next: i + 1,
};
}
function parseHeading(line: string): AdfHeadingNode | null {
const m = line.match(/^(#{1,6})\s+(.*)/);
if (!m) return null;
return { type: 'heading', attrs: { level: m[1].length }, content: parseInline(m[2]) };
}
function parseHr(line: string): AdfRuleNode | null {
return line.match(/^(-{3,}|\*{3,}|_{3,})$/) ? { type: 'rule' } : null;
}
function parseTaskList(lines: string[], i: number): { node: AdfTaskListNode; next: number } | null {
if (!lines[i].match(/^[-*+]\s+\[[ xX]\]\s*/)) return null;
const items: AdfTaskItemNode[] = [];
let counter = 0;
while (i < lines.length && lines[i].match(/^[-*+]\s+\[[ xX]\]\s*/)) {
const m = lines[i].match(/^[-*+]\s+\[([ xX])\]\s*(.*)/);
if (m) {
const state = m[1].toLowerCase() === 'x' ? ('DONE' as const) : ('TODO' as const);
const inline: AdfInlineContent[] = parseInline(m[2]);
i++;
while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) {
inline.push({ type: 'hardBreak' });
inline.push(...parseInline('- ' + lines[i].replace(/^\s+[-*+]\s+/, '')));
i++;
}
items.push({
type: 'taskItem',
attrs: { localId: `task-${Date.now()}-${counter++}`, state },
content: inline,
});
} else {
i++;
}
}
return { node: { type: 'taskList', attrs: { localId: `tasklist-${Date.now()}` }, content: items }, next: i };
}
function parseListItems(
lines: string[],
i: number,
itemRe: RegExp,
stripRe: RegExp,
): { items: AdfListItemNode[]; next: number } {
const items: AdfListItemNode[] = [];
while (i < lines.length && lines[i].match(itemRe)) {
const content: any[] = [{ type: 'paragraph', content: parseInline(lines[i].replace(stripRe, '')) }];
i++;
const subs: AdfListItemNode[] = [];
while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) {
subs.push({
type: 'listItem',
content: [{ type: 'paragraph', content: parseInline(lines[i].replace(/^\s+[-*+]\s+/, '')) }],
});
i++;
}
if (subs.length) content.push({ type: 'bulletList', content: subs });
items.push({ type: 'listItem', content: content as [AdfParagraphNode] });
}
return { items, next: i };
}
function isBlockStart(line: string): boolean {
return (
line.startsWith('```') ||
!!line.match(/^#{1,6}\s/) ||
!!line.match(/^[-*+]\s+\[[ xX]\]\s*/) ||
!!line.match(/^[-*+]\s/) ||
!!line.match(/^\d+\.\s/) ||
!!line.match(/^(-{3,}|\*{3,}|_{3,})$/)
);
}
// --- Markdown → ADF ----------------------------------------------------------
export function markdownToAdf(markdown: string): AdfDoc | null {
if (!markdown || !markdown.trim()) return null;
const lines = markdown.split('\n');
const content: AdfBlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const code = parseFencedCode(lines, i);
if (code) {
content.push(code.node);
i = code.next;
continue;
}
const heading = parseHeading(line);
if (heading) {
content.push(heading);
i++;
continue;
}
const hr = parseHr(line);
if (hr) {
content.push(hr);
i++;
continue;
}
const task = parseTaskList(lines, i);
if (task) {
content.push(task.node);
i = task.next;
continue;
}
if (line.match(/^[-*+]\s+/)) {
const r = parseListItems(lines, i, /^[-*+]\s+/, /^[-*+]\s+/);
content.push({ type: 'bulletList', content: r.items });
i = r.next;
continue;
}
if (line.match(/^\d+\.\s+/)) {
const r = parseListItems(lines, i, /^\d+\.\s+/, /^\d+\.\s+/);
content.push({ type: 'orderedList', content: r.items });
i = r.next;
continue;
}
if (line.trim() === '') {
i++;
continue;
}
const paraLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length) {
content.push({ type: 'paragraph', content: parseInline(paraLines.join('\n')) });
}
}
return content.length ? { version: 1, type: 'doc', content } : null;
}
// --- ADF → Markdown helpers --------------------------------------------------
function adfInlineToMarkdown(nodes: any[]): string {
if (!nodes) return '';
return nodes
.map((node: any) => {
if (node.type === 'hardBreak') return '\n';
if (node.type === 'mention') return node.attrs?.text || node.attrs?.displayName || '';
if (node.type === 'emoji') return node.attrs?.text || node.attrs?.shortName || '';
if (node.type === 'inlineCard') {
const url = node.attrs?.url || '';
return url ? `[${url}](${url})` : '';
}
if (node.type !== 'text') return '';
const text = node.text || '';
const marks: string[] = (node.marks || []).map((m: any) => m.type);
const linkMark = (node.marks || []).find((m: any) => m.type === 'link');
let result = text;
if (marks.includes('code')) return `\`${result}\``;
if (marks.includes('strike')) result = `<s>${result}</s>`;
if (marks.includes('underline')) result = `<u>${result}</u>`;
if (marks.includes('strong')) result = `<b>${result}</b>`;
if (marks.includes('em')) result = `<i>${result}</i>`;
if (linkMark) result = `[${result}](${linkMark.attrs?.href || ''})`;
return result;
})
.join('');
}
function renderList(items: any[], ordered: boolean): string {
return items
.map((item: any, idx: number) => {
const blocks: any[] = item.content || [];
const first = blocks[0];
const rest = blocks.slice(1);
const main = first ? adfBlockToMarkdown(first) : '';
const nested = rest
.map((b: any) => adfBlockToMarkdown(b))
.filter((s: string) => s.trim())
.map((s: string) =>
s
.split('\n')
.map((l: string) => ' ' + l)
.join('\n'),
)
.join('\n');
const prefix = ordered ? `${idx + 1}.` : '-';
return `${prefix} ${main}${nested ? '\n' + nested : ''}`;
})
.join('\n');
}
function adfBlockToMarkdown(node: any): string {
if (!node) return '';
switch (node.type) {
case 'heading':
return `${'#'.repeat(node.attrs?.level || 1)} ${adfInlineToMarkdown(node.content || [])}`;
case 'paragraph':
return adfInlineToMarkdown(node.content || []);
case 'codeBlock': {
const lang = node.attrs?.language || '';
const code = (node.content || []).map((n: any) => n.text || '').join('');
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
case 'bulletList':
return renderList(node.content || [], false);
case 'orderedList':
return renderList(node.content || [], true);
case 'taskList':
return (node.content || [])
.map((item: any) => {
const checked = item.attrs?.state === 'DONE';
const blocks: any[] = item.content || [];
const first = blocks[0];
const rest = blocks.slice(1);
const raw =
first?.type === 'paragraph'
? adfInlineToMarkdown(first.content || [])
: adfInlineToMarkdown(blocks);
const lines = raw.split('\n');
const full =
lines[0] +
(lines.slice(1).length
? '\n' +
lines
.slice(1)
.map((l: string) => ' ' + l)
.join('\n')
: '');
const nested = rest
.map((b: any) => adfBlockToMarkdown(b))
.filter((s: string) => s.trim())
.map((s: string) =>
s
.split('\n')
.map((l: string) => ' ' + l)
.join('\n'),
)
.join('\n');
return `- [${checked ? 'x' : ' '}] ${full}${nested ? '\n' + nested : ''}`;
})
.join('\n');
case 'rule':
return '---';
case 'blockquote': {
const inner = (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n');
return inner
.split('\n')
.map((l: string) => `> ${l}`)
.join('\n');
}
case 'table': {
const rows: any[] = node.content || [];
if (!rows.length) return '';
const mdRows = rows.map(
(row: any) =>
`| ${(row.content || [])
.map((cell: any) =>
(cell.content || [])
.map((n: any) => adfBlockToMarkdown(n))
.join(' ')
.replace(/\|/g, '\\|'),
)
.join(' | ')} |`,
);
const sep = `| ${rows[0].content.map(() => '---').join(' | ')} |`;
return [mdRows[0], sep, ...mdRows.slice(1)].join('\n');
}
case 'panel':
case 'expand':
case 'layoutSection':
case 'layoutColumn':
return (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n\n');
default:
return (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n');
}
}
// --- ADF → Markdown ----------------------------------------------------------
export function adfToMarkdown(adf: any): string | null {
if (adf === undefined) return null;
if (!adf || typeof adf !== 'object') return '';
const blocks = (adf.content || []).map((node: any) => adfBlockToMarkdown(node));
return blocks.filter((b: string) => b !== '').join('\n\n');
}

View file

@ -5,8 +5,8 @@ import {
TimekeepSettingsInterface,
} from '../settings/default';
export function checkMigrateSettings(data: any, saveSettings: () => void): any {
if (!data || typeof data !== 'object') return data;
export function checkMigrateSettings(data: any): { result: any; changed: boolean } {
if (!data || typeof data !== 'object') return { result: data, changed: false };
const result = { ...data };
let data_changed = false;
@ -84,6 +84,8 @@ export function checkMigrateSettings(data: any, saveSettings: () => void): any {
username: result.connection.username || '',
email: result.connection.email || '',
password: result.connection.password || '',
jqlPresets: [],
lastJqlQuery: '',
},
];
result.currentConnectionIndex = 0;
@ -91,9 +93,23 @@ export function checkMigrateSettings(data: any, saveSettings: () => void): any {
data_changed = true;
}
if (data_changed) {
saveSettings();
// Ensure all connections have jqlPresets and lastJqlQuery
if (result.connections && Array.isArray(result.connections)) {
for (const conn of result.connections) {
if (!('jqlPresets' in conn)) {
conn.jqlPresets = [];
data_changed = true;
}
if (!('lastJqlQuery' in conn)) {
conn.lastJqlQuery = '';
data_changed = true;
}
}
}
return result;
if (data_changed) {
return { result, changed: true };
}
return { result, changed: false };
}

View file

@ -1470,3 +1470,7 @@ code.hljs {
background: var(--interactive-hover);
}
}
.break-line {
white-space: pre-line;
}

183
tests/__mocks__/obsidian.ts Normal file
View file

@ -0,0 +1,183 @@
export class Plugin {
app: App;
settings: Record<string, any> = {};
constructor() {
this.app = new App();
}
async loadData(): Promise<any> {
return {};
}
async saveData(data: any): Promise<void> {}
addCommand(_cmd: any): void {}
addSettingTab(_tab: any): void {}
registerEvent(_event: any): void {}
registerEditorExtension(_ext: any): void {}
registerMarkdownPostProcessor(_proc: any): void {}
registerInterval(_id: number): number {
return 0;
}
}
export class App {
vault: Vault;
workspace: Workspace;
metadataCache: MetadataCache;
fileManager: FileManager;
}
export class Vault {
async read(_file: TFile): Promise<string> {
return '';
}
async process(_file: TFile, _fn: (data: string) => string): Promise<string> {
return '';
}
async create(_path: string, _content: string): Promise<TFile> {
return new TFile();
}
async rename(_file: TFile, _newPath: string): Promise<void> {}
getFileByPath(_path: string): TFile | null {
return null;
}
getFolderByPath(_path: string): TFolder | null {
return null;
}
async createFolder(_path: string): Promise<void> {}
}
export class TFile {
path: string = '';
extension: string = 'md';
name: string = '';
parent: TFolder | null = null;
vault: Vault = new Vault();
}
export class TFolder {
path: string = '';
name: string = '';
children: (TFile | TFolder)[] = [];
isRoot(): boolean {
return false;
}
}
export class Workspace {
on(_name: string, _cb: (...args: any[]) => any): void {}
openLinkText(_path: string, _sourcePath?: string): Promise<void> {
return Promise.resolve();
}
}
export class MetadataCache {
getFileCache(_file: TFile): any {
return null;
}
}
export class FileManager {
async processFrontMatter(_file: TFile, _fn: (frontmatter: any) => void): Promise<void> {}
}
export class Modal {
app: App;
contentEl: HTMLElement;
constructor(app: App) {
this.app = app;
this.contentEl = document.createElement('div');
}
open(): void {}
close(): void {}
}
export class Setting {
constructor(_containerEl: HTMLElement) {
this.setName('');
this.setDesc('');
}
setName(_name: string): this {
return this;
}
setDesc(_desc: string): this {
return this;
}
addText(_cb: (text: TextComponent) => any): this {
return this;
}
addTextArea(_cb: (text: TextComponent) => any): this {
return this;
}
addButton(_cb: (btn: ButtonComponent) => any): this {
return this;
}
addDropdown(_cb: (dropdown: DropdownComponent) => any): this {
return this;
}
}
export class TextComponent {
inputEl: HTMLInputElement;
constructor() {
this.inputEl = document.createElement('input');
}
setPlaceholder(_placeholder: string): this {
return this;
}
setValue(_value: string): this {
return this;
}
onChange(_cb: (value: string) => void): this {
return this;
}
}
export class ButtonComponent {
buttonEl: HTMLButtonElement;
constructor() {
this.buttonEl = document.createElement('button');
}
setButtonText(_text: string): this {
return this;
}
setCta(): this {
return this;
}
onClick(_cb: () => void): this {
return this;
}
}
export class DropdownComponent {
selectEl: HTMLSelectElement;
constructor() {
this.selectEl = document.createElement('select');
}
addOption(_value: string, _display: string): this {
return this;
}
setValue(_value: string): this {
return this;
}
onChange(_cb: (value: string) => void): this {
return this;
}
}
export class Notice {
constructor(_message: string) {}
}
export function requestUrl(params: any): Promise<any> {
return Promise.resolve({ status: 200, json: {}, text: '' });
}

View file

@ -0,0 +1,175 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
interface TestConnection {
name: string;
jiraUrl: string;
apiVersion: '2' | '3';
authMethod: 'bearer' | 'basic';
apiToken: string;
email?: string;
}
interface TestConfig {
connections: TestConnection[];
}
function loadConfig(): TestConfig | null {
try {
const configPath = path.resolve(__dirname, '../../test-config.local.json');
if (!fs.existsSync(configPath)) {
return null;
}
const raw = fs.readFileSync(configPath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
const config = loadConfig();
const runIntegration = !!config && process.env.RUN_INTEGRATION_TESTS === 'true';
async function mockBaseRequest(conn: TestConnection, method: string, urlPath: string, body?: string): Promise<any> {
const url = `${conn.jiraUrl}/rest/api/${conn.apiVersion}${urlPath}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (conn.authMethod === 'bearer') {
headers['Authorization'] = `Bearer ${conn.apiToken}`;
} else if (conn.authMethod === 'basic' && conn.email) {
const encoded = Buffer.from(`${conn.email}:${conn.apiToken}`).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
}
const fetchArgs: RequestInit = {
method,
headers,
body: body || undefined,
};
const response = await fetch(url, fetchArgs);
const json = await response.json();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${JSON.stringify(json)}`);
}
return json;
}
async function findExistingIssue(conn: TestConnection): Promise<string | null> {
try {
const result = await mockBaseRequest(
conn,
'POST',
'/search',
JSON.stringify({ jql: '', maxResults: 1, fields: ['key'] }),
);
if (result.issues && result.issues.length > 0) {
return result.issues[0].key;
}
return null;
} catch {
return null;
}
}
if (runIntegration) {
describe.each(config!.connections)('Integration: $name ($apiVersion)', (conn) => {
let existingIssueKey: string | null;
beforeAll(async () => {
existingIssueKey = await findExistingIssue(conn);
});
it('fetches server info (self)', async () => {
const result = await mockBaseRequest(conn, 'GET', '/serverInfo');
expect(result).toHaveProperty('baseUrl');
expect(result).toHaveProperty('version');
});
it('searches issues with empty JQL', async () => {
const result = await mockBaseRequest(
conn,
'POST',
'/search',
JSON.stringify({ jql: '', maxResults: 1, fields: ['key', 'summary'] }),
);
expect(result).toHaveProperty('issues');
expect(Array.isArray(result.issues)).toBe(true);
if (result.issues.length > 0) {
expect(result.issues[0]).toHaveProperty('key');
expect(result.issues[0]).toHaveProperty('fields');
}
});
it('fetches a specific issue by key', async () => {
if (!existingIssueKey) {
console.warn('No existing issues found, skipping test');
return;
}
const result = await mockBaseRequest(conn, 'GET', `/issue/${existingIssueKey}`);
expect(result).toHaveProperty('key', existingIssueKey);
expect(result).toHaveProperty('fields');
expect(result.fields).toHaveProperty('summary');
});
it('searches by project JQL', async () => {
if (!existingIssueKey) {
console.warn('No existing issues found, skipping test');
return;
}
const projectKey = existingIssueKey.split('-')[0];
const result = await mockBaseRequest(
conn,
'POST',
'/search',
JSON.stringify({ jql: `project = ${projectKey}`, maxResults: 3, fields: ['key', 'summary'] }),
);
expect(result).toHaveProperty('issues');
expect(result.issues.length).toBeGreaterThan(0);
for (const issue of result.issues) {
expect(issue.key).toMatch(new RegExp(`^${projectKey}-`));
}
});
it('fetches issue transitions', async () => {
if (!existingIssueKey) {
console.warn('No existing issues found, skipping test');
return;
}
const result = await mockBaseRequest(conn, 'GET', `/issue/${existingIssueKey}/transitions`);
expect(result).toHaveProperty('transitions');
expect(Array.isArray(result.transitions)).toBe(true);
});
if (conn.apiVersion === '3') {
it('uses approximate count endpoint (v3 specific)', async () => {
const result = await mockBaseRequest(
conn,
'POST',
'/search/approximate-count',
JSON.stringify({ jql: '' }),
);
expect(result).toHaveProperty('count');
expect(typeof result.count).toBe('number');
});
}
});
} else {
describe('Integration tests', () => {
it('are skipped unless RUN_INTEGRATION_TESTS=true and tests/test-config.local.json exists', () => {
if (config) {
console.info('Test config found. Set RUN_INTEGRATION_TESTS=true to run integration tests.');
} else {
console.info(
'No test config found. Create tests/test-config.local.json (see test-config.example.json).',
);
}
expect(true).toBe(true);
});
});
}

View file

@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFileName } from '../../../src/tools/sanitizers';
function generateFilenameFromTemplate(template: string, issue: { key: string; fields: { summary?: string } }): string {
let filename = template;
const summary = issue.fields?.summary || '';
const sanitizedSummary = sanitizeFileName(summary);
filename = filename.replace(/\{summary\}/g, sanitizedSummary);
const key = issue.key || '';
filename = filename.replace(/\{key\}/g, key);
filename = sanitizeFileName(filename);
if (!filename || filename.trim() === '') {
if (key) {
filename = key;
} else if (sanitizedSummary) {
filename = sanitizedSummary;
} else {
filename = 'jira-issue';
}
}
return filename.trim();
}
describe('generateFilenameFromTemplate', () => {
it('generates filename with summary and key default template', () => {
const issue = { key: 'PROJ-123', fields: { summary: 'Fix login bug' } };
const result = generateFilenameFromTemplate('{summary} ({key})', issue);
expect(result).toBe('Fix login bug (PROJ-123)');
});
it('handles template with only key', () => {
const issue = { key: 'PROJ-123', fields: { summary: 'Fix login bug' } };
const result = generateFilenameFromTemplate('{key}', issue);
expect(result).toBe('PROJ-123');
});
it('handles template with only summary', () => {
const issue = { key: 'PROJ-123', fields: { summary: 'Fix login bug' } };
const result = generateFilenameFromTemplate('{summary}', issue);
expect(result).toBe('Fix login bug');
});
it('sanitizes summary in filename', () => {
const issue = { key: 'PROJ-1', fields: { summary: 'bug: fix "critical" issue?' } };
const result = generateFilenameFromTemplate('{summary}', issue);
expect(result).not.toContain(':');
expect(result).not.toContain('"');
expect(result).not.toContain('?');
expect(result).toContain('-');
});
it('falls back to key when summary is empty but template has non-template text', () => {
const issue = { key: 'PROJ-1', fields: { summary: '' } };
const result = generateFilenameFromTemplate('{summary}', issue);
expect(result).toBe('PROJ-1');
});
it('falls back to jira-issue when both key and summary are empty', () => {
const issue = { key: '', fields: { summary: '' } };
const result = generateFilenameFromTemplate('{summary}', issue);
expect(result).toBe('jira-issue');
});
it('preserves custom template structure', () => {
const issue = { key: 'PROJ-42', fields: { summary: 'My Task' } };
const result = generateFilenameFromTemplate('{key}-{summary}', issue);
expect(result).toBe('PROJ-42-My Task');
});
it('handles missing fields gracefully by showing non-template parts', () => {
const issue = { key: 'PROJ-1', fields: {} };
const result = generateFilenameFromTemplate('{summary} ({key})', issue);
expect(result).toBe('(PROJ-1)');
});
});

View file

@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useTranslations, t } from '../../../src/localization/translator';
describe('translator', () => {
const originalLanguage = window.localStorage.getItem('language');
afterEach(() => {
if (originalLanguage) {
window.localStorage.setItem('language', originalLanguage);
} else {
window.localStorage.removeItem('language');
}
});
describe('useTranslations', () => {
it('returns translate function with correct prefix', () => {
const { t: translate } = useTranslations('modals.jql_search');
const result = translate('desc');
expect(result).toBe('Search issues by JQL query');
});
it('returns locale string', () => {
const { locale } = useTranslations();
expect(locale).toBe('en');
});
it('returns correct locale when language is set', () => {
// ru locale may not be compiled; test with en
const { locale } = useTranslations();
expect(locale).toBe('en');
});
});
describe('t', () => {
it('returns translation for valid key with prefix', () => {
const result = t('jql.name', 'modals.jql_search');
expect(result).toBe('JQL Query');
});
it('returns translation key when not found', () => {
const result = t('nonexistent.key', 'some_prefix');
expect(result).toBe('some_prefix.nonexistent.key');
});
it('replaces placeholders in translation', () => {
const result = t('total', 'modals.jql_search.preview', { total: '42' });
expect(result).toBe('Total 42 issues found');
});
it('returns command translation', () => {
const result = t('name', 'commands.batch_fetch_issues');
expect(result).toBe('Batch Fetch Issues by JQL');
});
});
});

4
tests/__tests__/setup.ts Normal file
View file

@ -0,0 +1,4 @@
import { vi } from 'vitest';
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(cb, 0));
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { validateFunctionString } from '../../../src/tools/convertFunctionString';
describe('validateFunctionString', () => {
it('accepts empty string', async () => {
const result = await validateFunctionString('');
expect(result.isValid).toBe(true);
});
it('accepts whitespace-only string', async () => {
const result = await validateFunctionString(' ');
expect(result.isValid).toBe(true);
});
it('rejects document reference', async () => {
const result = await validateFunctionString('document.title');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('document');
});
it('rejects window reference', async () => {
const result = await validateFunctionString('window.location');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('window');
});
it('rejects eval reference', async () => {
const result = await validateFunctionString('eval("1+1")');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('eval');
});
it('rejects Function reference', async () => {
const result = await validateFunctionString('Function("return 1")');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('Function');
});
it('rejects fetch reference', async () => {
const result = await validateFunctionString('fetch("/api")');
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('fetch');
});
it('accepts simple property access', async () => {
const result = await validateFunctionString('issue.fields.summary', ['issue']);
expect(result.isValid).toBe(true);
});
it('accepts simple expressions', async () => {
const result = await validateFunctionString('value + "!"', ['value']);
expect(result.isValid).toBe(true);
});
it('accepts arrow function syntax', async () => {
const result = await validateFunctionString('(issue, api_version) => issue.fields.summary', [
'issue',
'api_version',
]);
expect(result.isValid).toBe(true);
});
});

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFileName } from '../../../src/tools/sanitizers';
describe('sanitizeFileName', () => {
it('replaces backslash with dash', () => {
expect(sanitizeFileName('foo\\bar')).toBe('foo-bar');
});
it('replaces forward slash with dash', () => {
expect(sanitizeFileName('foo/bar')).toBe('foo-bar');
});
it('replaces colon with dash', () => {
expect(sanitizeFileName('foo: bar')).toBe('foo- bar');
});
it('replaces asterisk with dash', () => {
expect(sanitizeFileName('foo*bar')).toBe('foo-bar');
});
it('replaces question mark with dash', () => {
expect(sanitizeFileName('foo?bar')).toBe('foo-bar');
});
it('replaces double quote with dash', () => {
expect(sanitizeFileName('foo"bar')).toBe('foo-bar');
});
it('replaces left angle bracket with dash', () => {
expect(sanitizeFileName('foo<bar')).toBe('foo-bar');
});
it('replaces right angle bracket with dash', () => {
expect(sanitizeFileName('foo>bar')).toBe('foo-bar');
});
it('replaces pipe with dash', () => {
expect(sanitizeFileName('foo|bar')).toBe('foo-bar');
});
it('replaces all invalid characters', () => {
expect(sanitizeFileName('a<b>c"d:e/f\\g*h?i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
});
it('returns empty string for empty input', () => {
expect(sanitizeFileName('')).toBe('');
});
it('preserves valid characters', () => {
expect(sanitizeFileName('hello-world 123')).toBe('hello-world 123');
});
it('handles unicode characters', () => {
expect(sanitizeFileName('привет мир')).toBe('привет мир');
});
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import {
parseFileContent,
extractAllJiraSyncValuesFromContent,
updateJiraSyncContent,
} from '../../../src/tools/sectionTools';
describe('parseFileContent', () => {
it('parses a sync section (stops at next heading)', () => {
const content = '`jira-sync-section-status`\nIn Progress\n\n## Next Section\nmore text';
const blocks = parseFileContent(content);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('section');
expect(blocks[0].name).toBe('status');
expect(blocks[0].content).toBe('In Progress');
});
it('parses a sync section without heading captures everything', () => {
const content = '`jira-sync-section-status`\nIn Progress\n\nmore text';
const blocks = parseFileContent(content);
expect(blocks[0].type).toBe('section');
expect(blocks[0].content).toContain('In Progress');
expect(blocks[0].content).toContain('more text');
});
it('parses a sync line (captures rest of line)', () => {
const content = 'text `jira-sync-line-assignee`johndoe more text';
const blocks = parseFileContent(content);
expect(blocks[0].type).toBe('line');
expect(blocks[0].name).toBe('assignee');
expect(blocks[0].content).toBe('johndoe more text');
});
it('parses an inline block', () => {
const content = 'hello `jira-sync-inline-start-summary`my issue`jira-sync-end` world';
const blocks = parseFileContent(content);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('inline');
expect(blocks[0].name).toBe('summary');
expect(blocks[0].content).toBe('my issue');
});
it('parses a block', () => {
const content = 'before\n`jira-sync-block-start-description`\nline1\nline2\n`jira-sync-end`\nafter';
const blocks = parseFileContent(content);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('block');
expect(blocks[0].name).toBe('description');
expect(blocks[0].content).toBe('line1\nline2');
});
it('returns empty array for content without sync markers', () => {
const content = 'just plain text\nno markers here';
const blocks = parseFileContent(content);
expect(blocks).toHaveLength(0);
});
it('parses multiple blocks of different types', () => {
const content = [
'`jira-sync-section-status`',
'Done',
'',
'`jira-sync-line-assignee`john',
'`jira-sync-inline-start-priority`High`jira-sync-end`',
].join('\n');
const blocks = parseFileContent(content);
expect(blocks).toHaveLength(3);
});
});
describe('extractAllJiraSyncValuesFromContent', () => {
it('extracts values into a dictionary', () => {
const content = '`jira-sync-section-status`\nDone\n`jira-sync-line-assignee`john';
const result = extractAllJiraSyncValuesFromContent(content);
expect(result).toEqual({ status: 'Done', assignee: 'john' });
});
it('returns empty object for content without sync markers', () => {
const result = extractAllJiraSyncValuesFromContent('plain text');
expect(result).toEqual({});
});
});
describe('updateJiraSyncContent', () => {
it('updates a section with new content', () => {
const content = '`jira-sync-section-status`\nIn Progress';
const result = updateJiraSyncContent(content, { status: 'Done' });
expect(result).toContain('Done');
});
it('does not modify content for unknown field names', () => {
const content = '`jira-sync-section-status`\nIn Progress';
const result = updateJiraSyncContent(content, { assignee: 'john' });
expect(result).toBe(content);
});
it('updates only the specified fields', () => {
const content = ['`jira-sync-section-status`', 'In Progress', '`jira-sync-line-assignee`jane'].join('\n');
const result = updateJiraSyncContent(content, { status: 'Done' });
expect(result).toContain('Done');
expect(result).toContain('jane');
});
it('preserves content after section when a heading boundary is present', () => {
const content = 'some header\n\n`jira-sync-section-status`\nold value\n\n## Next\nfooter';
const result = updateJiraSyncContent(content, { status: 'new value' });
expect(result).toContain('`jira-sync-section-status`');
expect(result).toContain('new value');
expect(result).toContain('some header');
expect(result).toContain('footer');
});
});

View file

@ -0,0 +1,19 @@
{
"connections": [
{
"name": "Test Jira v2",
"jiraUrl": "http://jira-test.local:8080",
"apiVersion": "2",
"authMethod": "bearer",
"apiToken": ""
},
{
"name": "Test Jira v3",
"jiraUrl": "https://your-domain.atlassian.net",
"apiVersion": "3",
"authMethod": "basic",
"apiToken": "",
"email": ""
}
]
}

15
tests/vitest.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
include: ['tests/__tests__/**/*.test.ts'],
environment: 'jsdom',
setupFiles: ['tests/__tests__/setup.ts'],
},
resolve: {
alias: {
obsidian: path.resolve(__dirname, '__mocks__/obsidian.ts'),
},
},
});

View file

@ -14,5 +14,6 @@
"lib": ["DOM", "ES2017", "ES2018", "ES2019", "ES2020"],
"types": ["node"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
"exclude": ["tests/", "node_modules/"]
}

View file

@ -1,3 +1,3 @@
{
"1.0.0": "1.7.7"
"1.0.0": "1.7.4"
}

1895
yarn.lock Normal file

File diff suppressed because it is too large Load diff