mirror of
https://github.com/alamion/obsidian-jira-sync.git
synced 2026-07-22 12:20:29 +00:00
Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294a18b37f | ||
|
|
3365e6ba5d | ||
|
|
a1deb0e95a | ||
|
|
59f863eb97 | ||
|
|
f77c6ea3f6 |
19 changed files with 662 additions and 663 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
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).
|
||||
|
|
|
|||
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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export class FieldMappingItem {
|
|||
textarea.addEventListener('focus', resize);
|
||||
|
||||
// Initial resize
|
||||
setTimeout(resize, 0);
|
||||
window.setTimeout(resize, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.7.7"
|
||||
"1.0.0": "1.7.4"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue