Compare commits

...

32 commits

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

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

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

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

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

View file

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

7
.gitignore vendored
View file

@ -7,7 +7,6 @@
# npm
node_modules
yarn.lock
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
@ -32,3 +31,9 @@ tmp/
# localization compiled
src/localization/compiled
# test configuration with credentials
tests/test-config.local.json
# test configuration with credentials
tests/test-config.local.json

View file

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

View file

@ -69,9 +69,9 @@ The responsible person for this task is `jira-sync-inline-start-assignee`Bob`jir
Example:
```md
The responsible person for this task is `jira-sync-block-assignee`
The responsible person for this task is `jira-sync-block-start-assignee`
Bob
`jira-sync-end`, and the description is `jira-sync-block-description`
`jira-sync-end`, and the description is `jira-sync-block-start-description`
Some description
`jira-sync-end`.
```
@ -96,6 +96,7 @@ Currently, the plugin provides the following commands:
- `Update work log in Jira manually` - enables manual time tracking for a task. Currently, this is not reflected in the file, but it will be available in future updates.
- `Update work log in Jira by batch` - enables batch time tracking. If the formatter contains `jira_worklog_batch`, a batch of data from `jira_worklog_batch` will be sent, updating each listed entity.
- `Update issue status in Jira` - allows updating a task's status by selecting one of the available options.
- `Add comment to Jira` - allows adding a comment to the current task in Jira.
### Advanced Usage
@ -110,12 +111,14 @@ Similarly, you can configure the `progressPercentage` shown in the example. This
Additionally, you can use some of the built-in functions:
- `jiraToMarkdown` - converts Jira markup to Markdown.
- `markdownToJira` - converts Markdown to Jira markup.
- `jiraToMarkdown` - converts Jira markup to Markdown. (for Jira API v2)
- `markdownToJira` - converts Markdown to Jira markup. (for Jira API v2)
- `markdownToAdf` - converts Markdown to Atlassian Document Format (ADF). (for Jira API v3)
- `adfToMarkdown` - converts ADF to Markdown. (for Jira API v3)
- `JSON.parse` - converts a JSON string to an object.
- `JSON.stringify` - converts an object to a JSON string.
and modules: (original Javascript syntax)
and modules: (original JavaScript syntax)
- `Math` - provides access to mathematical functions, such as `Math.round()`.
- `Date` - provides access to date functions, such as `Date.now()`.

View file

@ -68,9 +68,9 @@
Пример:
```md
Ответственным за эту задачу является `jira-sync-block-assignee`
Ответственным за эту задачу является `jira-sync-block-start-assignee`
Bob
`jira-sync-end`, а описание — `jira-sync-block-description`
`jira-sync-end`, а описание — `jira-sync-block-start-description`
Некоторое описание
`jira-sync-end`.
```
@ -95,6 +95,7 @@ Bob
- `Update work log in Jira manually` - позволяет вести учёт потраченного на задачу времени вручную. В данный момент он никак не отображается в файле, это будет в ближайших обновлениях.
- `Update work log in Jira by batch` - позволяет вести учёт потраченного на задачу времени батчем. Если в файле в formatter есть `jira_worklog_batch`, то вместо ручного заполнения будет послан батч данных из `jira_worklog_batch` с обновлением каждой из представленных сущностей.
- `Update issue status in Jira` - позволяет обновить статус задачи, выбрав один из возможных вариантов.
- `Add comment to Jira` - позволяет добавить комментарий в выбранную задачу.
### Продвинутое использование
@ -109,12 +110,14 @@ Bob
Кроме того, вы можете использовать некоторые встроенные функции:
- `jiraToMarkdown` - преобразует разметку Jira в Markdown.
- `markdownToJira` - преобразует Markdown в разметку Jira.
- `jiraToMarkdown` - преобразует разметку Jira в Markdown. (Jira API v2)
- `markdownToJira` - преобразует Markdown в разметку Jira. (Jira API v2)
- `markdownToAdf` - преобразует Markdown в ADF. (Jira API v3)
- `adfToMarkdown` - преобразует ADF в Markdown. (Jira API v3)
- `JSON.parse` - преобразует строку JSON в объект.
- `JSON.stringify` - преобразует объект в строку JSON.
и модули: (исходный синтаксис Javascript)
и модули: (исходный синтаксис JavaScript)
- `Math` - предоставляет доступ к математическим функциям, таким как `Math.round()`.
- `Date` - предоставляет доступ к функциям даты, таким как `Date.now()`.

View file

@ -91,7 +91,7 @@ Status indicator: `jira-sync-inline-start-status_indicator``jira-sync-end`
### Whitespace Heavy Section `jira-sync-section-whitespace_test`
This section has leading and trailing whitespace
This section has leading and trailing whitespace
Should be trimmed properly before sending to Jira.

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-sample-plugin",
"version": "1.5.0",
"name": "obsidian-jira-sync",
"version": "1.7.2",
"packageManager": "yarn@1.22.22",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
@ -15,6 +15,9 @@
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run --config tests/vitest.config.ts",
"test:watch": "vitest --config tests/vitest.config.ts",
"test:int": "vitest run --config tests/vitest.config.ts tests/__tests__/api/",
"format": "prettier --write ."
},
"keywords": [],
@ -23,11 +26,11 @@
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/state": "6.5.0",
"@codemirror/view": "^6.38.1",
"@eslint/js": "^10.0.1",
"@types/lodash": "^4.17.16",
"@types/node": "^16.11.6",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"builtin-modules": "3.3.0",
@ -36,11 +39,13 @@
"eslint": "^10.2.1",
"globals": "^17.5.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lodash": "^4.17.23",
"obsidian": "latest",
"prettier": "^3.8.3",
"tslib": "2.4.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
"dependencies": {
"acorn": "^8.14.1",

View file

@ -3,6 +3,7 @@ import { JiraIssue, JiraTransitionType } from '../interfaces';
import { baseRequest, sanitizeObject } from './base';
import { Notice } from 'obsidian';
import { chunkArray, createLimiter } from '../tools/asyncLimiter';
import { markdownToAdf } from '../tools/markdownToAdf';
/**
* Fetch an issue from Jira by its key
@ -242,6 +243,25 @@ export async function addWorkLog(
return response;
}
/**
* Add a comment to a Jira issue
*/
export async function addComment(plugin: JiraPlugin, issueKey: string, markdownText: string): Promise<any> {
let body: any;
const conn = plugin.getCurrentConnection();
if (!conn) throw new Error('No connection configured');
if (conn.apiVersion === '3') {
body = markdownToAdf(markdownText);
} else {
body = markdownText;
}
const payload = JSON.stringify({ body });
const response = await baseRequest(plugin, 'post', `/issue/${issueKey}/comment`, payload);
new Notice(`Comment added to ${issueKey}`);
return response;
}
export async function bulkAddWorkLog(
plugin: JiraPlugin,
worklogs: {

View file

@ -0,0 +1,36 @@
import { Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian';
import JiraPlugin from '../main';
import { IssueCommentModal } from '../modals';
import { addComment, validateSettings } from '../api';
import { useTranslations } from '../localization/translator';
const t = useTranslations('commands.add_comment').t;
export function registerAddCommentCommand(plugin: JiraPlugin): void {
plugin.addCommand({
id: 'add-comment-jira',
name: t('name'),
editorCheckCallback: (checking: boolean, editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
if (!validateSettings(plugin)) return false;
if (!ctx.file) return false;
const frontmatter = plugin.app.metadataCache.getFileCache(ctx.file)?.frontmatter;
const issueKey = frontmatter?.key;
if (!issueKey) return false;
if (!checking) {
const selectedText = editor.getSelection();
new IssueCommentModal(plugin.app, selectedText, async (commentText: string) => {
try {
await addComment(plugin, issueKey, commentText);
} catch (error: any) {
new Notice(t('error') + ': ' + (error.message || 'Unknown error'));
console.error(error);
}
}).open();
}
return true;
},
});
}

View file

@ -1,3 +1,4 @@
export * from './addComment';
export * from './addWorkLogBatch';
export * from './addWorkLogManually';
export * from './batchFetchIssues';
@ -5,3 +6,5 @@ export * from './createIssue';
export * from './getIssue';
export * from './updateIssue';
export * from './updateStatus';
export * from './rebuildCache';
export * from './rebuildCache';

View file

@ -0,0 +1,13 @@
import { Notice } from 'obsidian';
import JiraPlugin from '../main';
export function registerRebuildCacheCommand(plugin: JiraPlugin): void {
plugin.addCommand({
id: 'rebuild-issue-cache',
name: 'Rebuild issue file cache from filesystem',
callback: async () => {
await plugin.rebuildCache();
new Notice('Issue cache rebuilt successfully');
},
});
}

View file

@ -1,9 +1,13 @@
import { JiraIssue } from '../interfaces';
import { jiraToMarkdown, markdownToJira } from '../tools/markdownHtml';
import { adfToMarkdown, markdownToAdf } from '../tools/markdownToAdf';
export interface FieldMapping {
toJira: (value: any) => any;
fromJira: (issue: JiraIssue, data_source: Record<string, any> | null) => any;
toJira: (value: any, api_version?: '2' | '3') => any;
fromJira: (issue: JiraIssue, api_version?: '2' | '3', data_source?: Record<string, any>) => any;
}
export const TO_JIRA_PARAMS = ['value', 'api_version'];
export const FROM_JIRA_PARAMS = ['issue', 'api_version', 'data_source'];
export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
summary: {
@ -11,8 +15,9 @@ export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
fromJira: (issue) => issue.fields.summary,
},
description: {
toJira: () => null,
fromJira: (issue) => issue.fields.description,
toJira: (value, api_version) => (api_version === '3' ? markdownToAdf(value) : markdownToJira(value)),
fromJira: (issue, api_version) =>
api_version === '3' ? adfToMarkdown(issue.fields.description) : jiraToMarkdown(issue.fields.description),
},
key: {
toJira: () => null,
@ -74,4 +79,23 @@ export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
toJira: () => null,
fromJira: (issue) => issue.fields.aggregateprogress.percent + '%',
},
comments: {
toJira: () => null,
fromJira: (issue, api_version) => {
const comments = issue.fields.comment?.comments;
if (!comments?.length) return '';
return comments
.map((c: any) => {
const author = c.author?.displayName ?? 'Unknown';
const date = c.created ? c.created.replace('T', ' ').substring(0, 19) : '';
const body = api_version === '3' ? adfToMarkdown(c.body) : c.body;
const calloutBody = body
.split('\n')
.map((l: string) => (l === '' ? '>' : `> ${l}`))
.join('\n');
return `> [!note]+ ${author}${date}\n> \n${calloutBody}`;
})
.join('\n\n');
},
},
};

View file

@ -1,23 +1,29 @@
import JiraPlugin from '../main';
import { TFile } from 'obsidian';
import { createJiraIssue, updateJiraIssue, updateJiraStatus } from '../api';
import { createJiraIssue, fetchIssue, updateJiraIssue, updateJiraStatus } from '../api';
import { prepareJiraFieldsFromFile } from './commonPrepareData';
import { localToJiraFields, updateJiraToLocal } from '../tools/mapObsidianJiraFields';
import { JiraIssue, JiraTransitionType } from '../interfaces';
import { obsidianJiraFieldMappings } from '../default/obsidianJiraFieldsMapping';
import { updateJiraSyncContent } from '../tools/sectionTools';
export async function updateIssueFromFile(plugin: JiraPlugin, file: TFile): Promise<string> {
let fields = await prepareJiraFieldsFromFile(plugin, file);
const issueKey = fields.key;
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
if (!issueKey) {
throw new Error('No issue key found in frontmatter');
}
fields = localToJiraFields(fields, {
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
});
fields = localToJiraFields(
fields,
{
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
},
apiVersion,
);
await updateJiraIssue(plugin, issueKey, fields);
return issueKey;
}
@ -27,13 +33,18 @@ export async function createIssueFromFile(
file: TFile,
fields?: Record<string, any>,
): Promise<string> {
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
if (!fields) {
fields = await prepareJiraFieldsFromFile(plugin, file);
}
fields = localToJiraFields(fields, {
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
});
fields = localToJiraFields(
fields,
{
...obsidianJiraFieldMappings,
...plugin.settings.fieldMapping.fieldMappings,
},
apiVersion,
);
// Create the issue
const issueData = await createJiraIssue(plugin, fields);
const issueKey = issueData.key;
@ -43,6 +54,10 @@ export async function createIssueFromFile(
frontmatter['key'] = issueKey;
});
// Pull the created issue back from Jira to populate all remaining synced fields
const createdIssue = await fetchIssue(plugin, issueKey);
await updateJiraToLocal(plugin, file, createdIssue);
return issueKey;
}
@ -58,8 +73,24 @@ export async function updateStatusFromFile(
}
await updateJiraStatus(plugin, fields.key, transition.id);
await updateJiraToLocal(plugin, file, {
fields: { status: { name: transition.status } },
} as JiraIssue);
// Only update the status field. Calling updateJiraToLocal with a partial issue
// causes all fromJira mappings to run with undefined inputs, producing empty strings
// that overwrite description and comments.
const allMappings = { ...obsidianJiraFieldMappings, ...plugin.settings.fieldMapping.fieldMappings };
const statusMapping = allMappings['status'];
const minimalIssue = { fields: { status: { name: transition.status } } } as JiraIssue;
const localStatusValue = statusMapping
? statusMapping.fromJira(minimalIssue, plugin.getCurrentConnection()?.apiVersion)
: transition.status;
if (localStatusValue !== null && localStatusValue !== undefined) {
await plugin.app.fileManager.processFrontMatter(file, (frontmatter: any) => {
frontmatter['status'] = localStatusValue;
});
await plugin.app.vault.process(file, (fileContent) => {
return updateJiraSyncContent(fileContent, { status: String(localStatusValue) });
});
}
return fields.key;
}

View file

@ -7,24 +7,18 @@ import { Notice, TFile, TFolder } from 'obsidian';
import { defaultTemplate } from '../default/defaultTemplate';
import { debugLog } from '../tools/debugLogging';
function generateFilenameFromTemplate(template: string, issue: JiraIssue): string {
export function generateFilenameFromTemplate(
template: string,
issue: { key: string; fields?: { summary?: string } },
): string {
let filename = template;
// Replace {summary} with sanitized summary
const summary = issue.fields?.summary || '';
const sanitizedSummary = sanitizeFileName(summary);
filename = filename.replace(/\{summary\}/g, sanitizedSummary);
// Replace {key} with issue key
const key = issue.key || '';
filename = filename.replace(/\{key\}/g, key);
// Sanitize the entire filename to remove any prohibited characters
filename = sanitizeFileName(filename);
// Fallback if the result is empty or only whitespace
if (!filename || filename.trim() === '') {
// Use key if available, otherwise use a default
if (key) {
filename = key;
} else if (sanitizedSummary) {
@ -33,10 +27,53 @@ function generateFilenameFromTemplate(template: string, issue: JiraIssue): strin
filename = 'jira-issue';
}
}
return filename.trim();
}
export async function renameExistingIssueFiles(plugin: JiraPlugin): Promise<{ renamed: number; errors: string[] }> {
const cacheMap = plugin.getAllIssueKeysMap();
const template = plugin.settings.fetchIssue.filenameTemplate || '{summary} ({key})';
const issuesFolder = plugin.settings.global.issuesFolder;
let renamed = 0;
const errors: string[] = [];
for (const [issueKey, currentPath] of cacheMap.entries()) {
try {
const file = plugin.app.vault.getFileByPath(currentPath);
if (!file) {
errors.push(`${issueKey}: file not found at ${currentPath}`);
continue;
}
let summary = '';
const metadata = plugin.app.metadataCache.getFileCache(file);
const cachedSummary = metadata?.frontmatter?.summary;
if (cachedSummary && typeof cachedSummary === 'string') {
summary = cachedSummary;
}
const newFilename = generateFilenameFromTemplate(template, { key: issueKey, fields: { summary } });
const newPath = `${issuesFolder}/${newFilename}.md`;
if (newPath === currentPath) continue;
const existingFile = plugin.app.vault.getFileByPath(newPath);
if (existingFile) {
errors.push(`${issueKey}: target path already exists: ${newPath}`);
continue;
}
await plugin.app.vault.rename(file, newPath);
await plugin.setFilePathForIssueKey(issueKey, newPath);
renamed++;
} catch (error) {
errors.push(`${issueKey}: ${(error as Error).message}`);
}
}
return { renamed, errors };
}
export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIssue, filePath?: string): Promise<void> {
try {
await ensureIssuesFolder(plugin);
@ -48,7 +85,6 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
targetPath = filePath;
targetFile = plugin.app.vault.getFileByPath(filePath);
} else {
// First, try to find the file using cache
const cachedPath = plugin.getFilePathForIssueKey(issue.key);
if (cachedPath) {
targetFile = plugin.app.vault.getFileByPath(cachedPath);
@ -60,19 +96,16 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
}
}
// If not found in cache or file doesn't exist, fallback to search
if (!targetFile) {
debugLog(`Issue ${issue.key} not in cache, searching filesystem...`);
targetFile = await findFileByIssueKey(plugin, issue.key);
if (targetFile) {
targetPath = targetFile.path;
// Add to cache for future use
plugin.setFilePathForIssueKey(issue.key, targetPath);
await plugin.setFilePathForIssueKey(issue.key, targetPath);
debugLog(`Found issue ${issue.key} via search, added to cache: ${targetPath}`);
}
}
// If still not found, create new file
if (!targetFile) {
const template = plugin.settings.fetchIssue.filenameTemplate || '{summary} ({key})';
const filename = generateFilenameFromTemplate(template, issue);
@ -84,12 +117,12 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
if (targetFile) {
await updateJiraToLocal(plugin, targetFile, issue);
await plugin.app.workspace.openLinkText(targetFile.path, '');
await plugin.setFilePathForIssueKey(issue.key, targetFile.path);
} else {
const newFile = await createNewIssueFile(plugin, targetPath);
await updateJiraToLocal(plugin, newFile, issue);
await plugin.app.workspace.openLinkText(newFile.path, '');
// Add new file to cache
plugin.setFilePathForIssueKey(issue.key, newFile.path);
await plugin.setFilePathForIssueKey(issue.key, newFile.path);
}
new Notice(`Issue ${issue.key} imported successfully`);
} catch (error: unknown) {
@ -108,7 +141,6 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
if (templatePath && templatePath.trim() !== '') {
const templateFile = plugin.app.vault.getFileByPath(templatePath);
if (templateFile) {
// Use the template as initial content
initialContent = await plugin.app.vault.read(templateFile);
} else {
new Notice(`Template file not found: ${templatePath}, using default template`);
@ -116,10 +148,8 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
}
if (initialContent === '') initialContent = defaultTemplate;
// Create the file with initial content
await plugin.app.vault.create(filePath, initialContent);
// Get file reference and update frontmatter
const newFile = plugin.app.vault.getFileByPath(filePath);
if (!newFile) {
throw new Error('Could not create file');
@ -129,11 +159,9 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
async function findFileByIssueKey(plugin: JiraPlugin, issueKey: string): Promise<TFile | null> {
const issuesFolder = plugin.app.vault.getAbstractFileByPath(plugin.settings.global.issuesFolder);
if (!issuesFolder || !(issuesFolder instanceof TFolder)) {
return null;
}
return await searchFolderForIssueKey(plugin, issuesFolder, issueKey);
}

View file

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

View file

@ -0,0 +1,11 @@
name: Add Jira comment
body:
name: Comment
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
submit: Submit
warns:
empty: Comment cannot be empty

View file

@ -12,6 +12,16 @@ maxResults:
submit: Search Issues
presets:
load: JQL Preset
load_desc: Load a saved JQL query preset
select: Select preset
save: Save as preset
save_desc: Save current JQL query as a named preset
save_hint: Preset name
save_btn: Save
delete: Delete preset
preview:
total: Total {total} issues found
showing: ' (showing first {limit} issues)'

View file

@ -26,3 +26,10 @@ filenameTemplate:
desc: |
Template for created issue filenames. Use {summary} for issue summary and {key} for issue key
placeholder: '{summary} ({key})'
rename_files: Rename existing files
no_files_to_rename: No files in cache to rename
rename_confirm: 'This will rename {count} cached file(s). Continue?'
rename_success: Successfully renamed {count} file(s)
rename_errors: 'Failed to rename {count} file(s)'
rename_no_changes: No files needed renaming

View file

@ -1,5 +1,15 @@
title: Field mappings
desc: Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function.
desc: |
Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function.
To Jira:
- value - the value of field in your .md file - can be a text string from file contents or a field value from frontmatter
- api_version - Jira API version used in current connection (could be either '2' or '3')
From Jira:
- issue - raw data from Jira (can be seen in lower `Fetch issue settings` section)
- api_version - the same as `To Jira`
- data_source - either parsed sections or frontmatter data in dictionary format, where keys are field names and values are field values
fv:
name: Enable field validation
desc: Check fields before saving based on the standard API response (validation does not account for custom fields)

View file

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

View file

@ -0,0 +1,11 @@
name: Add Jira comment
body:
name: Comment
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
submit: Submit
warns:
empty: Comment cannot be empty

View file

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

View file

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

View file

@ -1,6 +1,15 @@
title: Сопоставление полей
desc: Настройте, как поля будут сопоставляться между Obsidian и Jira. Для каждого
поля требуется функция преобразования 'В Jira' и 'Из Jira'.
desc: |
Настройте сопоставление полей между Obsidian и Jira. Для каждого поля необходимо указать функции преобразования "В Jira" и "Из Jira".
В Jira:
- value — значение поля в .md файле — это может быть текст из содержимого файла или значение поля из frontmatter
- api_version - Jira API версия используемая в текущем коннекторе (может быть '2' или '3')
Из Jira:
- issue — необработанные данные из Jira (можно увидеть в нижнем разделе "Настройка получаемых полей")
- api_version - То же самое что и в "В Jira"
- data_source — либо спаршенные секции данных из файла, либо данные frontmatter. Показывается в формате словаря, где ключи — это названия полей, а значения — данные из файла
fv:
name: Включить проверку полей
desc: Проверять поля перед сохранением базируясь на стандартном ответе API

View file

@ -10,11 +10,13 @@ import {
registerGetIssueCommandWithCustomKey,
registerUpdateIssueStatusCommand,
registerBatchFetchIssuesCommand,
registerAddCommentCommand,
registerRebuildCacheCommand,
} from './commands';
import { transform_string_to_functions_mappings } from './tools/convertFunctionString';
import { createJiraSyncExtension } from './postprocessing/livePreview';
import { hideJiraPointersReading } from './postprocessing/reading';
import { buildCacheFromFilesystem, validateCache } from './tools/cacheUtils';
import { buildCacheFromFilesystem } from './tools/cacheUtils';
import { checkMigrateSettings } from './tools/migrateSettings';
export default class JiraPlugin extends Plugin {
@ -26,9 +28,8 @@ export default class JiraPlugin extends Plugin {
async onload() {
await this.loadSettings();
// validate cache from settings
// initialize cache from settings
this.initializeCache();
await validateCache(this);
// Register all commands
registerUpdateIssueCommand(this);
@ -40,12 +41,14 @@ export default class JiraPlugin extends Plugin {
registerUpdateWorkLogManuallyCommand(this);
registerUpdateWorkLogBatchCommand(this);
registerAddCommentCommand(this);
registerRebuildCacheCommand(this);
// Add settings tab
this.addSettingTab(new JiraSettingTab(this.app, this));
// Handle Reading mode (post-processor for rendered markdown)
this.registerMarkdownPostProcessor(hideJiraPointersReading.bind(this));
this.registerMarkdownPostProcessor(hideJiraPointersReading());
// Handle Live Preview/Edit mode (CodeMirror extension)
this.registerEditorExtension(createJiraSyncExtension(this));
@ -56,13 +59,26 @@ export default class JiraPlugin extends Plugin {
async loadSettings() {
const old_data = await this.loadData();
// TODO: Cancel and delete migration check in future (approximately 2026-2027)
const new_data = checkMigrateSettings(old_data, this.saveSettings);
this.settings = Object.assign({}, DEFAULT_SETTINGS, new_data);
// TODO: Cancel and delete migration check in future (approximately 2026-2027)
const { result: migratedData, changed: migrationChanged } = checkMigrateSettings(old_data);
this.settings = {
...DEFAULT_SETTINGS,
...migratedData,
fieldMapping: {
...DEFAULT_SETTINGS.fieldMapping,
...(migratedData?.fieldMapping || {}),
},
};
this.settings.fieldMapping.fieldMappings = await transform_string_to_functions_mappings(
this.settings.fieldMapping.fieldMappingsStrings,
);
if (migrationChanged) {
await this.saveSettings();
}
}
async saveSettings() {
@ -91,36 +107,36 @@ export default class JiraPlugin extends Plugin {
return this.settings.connections[this.settings.currentConnectionIndex];
}
setFilePathForIssueKey(issueKey: string, filePath: string) {
async setFilePathForIssueKey(issueKey: string, filePath: string) {
this.issueKeyToFilePathCache.set(issueKey, filePath);
this.settings.issueKeyToFilePathCache[issueKey] = filePath;
this.saveSettings();
await this.saveSettings();
}
removeIssueKeyFromCache(issueKey: string) {
async removeIssueKeyFromCache(issueKey: string) {
this.issueKeyToFilePathCache.delete(issueKey);
delete this.settings.issueKeyToFilePathCache[issueKey];
this.saveSettings();
await this.saveSettings();
}
clearCache() {
async clearCache() {
this.issueKeyToFilePathCache.clear();
this.settings.issueKeyToFilePathCache = {};
this.saveSettings();
await this.saveSettings();
}
async rebuildCache() {
this.clearCache();
await this.clearCache();
await buildCacheFromFilesystem(this);
}
private registerVaultEventListeners() {
// Handle file renames
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => {
this.app.vault.on('rename', async (file, oldPath) => {
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
if (cachedPath === oldPath) {
this.setFilePathForIssueKey(issueKey, file.path);
await this.setFilePathForIssueKey(issueKey, file.path);
break;
}
}
@ -129,10 +145,10 @@ export default class JiraPlugin extends Plugin {
// Handle file deletions
this.registerEvent(
this.app.vault.on('delete', (file) => {
this.app.vault.on('delete', async (file) => {
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
if (cachedPath === file.path) {
this.removeIssueKeyFromCache(issueKey);
await this.removeIssueKeyFromCache(issueKey);
break;
}
}

View file

@ -0,0 +1,39 @@
import { App, Modal, Setting } from 'obsidian';
export class ConfirmModal extends Modal {
private message: string;
private onConfirm: () => void;
private onCancel: () => void;
constructor(app: App, message: string, onConfirm: () => void, onCancel: () => void) {
super(app);
this.message = message;
this.onConfirm = onConfirm;
this.onCancel = onCancel;
}
onOpen() {
this.contentEl.createEl('h2', { text: this.message });
new Setting(this.contentEl)
.addButton((btn) =>
btn
.setButtonText('Yes')
.setCta()
.onClick(() => {
this.close();
this.onConfirm();
}),
)
.addButton((btn) =>
btn.setButtonText('No').onClick(() => {
this.close();
this.onCancel();
}),
);
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,55 @@
import { App, Modal, Notice, Setting, TextAreaComponent } from 'obsidian';
import { useTranslations } from '../localization/translator';
const t = useTranslations('modals.comment').t;
/**
* Modal for adding a comment to a Jira issue.
* Pre-populated with any text that was selected in the editor when the command was invoked.
*/
export class IssueCommentModal extends Modal {
private onSubmit: (commentText: string) => void;
private commentText: string;
constructor(app: App, initialText: string, onSubmit: (commentText: string) => void) {
super(app);
this.commentText = initialText;
this.onSubmit = onSubmit;
}
onOpen() {
new Setting(this.contentEl).setName(t('name')).setHeading();
const textarea = new TextAreaComponent(this.contentEl)
.setPlaceholder(t('body.placeholder'))
.setValue(this.commentText)
.onChange((value) => {
this.commentText = value;
});
textarea.inputEl.rows = 10;
textarea.inputEl.style.width = '100%';
textarea.inputEl.style.marginBottom = '0.75em';
textarea.inputEl.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
this.submit();
}
});
new Setting(this.contentEl).addButton((btn) =>
btn
.setButtonText(t('submit'))
.setCta()
.onClick(() => this.submit()),
);
}
private submit() {
if (!this.commentText.trim()) {
new Notice(t('warns.empty'));
return;
}
this.close();
this.onSubmit(this.commentText);
}
}

View file

@ -1,13 +1,11 @@
import { App, Modal, Setting } from 'obsidian';
import { App, Modal, Setting, DropdownComponent } from 'obsidian';
import { useTranslations } from '../localization/translator';
import JiraPlugin from '../main';
import { JQLPreview } from '../settings/components/JQLPreview';
import { JQLPreset } from '../settings/default';
const t = useTranslations('modals.jql_search').t;
/**
* Modal for searching issues by JQL query
*/
export class JQLSearchModal extends Modal {
private onSubmit: (jql: string) => void;
private plugin: JiraPlugin;
@ -15,6 +13,8 @@ export class JQLSearchModal extends Modal {
private previewEl?: HTMLElement;
private preview?: JQLPreview;
private debounceTimer?: number;
private presetName: string = '';
private presetDropdown?: DropdownComponent;
constructor(app: App, plugin: JiraPlugin, onSubmit: (jql: string) => void) {
super(app);
@ -23,8 +23,68 @@ export class JQLSearchModal extends Modal {
}
onOpen() {
const conn = this.plugin.getCurrentConnection();
if (conn?.lastJqlQuery) {
this.jql = conn.lastJqlQuery;
this.schedulePreview();
}
this.contentEl.createEl('h2', { text: t('desc') });
const presetSetting = new Setting(this.contentEl).setName(t('presets.load')).setDesc(t('presets.load_desc'));
presetSetting.addDropdown((dropdown) => {
this.presetDropdown = dropdown;
this.populateDropdown(dropdown);
dropdown.onChange((value) => {
if (value) {
const preset = this.getPresets().find((p) => p.name === value);
if (preset) {
this.jql = preset.query;
const textarea = this.contentEl.querySelector('textarea');
if (textarea) {
textarea.value = preset.query;
}
this.schedulePreview();
}
}
});
});
presetSetting.addButton((btn) =>
btn.setButtonText(t('presets.delete')).onClick(() => {
const selectedName = this.presetDropdown?.getValue();
if (selectedName) {
this.deletePreset(selectedName);
if (this.presetDropdown) {
this.populateDropdown(this.presetDropdown);
}
}
}),
);
new Setting(this.contentEl)
.setName(t('presets.save'))
.setDesc(t('presets.save_desc'))
.addText((text) =>
text.setPlaceholder(t('presets.save_hint')).onChange((value) => {
this.presetName = value;
}),
)
.addButton((btn) =>
btn.setButtonText(t('presets.save_btn')).onClick(() => {
if (this.jql.trim() && this.presetName.trim()) {
this.savePreset(this.presetName.trim(), this.jql.trim());
this.presetName = '';
const nameInput = this.contentEl.querySelectorAll('input')[1];
if (nameInput) nameInput.value = '';
if (this.presetDropdown) {
this.populateDropdown(this.presetDropdown);
}
}
}),
);
new Setting(this.contentEl)
.setName(t('jql.name'))
.setDesc(t('jql.desc'))
@ -38,7 +98,6 @@ export class JQLSearchModal extends Modal {
text.inputEl.style.height = '100px';
});
// Create preview container and initialize preview component
this.previewEl = this.contentEl.createDiv();
this.preview = new JQLPreview(this.plugin, this.previewEl, 5);
this.preview.loadPreview('');
@ -49,17 +108,18 @@ export class JQLSearchModal extends Modal {
.setCta()
.onClick(() => {
if (this.jql.trim()) {
this.saveLastQuery(this.jql.trim());
this.close();
this.onSubmit(this.jql.trim());
}
}),
);
// Handle Enter key (Ctrl+Enter for textarea)
this.contentEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
if (this.jql.trim()) {
this.saveLastQuery(this.jql.trim());
this.close();
this.onSubmit(this.jql.trim());
}
@ -67,21 +127,56 @@ export class JQLSearchModal extends Modal {
});
}
private populateDropdown(dropdown: DropdownComponent) {
dropdown.selectEl.innerHTML = '';
dropdown.addOption('', '— ' + t('presets.select') + ' —');
for (const preset of this.getPresets()) {
dropdown.addOption(preset.name, preset.name);
}
}
private getPresets(): JQLPreset[] {
const conn = this.plugin.getCurrentConnection();
return conn?.jqlPresets || [];
}
private savePreset(name: string, query: string) {
const conn = this.plugin.getCurrentConnection();
if (!conn) return;
const existingIndex = conn.jqlPresets.findIndex((p) => p.name === name);
if (existingIndex >= 0) {
conn.jqlPresets[existingIndex].query = query;
} else {
conn.jqlPresets.push({ name, query });
}
this.plugin.saveSettings();
}
private deletePreset(name: string) {
const conn = this.plugin.getCurrentConnection();
if (!conn) return;
conn.jqlPresets = conn.jqlPresets.filter((p) => p.name !== name);
this.plugin.saveSettings();
}
private saveLastQuery(jql: string) {
const conn = this.plugin.getCurrentConnection();
if (!conn) return;
conn.lastJqlQuery = jql;
this.plugin.saveSettings();
}
private schedulePreview() {
if (this.debounceTimer) {
window.clearTimeout(this.debounceTimer);
}
// Show loading immediately if there's text
if (this.jql.trim() && this.preview) {
this.preview.showLoading();
}
this.debounceTimer = window.setTimeout(() => {
if (this.preview) {
this.preview.loadPreview(this.jql).catch(() => {
// Error handling is done within the preview component
});
this.preview.loadPreview(this.jql).catch(() => {});
}
}, 600);
}

View file

@ -1,6 +1,9 @@
export * from './IssueCommentModal';
export * from './IssueSearchModal';
export * from './IssueStatusModal';
export * from './IssueTypeModal';
export * from './IssueWorkLogModal';
export * from './ProjectModal';
export * from './JQLSearchModal';
export * from './ConfirmModal';
export * from './ConfirmModal';

View file

@ -31,35 +31,39 @@ export function createJiraSyncExtension(plugin: JiraPlugin): Extension {
buildDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>();
const sel = view.state.selection;
const allDecs: Array<{ from: number; to: number; dec: Decoration }> = [];
for (let { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
const codeBlocks = this.findCodeBlocks(text, from);
// Collect all jira-sync marker positions
const markers: Array<{ start: number; end: number; name: string }> = [];
for (const match of text.matchAll(/`(jira-sync-[^`]+)`/g)) {
const start = from + match.index!;
const end = start + match[0].length;
const contentStart = start; // без первой `
const contentEnd = end; // без последней `
if (this.isInsideCodeBlock(start, end, codeBlocks)) continue;
markers.push({ start, end, name: match[1] });
}
// Find code blocks to ignore
if (this.isInsideCodeBlock(contentStart, contentEnd, codeBlocks)) {
continue;
}
let className = 'jira-sync-hidden';
// Check if cursor is inside
const sel = view.state.selection;
if (sel.ranges.some((r) => r.from <= contentEnd && r.to >= contentStart)) {
className += ' jira-sync-active';
}
builder.add(start, end, Decoration.mark({ class: className }));
// Always hide the markers themselves, show when cursor is inside
for (const { start, end } of markers) {
const isActive = sel.ranges.some((r) => r.from <= end && r.to >= start);
const cls = isActive ? 'jira-sync-hidden jira-sync-active' : 'jira-sync-hidden';
allDecs.push({ from: start, to: end, dec: Decoration.mark({ class: cls }) });
}
}
return builder.finish();
// RangeSetBuilder requires ranges in ascending order of `from`, then `to`
allDecs.sort((a, b) => a.from - b.from || a.to - b.to);
for (const { from, to, dec } of allDecs) {
builder.add(from, to, dec);
}
return builder.finish() as DecorationSet;
}
findCodeBlocks(text: string, offset: number) {

View file

@ -1,13 +1,12 @@
// For Reading mode - targets <code> elements in <p> tags
export function hideJiraPointersReading(element: HTMLElement) {
// In Reading mode, inline code becomes <code> elements
const codeElements = element.querySelectorAll(':not(pre) > code');
export function hideJiraPointersReading() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return function (element: HTMLElement, _: any) {
const codeElements = Array.from(element.querySelectorAll(':not(pre) > code')).filter((el) =>
el.textContent?.startsWith('jira-sync-'),
) as HTMLElement[];
codeElements.forEach((codeEl) => {
const text = codeEl.textContent || '';
if (text.startsWith('jira-sync-')) {
for (const codeEl of codeElements) {
codeEl.addClass('jira-sync-hidden');
}
});
};
}

View file

@ -66,6 +66,8 @@ export class ConnectionSettingsComponent implements SettingsComponent {
password: '',
jiraUrl: '',
apiVersion: '2' as const,
jqlPresets: [],
lastJqlQuery: '',
};
plugin.settings.connections.push(newConnection);
plugin.settings.currentConnectionIndex = plugin.settings.connections.length - 1;

View file

@ -1,6 +1,8 @@
import { Notice, Setting } from 'obsidian';
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
import { fetchIssue } from '../../api';
import { fetchIssue, validateSettings } from '../../api';
import { renameExistingIssueFiles } from '../../file_operations/getIssue';
import { ConfirmModal } from '../../modals/ConfirmModal';
import debounce from 'lodash/debounce';
import hljs from 'highlight.js';
import { useTranslations } from '../../localization/translator';
@ -23,7 +25,6 @@ export class FetchIssueComponent implements SettingsComponent {
render(containerEl: HTMLElement): void {
const { plugin } = this.props;
// Filename template setting
const filenameTemplateSetting = new Setting(containerEl)
.setName(t('filenameTemplate.name'))
.setDesc(t('filenameTemplate.desc'));
@ -37,6 +38,40 @@ export class FetchIssueComponent implements SettingsComponent {
});
});
filenameTemplateSetting.addButton((btn) =>
btn.setButtonText(t('rename_files')).onClick(async () => {
if (!validateSettings(plugin)) return;
const cacheSize = plugin.getAllIssueKeysMap().size;
if (cacheSize === 0) {
new Notice(t('no_files_to_rename'));
return;
}
new ConfirmModal(
plugin.app,
t('rename_confirm', { count: cacheSize.toString() }),
async () => {
const result = await renameExistingIssueFiles(plugin);
if (result.renamed > 0) {
new Notice(t('rename_success', { count: result.renamed.toString() }));
}
if (result.errors.length > 0) {
new Notice(
t('rename_errors', { count: result.errors.length.toString() }) +
': ' +
result.errors.slice(0, 3).join(', '),
);
}
if (result.renamed === 0 && result.errors.length === 0) {
new Notice(t('rename_no_changes'));
}
},
() => {},
).open();
}),
);
const link =
plugin.getCurrentConnection()?.apiVersion === '3'
? 'https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post'
@ -87,7 +122,6 @@ export class FetchIssueComponent implements SettingsComponent {
});
});
// Add input field for issue key
new Setting(containerEl)
.setName(t('key.name'))
.setDesc(t('key.desc'))
@ -98,7 +132,6 @@ export class FetchIssueComponent implements SettingsComponent {
}),
);
// Create container for issue data
this.issueDataContainer = containerEl.createDiv({
cls: 'jira-raw-issue-container',
});

View file

@ -1,5 +1,6 @@
import { validateField } from '../tools/fieldValidation';
import { useTranslations } from '../../localization/translator';
import { FROM_JIRA_PARAMS, TO_JIRA_PARAMS } from '../../default/obsidianJiraFieldsMapping';
const t = useTranslations('settings.fm').t;
/**
@ -82,7 +83,8 @@ export class FieldMappingItem {
this.toJiraInput = toJiraContainer.createEl('textarea', {
cls: 'to-jira-input',
});
this.toJiraInput.placeholder = '(value) => {\n // Transform Obsidian value to Jira\n return value;\n}';
this.toJiraInput.placeholder =
'(value, api_version) => {\n // Transform Obsidian value to Jira\n return value;\n}';
this.toJiraInput.value = toJira;
// Create fromJira function input
@ -98,7 +100,7 @@ export class FieldMappingItem {
});
this.fromJiraInput.value = fromJira;
this.fromJiraInput.placeholder =
'(issue, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}';
'(issue, api_version, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}';
// Add remove button
const removeBtn = this.fieldContainer.createEl('button', {
@ -139,8 +141,8 @@ export class FieldMappingItem {
*/
async setupValidation(enableValidation: boolean) {
await validateField(this.fieldNameInput, enableValidation, 'string');
await validateField(this.toJiraInput, enableValidation, 'function', ['value']);
await validateField(this.fromJiraInput, enableValidation, 'function', ['issue', 'data_source']);
await validateField(this.toJiraInput, enableValidation, 'function', TO_JIRA_PARAMS);
await validateField(this.fromJiraInput, enableValidation, 'function', FROM_JIRA_PARAMS);
}
/**

View file

@ -35,6 +35,7 @@ export class FieldMappingsComponent implements SettingsComponent {
// Add explanation
mappingSection.createEl('p', {
text: t('desc'),
cls: 'break-line',
});
// Add validation toggle
@ -314,6 +315,12 @@ export class FieldMappingsComponent implements SettingsComponent {
toJira: '({ key: value })',
fromJira: 'issue.fields.project ? issue.fields.project.key : ""',
},
{
name: 'description',
toJira: 'api_version === "3" ? markdownToAdf(value) : markdownToJira(value)',
fromJira:
'api_version === "3" ? adfToMarkdown(issue.fields.description) : jiraToMarkdown(issue.fields.description)',
},
];
examples.forEach((example) => {

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,440 @@
interface AdfTextMark {
type: 'strong' | 'em' | 'code' | 'link';
attrs?: { href: string };
}
interface AdfTextNode {
type: 'text';
text: string;
marks?: AdfTextMark[];
}
interface AdfInlineNode {
type: 'hardBreak';
}
type AdfInlineContent = AdfTextNode | AdfInlineNode;
interface AdfParagraphNode {
type: 'paragraph';
content: AdfInlineContent[];
}
interface AdfHeadingNode {
type: 'heading';
attrs: { level: number };
content: AdfInlineContent[];
}
interface AdfCodeBlockNode {
type: 'codeBlock';
attrs: { language: string };
content: [{ type: 'text'; text: string }];
}
interface AdfListItemNode {
type: 'listItem';
content: [AdfParagraphNode];
}
interface AdfBulletListNode {
type: 'bulletList';
content: AdfListItemNode[];
}
interface AdfOrderedListNode {
type: 'orderedList';
content: AdfListItemNode[];
}
interface AdfTaskItemNode {
type: 'taskItem';
attrs: { localId: string; state: 'TODO' | 'DONE' };
content: AdfInlineContent[];
}
interface AdfTaskListNode {
type: 'taskList';
content: AdfTaskItemNode[];
attrs?: { localId: string };
}
interface AdfRuleNode {
type: 'rule';
}
type AdfBlockNode =
| AdfParagraphNode
| AdfHeadingNode
| AdfCodeBlockNode
| AdfBulletListNode
| AdfOrderedListNode
| AdfTaskListNode
| AdfRuleNode;
interface AdfDoc {
version: 1;
type: 'doc';
content: AdfBlockNode[];
}
function parseInline(text: string): AdfInlineContent[] {
const nodes: AdfInlineContent[] = [];
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)|\[\[[^\]]+\]\])/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
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 }];
}
export function markdownToAdf(markdown: string): AdfDoc | null {
if (!markdown || !markdown.trim()) return null;
const lines = markdown.split('\n');
const content: AdfBlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 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') }],
});
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]),
});
i++;
continue;
}
// Horizontal rule
if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
content.push({ type: 'rule' });
i++;
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 });
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 });
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,})$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
content.push({
type: 'paragraph',
content: parseInline(paraLines.join('\n')),
});
}
}
return content.length > 0 ? { version: 1, type: 'doc', content } : null;
}
function adfInlineToMarkdown(nodes: any[]): string {
if (!nodes) return '';
return nodes
.map((node) => {
if (node.type === 'hardBreak') return '\n';
if (node.type === 'mention') return node.attrs?.text || node.attrs?.displayName || '';
if (node.type === 'emoji') return node.attrs?.text || node.attrs?.shortName || '';
if (node.type === 'inlineCard') {
const url = node.attrs?.url || '';
return url ? `[${url}](${url})` : '';
}
if (node.type !== 'text') return '';
const text = node.text || '';
const marks: string[] = (node.marks || []).map((m: any) => m.type);
const linkMark = (node.marks || []).find((m: any) => m.type === 'link');
let result = text;
if (marks.includes('code')) return `\`${result}\``;
if (marks.includes('strike')) result = `~~${result}~~`;
if (marks.includes('strong')) result = `**${result}**`;
if (marks.includes('em')) result = `*${result}*`;
if (linkMark) result = `[${result}](${linkMark.attrs?.href || ''})`;
return result;
})
.join('');
}
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 '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': {
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 =
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
? '\n' +
textLines
.slice(1)
.map((l: string) => ' ' + l)
.join('\n')
: '');
const nested = rest
.map((b: any) => adfBlockToMarkdown(b))
.filter((s: string) => s.trim() !== '')
.map((s: string) =>
s
.split('\n')
.map((l: string) => ' ' + l)
.join('\n'),
)
.join('\n');
return `- [${checked ? 'x' : ' '}] ${fullText}${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}`)
.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 '';
const sep = `| ${rows[0].content.map(() => '---').join(' | ')} |`;
return [mdRows[0], sep, ...mdRows.slice(1)].join('\n');
}
case 'panel':
case 'expand':
case 'layoutSection':
case 'layoutColumn':
return (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n\n');
default:
return (node.content || []).map((n: any) => adfBlockToMarkdown(n)).join('\n');
}
}
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');
}

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

1895
yarn.lock Normal file

File diff suppressed because it is too large Load diff