mirror of
https://github.com/alamion/obsidian-jira-sync.git
synced 2026-07-22 12:20:29 +00:00
Compare commits
36 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294a18b37f | ||
|
|
3365e6ba5d | ||
|
|
a1deb0e95a | ||
|
|
59f863eb97 | ||
|
|
f77c6ea3f6 | ||
|
|
57fa0d1873 | ||
|
|
2aeb8c1bee | ||
|
|
3278de8748 | ||
|
|
ad06c8f4d8 | ||
|
|
e3a2a01270 | ||
|
|
d4d9746647 | ||
|
|
f259446b74 | ||
|
|
5229b2d1a5 | ||
|
|
a3211bcdb2 | ||
|
|
75dd9d75cd | ||
|
|
d45309743a | ||
|
|
6e35db6254 | ||
|
|
f50873d925 | ||
|
|
bca0cae215 | ||
|
|
a3d2c67da3 | ||
|
|
ed988fe101 | ||
|
|
0639461a08 | ||
|
|
4958fc6a3c | ||
|
|
5df851934d | ||
|
|
1b400e7e55 | ||
|
|
ad11b3e89a | ||
|
|
e99e14c043 | ||
|
|
aa6e36ba5d | ||
|
|
5199ad333f | ||
|
|
3169273f5e | ||
|
|
827a28f3ed | ||
|
|
1562f4ff52 | ||
|
|
f4732263f8 | ||
|
|
45f9c297ed | ||
|
|
75f9277387 | ||
|
|
1a14ac37c7 |
67 changed files with 4275 additions and 628 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
118
.opencode/skills/jira-sync/SKILL.md
Normal file
118
.opencode/skills/jira-sync/SKILL.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
name: jira-sync
|
||||
description: >-
|
||||
Project-specific knowledge for the Jira Issue Manager plugin.
|
||||
Covers architecture, known bugs, commands, cache, localization,
|
||||
field mapping, indicator system, build/test commands, and release process.
|
||||
Use this skill when implementing features, fixing bugs, or
|
||||
navigating the codebase.
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
plugin-id: jira-sync
|
||||
display-name: Jira Issue Manager
|
||||
entry: src/main.ts
|
||||
---
|
||||
|
||||
# Jira Sync — Project-specific knowledge
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Plugin ID**: `jira-sync`, display name: "Jira Issue Manager"
|
||||
- **Entry**: `src/main.ts` → `JiraPlugin extends Plugin`
|
||||
- **10 commands** registered via `register*Command` wrappers in `src/commands/`
|
||||
- **Settings**: `src/settings/default.ts` — `JiraSettingsInterface` with connections, field mappings, cache
|
||||
- **API layer**: `src/api/base.ts` — `baseRequest(plugin, method, path)` constructs `{jiraUrl}/rest/api/{version}{path}`
|
||||
- **Multi-connection**: `settings.connections[]`, `currentConnectionIndex`
|
||||
|
||||
## Known bugs
|
||||
|
||||
- `src/tools/debugLogging.ts:1` — `process.env.NODE_ENV` is always `undefined` in Obsidian (no Node.js runtime). Logging is **always on** in both dev and production. Prefix: `[DEBUG]`, `[DEBUG WARNING]`, `[DEBUG ERROR]`.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Plugin lifecycle, cache init, command/settings registration
|
||||
├── api/ # Jira REST API (base, auth, issues, projects, self)
|
||||
├── commands/ # 10 user-facing commands
|
||||
├── modals/ # UI dialogs (search, JQL, worklog, project/type/status selectors)
|
||||
├── settings/ # Settings tab + components (connection, field mapping, timekeep)
|
||||
├── file_operations/ # File read/write for issue notes
|
||||
├── tools/ # Utilities (logging, cache, files, field mapping, sanitizers, etc.)
|
||||
├── postprocessing/ # Live Preview (CM6) + Reading mode hide-jira-markers
|
||||
├── default/ # Default template, mock issue, built-in field mappings
|
||||
├── interfaces/ # TypeScript types
|
||||
└── localization/ # i18n (en/ru YAML sources → compiled JSON)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| ID | Name |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| `get-issue-jira` | Get current issue from Jira |
|
||||
| `get-issue-jira-key` | Get issue from Jira with custom key |
|
||||
| `create-issue-jira` | Create issue in Jira |
|
||||
| `update-issue-jira` | Update issue in Jira |
|
||||
| `update-issue-status-jira` | Update issue status in Jira |
|
||||
| `update-work-log-jira-manually` | Update work log manually |
|
||||
| `update-work-log-jira-batch` | Update work log by batch |
|
||||
| `batch-fetch-issues-jira` | Batch Fetch Issues by JQL |
|
||||
| `add-comment-jira` | Add comment to Jira |
|
||||
| `rebuild-issue-cache` | Rebuild issue file cache from filesystem |
|
||||
|
||||
## Cache system
|
||||
|
||||
- In-memory `Map<string, string>` in `JiraPlugin` instance
|
||||
- Persisted in `settings.issueKeyToFilePathCache`
|
||||
- Vault event listeners for maintenance: `rename` updates path, `delete` removes entry
|
||||
- `rebuildCache()` scans issues folder, reads frontmatter `key` fields
|
||||
|
||||
## Localization
|
||||
|
||||
- Sources: `src/localization/source/{lang}/*.yaml` → compiled to `src/localization/compiled/{lang}.json`
|
||||
- Locale auto-detected from `window.localStorage.getItem('language')`, falls back to `en`
|
||||
- Build commands: `yarn run compile_locale_once`, `yarn run validate_locale_once`
|
||||
|
||||
## Field mapping system
|
||||
|
||||
- User-defined JS arrow-functions stored as strings in `fieldMappingsStrings`
|
||||
- Validated via `acorn` parser, evaluated via `new Function()` with sandboxed context
|
||||
- Helpers available: `jiraToMarkdown`, `markdownToJira`, `markdownToAdf`, `adfToMarkdown`, `JSON`, `Math`, `Date`
|
||||
- Built-in mappings in `src/default/obsidianJiraFieldsMapping.ts`
|
||||
|
||||
## Indicator system
|
||||
|
||||
Three marker types in notes (hidden in both Live Preview and Reading mode):
|
||||
|
||||
- **Section**: `` `jira-sync-section-fieldname` `` — content until next heading
|
||||
- **Line**: `` `jira-sync-line-fieldname` `` — content on same line
|
||||
- **Inline**: `` `jira-sync-inline-start-fieldname` content `jira-sync-end` ``
|
||||
|
||||
## Build commands
|
||||
|
||||
```bash
|
||||
yarn install # Install
|
||||
yarn run dev # Watch mode
|
||||
yarn run build # Production (compile_locale → tsc check → esbuild)
|
||||
yarn run format # Prettier
|
||||
yarn run lint:fix # ESLint autofix
|
||||
yarn run test # Vitest unit tests
|
||||
yarn run test:int # API integration tests
|
||||
```
|
||||
|
||||
## Pre-commit hooks (husky)
|
||||
|
||||
Currently runs `node check-version.js` — validates version consistency across files.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `yarn run version` bumps via `version-bump.mjs` (syncs `package.json` → `manifest.json` + `versions.json`)
|
||||
- GitHub release tag = version string (no leading `v`)
|
||||
- Attach: `main.js`, `manifest.json`, `styles.css` (if present)
|
||||
|
||||
## Testing
|
||||
|
||||
- `yarn run test` — Vitest, config at `tests/vitest.config.ts`
|
||||
- `yarn run test:int` — API integration tests at `tests/__tests__/api/`
|
||||
- Manual: copy `main.js`, `manifest.json`, `styles.css` to `<vault>/.obsidian/plugins/jira-sync/`
|
||||
54
.opencode/skills/obsidian-plugin/SKILL.md
Normal file
54
.opencode/skills/obsidian-plugin/SKILL.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
name: obsidian-plugin
|
||||
description: >-
|
||||
General Obsidian plugin development conventions — bundling with esbuild,
|
||||
manifest structure, plugin lifecycle (onload/onunload), settings persistence,
|
||||
and coding patterns for community plugins. Use when writing or modifying
|
||||
any Obsidian plugin.
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
---
|
||||
|
||||
# Obsidian Plugin Development
|
||||
|
||||
## Plugin anatomy
|
||||
|
||||
- `Plugin` class from `obsidian` package with `onload()` / `onunload()` lifecycle
|
||||
- Entry: `src/main.ts`, output: `main.js` (bundled via esbuild)
|
||||
- Required artifacts: `main.js`, `manifest.json`, optional `styles.css`
|
||||
- Settings: `this.loadData()` / `this.saveData()` (JSON persisted by Obsidian)
|
||||
|
||||
## Bundling
|
||||
|
||||
- **esbuild**: bundles all source + runtime deps into single `main.js`
|
||||
- Externals: `obsidian`, `electron`, `@codemirror/*`, `@lezer/*`, built-in Node modules
|
||||
- Format: CJS, target: ES2018
|
||||
- Sourcemaps in dev only
|
||||
|
||||
## Manifest (`manifest.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "stable-id", // Never change after release
|
||||
"name": "Display Name",
|
||||
"version": "x.y.z", // SemVer
|
||||
"minAppVersion": "1.x.x",
|
||||
"description": "...",
|
||||
"author": "...",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
- Use `this.register*` helpers (`registerEvent`, `registerDomEvent`, `registerInterval`, `registerEditorExtension`) for automatic cleanup on unload
|
||||
- Never leak event listeners or intervals
|
||||
|
||||
## Code conventions
|
||||
|
||||
- TypeScript with `strict: true`
|
||||
- `src/main.ts` minimal — delegate to modules
|
||||
- `async/await` over promises
|
||||
- No comments unless explicitly requested
|
||||
- Bundle all deps — no unbundled runtime dependencies
|
||||
- Prefer `checkCallback` for commands (cleaner UX)
|
||||
301
AGENTS.md
301
AGENTS.md
|
|
@ -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
64
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Contributing
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
yarn run dev # Watch mode (esbuild + localization)
|
||||
yarn run build # Production build
|
||||
yarn run format # Prettier formatting
|
||||
yarn run lint:fix # ESLint autofix
|
||||
yarn run test # Unit tests (Vitest)
|
||||
yarn run test:int # API integration tests
|
||||
yarn run validate_locale_once # Validate translation keys
|
||||
yarn run compile_locale_once # Compile YAML -> JSON
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Source in `src/`**, keep `main.ts` focused on lifecycle
|
||||
- **No comments** in code unless the logic is non-obvious
|
||||
- **Bundle everything** — no unbundled runtime dependencies
|
||||
- **Use `this.register*`** helpers for all listeners and intervals
|
||||
- **Stable command IDs** — never rename after release
|
||||
- **Follow existing patterns** — look at similar files first
|
||||
- **Prefer `checkCallback`** for commands (cleaner UX)
|
||||
- **Async/await** over promise chains
|
||||
- **Handle errors gracefully** — no silent failures
|
||||
|
||||
## Pre-commit checks
|
||||
|
||||
The repo uses husky. Currently runs `node check-version.js` to validate version consistency across `package.json`, `manifest.json`, and `versions.json`.
|
||||
|
||||
## Versioning
|
||||
|
||||
Run `yarn run version` to bump — this syncs `package.json` → `manifest.json` + `versions.json` via `version-bump.mjs`. Semantic versioning (x.y.z).
|
||||
|
||||
## Release
|
||||
|
||||
1. Bump version
|
||||
2. Build: `yarn run build`
|
||||
3. Create GitHub release with tag matching `manifest.json` version (no leading `v`)
|
||||
4. Attach: `main.js`, `manifest.json`, `styles.css` (if changed)
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `yarn run test`
|
||||
- Integration tests: `yarn run test:int` (requires a running Jira instance)
|
||||
- Manual test: copy `main.js`, `manifest.json`, `styles.css` to `<vault>/.obsidian/plugins/jira-sync/` and reload Obsidian
|
||||
|
||||
## Translation / i18n
|
||||
|
||||
- Source files: `src/localization/source/{lang}/*.yaml` (one YAML per component)
|
||||
- Compile: `yarn run compile_locale_once`
|
||||
- Validate: `yarn run validate_locale_once` (checks all keys exist and are referenced in code)
|
||||
- Locale is auto-detected from `window.localStorage.getItem('language')`, falls back to `en`
|
||||
|
||||
## Getting help
|
||||
|
||||
Open an issue on GitHub for bugs, feature requests, or questions.
|
||||
182
README.md
182
README.md
|
|
@ -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
|
||||

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

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

|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
MIT — originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira).
|
||||
|
|
|
|||
|
|
@ -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()`.
|
||||
|
|
|
|||
|
|
@ -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
BIN
docs/images/demo_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 887 KiB |
BIN
docs/images/demo_video.mp4
Normal file
BIN
docs/images/demo_video.mp4
Normal file
Binary file not shown.
BIN
docs/images/field_mapping_demo.png
Normal file
BIN
docs/images/field_mapping_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
36
src/commands/addComment.ts
Normal file
36
src/commands/addComment.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { IssueCommentModal } from '../modals';
|
||||
import { addComment, validateSettings } from '../api';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations('commands.add_comment').t;
|
||||
|
||||
export function registerAddCommentCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'add-comment-jira',
|
||||
name: t('name'),
|
||||
editorCheckCallback: (checking: boolean, editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
|
||||
if (!validateSettings(plugin)) return false;
|
||||
if (!ctx.file) return false;
|
||||
|
||||
const frontmatter = plugin.app.metadataCache.getFileCache(ctx.file)?.frontmatter;
|
||||
const issueKey = frontmatter?.key;
|
||||
if (!issueKey) return false;
|
||||
|
||||
if (!checking) {
|
||||
const selectedText = editor.getSelection();
|
||||
new IssueCommentModal(plugin.app, selectedText, async (commentText: string) => {
|
||||
try {
|
||||
await addComment(plugin, issueKey, commentText);
|
||||
} catch (error: any) {
|
||||
new Notice(t('error') + ': ' + (error.message || 'Unknown error'));
|
||||
console.error(error);
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './addComment';
|
||||
export * from './addWorkLogBatch';
|
||||
export * from './addWorkLogManually';
|
||||
export * from './batchFetchIssues';
|
||||
|
|
@ -5,3 +6,5 @@ export * from './createIssue';
|
|||
export * from './getIssue';
|
||||
export * from './updateIssue';
|
||||
export * from './updateStatus';
|
||||
export * from './rebuildCache';
|
||||
export * from './rebuildCache';
|
||||
|
|
|
|||
13
src/commands/rebuildCache.ts
Normal file
13
src/commands/rebuildCache.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
|
||||
export function registerRebuildCacheCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'rebuild-issue-cache',
|
||||
name: 'Rebuild issue file cache from filesystem',
|
||||
callback: async () => {
|
||||
await plugin.rebuildCache();
|
||||
new Notice('Issue cache rebuilt successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
2
src/localization/source/en/commands/add_comment.yaml
Normal file
2
src/localization/source/en/commands/add_comment.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name: Add comment to Jira
|
||||
error: Error adding comment
|
||||
11
src/localization/source/en/modals/comment.yaml
Normal file
11
src/localization/source/en/modals/comment.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name: Add Jira comment
|
||||
|
||||
body:
|
||||
name: Comment
|
||||
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
|
||||
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
|
||||
|
||||
submit: Submit
|
||||
|
||||
warns:
|
||||
empty: Comment cannot be empty
|
||||
|
|
@ -12,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)'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
src/localization/source/ru/commands/add_comment.yaml
Normal file
2
src/localization/source/ru/commands/add_comment.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name: Add comment to Jira
|
||||
error: Error adding comment
|
||||
11
src/localization/source/ru/modals/comment.yaml
Normal file
11
src/localization/source/ru/modals/comment.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name: Add Jira comment
|
||||
|
||||
body:
|
||||
name: Comment
|
||||
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
|
||||
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
|
||||
|
||||
submit: Submit
|
||||
|
||||
warns:
|
||||
empty: Comment cannot be empty
|
||||
|
|
@ -12,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: Загрузка...
|
||||
|
|
|
|||
|
|
@ -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: Файлы не нуждаются в переименовании
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
src/main.ts
52
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/modals/ConfirmModal.ts
Normal file
39
src/modals/ConfirmModal.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
private message: string;
|
||||
private onConfirm: () => void;
|
||||
private onCancel: () => void;
|
||||
|
||||
constructor(app: App, message: string, onConfirm: () => void, onCancel: () => void) {
|
||||
super(app);
|
||||
this.message = message;
|
||||
this.onConfirm = onConfirm;
|
||||
this.onCancel = onCancel;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl('h2', { text: this.message });
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Yes')
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
this.onConfirm();
|
||||
}),
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('No').onClick(() => {
|
||||
this.close();
|
||||
this.onCancel();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
55
src/modals/IssueCommentModal.ts
Normal file
55
src/modals/IssueCommentModal.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { App, Modal, Notice, Setting, TextAreaComponent } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations('modals.comment').t;
|
||||
|
||||
/**
|
||||
* Modal for adding a comment to a Jira issue.
|
||||
* Pre-populated with any text that was selected in the editor when the command was invoked.
|
||||
*/
|
||||
export class IssueCommentModal extends Modal {
|
||||
private onSubmit: (commentText: string) => void;
|
||||
private commentText: string;
|
||||
|
||||
constructor(app: App, initialText: string, onSubmit: (commentText: string) => void) {
|
||||
super(app);
|
||||
this.commentText = initialText;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
new Setting(this.contentEl).setName(t('name')).setHeading();
|
||||
|
||||
const textarea = new TextAreaComponent(this.contentEl)
|
||||
.setPlaceholder(t('body.placeholder'))
|
||||
.setValue(this.commentText)
|
||||
.onChange((value) => {
|
||||
this.commentText = value;
|
||||
});
|
||||
textarea.inputEl.rows = 10;
|
||||
textarea.inputEl.style.width = '100%';
|
||||
textarea.inputEl.style.marginBottom = '0.75em';
|
||||
textarea.inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(this.contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('submit'))
|
||||
.setCta()
|
||||
.onClick(() => this.submit()),
|
||||
);
|
||||
}
|
||||
|
||||
private submit() {
|
||||
if (!this.commentText.trim()) {
|
||||
new Notice(t('warns.empty'));
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSubmit(this.commentText);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
510
src/tools/markdownToAdf.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1470,3 +1470,7 @@ code.hljs {
|
|||
background: var(--interactive-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.break-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
|
|
|||
183
tests/__mocks__/obsidian.ts
Normal file
183
tests/__mocks__/obsidian.ts
Normal 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: '' });
|
||||
}
|
||||
175
tests/__tests__/api/issues.test.ts
Normal file
175
tests/__tests__/api/issues.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
75
tests/__tests__/file_operations/getIssue.test.ts
Normal file
75
tests/__tests__/file_operations/getIssue.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
55
tests/__tests__/localization/translator.test.ts
Normal file
55
tests/__tests__/localization/translator.test.ts
Normal 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
4
tests/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(cb, 0));
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
|
||||
62
tests/__tests__/tools/convertFunctionString.test.ts
Normal file
62
tests/__tests__/tools/convertFunctionString.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
tests/__tests__/tools/sanitizers.test.ts
Normal file
56
tests/__tests__/tools/sanitizers.test.ts
Normal 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('привет мир');
|
||||
});
|
||||
});
|
||||
112
tests/__tests__/tools/sectionTools.test.ts
Normal file
112
tests/__tests__/tools/sectionTools.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
19
tests/test-config.example.json
Normal file
19
tests/test-config.example.json
Normal 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
15
tests/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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/"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.7.7"
|
||||
"1.0.0": "1.7.4"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue