Compare commits

..

5 commits

Author SHA1 Message Date
Alamion
294a18b37f
Update README.md 2026-07-22 14:17:29 +03:00
Alamion
3365e6ba5d
Update README.md 2026-07-22 14:15:44 +03:00
Alamion
a1deb0e95a
fixes:
- changed video format
2026-07-22 14:05:52 +03:00
Alamion
59f863eb97
fixes:
- markdownToAdf refactor
  - readme refactor
  - minor obsidian plugin dev best practices issues fixed
2026-07-22 13:38:15 +03:00
Alamion
f77c6ea3f6
fixes:
- another node dependencies fix
2026-06-25 18:14:48 +03:00
19 changed files with 662 additions and 663 deletions

View file

@ -15,7 +15,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
node-version: '20.x'
- name: Build plugin
run: |

View file

@ -0,0 +1,118 @@
---
name: jira-sync
description: >-
Project-specific knowledge for the Jira Issue Manager plugin.
Covers architecture, known bugs, commands, cache, localization,
field mapping, indicator system, build/test commands, and release process.
Use this skill when implementing features, fixing bugs, or
navigating the codebase.
license: MIT
compatibility: opencode
metadata:
plugin-id: jira-sync
display-name: Jira Issue Manager
entry: src/main.ts
---
# Jira Sync — Project-specific knowledge
## Architecture
- **Plugin ID**: `jira-sync`, display name: "Jira Issue Manager"
- **Entry**: `src/main.ts``JiraPlugin extends Plugin`
- **10 commands** registered via `register*Command` wrappers in `src/commands/`
- **Settings**: `src/settings/default.ts``JiraSettingsInterface` with connections, field mappings, cache
- **API layer**: `src/api/base.ts``baseRequest(plugin, method, path)` constructs `{jiraUrl}/rest/api/{version}{path}`
- **Multi-connection**: `settings.connections[]`, `currentConnectionIndex`
## Known bugs
- `src/tools/debugLogging.ts:1``process.env.NODE_ENV` is always `undefined` in Obsidian (no Node.js runtime). Logging is **always on** in both dev and production. Prefix: `[DEBUG]`, `[DEBUG WARNING]`, `[DEBUG ERROR]`.
## Directory structure
```
src/
├── main.ts # Plugin lifecycle, cache init, command/settings registration
├── api/ # Jira REST API (base, auth, issues, projects, self)
├── commands/ # 10 user-facing commands
├── modals/ # UI dialogs (search, JQL, worklog, project/type/status selectors)
├── settings/ # Settings tab + components (connection, field mapping, timekeep)
├── file_operations/ # File read/write for issue notes
├── tools/ # Utilities (logging, cache, files, field mapping, sanitizers, etc.)
├── postprocessing/ # Live Preview (CM6) + Reading mode hide-jira-markers
├── default/ # Default template, mock issue, built-in field mappings
├── interfaces/ # TypeScript types
└── localization/ # i18n (en/ru YAML sources → compiled JSON)
```
## Commands
| ID | Name |
| ------------------------------- | ---------------------------------------- |
| `get-issue-jira` | Get current issue from Jira |
| `get-issue-jira-key` | Get issue from Jira with custom key |
| `create-issue-jira` | Create issue in Jira |
| `update-issue-jira` | Update issue in Jira |
| `update-issue-status-jira` | Update issue status in Jira |
| `update-work-log-jira-manually` | Update work log manually |
| `update-work-log-jira-batch` | Update work log by batch |
| `batch-fetch-issues-jira` | Batch Fetch Issues by JQL |
| `add-comment-jira` | Add comment to Jira |
| `rebuild-issue-cache` | Rebuild issue file cache from filesystem |
## Cache system
- In-memory `Map<string, string>` in `JiraPlugin` instance
- Persisted in `settings.issueKeyToFilePathCache`
- Vault event listeners for maintenance: `rename` updates path, `delete` removes entry
- `rebuildCache()` scans issues folder, reads frontmatter `key` fields
## Localization
- Sources: `src/localization/source/{lang}/*.yaml` → compiled to `src/localization/compiled/{lang}.json`
- Locale auto-detected from `window.localStorage.getItem('language')`, falls back to `en`
- Build commands: `yarn run compile_locale_once`, `yarn run validate_locale_once`
## Field mapping system
- User-defined JS arrow-functions stored as strings in `fieldMappingsStrings`
- Validated via `acorn` parser, evaluated via `new Function()` with sandboxed context
- Helpers available: `jiraToMarkdown`, `markdownToJira`, `markdownToAdf`, `adfToMarkdown`, `JSON`, `Math`, `Date`
- Built-in mappings in `src/default/obsidianJiraFieldsMapping.ts`
## Indicator system
Three marker types in notes (hidden in both Live Preview and Reading mode):
- **Section**: `` `jira-sync-section-fieldname` `` — content until next heading
- **Line**: `` `jira-sync-line-fieldname` `` — content on same line
- **Inline**: `` `jira-sync-inline-start-fieldname` content `jira-sync-end` ``
## Build commands
```bash
yarn install # Install
yarn run dev # Watch mode
yarn run build # Production (compile_locale → tsc check → esbuild)
yarn run format # Prettier
yarn run lint:fix # ESLint autofix
yarn run test # Vitest unit tests
yarn run test:int # API integration tests
```
## Pre-commit hooks (husky)
Currently runs `node check-version.js` — validates version consistency across files.
## Versioning
- `yarn run version` bumps via `version-bump.mjs` (syncs `package.json``manifest.json` + `versions.json`)
- GitHub release tag = version string (no leading `v`)
- Attach: `main.js`, `manifest.json`, `styles.css` (if present)
## Testing
- `yarn run test` — Vitest, config at `tests/vitest.config.ts`
- `yarn run test:int` — API integration tests at `tests/__tests__/api/`
- Manual: copy `main.js`, `manifest.json`, `styles.css` to `<vault>/.obsidian/plugins/jira-sync/`

View file

@ -0,0 +1,54 @@
---
name: obsidian-plugin
description: >-
General Obsidian plugin development conventions — bundling with esbuild,
manifest structure, plugin lifecycle (onload/onunload), settings persistence,
and coding patterns for community plugins. Use when writing or modifying
any Obsidian plugin.
license: MIT
compatibility: opencode
---
# Obsidian Plugin Development
## Plugin anatomy
- `Plugin` class from `obsidian` package with `onload()` / `onunload()` lifecycle
- Entry: `src/main.ts`, output: `main.js` (bundled via esbuild)
- Required artifacts: `main.js`, `manifest.json`, optional `styles.css`
- Settings: `this.loadData()` / `this.saveData()` (JSON persisted by Obsidian)
## Bundling
- **esbuild**: bundles all source + runtime deps into single `main.js`
- Externals: `obsidian`, `electron`, `@codemirror/*`, `@lezer/*`, built-in Node modules
- Format: CJS, target: ES2018
- Sourcemaps in dev only
## Manifest (`manifest.json`)
```json
{
"id": "stable-id", // Never change after release
"name": "Display Name",
"version": "x.y.z", // SemVer
"minAppVersion": "1.x.x",
"description": "...",
"author": "...",
"isDesktopOnly": false
}
```
## Cleanup
- Use `this.register*` helpers (`registerEvent`, `registerDomEvent`, `registerInterval`, `registerEditorExtension`) for automatic cleanup on unload
- Never leak event listeners or intervals
## Code conventions
- TypeScript with `strict: true`
- `src/main.ts` minimal — delegate to modules
- `async/await` over promises
- No comments unless explicitly requested
- Bundle all deps — no unbundled runtime dependencies
- Prefer `checkCallback` for commands (cleaner UX)

301
AGENTS.md
View file

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

64
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,64 @@
# Contributing
## Development setup
```bash
yarn install
```
## Commands
```bash
yarn run dev # Watch mode (esbuild + localization)
yarn run build # Production build
yarn run format # Prettier formatting
yarn run lint:fix # ESLint autofix
yarn run test # Unit tests (Vitest)
yarn run test:int # API integration tests
yarn run validate_locale_once # Validate translation keys
yarn run compile_locale_once # Compile YAML -> JSON
```
## Guidelines
- **Source in `src/`**, keep `main.ts` focused on lifecycle
- **No comments** in code unless the logic is non-obvious
- **Bundle everything** — no unbundled runtime dependencies
- **Use `this.register*`** helpers for all listeners and intervals
- **Stable command IDs** — never rename after release
- **Follow existing patterns** — look at similar files first
- **Prefer `checkCallback`** for commands (cleaner UX)
- **Async/await** over promise chains
- **Handle errors gracefully** — no silent failures
## Pre-commit checks
The repo uses husky. Currently runs `node check-version.js` to validate version consistency across `package.json`, `manifest.json`, and `versions.json`.
## Versioning
Run `yarn run version` to bump — this syncs `package.json``manifest.json` + `versions.json` via `version-bump.mjs`. Semantic versioning (x.y.z).
## Release
1. Bump version
2. Build: `yarn run build`
3. Create GitHub release with tag matching `manifest.json` version (no leading `v`)
4. Attach: `main.js`, `manifest.json`, `styles.css` (if changed)
## Testing
- Unit tests: `yarn run test`
- Integration tests: `yarn run test:int` (requires a running Jira instance)
- Manual test: copy `main.js`, `manifest.json`, `styles.css` to `<vault>/.obsidian/plugins/jira-sync/` and reload Obsidian
## Translation / i18n
- Source files: `src/localization/source/{lang}/*.yaml` (one YAML per component)
- Compile: `yarn run compile_locale_once`
- Validate: `yarn run validate_locale_once` (checks all keys exist and are referenced in code)
- Locale is auto-detected from `window.localStorage.getItem('language')`, falls back to `en`
## Getting help
Open an issue on GitHub for bugs, feature requests, or questions.

182
README.md
View file

@ -1,160 +1,52 @@
# Jira Sync Plugin for Obsidian
# Jira Issue Manager
Tired of Jira plugins that only give you basic issue links and descriptions? This one's different. It brings **your entire Jira workflow into Obsidian** with customizable templates, deep field mapping, and seamless two-way synchronization—including all those custom fields your team added over the years.
A two-way Jira synchronization plugin for Obsidian. Templates, custom field mapping, time tracking, and batch operations — all without leaving your notes.
Originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira), but now packed with features you won't find elsewhere.
https://github.com/user-attachments/assets/55cb2c99-34a9-47fc-85f1-f79dba5a27b1
## Why this plugin exists
## Features
Other plugins treat Jira issues as read-only reference material. This one lets you:
- **Full two-way sync** — fetch issues from Jira, edit in Obsidian, push changes back
- **Custom templates** — structure your issue notes however you want using `jira-sync-section`, `jira-sync-line`, and `jira-sync-inline` markers that are hidden from view
- **Deep field mapping** — sync any Jira field, including custom fields from ScriptRunner, Insight, and other add-ons. Built-in mappings for 15+ standard fields, with support for custom JavaScript transform functions
- **Time tracking** — built-in work log statistics and batch submission. Supports Timekeep and Super Simple Time Tracker formats
- **Status management** — transition issues through workflows directly from Obsidian
- **Multiple authentication methods** — Bearer Token (PAT), Basic Auth, or Session Cookie
- **Multi-connection support** — work with multiple Jira instances
- **JQL search and batch fetch** — find and pull in issues by query
- **Inline comments** — add comments to Jira issues with text selection support
- **Build full-featured issue templates** that mirror your Jira workflow
- **Sync ANY field**—even custom ones from plugins like ScriptRunner or Insight
- **Create new fields** with dynamic field mapping (e.g. auto-generate browse URLs from `issue.self + issue.key` from API response)
- **Track time seamlessly** with integrated work log statistics and batch operations
- **Work offline** in Obsidian, then sync everything back to Jira when ready
![demo_image.png](docs/images/demo_image.png)
> **Pro Tip**: While TypeScript skills help for advanced mappings, we include ready-made mappings for 90% of basic use cases.
## Killer features
### 🧩 Your Jira, your template
Create Obsidian notes that look exactly like your team's Jira workflow. Pull in:
- Standard fields (status, assignee, priority)
- Custom fields (progress bars, sprint IDs, epic links)
- Plugin fields (ScriptRunner outputs, Insight assets)
- Inline indicators that work anywhere in your text
### Brief example:
#### Here is how template can look like:
```markdown
---
key: ""
status: "" <!-- Built-in field -->
priority: "" <!-- Another built-in field -->
sprint: "" <!-- Custom field -->
epic: "" <!-- Another Custom field -->
link: "" <!-- Built-in auto-generated link -->
---
### Customer Impact `jira-sync-section-customfield_10842` <!-- From your CRM plugin -->
### Other
User `jira-sync-inline-start-assignee``jira-sync-inline-end` should be working on this. <!-- Inline indicator, built-in -->
Expected time spent on the task: `jira-sync-line-originalEstimate` <!-- Line indicator, custom -->
```
#### Here is how it will look after syncing:
```markdown
---
key: JIR-1234
status: In Progress
priority: Medium
sprint: Mobile-Q2-24
epic: API-Overhaul
link: http://jira.local:8000/browse/JIR-1234
---
### Customer Impact `jira-sync-section-customfield_10842`
The current API structure is too fragmented and requires standardization.
### Other
User `jira-sync-inline-start-assignee`Jack A.M.`jira-sync-inline-end` should be working on this.
Expected time spent on the task: `jira-sync-line-originalEstimate`1w 3d
```
#### Here is how the user will see it most cases (all indicators are hidden from view):
```markdown
---
key: JIR-1234
status: In Progress
priority: Medium
sprint: Mobile-Q2-24
epic: API-Overhaul
link: http://jira.local:8000/browse/JIR-1234
---
### Customer Impact
The current API structure is too fragmented and requires standardization.
### Other
User Jack A.M. should be working on this.
Expected time spent on the task: 1w 3d
```
### 🔐 Multiple authentication methods
Choose the authentication that fits your security requirements:
- **Bearer Token (PAT)** - Personal Access Token for secure API access
- **Basic Auth (Username + PAT)** - Username with Personal Access Token
- **Session Cookie (Username + Password)** - Traditional authentication
> **Security Note**: When using PAT authentication, ensure `write:jira-work` and `read:jira-work` scopes are enabled.
### 🔄 Two-way sync that doesn't fight you
- **Smart conflict resolution** when notes change locally while syncing
- **Partial updates**—edit just the fields you care about
- **Worklog batching** push a week's worth of time entries at once
- **Status management** update issue status directly from Obsidian
### ⚙️ Field mapping kitchen
We include mappings for:
- Basic fields (like status, priority, assignee)
- Temporal fields (created, updated, due dates)
- Calculated fields (progress %, time estimates, custom formulas)
- **Bring your own** for custom integrations and complex transformations
### 📊 Integrated work log statistics
- **No external plugins required** - everything is built-in
- **Support for multiple formats** - works with Timekeep and Super Simple Time Tracker time tracking formats
- **Dynamic time period selection** - view stats for days, weeks, or months
- **Batch work log submission** - send multiple time entries at once
- **Visual progress tracking** - see your work patterns at a glance
![field_mapping_demo.png](docs/images/field_mapping_demo.png)
## Quick start
1. **Install**: Community plugins → Search "Jira Issue Manager"
2. **Connect**: Settings → Choose authentication method + add your Jira URL + credentials
3. **Go**:
- `Get issue from Jira with custom key` from command palette
- Edit like any note using our flexible indicator system
- When you are ready → `Update issue in Jira` - changes sync back
## What makes us different
- **🎯 No external dependencies** - Statistics and work tracking built right in
- **🔧 Flexible indicators** - Use `section`, `line`, or `inline` patterns wherever you need them
- **📈 Real-time insights** - See your work patterns and productivity trends
- **🔄 True two-way sync** - Create, update, and manage issues entirely from Obsidian
- **⚡ Batch operations** - Handle multiple work logs and updates efficiently
1. Install from Community Plugins → search "Jira Issue Manager"
2. Open Settings → configure Jira URL and authentication
3. Use `Get issue from Jira with custom key` from the command palette
4. Edit your note — changes sync back with `Update issue in Jira`
## Documentation
For detailed setup, configuration, and advanced usage:
- [English guide](docs/how_to_en.md)
- [Russian guide](docs/how_to_ru.md)
- [Template examples](docs/template_example.md)
- [English Guide](docs/how_to_en.md)
- [Russian Guide](docs/how_to_ru.md)
- [Template Examples](docs/template_example.md)
## Why this one?
---
Most Jira plugins for Obsidian treat issues as read-only. This one is built for people who want to work _in_ Obsidian and push changes back to Jira. Key differentiators:
**Ready to transform your Jira workflow?** Install the plugin and start building your perfect issue management system in Obsidian today!
- True two-way synchronization (not just display)
- Support for arbitrary custom fields with programmable mapping
- Integrated time tracking and batch work log submission
- No external dependencies for statistics or time tracking
- Markers are hidden in both Live Preview and Reading mode
![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&label=downloads&query=$[%27jira-sync%27].downloads&url=https://raw.githubusercontent.com/obsidianmd/obsidian-releases/refs/heads/master/community-plugin-stats.json)
![GitHub release](https://img.shields.io/github/v/release/Alamion/obsidian-jira-sync)
![Platform](https://img.shields.io/badge/platform-desktop%20%7C%20mobile-blue)
## License
MIT — originally forked from [obsidian-to-jira](https://github.com/angelperezasenjo/obsidian-to-jira).

BIN
docs/images/demo_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

BIN
docs/images/demo_video.mp4 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-jira-sync",
"version": "1.7.1",
"version": "1.7.4",
"packageManager": "yarn@1.22.22",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",

View file

@ -1,10 +1,6 @@
import { Notice, requestUrl } from 'obsidian';
import JiraPlugin from '../main';
export function getSessionCookieKey(plugin: JiraPlugin): string {
return 'jira-issue-managing-session-cookie-' + (plugin.app as any).appId;
}
// Session cookie only
export async function authenticate(plugin: JiraPlugin): Promise<boolean> {
try {
@ -29,7 +25,8 @@ export async function authenticate(plugin: JiraPlugin): Promise<boolean> {
},
});
localStorage.setItem(getSessionCookieKey(plugin), response.json.session.value);
conn.sessionCookie = response.json.session.value;
await plugin.saveSettings();
return true;
} catch (error: unknown) {
new Notice('Authentication failed: ' + ((error as Error).message || 'Unknown error'));
@ -87,10 +84,10 @@ export async function getAuthHeaders(plugin: JiraPlugin): Promise<Record<string,
};
} else if (conn.authMethod === 'session') {
// Option 3: Session cookie
let cookie = localStorage.getItem(getSessionCookieKey(plugin));
let cookie = conn.sessionCookie;
if (!cookie) {
await authenticate(plugin);
cookie = localStorage.getItem(getSessionCookieKey(plugin));
cookie = conn.sessionCookie;
}
if (cookie) {

View file

@ -128,7 +128,7 @@ export class JQLSearchModal extends Modal {
}
private populateDropdown(dropdown: DropdownComponent) {
dropdown.selectEl.innerHTML = '';
dropdown.selectEl.empty();
dropdown.addOption('', '— ' + t('presets.select') + ' —');
for (const preset of this.getPresets()) {
dropdown.addOption(preset.name, preset.name);

View file

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

View file

@ -133,7 +133,7 @@ export class FieldMappingItem {
textarea.addEventListener('focus', resize);
// Initial resize
setTimeout(resize, 0);
window.setTimeout(resize, 0);
}
/**

View file

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

View file

@ -16,6 +16,7 @@ export interface ConnectionSettingsInterface {
password: string;
jqlPresets: JQLPreset[];
lastJqlQuery: string;
sessionCookie: string;
}
export interface GlobalSettingsInterface {
@ -87,6 +88,7 @@ export const DEFAULT_SETTINGS: JiraSettingsInterface = {
apiVersion: '2',
jqlPresets: [],
lastJqlQuery: '',
sessionCookie: '',
},
],
currentConnectionIndex: 0,

View file

@ -1,5 +1,5 @@
interface AdfTextMark {
type: 'strong' | 'em' | 'code' | 'link';
type: 'strong' | 'em' | 'code' | 'link' | 'underline' | 'strike';
attrs?: { href: string };
}
@ -78,45 +78,219 @@ interface AdfDoc {
content: AdfBlockNode[];
}
function parseInline(text: string): AdfInlineContent[] {
const nodes: AdfInlineContent[] = [];
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)|\[\[[^\]]+\]\])/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
// --- Inline formatting rules -------------------------------------------------
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: 'text', text: text.slice(lastIndex, match.index) });
}
if (match[2] !== undefined) {
// ***bold+italic***
nodes.push({ type: 'text', text: match[2], marks: [{ type: 'strong' }, { type: 'em' }] });
} else if (match[3] !== undefined) {
// **bold**
nodes.push({ type: 'text', text: match[2], marks: [{ type: 'strong' }] });
} else if (match[4] !== undefined) {
// *italic*
nodes.push({ type: 'text', text: match[3], marks: [{ type: 'em' }] });
} else if (match[5] !== undefined) {
// `code`
nodes.push({ type: 'text', text: match[4], marks: [{ type: 'code' }] });
} else if (match[6] !== undefined) {
// [text](url) — convert to ADF link mark
nodes.push({ type: 'text', text: match[5], marks: [{ type: 'link', attrs: { href: match[6] } }] });
} else {
// [[wikilink]] — preserve as plain text so round-trip survives
nodes.push({ type: 'text', text: match[0] });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: 'text', text: text.slice(lastIndex) });
}
return nodes.length > 0 ? nodes : [{ type: 'text', text }];
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;
@ -127,159 +301,72 @@ export function markdownToAdf(markdown: string): AdfDoc | null {
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
content.push({
type: 'codeBlock',
attrs: { language: lang },
content: [{ type: 'text', text: codeLines.join('\n') }],
});
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;
}
// Heading
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
if (headingMatch) {
content.push({
type: 'heading',
attrs: { level: headingMatch[1].length },
content: parseInline(headingMatch[2]),
});
const hr = parseHr(line);
if (hr) {
content.push(hr);
i++;
continue;
}
// Horizontal rule
if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
content.push({ type: 'rule' });
i++;
const task = parseTaskList(lines, i);
if (task) {
content.push(task.node);
i = task.next;
continue;
}
// Task list (checkboxes) — must be checked before bullet list
if (line.match(/^[-*+]\s+\[[ xX]\]\s*/)) {
const items: any[] = [];
let taskCounter = 0;
while (i < lines.length && lines[i].match(/^[-*+]\s+\[[ xX]\]\s*/)) {
const checkMatch = lines[i].match(/^[-*+]\s+\[([ xX])\]\s*(.*)/);
if (checkMatch) {
const state = checkMatch[1].toLowerCase() === 'x' ? 'DONE' : 'TODO';
// taskItem only allows inline nodes — flatten sub-items as hardBreak + inline
const inlineContent: AdfInlineContent[] = parseInline(checkMatch[2]);
i++;
while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) {
inlineContent.push({ type: 'hardBreak' });
inlineContent.push(...parseInline('- ' + lines[i].replace(/^\s+[-*+]\s+/, '')));
i++;
}
items.push({
type: 'taskItem',
attrs: { localId: `task-${Date.now()}-${taskCounter++}`, state },
content: inlineContent,
});
} else {
i++;
}
}
content.push({ type: 'taskList', attrs: { localId: `tasklist-${Date.now()}` }, content: items });
continue;
}
// Bullet list
if (line.match(/^[-*+]\s+/)) {
const items: AdfListItemNode[] = [];
while (i < lines.length && lines[i].match(/^[-*+]\s+/)) {
const itemContent: any[] = [
{ type: 'paragraph', content: parseInline(lines[i].replace(/^[-*+]\s+/, '')) },
];
i++;
const subItems: AdfListItemNode[] = [];
while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) {
subItems.push({
type: 'listItem',
content: [{ type: 'paragraph', content: parseInline(lines[i].replace(/^\s+[-*+]\s+/, '')) }],
});
i++;
}
if (subItems.length > 0) {
itemContent.push({ type: 'bulletList', content: subItems });
}
items.push({ type: 'listItem', content: itemContent as [AdfParagraphNode] });
}
content.push({ type: 'bulletList', content: items });
const r = parseListItems(lines, i, /^[-*+]\s+/, /^[-*+]\s+/);
content.push({ type: 'bulletList', content: r.items });
i = r.next;
continue;
}
// Ordered list
if (line.match(/^\d+\.\s+/)) {
const items: AdfListItemNode[] = [];
while (i < lines.length && lines[i].match(/^\d+\.\s+/)) {
const itemContent: any[] = [
{ type: 'paragraph', content: parseInline(lines[i].replace(/^\d+\.\s+/, '')) },
];
i++;
const subItems: AdfListItemNode[] = [];
while (i < lines.length && lines[i].match(/^\s+[-*+]\s+/)) {
subItems.push({
type: 'listItem',
content: [{ type: 'paragraph', content: parseInline(lines[i].replace(/^\s+[-*+]\s+/, '')) }],
});
i++;
}
if (subItems.length > 0) {
itemContent.push({ type: 'bulletList', content: subItems });
}
items.push({ type: 'listItem', content: itemContent as [AdfParagraphNode] });
}
content.push({ type: 'orderedList', content: items });
const r = parseListItems(lines, i, /^\d+\.\s+/, /^\d+\.\s+/);
content.push({ type: 'orderedList', content: r.items });
i = r.next;
continue;
}
// Empty line
if (line.trim() === '') {
i++;
continue;
}
// Paragraph — collect until empty line or block-level element
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== '' &&
!lines[i].match(/^#{1,6}\s/) &&
!lines[i].match(/^[-*+]\s+\[[ xX]\]\s*/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+\.\s/) &&
!lines[i].startsWith('```') &&
!lines[i].match(/^(-{3,}|\*{3,}|_{3,})$/)
) {
while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
content.push({
type: 'paragraph',
content: parseInline(paraLines.join('\n')),
});
if (paraLines.length) {
content.push({ type: 'paragraph', content: parseInline(paraLines.join('\n')) });
}
}
return content.length > 0 ? { version: 1, type: 'doc', content } : null;
return content.length ? { version: 1, type: 'doc', content } : null;
}
// --- ADF → Markdown helpers --------------------------------------------------
function adfInlineToMarkdown(nodes: any[]): string {
if (!nodes) return '';
return nodes
.map((node) => {
.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 || '';
@ -293,100 +380,80 @@ function adfInlineToMarkdown(nodes: any[]): string {
const linkMark = (node.marks || []).find((m: any) => m.type === 'link');
let result = text;
if (marks.includes('code')) return `\`${result}\``;
if (marks.includes('strike')) result = `~~${result}~~`;
if (marks.includes('strong')) result = `**${result}**`;
if (marks.includes('em')) result = `*${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': {
const level = node.attrs?.level || 1;
const text = adfInlineToMarkdown(node.content || []);
return `${'#'.repeat(level)} ${text}`;
}
case 'paragraph': {
const text = adfInlineToMarkdown(node.content || []);
return text;
}
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 (node.content || [])
.map((item: any) => {
const blocks: any[] = item.content || [];
const first = blocks[0];
const rest = blocks.slice(1);
const mainText = 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');
return `- ${mainText}${nested ? '\n' + nested : ''}`;
})
.join('\n');
}
case 'orderedList': {
return (node.content || [])
.map((item: any, idx: number) => {
const blocks: any[] = item.content || [];
const first = blocks[0];
const rest = blocks.slice(1);
const mainText = 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');
return `${idx + 1}. ${mainText}${nested ? '\n' + nested : ''}`;
})
.join('\n');
}
case 'taskList': {
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 rawText =
const raw =
first?.type === 'paragraph'
? adfInlineToMarkdown(first.content || [])
: adfInlineToMarkdown(blocks);
// Indent continuation lines (e.g. hardBreak + sub-item text)
const textLines = rawText.split('\n');
const fullText =
textLines[0] +
(textLines.slice(1).length
const lines = raw.split('\n');
const full =
lines[0] +
(lines.slice(1).length
? '\n' +
textLines
lines
.slice(1)
.map((l: string) => ' ' + l)
.join('\n')
: '');
const nested = rest
.map((b: any) => adfBlockToMarkdown(b))
.filter((s: string) => s.trim() !== '')
.filter((s: string) => s.trim())
.map((s: string) =>
s
.split('\n')
@ -394,31 +461,32 @@ function adfBlockToMarkdown(node: any): string {
.join('\n'),
)
.join('\n');
return `- [${checked ? 'x' : ' '}] ${fullText}${nested ? '\n' + nested : ''}`;
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((line: string) => `> ${line}`)
.map((l: string) => `> ${l}`)
.join('\n');
}
case 'table': {
const rows: any[] = node.content || [];
const mdRows = rows.map((row: any) => {
const cells = (row.content || []).map((cell: any) =>
(cell.content || [])
.map((n: any) => adfBlockToMarkdown(n))
.join(' ')
.replace(/\|/g, '\\|'),
);
return `| ${cells.join(' | ')} |`;
});
if (mdRows.length === 0) return '';
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');
}
@ -432,9 +500,11 @@ function adfBlockToMarkdown(node: any): string {
}
}
// --- ADF → Markdown ----------------------------------------------------------
export function adfToMarkdown(adf: any): string | null {
if (adf === undefined) return null;
if (!adf || typeof adf !== 'object') return '';
const blocks: string[] = (adf.content || []).map((node: any) => adfBlockToMarkdown(node));
return blocks.filter((b) => b !== '').join('\n\n');
const blocks = (adf.content || []).map((node: any) => adfBlockToMarkdown(node));
return blocks.filter((b: string) => b !== '').join('\n\n');
}

View file

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