mirror of
https://github.com/alamion/obsidian-jira-sync.git
synced 2026-07-22 05:43:04 +00:00
Compare commits
32 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77c6ea3f6 | ||
|
|
57fa0d1873 | ||
|
|
2aeb8c1bee | ||
|
|
3278de8748 | ||
|
|
ad06c8f4d8 | ||
|
|
e3a2a01270 | ||
|
|
d4d9746647 | ||
|
|
f259446b74 | ||
|
|
5229b2d1a5 | ||
|
|
a3211bcdb2 | ||
|
|
75dd9d75cd | ||
|
|
d45309743a | ||
|
|
6e35db6254 | ||
|
|
f50873d925 | ||
|
|
bca0cae215 | ||
|
|
a3d2c67da3 | ||
|
|
ed988fe101 | ||
|
|
0639461a08 | ||
|
|
4958fc6a3c | ||
|
|
5df851934d | ||
|
|
1b400e7e55 | ||
|
|
ad11b3e89a | ||
|
|
e99e14c043 | ||
|
|
aa6e36ba5d | ||
|
|
5199ad333f | ||
|
|
3169273f5e | ||
|
|
827a28f3ed | ||
|
|
1562f4ff52 | ||
|
|
f4732263f8 | ||
|
|
45f9c297ed | ||
|
|
75f9277387 | ||
|
|
1a14ac37c7 |
57 changed files with 3849 additions and 201 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -15,12 +15,12 @@ jobs:
|
|||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18.x'
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
yarn install --frozen-lockfile
|
||||
yarn run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
# npm
|
||||
node_modules
|
||||
yarn.lock
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
|
|
@ -32,3 +31,9 @@ tmp/
|
|||
|
||||
# localization compiled
|
||||
src/localization/compiled
|
||||
|
||||
# test configuration with credentials
|
||||
tests/test-config.local.json
|
||||
|
||||
# test configuration with credentials
|
||||
tests/test-config.local.json
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
node check-version.js
|
||||
yarn run format
|
||||
yarn run lint:fix
|
||||
yarn run test
|
||||
yarn run build
|
||||
yarn run validate_locale_once
|
||||
|
|
@ -69,9 +69,9 @@ The responsible person for this task is `jira-sync-inline-start-assignee`Bob`jir
|
|||
Example:
|
||||
|
||||
```md
|
||||
The responsible person for this task is `jira-sync-block-assignee`
|
||||
The responsible person for this task is `jira-sync-block-start-assignee`
|
||||
Bob
|
||||
`jira-sync-end`, and the description is `jira-sync-block-description`
|
||||
`jira-sync-end`, and the description is `jira-sync-block-start-description`
|
||||
Some description
|
||||
`jira-sync-end`.
|
||||
```
|
||||
|
|
@ -96,6 +96,7 @@ Currently, the plugin provides the following commands:
|
|||
- `Update work log in Jira manually` - enables manual time tracking for a task. Currently, this is not reflected in the file, but it will be available in future updates.
|
||||
- `Update work log in Jira by batch` - enables batch time tracking. If the formatter contains `jira_worklog_batch`, a batch of data from `jira_worklog_batch` will be sent, updating each listed entity.
|
||||
- `Update issue status in Jira` - allows updating a task's status by selecting one of the available options.
|
||||
- `Add comment to Jira` - allows adding a comment to the current task in Jira.
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
|
|
@ -110,12 +111,14 @@ Similarly, you can configure the `progressPercentage` shown in the example. This
|
|||
|
||||
Additionally, you can use some of the built-in functions:
|
||||
|
||||
- `jiraToMarkdown` - converts Jira markup to Markdown.
|
||||
- `markdownToJira` - converts Markdown to Jira markup.
|
||||
- `jiraToMarkdown` - converts Jira markup to Markdown. (for Jira API v2)
|
||||
- `markdownToJira` - converts Markdown to Jira markup. (for Jira API v2)
|
||||
- `markdownToAdf` - converts Markdown to Atlassian Document Format (ADF). (for Jira API v3)
|
||||
- `adfToMarkdown` - converts ADF to Markdown. (for Jira API v3)
|
||||
- `JSON.parse` - converts a JSON string to an object.
|
||||
- `JSON.stringify` - converts an object to a JSON string.
|
||||
|
||||
and modules: (original Javascript syntax)
|
||||
and modules: (original JavaScript syntax)
|
||||
|
||||
- `Math` - provides access to mathematical functions, such as `Math.round()`.
|
||||
- `Date` - provides access to date functions, such as `Date.now()`.
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@
|
|||
Пример:
|
||||
|
||||
```md
|
||||
Ответственным за эту задачу является `jira-sync-block-assignee`
|
||||
Ответственным за эту задачу является `jira-sync-block-start-assignee`
|
||||
Bob
|
||||
`jira-sync-end`, а описание — `jira-sync-block-description`
|
||||
`jira-sync-end`, а описание — `jira-sync-block-start-description`
|
||||
Некоторое описание
|
||||
`jira-sync-end`.
|
||||
```
|
||||
|
|
@ -95,6 +95,7 @@ Bob
|
|||
- `Update work log in Jira manually` - позволяет вести учёт потраченного на задачу времени вручную. В данный момент он никак не отображается в файле, это будет в ближайших обновлениях.
|
||||
- `Update work log in Jira by batch` - позволяет вести учёт потраченного на задачу времени батчем. Если в файле в formatter есть `jira_worklog_batch`, то вместо ручного заполнения будет послан батч данных из `jira_worklog_batch` с обновлением каждой из представленных сущностей.
|
||||
- `Update issue status in Jira` - позволяет обновить статус задачи, выбрав один из возможных вариантов.
|
||||
- `Add comment to Jira` - позволяет добавить комментарий в выбранную задачу.
|
||||
|
||||
### Продвинутое использование
|
||||
|
||||
|
|
@ -109,12 +110,14 @@ Bob
|
|||
|
||||
Кроме того, вы можете использовать некоторые встроенные функции:
|
||||
|
||||
- `jiraToMarkdown` - преобразует разметку Jira в Markdown.
|
||||
- `markdownToJira` - преобразует Markdown в разметку Jira.
|
||||
- `jiraToMarkdown` - преобразует разметку Jira в Markdown. (Jira API v2)
|
||||
- `markdownToJira` - преобразует Markdown в разметку Jira. (Jira API v2)
|
||||
- `markdownToAdf` - преобразует Markdown в ADF. (Jira API v3)
|
||||
- `adfToMarkdown` - преобразует ADF в Markdown. (Jira API v3)
|
||||
- `JSON.parse` - преобразует строку JSON в объект.
|
||||
- `JSON.stringify` - преобразует объект в строку JSON.
|
||||
|
||||
и модули: (исходный синтаксис Javascript)
|
||||
и модули: (исходный синтаксис JavaScript)
|
||||
|
||||
- `Math` - предоставляет доступ к математическим функциям, таким как `Math.round()`.
|
||||
- `Date` - предоставляет доступ к функциям даты, таким как `Date.now()`.
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ Status indicator: `jira-sync-inline-start-status_indicator``jira-sync-end`
|
|||
|
||||
### Whitespace Heavy Section `jira-sync-section-whitespace_test`
|
||||
|
||||
This section has leading and trailing whitespace
|
||||
This section has leading and trailing whitespace
|
||||
|
||||
Should be trimmed properly before sending to Jira.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import tsPlugin from '@typescript-eslint/eslint-plugin';
|
|||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ['main.js', 'node_modules/**', 'src/**/*.js'],
|
||||
ignores: ['main.js', 'node_modules/**', 'src/**/*.js', 'tests/**'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "jira-sync",
|
||||
"name": "Jira Issue Manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.7.2",
|
||||
"minAppVersion": "1.10.1",
|
||||
"description": "Get Jira issues, create and update them. Issue status and worklog management.",
|
||||
"author": "Alamion",
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.5.0",
|
||||
"name": "obsidian-jira-sync",
|
||||
"version": "1.7.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",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { JiraIssue, JiraTransitionType } from '../interfaces';
|
|||
import { baseRequest, sanitizeObject } from './base';
|
||||
import { Notice } from 'obsidian';
|
||||
import { chunkArray, createLimiter } from '../tools/asyncLimiter';
|
||||
import { markdownToAdf } from '../tools/markdownToAdf';
|
||||
|
||||
/**
|
||||
* Fetch an issue from Jira by its key
|
||||
|
|
@ -242,6 +243,25 @@ export async function addWorkLog(
|
|||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to a Jira issue
|
||||
*/
|
||||
export async function addComment(plugin: JiraPlugin, issueKey: string, markdownText: string): Promise<any> {
|
||||
let body: any;
|
||||
const conn = plugin.getCurrentConnection();
|
||||
if (!conn) throw new Error('No connection configured');
|
||||
|
||||
if (conn.apiVersion === '3') {
|
||||
body = markdownToAdf(markdownText);
|
||||
} else {
|
||||
body = markdownText;
|
||||
}
|
||||
const payload = JSON.stringify({ body });
|
||||
const response = await baseRequest(plugin, 'post', `/issue/${issueKey}/comment`, payload);
|
||||
new Notice(`Comment added to ${issueKey}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function bulkAddWorkLog(
|
||||
plugin: JiraPlugin,
|
||||
worklogs: {
|
||||
|
|
|
|||
36
src/commands/addComment.ts
Normal file
36
src/commands/addComment.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { IssueCommentModal } from '../modals';
|
||||
import { addComment, validateSettings } from '../api';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations('commands.add_comment').t;
|
||||
|
||||
export function registerAddCommentCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'add-comment-jira',
|
||||
name: t('name'),
|
||||
editorCheckCallback: (checking: boolean, editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
|
||||
if (!validateSettings(plugin)) return false;
|
||||
if (!ctx.file) return false;
|
||||
|
||||
const frontmatter = plugin.app.metadataCache.getFileCache(ctx.file)?.frontmatter;
|
||||
const issueKey = frontmatter?.key;
|
||||
if (!issueKey) return false;
|
||||
|
||||
if (!checking) {
|
||||
const selectedText = editor.getSelection();
|
||||
new IssueCommentModal(plugin.app, selectedText, async (commentText: string) => {
|
||||
try {
|
||||
await addComment(plugin, issueKey, commentText);
|
||||
} catch (error: any) {
|
||||
new Notice(t('error') + ': ' + (error.message || 'Unknown error'));
|
||||
console.error(error);
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './addComment';
|
||||
export * from './addWorkLogBatch';
|
||||
export * from './addWorkLogManually';
|
||||
export * from './batchFetchIssues';
|
||||
|
|
@ -5,3 +6,5 @@ export * from './createIssue';
|
|||
export * from './getIssue';
|
||||
export * from './updateIssue';
|
||||
export * from './updateStatus';
|
||||
export * from './rebuildCache';
|
||||
export * from './rebuildCache';
|
||||
|
|
|
|||
13
src/commands/rebuildCache.ts
Normal file
13
src/commands/rebuildCache.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
|
||||
export function registerRebuildCacheCommand(plugin: JiraPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'rebuild-issue-cache',
|
||||
name: 'Rebuild issue file cache from filesystem',
|
||||
callback: async () => {
|
||||
await plugin.rebuildCache();
|
||||
new Notice('Issue cache rebuilt successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import { JiraIssue } from '../interfaces';
|
||||
import { jiraToMarkdown, markdownToJira } from '../tools/markdownHtml';
|
||||
import { adfToMarkdown, markdownToAdf } from '../tools/markdownToAdf';
|
||||
|
||||
export interface FieldMapping {
|
||||
toJira: (value: any) => any;
|
||||
fromJira: (issue: JiraIssue, data_source: Record<string, any> | null) => any;
|
||||
toJira: (value: any, api_version?: '2' | '3') => any;
|
||||
fromJira: (issue: JiraIssue, api_version?: '2' | '3', data_source?: Record<string, any>) => any;
|
||||
}
|
||||
export const TO_JIRA_PARAMS = ['value', 'api_version'];
|
||||
export const FROM_JIRA_PARAMS = ['issue', 'api_version', 'data_source'];
|
||||
|
||||
export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
|
||||
summary: {
|
||||
|
|
@ -11,8 +15,9 @@ export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
|
|||
fromJira: (issue) => issue.fields.summary,
|
||||
},
|
||||
description: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.description,
|
||||
toJira: (value, api_version) => (api_version === '3' ? markdownToAdf(value) : markdownToJira(value)),
|
||||
fromJira: (issue, api_version) =>
|
||||
api_version === '3' ? adfToMarkdown(issue.fields.description) : jiraToMarkdown(issue.fields.description),
|
||||
},
|
||||
key: {
|
||||
toJira: () => null,
|
||||
|
|
@ -74,4 +79,23 @@ export const obsidianJiraFieldMappings: Record<string, FieldMapping> = {
|
|||
toJira: () => null,
|
||||
fromJira: (issue) => issue.fields.aggregateprogress.percent + '%',
|
||||
},
|
||||
comments: {
|
||||
toJira: () => null,
|
||||
fromJira: (issue, api_version) => {
|
||||
const comments = issue.fields.comment?.comments;
|
||||
if (!comments?.length) return '';
|
||||
return comments
|
||||
.map((c: any) => {
|
||||
const author = c.author?.displayName ?? 'Unknown';
|
||||
const date = c.created ? c.created.replace('T', ' ').substring(0, 19) : '';
|
||||
const body = api_version === '3' ? adfToMarkdown(c.body) : c.body;
|
||||
const calloutBody = body
|
||||
.split('\n')
|
||||
.map((l: string) => (l === '' ? '>' : `> ${l}`))
|
||||
.join('\n');
|
||||
return `> [!note]+ ${author} — ${date}\n> \n${calloutBody}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
import JiraPlugin from '../main';
|
||||
import { TFile } from 'obsidian';
|
||||
import { createJiraIssue, updateJiraIssue, updateJiraStatus } from '../api';
|
||||
import { createJiraIssue, fetchIssue, updateJiraIssue, updateJiraStatus } from '../api';
|
||||
import { prepareJiraFieldsFromFile } from './commonPrepareData';
|
||||
import { localToJiraFields, updateJiraToLocal } from '../tools/mapObsidianJiraFields';
|
||||
import { JiraIssue, JiraTransitionType } from '../interfaces';
|
||||
import { obsidianJiraFieldMappings } from '../default/obsidianJiraFieldsMapping';
|
||||
import { updateJiraSyncContent } from '../tools/sectionTools';
|
||||
|
||||
export async function updateIssueFromFile(plugin: JiraPlugin, file: TFile): Promise<string> {
|
||||
let fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
const issueKey = fields.key;
|
||||
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
|
||||
|
||||
if (!issueKey) {
|
||||
throw new Error('No issue key found in frontmatter');
|
||||
}
|
||||
|
||||
fields = localToJiraFields(fields, {
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
});
|
||||
fields = localToJiraFields(
|
||||
fields,
|
||||
{
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
},
|
||||
apiVersion,
|
||||
);
|
||||
await updateJiraIssue(plugin, issueKey, fields);
|
||||
return issueKey;
|
||||
}
|
||||
|
|
@ -27,13 +33,18 @@ export async function createIssueFromFile(
|
|||
file: TFile,
|
||||
fields?: Record<string, any>,
|
||||
): Promise<string> {
|
||||
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
|
||||
if (!fields) {
|
||||
fields = await prepareJiraFieldsFromFile(plugin, file);
|
||||
}
|
||||
fields = localToJiraFields(fields, {
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
});
|
||||
fields = localToJiraFields(
|
||||
fields,
|
||||
{
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
},
|
||||
apiVersion,
|
||||
);
|
||||
// Create the issue
|
||||
const issueData = await createJiraIssue(plugin, fields);
|
||||
const issueKey = issueData.key;
|
||||
|
|
@ -43,6 +54,10 @@ export async function createIssueFromFile(
|
|||
frontmatter['key'] = issueKey;
|
||||
});
|
||||
|
||||
// Pull the created issue back from Jira to populate all remaining synced fields
|
||||
const createdIssue = await fetchIssue(plugin, issueKey);
|
||||
await updateJiraToLocal(plugin, file, createdIssue);
|
||||
|
||||
return issueKey;
|
||||
}
|
||||
|
||||
|
|
@ -58,8 +73,24 @@ export async function updateStatusFromFile(
|
|||
}
|
||||
|
||||
await updateJiraStatus(plugin, fields.key, transition.id);
|
||||
await updateJiraToLocal(plugin, file, {
|
||||
fields: { status: { name: transition.status } },
|
||||
} as JiraIssue);
|
||||
|
||||
// Only update the status field. Calling updateJiraToLocal with a partial issue
|
||||
// causes all fromJira mappings to run with undefined inputs, producing empty strings
|
||||
// that overwrite description and comments.
|
||||
const allMappings = { ...obsidianJiraFieldMappings, ...plugin.settings.fieldMapping.fieldMappings };
|
||||
const statusMapping = allMappings['status'];
|
||||
const minimalIssue = { fields: { status: { name: transition.status } } } as JiraIssue;
|
||||
const localStatusValue = statusMapping
|
||||
? statusMapping.fromJira(minimalIssue, plugin.getCurrentConnection()?.apiVersion)
|
||||
: transition.status;
|
||||
|
||||
if (localStatusValue !== null && localStatusValue !== undefined) {
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter: any) => {
|
||||
frontmatter['status'] = localStatusValue;
|
||||
});
|
||||
await plugin.app.vault.process(file, (fileContent) => {
|
||||
return updateJiraSyncContent(fileContent, { status: String(localStatusValue) });
|
||||
});
|
||||
}
|
||||
return fields.key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,24 +7,18 @@ import { Notice, TFile, TFolder } from 'obsidian';
|
|||
import { defaultTemplate } from '../default/defaultTemplate';
|
||||
import { debugLog } from '../tools/debugLogging';
|
||||
|
||||
function generateFilenameFromTemplate(template: string, issue: JiraIssue): string {
|
||||
export function generateFilenameFromTemplate(
|
||||
template: string,
|
||||
issue: { key: string; fields?: { summary?: string } },
|
||||
): string {
|
||||
let filename = template;
|
||||
|
||||
// Replace {summary} with sanitized summary
|
||||
const summary = issue.fields?.summary || '';
|
||||
const sanitizedSummary = sanitizeFileName(summary);
|
||||
filename = filename.replace(/\{summary\}/g, sanitizedSummary);
|
||||
|
||||
// Replace {key} with issue key
|
||||
const key = issue.key || '';
|
||||
filename = filename.replace(/\{key\}/g, key);
|
||||
|
||||
// Sanitize the entire filename to remove any prohibited characters
|
||||
filename = sanitizeFileName(filename);
|
||||
|
||||
// Fallback if the result is empty or only whitespace
|
||||
if (!filename || filename.trim() === '') {
|
||||
// Use key if available, otherwise use a default
|
||||
if (key) {
|
||||
filename = key;
|
||||
} else if (sanitizedSummary) {
|
||||
|
|
@ -33,10 +27,53 @@ function generateFilenameFromTemplate(template: string, issue: JiraIssue): strin
|
|||
filename = 'jira-issue';
|
||||
}
|
||||
}
|
||||
|
||||
return filename.trim();
|
||||
}
|
||||
|
||||
export async function renameExistingIssueFiles(plugin: JiraPlugin): Promise<{ renamed: number; errors: string[] }> {
|
||||
const cacheMap = plugin.getAllIssueKeysMap();
|
||||
const template = plugin.settings.fetchIssue.filenameTemplate || '{summary} ({key})';
|
||||
const issuesFolder = plugin.settings.global.issuesFolder;
|
||||
let renamed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [issueKey, currentPath] of cacheMap.entries()) {
|
||||
try {
|
||||
const file = plugin.app.vault.getFileByPath(currentPath);
|
||||
if (!file) {
|
||||
errors.push(`${issueKey}: file not found at ${currentPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let summary = '';
|
||||
const metadata = plugin.app.metadataCache.getFileCache(file);
|
||||
const cachedSummary = metadata?.frontmatter?.summary;
|
||||
if (cachedSummary && typeof cachedSummary === 'string') {
|
||||
summary = cachedSummary;
|
||||
}
|
||||
|
||||
const newFilename = generateFilenameFromTemplate(template, { key: issueKey, fields: { summary } });
|
||||
const newPath = `${issuesFolder}/${newFilename}.md`;
|
||||
|
||||
if (newPath === currentPath) continue;
|
||||
|
||||
const existingFile = plugin.app.vault.getFileByPath(newPath);
|
||||
if (existingFile) {
|
||||
errors.push(`${issueKey}: target path already exists: ${newPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await plugin.app.vault.rename(file, newPath);
|
||||
await plugin.setFilePathForIssueKey(issueKey, newPath);
|
||||
renamed++;
|
||||
} catch (error) {
|
||||
errors.push(`${issueKey}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { renamed, errors };
|
||||
}
|
||||
|
||||
export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIssue, filePath?: string): Promise<void> {
|
||||
try {
|
||||
await ensureIssuesFolder(plugin);
|
||||
|
|
@ -48,7 +85,6 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
targetPath = filePath;
|
||||
targetFile = plugin.app.vault.getFileByPath(filePath);
|
||||
} else {
|
||||
// First, try to find the file using cache
|
||||
const cachedPath = plugin.getFilePathForIssueKey(issue.key);
|
||||
if (cachedPath) {
|
||||
targetFile = plugin.app.vault.getFileByPath(cachedPath);
|
||||
|
|
@ -60,19 +96,16 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
}
|
||||
}
|
||||
|
||||
// If not found in cache or file doesn't exist, fallback to search
|
||||
if (!targetFile) {
|
||||
debugLog(`Issue ${issue.key} not in cache, searching filesystem...`);
|
||||
targetFile = await findFileByIssueKey(plugin, issue.key);
|
||||
if (targetFile) {
|
||||
targetPath = targetFile.path;
|
||||
// Add to cache for future use
|
||||
plugin.setFilePathForIssueKey(issue.key, targetPath);
|
||||
await plugin.setFilePathForIssueKey(issue.key, targetPath);
|
||||
debugLog(`Found issue ${issue.key} via search, added to cache: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, create new file
|
||||
if (!targetFile) {
|
||||
const template = plugin.settings.fetchIssue.filenameTemplate || '{summary} ({key})';
|
||||
const filename = generateFilenameFromTemplate(template, issue);
|
||||
|
|
@ -84,12 +117,12 @@ export async function createOrUpdateIssueNote(plugin: JiraPlugin, issue: JiraIss
|
|||
if (targetFile) {
|
||||
await updateJiraToLocal(plugin, targetFile, issue);
|
||||
await plugin.app.workspace.openLinkText(targetFile.path, '');
|
||||
await plugin.setFilePathForIssueKey(issue.key, targetFile.path);
|
||||
} else {
|
||||
const newFile = await createNewIssueFile(plugin, targetPath);
|
||||
await updateJiraToLocal(plugin, newFile, issue);
|
||||
await plugin.app.workspace.openLinkText(newFile.path, '');
|
||||
// Add new file to cache
|
||||
plugin.setFilePathForIssueKey(issue.key, newFile.path);
|
||||
await plugin.setFilePathForIssueKey(issue.key, newFile.path);
|
||||
}
|
||||
new Notice(`Issue ${issue.key} imported successfully`);
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -108,7 +141,6 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
|
|||
if (templatePath && templatePath.trim() !== '') {
|
||||
const templateFile = plugin.app.vault.getFileByPath(templatePath);
|
||||
if (templateFile) {
|
||||
// Use the template as initial content
|
||||
initialContent = await plugin.app.vault.read(templateFile);
|
||||
} else {
|
||||
new Notice(`Template file not found: ${templatePath}, using default template`);
|
||||
|
|
@ -116,10 +148,8 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
|
|||
}
|
||||
if (initialContent === '') initialContent = defaultTemplate;
|
||||
|
||||
// Create the file with initial content
|
||||
await plugin.app.vault.create(filePath, initialContent);
|
||||
|
||||
// Get file reference and update frontmatter
|
||||
const newFile = plugin.app.vault.getFileByPath(filePath);
|
||||
if (!newFile) {
|
||||
throw new Error('Could not create file');
|
||||
|
|
@ -129,11 +159,9 @@ async function createNewIssueFile(plugin: JiraPlugin, filePath: string): Promise
|
|||
|
||||
async function findFileByIssueKey(plugin: JiraPlugin, issueKey: string): Promise<TFile | null> {
|
||||
const issuesFolder = plugin.app.vault.getAbstractFileByPath(plugin.settings.global.issuesFolder);
|
||||
|
||||
if (!issuesFolder || !(issuesFolder instanceof TFolder)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await searchFolderForIssueKey(plugin, issuesFolder, issueKey);
|
||||
}
|
||||
|
||||
|
|
|
|||
2
src/localization/source/en/commands/add_comment.yaml
Normal file
2
src/localization/source/en/commands/add_comment.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name: Add comment to Jira
|
||||
error: Error adding comment
|
||||
11
src/localization/source/en/modals/comment.yaml
Normal file
11
src/localization/source/en/modals/comment.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name: Add Jira comment
|
||||
|
||||
body:
|
||||
name: Comment
|
||||
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
|
||||
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
|
||||
|
||||
submit: Submit
|
||||
|
||||
warns:
|
||||
empty: Comment cannot be empty
|
||||
|
|
@ -12,6 +12,16 @@ maxResults:
|
|||
|
||||
submit: Search Issues
|
||||
|
||||
presets:
|
||||
load: JQL Preset
|
||||
load_desc: Load a saved JQL query preset
|
||||
select: Select preset
|
||||
save: Save as preset
|
||||
save_desc: Save current JQL query as a named preset
|
||||
save_hint: Preset name
|
||||
save_btn: Save
|
||||
delete: Delete preset
|
||||
|
||||
preview:
|
||||
total: Total {total} issues found
|
||||
showing: ' (showing first {limit} issues)'
|
||||
|
|
|
|||
|
|
@ -26,3 +26,10 @@ filenameTemplate:
|
|||
desc: |
|
||||
Template for created issue filenames. Use {summary} for issue summary and {key} for issue key
|
||||
placeholder: '{summary} ({key})'
|
||||
|
||||
rename_files: Rename existing files
|
||||
no_files_to_rename: No files in cache to rename
|
||||
rename_confirm: 'This will rename {count} cached file(s). Continue?'
|
||||
rename_success: Successfully renamed {count} file(s)
|
||||
rename_errors: 'Failed to rename {count} file(s)'
|
||||
rename_no_changes: No files needed renaming
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
title: Field mappings
|
||||
desc: Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function.
|
||||
desc: |
|
||||
Configure how fields are mapped between Obsidian and Jira. Each field requires both a 'To Jira' and 'From Jira' transformation function.
|
||||
|
||||
To Jira:
|
||||
- value - the value of field in your .md file - can be a text string from file contents or a field value from frontmatter
|
||||
- api_version - Jira API version used in current connection (could be either '2' or '3')
|
||||
|
||||
From Jira:
|
||||
- issue - raw data from Jira (can be seen in lower `Fetch issue settings` section)
|
||||
- api_version - the same as `To Jira`
|
||||
- data_source - either parsed sections or frontmatter data in dictionary format, where keys are field names and values are field values
|
||||
fv:
|
||||
name: Enable field validation
|
||||
desc: Check fields before saving based on the standard API response (validation does not account for custom fields)
|
||||
|
|
|
|||
2
src/localization/source/ru/commands/add_comment.yaml
Normal file
2
src/localization/source/ru/commands/add_comment.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name: Add comment to Jira
|
||||
error: Error adding comment
|
||||
11
src/localization/source/ru/modals/comment.yaml
Normal file
11
src/localization/source/ru/modals/comment.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name: Add Jira comment
|
||||
|
||||
body:
|
||||
name: Comment
|
||||
desc: 'Write your comment here, or select text in the editor before running this command to pre-fill it'
|
||||
placeholder: 'Write your comment here. Tip: select text in the editor before running this command to pre-fill.'
|
||||
|
||||
submit: Submit
|
||||
|
||||
warns:
|
||||
empty: Comment cannot be empty
|
||||
|
|
@ -12,8 +12,18 @@ maxResults:
|
|||
|
||||
submit: Найти задачи
|
||||
|
||||
presets:
|
||||
load: JQL пресет
|
||||
load_desc: Загрузить сохраненный JQL запрос
|
||||
select: Выберите пресет
|
||||
save: Сохранить как пресет
|
||||
save_desc: Сохранить текущий JQL запрос как именованный пресет
|
||||
save_hint: Имя пресета
|
||||
save_btn: Сохранить
|
||||
delete: Удалить пресет
|
||||
|
||||
preview:
|
||||
total: '{total} задач найдено'
|
||||
total: '{total} задач найдено'
|
||||
showing: ' (показаны первые {limit} задач)'
|
||||
error: '{error}'
|
||||
loading: Загрузка...
|
||||
|
|
|
|||
|
|
@ -21,3 +21,10 @@ filenameTemplate:
|
|||
desc: |
|
||||
Шаблон для названий файлов созданных задач. Используйте {summary} для названия задачи и {key} для ключа задачи
|
||||
placeholder: '{summary} ({key})'
|
||||
|
||||
rename_files: Переименовать существующие файлы
|
||||
no_files_to_rename: Нет файлов в кэше для переименования
|
||||
rename_confirm: 'Будет переименовано {count} файл(ов). Продолжить?'
|
||||
rename_success: 'Успешно переименовано {count} файл(ов)'
|
||||
rename_errors: 'Не удалось переименовать {count} файл(ов)'
|
||||
rename_no_changes: Файлы не нуждаются в переименовании
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
title: Сопоставление полей
|
||||
desc: Настройте, как поля будут сопоставляться между Obsidian и Jira. Для каждого
|
||||
поля требуется функция преобразования 'В Jira' и 'Из Jira'.
|
||||
desc: |
|
||||
Настройте сопоставление полей между Obsidian и Jira. Для каждого поля необходимо указать функции преобразования "В Jira" и "Из Jira".
|
||||
|
||||
В Jira:
|
||||
- value — значение поля в .md файле — это может быть текст из содержимого файла или значение поля из frontmatter
|
||||
- api_version - Jira API версия используемая в текущем коннекторе (может быть '2' или '3')
|
||||
|
||||
Из Jira:
|
||||
- issue — необработанные данные из Jira (можно увидеть в нижнем разделе "Настройка получаемых полей")
|
||||
- api_version - То же самое что и в "В Jira"
|
||||
- data_source — либо спаршенные секции данных из файла, либо данные frontmatter. Показывается в формате словаря, где ключи — это названия полей, а значения — данные из файла
|
||||
fv:
|
||||
name: Включить проверку полей
|
||||
desc: Проверять поля перед сохранением базируясь на стандартном ответе API
|
||||
|
|
|
|||
52
src/main.ts
52
src/main.ts
|
|
@ -10,11 +10,13 @@ import {
|
|||
registerGetIssueCommandWithCustomKey,
|
||||
registerUpdateIssueStatusCommand,
|
||||
registerBatchFetchIssuesCommand,
|
||||
registerAddCommentCommand,
|
||||
registerRebuildCacheCommand,
|
||||
} from './commands';
|
||||
import { transform_string_to_functions_mappings } from './tools/convertFunctionString';
|
||||
import { createJiraSyncExtension } from './postprocessing/livePreview';
|
||||
import { hideJiraPointersReading } from './postprocessing/reading';
|
||||
import { buildCacheFromFilesystem, validateCache } from './tools/cacheUtils';
|
||||
import { buildCacheFromFilesystem } from './tools/cacheUtils';
|
||||
import { checkMigrateSettings } from './tools/migrateSettings';
|
||||
|
||||
export default class JiraPlugin extends Plugin {
|
||||
|
|
@ -26,9 +28,8 @@ export default class JiraPlugin extends Plugin {
|
|||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// validate cache from settings
|
||||
// initialize cache from settings
|
||||
this.initializeCache();
|
||||
await validateCache(this);
|
||||
|
||||
// Register all commands
|
||||
registerUpdateIssueCommand(this);
|
||||
|
|
@ -40,12 +41,14 @@ export default class JiraPlugin extends Plugin {
|
|||
|
||||
registerUpdateWorkLogManuallyCommand(this);
|
||||
registerUpdateWorkLogBatchCommand(this);
|
||||
registerAddCommentCommand(this);
|
||||
registerRebuildCacheCommand(this);
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new JiraSettingTab(this.app, this));
|
||||
|
||||
// Handle Reading mode (post-processor for rendered markdown)
|
||||
this.registerMarkdownPostProcessor(hideJiraPointersReading.bind(this));
|
||||
this.registerMarkdownPostProcessor(hideJiraPointersReading());
|
||||
|
||||
// Handle Live Preview/Edit mode (CodeMirror extension)
|
||||
this.registerEditorExtension(createJiraSyncExtension(this));
|
||||
|
|
@ -56,13 +59,26 @@ export default class JiraPlugin extends Plugin {
|
|||
|
||||
async loadSettings() {
|
||||
const old_data = await this.loadData();
|
||||
// TODO: Cancel and delete migration check in future (approximately 2026-2027)
|
||||
const new_data = checkMigrateSettings(old_data, this.saveSettings);
|
||||
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, new_data);
|
||||
// TODO: Cancel and delete migration check in future (approximately 2026-2027)
|
||||
const { result: migratedData, changed: migrationChanged } = checkMigrateSettings(old_data);
|
||||
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...migratedData,
|
||||
fieldMapping: {
|
||||
...DEFAULT_SETTINGS.fieldMapping,
|
||||
...(migratedData?.fieldMapping || {}),
|
||||
},
|
||||
};
|
||||
|
||||
this.settings.fieldMapping.fieldMappings = await transform_string_to_functions_mappings(
|
||||
this.settings.fieldMapping.fieldMappingsStrings,
|
||||
);
|
||||
|
||||
if (migrationChanged) {
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -91,36 +107,36 @@ export default class JiraPlugin extends Plugin {
|
|||
return this.settings.connections[this.settings.currentConnectionIndex];
|
||||
}
|
||||
|
||||
setFilePathForIssueKey(issueKey: string, filePath: string) {
|
||||
async setFilePathForIssueKey(issueKey: string, filePath: string) {
|
||||
this.issueKeyToFilePathCache.set(issueKey, filePath);
|
||||
this.settings.issueKeyToFilePathCache[issueKey] = filePath;
|
||||
this.saveSettings();
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
removeIssueKeyFromCache(issueKey: string) {
|
||||
async removeIssueKeyFromCache(issueKey: string) {
|
||||
this.issueKeyToFilePathCache.delete(issueKey);
|
||||
delete this.settings.issueKeyToFilePathCache[issueKey];
|
||||
this.saveSettings();
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
async clearCache() {
|
||||
this.issueKeyToFilePathCache.clear();
|
||||
this.settings.issueKeyToFilePathCache = {};
|
||||
this.saveSettings();
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async rebuildCache() {
|
||||
this.clearCache();
|
||||
await this.clearCache();
|
||||
await buildCacheFromFilesystem(this);
|
||||
}
|
||||
|
||||
private registerVaultEventListeners() {
|
||||
// Handle file renames
|
||||
this.registerEvent(
|
||||
this.app.vault.on('rename', (file, oldPath) => {
|
||||
this.app.vault.on('rename', async (file, oldPath) => {
|
||||
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
|
||||
if (cachedPath === oldPath) {
|
||||
this.setFilePathForIssueKey(issueKey, file.path);
|
||||
await this.setFilePathForIssueKey(issueKey, file.path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -129,10 +145,10 @@ export default class JiraPlugin extends Plugin {
|
|||
|
||||
// Handle file deletions
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
this.app.vault.on('delete', async (file) => {
|
||||
for (const [issueKey, cachedPath] of this.issueKeyToFilePathCache.entries()) {
|
||||
if (cachedPath === file.path) {
|
||||
this.removeIssueKeyFromCache(issueKey);
|
||||
await this.removeIssueKeyFromCache(issueKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/modals/ConfirmModal.ts
Normal file
39
src/modals/ConfirmModal.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
private message: string;
|
||||
private onConfirm: () => void;
|
||||
private onCancel: () => void;
|
||||
|
||||
constructor(app: App, message: string, onConfirm: () => void, onCancel: () => void) {
|
||||
super(app);
|
||||
this.message = message;
|
||||
this.onConfirm = onConfirm;
|
||||
this.onCancel = onCancel;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.contentEl.createEl('h2', { text: this.message });
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Yes')
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
this.onConfirm();
|
||||
}),
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('No').onClick(() => {
|
||||
this.close();
|
||||
this.onCancel();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
55
src/modals/IssueCommentModal.ts
Normal file
55
src/modals/IssueCommentModal.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { App, Modal, Notice, Setting, TextAreaComponent } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
|
||||
const t = useTranslations('modals.comment').t;
|
||||
|
||||
/**
|
||||
* Modal for adding a comment to a Jira issue.
|
||||
* Pre-populated with any text that was selected in the editor when the command was invoked.
|
||||
*/
|
||||
export class IssueCommentModal extends Modal {
|
||||
private onSubmit: (commentText: string) => void;
|
||||
private commentText: string;
|
||||
|
||||
constructor(app: App, initialText: string, onSubmit: (commentText: string) => void) {
|
||||
super(app);
|
||||
this.commentText = initialText;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
new Setting(this.contentEl).setName(t('name')).setHeading();
|
||||
|
||||
const textarea = new TextAreaComponent(this.contentEl)
|
||||
.setPlaceholder(t('body.placeholder'))
|
||||
.setValue(this.commentText)
|
||||
.onChange((value) => {
|
||||
this.commentText = value;
|
||||
});
|
||||
textarea.inputEl.rows = 10;
|
||||
textarea.inputEl.style.width = '100%';
|
||||
textarea.inputEl.style.marginBottom = '0.75em';
|
||||
textarea.inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(this.contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText(t('submit'))
|
||||
.setCta()
|
||||
.onClick(() => this.submit()),
|
||||
);
|
||||
}
|
||||
|
||||
private submit() {
|
||||
if (!this.commentText.trim()) {
|
||||
new Notice(t('warns.empty'));
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSubmit(this.commentText);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
import { App, Modal, Setting, DropdownComponent } from 'obsidian';
|
||||
import { useTranslations } from '../localization/translator';
|
||||
import JiraPlugin from '../main';
|
||||
import { JQLPreview } from '../settings/components/JQLPreview';
|
||||
import { JQLPreset } from '../settings/default';
|
||||
|
||||
const t = useTranslations('modals.jql_search').t;
|
||||
|
||||
/**
|
||||
* Modal for searching issues by JQL query
|
||||
*/
|
||||
export class JQLSearchModal extends Modal {
|
||||
private onSubmit: (jql: string) => void;
|
||||
private plugin: JiraPlugin;
|
||||
|
|
@ -15,6 +13,8 @@ export class JQLSearchModal extends Modal {
|
|||
private previewEl?: HTMLElement;
|
||||
private preview?: JQLPreview;
|
||||
private debounceTimer?: number;
|
||||
private presetName: string = '';
|
||||
private presetDropdown?: DropdownComponent;
|
||||
|
||||
constructor(app: App, plugin: JiraPlugin, onSubmit: (jql: string) => void) {
|
||||
super(app);
|
||||
|
|
@ -23,8 +23,68 @@ export class JQLSearchModal extends Modal {
|
|||
}
|
||||
|
||||
onOpen() {
|
||||
const conn = this.plugin.getCurrentConnection();
|
||||
if (conn?.lastJqlQuery) {
|
||||
this.jql = conn.lastJqlQuery;
|
||||
this.schedulePreview();
|
||||
}
|
||||
|
||||
this.contentEl.createEl('h2', { text: t('desc') });
|
||||
|
||||
const presetSetting = new Setting(this.contentEl).setName(t('presets.load')).setDesc(t('presets.load_desc'));
|
||||
|
||||
presetSetting.addDropdown((dropdown) => {
|
||||
this.presetDropdown = dropdown;
|
||||
this.populateDropdown(dropdown);
|
||||
dropdown.onChange((value) => {
|
||||
if (value) {
|
||||
const preset = this.getPresets().find((p) => p.name === value);
|
||||
if (preset) {
|
||||
this.jql = preset.query;
|
||||
const textarea = this.contentEl.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.value = preset.query;
|
||||
}
|
||||
this.schedulePreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
presetSetting.addButton((btn) =>
|
||||
btn.setButtonText(t('presets.delete')).onClick(() => {
|
||||
const selectedName = this.presetDropdown?.getValue();
|
||||
if (selectedName) {
|
||||
this.deletePreset(selectedName);
|
||||
if (this.presetDropdown) {
|
||||
this.populateDropdown(this.presetDropdown);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t('presets.save'))
|
||||
.setDesc(t('presets.save_desc'))
|
||||
.addText((text) =>
|
||||
text.setPlaceholder(t('presets.save_hint')).onChange((value) => {
|
||||
this.presetName = value;
|
||||
}),
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText(t('presets.save_btn')).onClick(() => {
|
||||
if (this.jql.trim() && this.presetName.trim()) {
|
||||
this.savePreset(this.presetName.trim(), this.jql.trim());
|
||||
this.presetName = '';
|
||||
const nameInput = this.contentEl.querySelectorAll('input')[1];
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (this.presetDropdown) {
|
||||
this.populateDropdown(this.presetDropdown);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName(t('jql.name'))
|
||||
.setDesc(t('jql.desc'))
|
||||
|
|
@ -38,7 +98,6 @@ export class JQLSearchModal extends Modal {
|
|||
text.inputEl.style.height = '100px';
|
||||
});
|
||||
|
||||
// Create preview container and initialize preview component
|
||||
this.previewEl = this.contentEl.createDiv();
|
||||
this.preview = new JQLPreview(this.plugin, this.previewEl, 5);
|
||||
this.preview.loadPreview('');
|
||||
|
|
@ -49,17 +108,18 @@ export class JQLSearchModal extends Modal {
|
|||
.setCta()
|
||||
.onClick(() => {
|
||||
if (this.jql.trim()) {
|
||||
this.saveLastQuery(this.jql.trim());
|
||||
this.close();
|
||||
this.onSubmit(this.jql.trim());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Handle Enter key (Ctrl+Enter for textarea)
|
||||
this.contentEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
if (this.jql.trim()) {
|
||||
this.saveLastQuery(this.jql.trim());
|
||||
this.close();
|
||||
this.onSubmit(this.jql.trim());
|
||||
}
|
||||
|
|
@ -67,21 +127,56 @@ export class JQLSearchModal extends Modal {
|
|||
});
|
||||
}
|
||||
|
||||
private populateDropdown(dropdown: DropdownComponent) {
|
||||
dropdown.selectEl.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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
export * from './IssueCommentModal';
|
||||
export * from './IssueSearchModal';
|
||||
export * from './IssueStatusModal';
|
||||
export * from './IssueTypeModal';
|
||||
export * from './IssueWorkLogModal';
|
||||
export * from './ProjectModal';
|
||||
export * from './JQLSearchModal';
|
||||
export * from './ConfirmModal';
|
||||
export * from './ConfirmModal';
|
||||
|
|
|
|||
|
|
@ -31,35 +31,39 @@ export function createJiraSyncExtension(plugin: JiraPlugin): Extension {
|
|||
|
||||
buildDecorations(view: EditorView) {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const sel = view.state.selection;
|
||||
|
||||
const allDecs: Array<{ from: number; to: number; dec: Decoration }> = [];
|
||||
|
||||
for (let { from, to } of view.visibleRanges) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
const codeBlocks = this.findCodeBlocks(text, from);
|
||||
|
||||
// Collect all jira-sync marker positions
|
||||
const markers: Array<{ start: number; end: number; name: string }> = [];
|
||||
|
||||
for (const match of text.matchAll(/`(jira-sync-[^`]+)`/g)) {
|
||||
const start = from + match.index!;
|
||||
const end = start + match[0].length;
|
||||
const contentStart = start; // без первой `
|
||||
const contentEnd = end; // без последней `
|
||||
if (this.isInsideCodeBlock(start, end, codeBlocks)) continue;
|
||||
markers.push({ start, end, name: match[1] });
|
||||
}
|
||||
|
||||
// Find code blocks to ignore
|
||||
if (this.isInsideCodeBlock(contentStart, contentEnd, codeBlocks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let className = 'jira-sync-hidden';
|
||||
|
||||
// Check if cursor is inside
|
||||
const sel = view.state.selection;
|
||||
if (sel.ranges.some((r) => r.from <= contentEnd && r.to >= contentStart)) {
|
||||
className += ' jira-sync-active';
|
||||
}
|
||||
|
||||
builder.add(start, end, Decoration.mark({ class: className }));
|
||||
// Always hide the markers themselves, show when cursor is inside
|
||||
for (const { start, end } of markers) {
|
||||
const isActive = sel.ranges.some((r) => r.from <= end && r.to >= start);
|
||||
const cls = isActive ? 'jira-sync-hidden jira-sync-active' : 'jira-sync-hidden';
|
||||
allDecs.push({ from: start, to: end, dec: Decoration.mark({ class: cls }) });
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
// RangeSetBuilder requires ranges in ascending order of `from`, then `to`
|
||||
allDecs.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
for (const { from, to, dec } of allDecs) {
|
||||
builder.add(from, to, dec);
|
||||
}
|
||||
|
||||
return builder.finish() as DecorationSet;
|
||||
}
|
||||
|
||||
findCodeBlocks(text: string, offset: number) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// For Reading mode - targets <code> elements in <p> tags
|
||||
export function hideJiraPointersReading(element: HTMLElement) {
|
||||
// In Reading mode, inline code becomes <code> elements
|
||||
const codeElements = element.querySelectorAll(':not(pre) > code');
|
||||
export function hideJiraPointersReading() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return function (element: HTMLElement, _: any) {
|
||||
const codeElements = Array.from(element.querySelectorAll(':not(pre) > code')).filter((el) =>
|
||||
el.textContent?.startsWith('jira-sync-'),
|
||||
) as HTMLElement[];
|
||||
|
||||
codeElements.forEach((codeEl) => {
|
||||
const text = codeEl.textContent || '';
|
||||
|
||||
if (text.startsWith('jira-sync-')) {
|
||||
for (const codeEl of codeElements) {
|
||||
codeEl.addClass('jira-sync-hidden');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Notice, Setting } from 'obsidian';
|
||||
import { SettingsComponent, SettingsComponentProps } from '../../interfaces/settingsTypes';
|
||||
import { fetchIssue } from '../../api';
|
||||
import { fetchIssue, validateSettings } from '../../api';
|
||||
import { renameExistingIssueFiles } from '../../file_operations/getIssue';
|
||||
import { ConfirmModal } from '../../modals/ConfirmModal';
|
||||
import debounce from 'lodash/debounce';
|
||||
import hljs from 'highlight.js';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
|
|
@ -23,7 +25,6 @@ export class FetchIssueComponent implements SettingsComponent {
|
|||
render(containerEl: HTMLElement): void {
|
||||
const { plugin } = this.props;
|
||||
|
||||
// Filename template setting
|
||||
const filenameTemplateSetting = new Setting(containerEl)
|
||||
.setName(t('filenameTemplate.name'))
|
||||
.setDesc(t('filenameTemplate.desc'));
|
||||
|
|
@ -37,6 +38,40 @@ export class FetchIssueComponent implements SettingsComponent {
|
|||
});
|
||||
});
|
||||
|
||||
filenameTemplateSetting.addButton((btn) =>
|
||||
btn.setButtonText(t('rename_files')).onClick(async () => {
|
||||
if (!validateSettings(plugin)) return;
|
||||
|
||||
const cacheSize = plugin.getAllIssueKeysMap().size;
|
||||
if (cacheSize === 0) {
|
||||
new Notice(t('no_files_to_rename'));
|
||||
return;
|
||||
}
|
||||
|
||||
new ConfirmModal(
|
||||
plugin.app,
|
||||
t('rename_confirm', { count: cacheSize.toString() }),
|
||||
async () => {
|
||||
const result = await renameExistingIssueFiles(plugin);
|
||||
if (result.renamed > 0) {
|
||||
new Notice(t('rename_success', { count: result.renamed.toString() }));
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
new Notice(
|
||||
t('rename_errors', { count: result.errors.length.toString() }) +
|
||||
': ' +
|
||||
result.errors.slice(0, 3).join(', '),
|
||||
);
|
||||
}
|
||||
if (result.renamed === 0 && result.errors.length === 0) {
|
||||
new Notice(t('rename_no_changes'));
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
).open();
|
||||
}),
|
||||
);
|
||||
|
||||
const link =
|
||||
plugin.getCurrentConnection()?.apiVersion === '3'
|
||||
? 'https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post'
|
||||
|
|
@ -87,7 +122,6 @@ export class FetchIssueComponent implements SettingsComponent {
|
|||
});
|
||||
});
|
||||
|
||||
// Add input field for issue key
|
||||
new Setting(containerEl)
|
||||
.setName(t('key.name'))
|
||||
.setDesc(t('key.desc'))
|
||||
|
|
@ -98,7 +132,6 @@ export class FetchIssueComponent implements SettingsComponent {
|
|||
}),
|
||||
);
|
||||
|
||||
// Create container for issue data
|
||||
this.issueDataContainer = containerEl.createDiv({
|
||||
cls: 'jira-raw-issue-container',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { validateField } from '../tools/fieldValidation';
|
||||
import { useTranslations } from '../../localization/translator';
|
||||
import { FROM_JIRA_PARAMS, TO_JIRA_PARAMS } from '../../default/obsidianJiraFieldsMapping';
|
||||
|
||||
const t = useTranslations('settings.fm').t;
|
||||
/**
|
||||
|
|
@ -82,7 +83,8 @@ export class FieldMappingItem {
|
|||
this.toJiraInput = toJiraContainer.createEl('textarea', {
|
||||
cls: 'to-jira-input',
|
||||
});
|
||||
this.toJiraInput.placeholder = '(value) => {\n // Transform Obsidian value to Jira\n return value;\n}';
|
||||
this.toJiraInput.placeholder =
|
||||
'(value, api_version) => {\n // Transform Obsidian value to Jira\n return value;\n}';
|
||||
this.toJiraInput.value = toJira;
|
||||
|
||||
// Create fromJira function input
|
||||
|
|
@ -98,7 +100,7 @@ export class FieldMappingItem {
|
|||
});
|
||||
this.fromJiraInput.value = fromJira;
|
||||
this.fromJiraInput.placeholder =
|
||||
'(issue, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}';
|
||||
'(issue, api_version, data_source) => {\n // Extract value from Jira issue\n return issue.fields.fieldName;\n}';
|
||||
|
||||
// Add remove button
|
||||
const removeBtn = this.fieldContainer.createEl('button', {
|
||||
|
|
@ -139,8 +141,8 @@ export class FieldMappingItem {
|
|||
*/
|
||||
async setupValidation(enableValidation: boolean) {
|
||||
await validateField(this.fieldNameInput, enableValidation, 'string');
|
||||
await validateField(this.toJiraInput, enableValidation, 'function', ['value']);
|
||||
await validateField(this.fromJiraInput, enableValidation, 'function', ['issue', 'data_source']);
|
||||
await validateField(this.toJiraInput, enableValidation, 'function', TO_JIRA_PARAMS);
|
||||
await validateField(this.fromJiraInput, enableValidation, 'function', FROM_JIRA_PARAMS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class FieldMappingsComponent implements SettingsComponent {
|
|||
// Add explanation
|
||||
mappingSection.createEl('p', {
|
||||
text: t('desc'),
|
||||
cls: 'break-line',
|
||||
});
|
||||
|
||||
// Add validation toggle
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ async function scanFolderForIssueKeys(plugin: JiraPlugin, folder: TFolder): Prom
|
|||
const metadata = plugin.app.metadataCache.getFileCache(child);
|
||||
const issueKey = metadata?.frontmatter?.key;
|
||||
if (issueKey && typeof issueKey === 'string') {
|
||||
plugin.setFilePathForIssueKey(issueKey, child.path);
|
||||
await plugin.setFilePathForIssueKey(issueKey, child.path);
|
||||
}
|
||||
} else if (child instanceof TFolder) {
|
||||
await scanFolderForIssueKeys(plugin, child);
|
||||
|
|
@ -51,7 +51,7 @@ export async function validateCache(plugin: JiraPlugin): Promise<void> {
|
|||
}
|
||||
|
||||
// Remove invalid entries
|
||||
entriesToRemove.forEach((issueKey) => {
|
||||
plugin.removeIssueKeyFromCache(issueKey);
|
||||
});
|
||||
for (const issueKey of entriesToRemove) {
|
||||
await plugin.removeIssueKeyFromCache(issueKey);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { Notice } from 'obsidian';
|
|||
import { JiraIssue } from '../interfaces';
|
||||
import { parse } from 'acorn';
|
||||
import { debugLog } from './debugLogging';
|
||||
import { FieldMapping } from '../default/obsidianJiraFieldsMapping';
|
||||
import { FieldMapping, FROM_JIRA_PARAMS, TO_JIRA_PARAMS } from '../default/obsidianJiraFieldsMapping';
|
||||
import { defaultIssue } from '../default/defaultIssue';
|
||||
import { jiraToMarkdown, markdownToJira } from './markdownHtml';
|
||||
import { markdownToAdf, adfToMarkdown } from './markdownToAdf';
|
||||
|
||||
// Constants for validation and error messages
|
||||
const FORBIDDEN_PATTERNS = ['document', 'window', 'eval', 'Function', 'fetch', 'setTimeout', 'globalThis'];
|
||||
|
|
@ -12,6 +13,8 @@ const SYNTAX_KEYWORDS = ['return', 'if', 'else', 'for', 'while', 'switch', 'try'
|
|||
const SAFE_GLOBALS = {
|
||||
jiraToMarkdown,
|
||||
markdownToJira,
|
||||
markdownToAdf,
|
||||
adfToMarkdown,
|
||||
JSON: {
|
||||
parse: JSON.parse,
|
||||
stringify: JSON.stringify,
|
||||
|
|
@ -54,6 +57,7 @@ export function validateFunctionStringBrowser(
|
|||
const varDeclarations = approved_vars
|
||||
.map((varName) => {
|
||||
if (varName === 'issue') return `${varName} = ${JSON.stringify(defaultIssue)}`;
|
||||
if (varName === 'api_version') return `${varName} = "2"`;
|
||||
if (varName === 'value') return `${varName} = "test value"`;
|
||||
if (varName === 'data_source') return `${varName} = {}`;
|
||||
return `${varName} = {}`;
|
||||
|
|
@ -88,6 +92,7 @@ export function validateFunctionStringBrowser(
|
|||
|
||||
const testArgs = approved_vars.map((varName) => {
|
||||
if (varName === 'issue') return defaultIssue;
|
||||
if (varName === 'api_version') return '2';
|
||||
if (varName === 'value') return 'test value';
|
||||
if (varName === 'data_source') return {};
|
||||
return {};
|
||||
|
|
@ -177,7 +182,7 @@ export async function safeStringToFunction(
|
|||
if (extraValidate) {
|
||||
const validation = await validateFunctionString(
|
||||
exprString,
|
||||
type === 'fromJira' ? ['issue', 'data_source'] : ['value'],
|
||||
type === 'fromJira' ? FROM_JIRA_PARAMS : TO_JIRA_PARAMS,
|
||||
);
|
||||
if (!validation.isValid) {
|
||||
console.warn(`Invalid function: ${validation.errorMessage}`);
|
||||
|
|
@ -200,6 +205,8 @@ export async function safeStringToFunction(
|
|||
const context = {
|
||||
jiraToMarkdown,
|
||||
markdownToJira,
|
||||
markdownToAdf,
|
||||
adfToMarkdown,
|
||||
JSON,
|
||||
Math,
|
||||
Date,
|
||||
|
|
@ -211,9 +218,9 @@ export async function safeStringToFunction(
|
|||
};
|
||||
|
||||
if (type === 'toJira') {
|
||||
return function (value: any) {
|
||||
return function (value: any, api_version?: '2' | '3') {
|
||||
const fn = new Function(
|
||||
'value',
|
||||
...TO_JIRA_PARAMS,
|
||||
'context',
|
||||
`
|
||||
with (context) {
|
||||
|
|
@ -227,14 +234,13 @@ export async function safeStringToFunction(
|
|||
`,
|
||||
);
|
||||
|
||||
return fn.call(context, value, context);
|
||||
return fn.call(context, value, api_version, context);
|
||||
};
|
||||
} else {
|
||||
// fromJira
|
||||
return function (issue: JiraIssue, data_source: Record<string, any> | null) {
|
||||
return function (issue: JiraIssue, api_version?: '2' | '3', data_source?: Record<string, any>) {
|
||||
const fn = new Function(
|
||||
'issue',
|
||||
'data_source',
|
||||
...FROM_JIRA_PARAMS,
|
||||
'context',
|
||||
`
|
||||
with (context) {
|
||||
|
|
@ -248,7 +254,7 @@ export async function safeStringToFunction(
|
|||
`,
|
||||
);
|
||||
|
||||
return fn.call(context, issue, data_source, context);
|
||||
return fn.call(context, issue, api_version, data_source, context);
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -263,38 +269,26 @@ export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: bo
|
|||
|
||||
if (!baseStr) return baseStr;
|
||||
|
||||
if (isFromJira) {
|
||||
const fnStr = fn.toString().trim();
|
||||
const fnStr = fn.toString().trim();
|
||||
|
||||
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
|
||||
if (paramMatch) {
|
||||
const params = (paramMatch[1] || paramMatch[2] || '').split(',').map((p) => p.trim());
|
||||
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
|
||||
if (paramMatch) {
|
||||
const params = (paramMatch[1] || paramMatch[2] || '').split(',').map((p) => p.trim());
|
||||
let resultStr = baseStr;
|
||||
|
||||
let resultStr = baseStr;
|
||||
|
||||
if (params.length >= 1 && params[0]) {
|
||||
const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g');
|
||||
resultStr = resultStr.replace(regex1, 'issue');
|
||||
}
|
||||
if (params.length >= 2 && params[1]) {
|
||||
const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g');
|
||||
resultStr = resultStr.replace(regex2, 'data_source');
|
||||
}
|
||||
|
||||
return resultStr;
|
||||
if (params.length >= 1 && params[0]) {
|
||||
const regex1 = new RegExp(`\\b${params[0]}\\b`, 'g');
|
||||
resultStr = isFromJira ? resultStr.replace(regex1, 'issue') : resultStr.replace(regex1, 'value');
|
||||
}
|
||||
} else {
|
||||
const fnStr = fn.toString().trim();
|
||||
|
||||
const paramMatch = fnStr.match(/^\s*(?:\(?([^)]*)\)?\s*=>|\s*function\s*\(([^)]*)\))/);
|
||||
if (paramMatch) {
|
||||
const param = (paramMatch[1] || paramMatch[2] || '').trim();
|
||||
|
||||
if (param) {
|
||||
const regex = new RegExp(`\\b${param}\\b`, 'g');
|
||||
return baseStr.replace(regex, 'value');
|
||||
}
|
||||
if (params.length >= 2 && params[1]) {
|
||||
const regex2 = new RegExp(`\\b${params[1]}\\b`, 'g');
|
||||
resultStr = resultStr.replace(regex2, 'api_version');
|
||||
}
|
||||
if (params.length >= 3 && params[2]) {
|
||||
const regex3 = new RegExp(`\\b${params[2]}\\b`, 'g');
|
||||
resultStr = resultStr.replace(regex3, 'data_source');
|
||||
}
|
||||
return resultStr;
|
||||
}
|
||||
|
||||
return baseStr;
|
||||
|
|
@ -303,6 +297,13 @@ export function jiraFunctionToString(fn: (...args: any[]) => any, isFromJira: bo
|
|||
export function functionToExpressionString(fn: (...args: any[]) => any): string {
|
||||
try {
|
||||
const fnStr = fn.toString().trim();
|
||||
if (fnStr.contains('\n')) {
|
||||
// return everything for multiline funcs
|
||||
return fnStr
|
||||
.split('\n')
|
||||
.map((s: string) => s.trim())
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Handle arrow functions
|
||||
const arrowMatch = fnStr.match(/^\s*\(?([^)]*)\)?\s*=>\s*(.+)$/s);
|
||||
|
|
@ -339,13 +340,17 @@ export async function transform_string_to_functions_mappings(
|
|||
// Also convert to functions for runtime use
|
||||
const transformedMappings: Record<string, FieldMapping> = {};
|
||||
for (const [fieldName, { toJira, fromJira }] of Object.entries(mappings)) {
|
||||
const toJiraFn = safeStringToFunction(toJira, 'toJira', extraValidate);
|
||||
const fromJiraFn = safeStringToFunction(fromJira, 'fromJira', extraValidate);
|
||||
const toJiraFn = await safeStringToFunction(toJira, 'toJira', extraValidate);
|
||||
const fromJiraFn = await safeStringToFunction(fromJira, 'fromJira', extraValidate);
|
||||
|
||||
if (toJiraFn && fromJiraFn) {
|
||||
transformedMappings[fieldName] = {
|
||||
toJira: (await toJiraFn) as (value: any) => any,
|
||||
fromJira: (await fromJiraFn) as (issue: JiraIssue, data_source: Record<string, any> | null) => any,
|
||||
toJira: toJiraFn as (value: any, api_version?: '2' | '3') => any,
|
||||
fromJira: fromJiraFn as (
|
||||
issue: JiraIssue,
|
||||
api_version?: '2' | '3',
|
||||
data_source?: Record<string, any>,
|
||||
) => any,
|
||||
};
|
||||
} else {
|
||||
console.warn(`Invalid function in field: ${fieldName}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { JiraIssue } from '../interfaces';
|
||||
import { jiraToMarkdown } from './markdownHtml';
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import JiraPlugin from '../main';
|
||||
import { extractAllJiraSyncValuesFromContent, updateJiraSyncContent } from './sectionTools';
|
||||
|
|
@ -9,6 +8,7 @@ import { debugLog } from './debugLogging';
|
|||
export function localToJiraFields(
|
||||
data_source: Record<string, any>,
|
||||
customFieldMappings: Record<string, FieldMapping>,
|
||||
apiVersion?: '2' | '3',
|
||||
): Record<string, any> {
|
||||
const jiraFields: Record<string, any> = {};
|
||||
|
||||
|
|
@ -24,10 +24,12 @@ export function localToJiraFields(
|
|||
const mapping = customFieldMappings[key];
|
||||
|
||||
try {
|
||||
// Skip fields that shouldn't be sent to Jira
|
||||
if (mapping.toJira(value) === null) continue;
|
||||
const result = mapping.toJira(value, apiVersion);
|
||||
|
||||
jiraFields[key] = mapping.toJira(value);
|
||||
// Skip fields that shouldn't be sent to Jira
|
||||
if (result === null) continue;
|
||||
|
||||
jiraFields[key] = result;
|
||||
} catch (e) {
|
||||
console.error(`Error mapping for ${key}: ${e}`);
|
||||
new Notice(`Error mapping for ${key}: ${e}`);
|
||||
|
|
@ -43,13 +45,19 @@ export function localToJiraFields(
|
|||
}
|
||||
|
||||
export async function updateJiraToLocal(plugin: JiraPlugin, file: TFile, issue: JiraIssue): Promise<void> {
|
||||
const apiVersion = plugin.getCurrentConnection()?.apiVersion;
|
||||
// First, update the frontmatter using processFrontMatter
|
||||
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
// Update frontmatter with Jira data
|
||||
applyJiraDataToLocal(frontmatter, issue, {
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
});
|
||||
applyJiraDataToLocal(
|
||||
frontmatter,
|
||||
issue,
|
||||
{
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
},
|
||||
apiVersion,
|
||||
);
|
||||
});
|
||||
|
||||
// Then, process the file content to update sync sections
|
||||
|
|
@ -58,16 +66,21 @@ export async function updateJiraToLocal(plugin: JiraPlugin, file: TFile, issue:
|
|||
const syncSections = extractAllJiraSyncValuesFromContent(fileContent);
|
||||
|
||||
// Update sync sections with Jira data
|
||||
applyJiraDataToLocal(syncSections, issue, {
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
});
|
||||
applyJiraDataToLocal(
|
||||
syncSections,
|
||||
issue,
|
||||
{
|
||||
...obsidianJiraFieldMappings,
|
||||
...plugin.settings.fieldMapping.fieldMappings,
|
||||
},
|
||||
apiVersion,
|
||||
);
|
||||
|
||||
// Update content sections from Jira fields
|
||||
let updatedContent = fileContent;
|
||||
let updatesDict: Record<string, string> = {};
|
||||
for (const [fieldName, fieldValue] of Object.entries(syncSections)) {
|
||||
updatesDict[fieldName] = jiraToMarkdown(fieldValue);
|
||||
updatesDict[fieldName] = fieldValue;
|
||||
}
|
||||
|
||||
debugLog(`Updating sync sections: ${JSON.stringify(updatesDict)}`);
|
||||
|
|
@ -81,10 +94,11 @@ export function applyJiraDataToLocal(
|
|||
localData: Record<string, any>,
|
||||
issue: JiraIssue,
|
||||
fieldMappings: Record<string, FieldMapping>,
|
||||
apiVersion?: '2' | '3',
|
||||
): void {
|
||||
// Process existing fields in local data
|
||||
for (const key of Object.keys(localData)) {
|
||||
updateFieldFromJira(key, localData, issue, fieldMappings);
|
||||
updateFieldFromJira(key, localData, issue, fieldMappings, apiVersion);
|
||||
}
|
||||
|
||||
// Ensure required fields are always processed
|
||||
|
|
@ -101,13 +115,14 @@ function updateFieldFromJira(
|
|||
targetObject: Record<string, any>,
|
||||
issue: JiraIssue,
|
||||
fieldMappings: Record<string, FieldMapping>,
|
||||
apiVersion?: '2' | '3',
|
||||
): void {
|
||||
try {
|
||||
let value = issue.fields[key];
|
||||
|
||||
// Apply custom mapping if available
|
||||
if (key in fieldMappings) {
|
||||
value = fieldMappings[key].fromJira(issue, targetObject);
|
||||
value = fieldMappings[key].fromJira(issue, apiVersion, targetObject);
|
||||
}
|
||||
// debugLog(`Updating field: ${key}: ${issue.fields[key]}`);
|
||||
|
||||
|
|
@ -129,14 +144,14 @@ function logMappingDebugInfo(key: string, error: Error, fieldMappings: Record<st
|
|||
new Notice(`Error mapping for ${key}: ${error}`);
|
||||
|
||||
// Create debug info about available mappings
|
||||
const mappingInfo: Record<string, { hasToJira: string; hasFromJira: string }> = {};
|
||||
const mappingInfo: Record<string, { toJira: string; fromJira: string }> = {};
|
||||
|
||||
for (const mappingKey of Object.keys(fieldMappings)) {
|
||||
mappingInfo[mappingKey] = {
|
||||
hasToJira: typeof fieldMappings[mappingKey].toJira,
|
||||
hasFromJira: typeof fieldMappings[mappingKey].fromJira,
|
||||
toJira: fieldMappings[mappingKey].toJira.toString().trim(),
|
||||
fromJira: fieldMappings[mappingKey].fromJira.toString().trim(),
|
||||
};
|
||||
}
|
||||
|
||||
console.debug(`Available mappings: ${JSON.stringify(mappingInfo)}`);
|
||||
console.debug(`Available mappings:`, mappingInfo);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
* @param str The Jira markup string
|
||||
* @returns The Markdown string
|
||||
*/
|
||||
export function jiraToMarkdown(str: any): string {
|
||||
export function jiraToMarkdown(str: any): string | null {
|
||||
try {
|
||||
if (str === null || str === undefined) return '';
|
||||
if (str === undefined) return null;
|
||||
if (str === null) return '';
|
||||
|
||||
// Initial normalization to string
|
||||
let content: string = '';
|
||||
|
|
|
|||
440
src/tools/markdownToAdf.ts
Normal file
440
src/tools/markdownToAdf.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ import {
|
|||
TimekeepSettingsInterface,
|
||||
} from '../settings/default';
|
||||
|
||||
export function checkMigrateSettings(data: any, saveSettings: () => void): any {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
export function checkMigrateSettings(data: any): { result: any; changed: boolean } {
|
||||
if (!data || typeof data !== 'object') return { result: data, changed: false };
|
||||
|
||||
const result = { ...data };
|
||||
let data_changed = false;
|
||||
|
|
@ -84,6 +84,8 @@ export function checkMigrateSettings(data: any, saveSettings: () => void): any {
|
|||
username: result.connection.username || '',
|
||||
email: result.connection.email || '',
|
||||
password: result.connection.password || '',
|
||||
jqlPresets: [],
|
||||
lastJqlQuery: '',
|
||||
},
|
||||
];
|
||||
result.currentConnectionIndex = 0;
|
||||
|
|
@ -91,9 +93,23 @@ export function checkMigrateSettings(data: any, saveSettings: () => void): any {
|
|||
data_changed = true;
|
||||
}
|
||||
|
||||
if (data_changed) {
|
||||
saveSettings();
|
||||
// Ensure all connections have jqlPresets and lastJqlQuery
|
||||
if (result.connections && Array.isArray(result.connections)) {
|
||||
for (const conn of result.connections) {
|
||||
if (!('jqlPresets' in conn)) {
|
||||
conn.jqlPresets = [];
|
||||
data_changed = true;
|
||||
}
|
||||
if (!('lastJqlQuery' in conn)) {
|
||||
conn.lastJqlQuery = '';
|
||||
data_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
if (data_changed) {
|
||||
return { result, changed: true };
|
||||
}
|
||||
|
||||
return { result, changed: false };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1470,3 +1470,7 @@ code.hljs {
|
|||
background: var(--interactive-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.break-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
|
|
|||
183
tests/__mocks__/obsidian.ts
Normal file
183
tests/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
export class Plugin {
|
||||
app: App;
|
||||
settings: Record<string, any> = {};
|
||||
|
||||
constructor() {
|
||||
this.app = new App();
|
||||
}
|
||||
|
||||
async loadData(): Promise<any> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async saveData(data: any): Promise<void> {}
|
||||
|
||||
addCommand(_cmd: any): void {}
|
||||
|
||||
addSettingTab(_tab: any): void {}
|
||||
|
||||
registerEvent(_event: any): void {}
|
||||
|
||||
registerEditorExtension(_ext: any): void {}
|
||||
|
||||
registerMarkdownPostProcessor(_proc: any): void {}
|
||||
|
||||
registerInterval(_id: number): number {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class App {
|
||||
vault: Vault;
|
||||
workspace: Workspace;
|
||||
metadataCache: MetadataCache;
|
||||
fileManager: FileManager;
|
||||
}
|
||||
|
||||
export class Vault {
|
||||
async read(_file: TFile): Promise<string> {
|
||||
return '';
|
||||
}
|
||||
async process(_file: TFile, _fn: (data: string) => string): Promise<string> {
|
||||
return '';
|
||||
}
|
||||
async create(_path: string, _content: string): Promise<TFile> {
|
||||
return new TFile();
|
||||
}
|
||||
async rename(_file: TFile, _newPath: string): Promise<void> {}
|
||||
getFileByPath(_path: string): TFile | null {
|
||||
return null;
|
||||
}
|
||||
getFolderByPath(_path: string): TFolder | null {
|
||||
return null;
|
||||
}
|
||||
async createFolder(_path: string): Promise<void> {}
|
||||
}
|
||||
|
||||
export class TFile {
|
||||
path: string = '';
|
||||
extension: string = 'md';
|
||||
name: string = '';
|
||||
parent: TFolder | null = null;
|
||||
vault: Vault = new Vault();
|
||||
}
|
||||
|
||||
export class TFolder {
|
||||
path: string = '';
|
||||
name: string = '';
|
||||
children: (TFile | TFolder)[] = [];
|
||||
isRoot(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class Workspace {
|
||||
on(_name: string, _cb: (...args: any[]) => any): void {}
|
||||
openLinkText(_path: string, _sourcePath?: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export class MetadataCache {
|
||||
getFileCache(_file: TFile): any {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FileManager {
|
||||
async processFrontMatter(_file: TFile, _fn: (frontmatter: any) => void): Promise<void> {}
|
||||
}
|
||||
|
||||
export class Modal {
|
||||
app: App;
|
||||
contentEl: HTMLElement;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
this.contentEl = document.createElement('div');
|
||||
}
|
||||
|
||||
open(): void {}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
export class Setting {
|
||||
constructor(_containerEl: HTMLElement) {
|
||||
this.setName('');
|
||||
this.setDesc('');
|
||||
}
|
||||
setName(_name: string): this {
|
||||
return this;
|
||||
}
|
||||
setDesc(_desc: string): this {
|
||||
return this;
|
||||
}
|
||||
addText(_cb: (text: TextComponent) => any): this {
|
||||
return this;
|
||||
}
|
||||
addTextArea(_cb: (text: TextComponent) => any): this {
|
||||
return this;
|
||||
}
|
||||
addButton(_cb: (btn: ButtonComponent) => any): this {
|
||||
return this;
|
||||
}
|
||||
addDropdown(_cb: (dropdown: DropdownComponent) => any): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextComponent {
|
||||
inputEl: HTMLInputElement;
|
||||
constructor() {
|
||||
this.inputEl = document.createElement('input');
|
||||
}
|
||||
setPlaceholder(_placeholder: string): this {
|
||||
return this;
|
||||
}
|
||||
setValue(_value: string): this {
|
||||
return this;
|
||||
}
|
||||
onChange(_cb: (value: string) => void): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonComponent {
|
||||
buttonEl: HTMLButtonElement;
|
||||
constructor() {
|
||||
this.buttonEl = document.createElement('button');
|
||||
}
|
||||
setButtonText(_text: string): this {
|
||||
return this;
|
||||
}
|
||||
setCta(): this {
|
||||
return this;
|
||||
}
|
||||
onClick(_cb: () => void): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class DropdownComponent {
|
||||
selectEl: HTMLSelectElement;
|
||||
constructor() {
|
||||
this.selectEl = document.createElement('select');
|
||||
}
|
||||
addOption(_value: string, _display: string): this {
|
||||
return this;
|
||||
}
|
||||
setValue(_value: string): this {
|
||||
return this;
|
||||
}
|
||||
onChange(_cb: (value: string) => void): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class Notice {
|
||||
constructor(_message: string) {}
|
||||
}
|
||||
|
||||
export function requestUrl(params: any): Promise<any> {
|
||||
return Promise.resolve({ status: 200, json: {}, text: '' });
|
||||
}
|
||||
175
tests/__tests__/api/issues.test.ts
Normal file
175
tests/__tests__/api/issues.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface TestConnection {
|
||||
name: string;
|
||||
jiraUrl: string;
|
||||
apiVersion: '2' | '3';
|
||||
authMethod: 'bearer' | 'basic';
|
||||
apiToken: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface TestConfig {
|
||||
connections: TestConnection[];
|
||||
}
|
||||
|
||||
function loadConfig(): TestConfig | null {
|
||||
try {
|
||||
const configPath = path.resolve(__dirname, '../../test-config.local.json');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const runIntegration = !!config && process.env.RUN_INTEGRATION_TESTS === 'true';
|
||||
|
||||
async function mockBaseRequest(conn: TestConnection, method: string, urlPath: string, body?: string): Promise<any> {
|
||||
const url = `${conn.jiraUrl}/rest/api/${conn.apiVersion}${urlPath}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (conn.authMethod === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${conn.apiToken}`;
|
||||
} else if (conn.authMethod === 'basic' && conn.email) {
|
||||
const encoded = Buffer.from(`${conn.email}:${conn.apiToken}`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
const fetchArgs: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body: body || undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchArgs);
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
async function findExistingIssue(conn: TestConnection): Promise<string | null> {
|
||||
try {
|
||||
const result = await mockBaseRequest(
|
||||
conn,
|
||||
'POST',
|
||||
'/search',
|
||||
JSON.stringify({ jql: '', maxResults: 1, fields: ['key'] }),
|
||||
);
|
||||
if (result.issues && result.issues.length > 0) {
|
||||
return result.issues[0].key;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (runIntegration) {
|
||||
describe.each(config!.connections)('Integration: $name ($apiVersion)', (conn) => {
|
||||
let existingIssueKey: string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
existingIssueKey = await findExistingIssue(conn);
|
||||
});
|
||||
|
||||
it('fetches server info (self)', async () => {
|
||||
const result = await mockBaseRequest(conn, 'GET', '/serverInfo');
|
||||
expect(result).toHaveProperty('baseUrl');
|
||||
expect(result).toHaveProperty('version');
|
||||
});
|
||||
|
||||
it('searches issues with empty JQL', async () => {
|
||||
const result = await mockBaseRequest(
|
||||
conn,
|
||||
'POST',
|
||||
'/search',
|
||||
JSON.stringify({ jql: '', maxResults: 1, fields: ['key', 'summary'] }),
|
||||
);
|
||||
expect(result).toHaveProperty('issues');
|
||||
expect(Array.isArray(result.issues)).toBe(true);
|
||||
if (result.issues.length > 0) {
|
||||
expect(result.issues[0]).toHaveProperty('key');
|
||||
expect(result.issues[0]).toHaveProperty('fields');
|
||||
}
|
||||
});
|
||||
|
||||
it('fetches a specific issue by key', async () => {
|
||||
if (!existingIssueKey) {
|
||||
console.warn('No existing issues found, skipping test');
|
||||
return;
|
||||
}
|
||||
const result = await mockBaseRequest(conn, 'GET', `/issue/${existingIssueKey}`);
|
||||
expect(result).toHaveProperty('key', existingIssueKey);
|
||||
expect(result).toHaveProperty('fields');
|
||||
expect(result.fields).toHaveProperty('summary');
|
||||
});
|
||||
|
||||
it('searches by project JQL', async () => {
|
||||
if (!existingIssueKey) {
|
||||
console.warn('No existing issues found, skipping test');
|
||||
return;
|
||||
}
|
||||
const projectKey = existingIssueKey.split('-')[0];
|
||||
const result = await mockBaseRequest(
|
||||
conn,
|
||||
'POST',
|
||||
'/search',
|
||||
JSON.stringify({ jql: `project = ${projectKey}`, maxResults: 3, fields: ['key', 'summary'] }),
|
||||
);
|
||||
expect(result).toHaveProperty('issues');
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
for (const issue of result.issues) {
|
||||
expect(issue.key).toMatch(new RegExp(`^${projectKey}-`));
|
||||
}
|
||||
});
|
||||
|
||||
it('fetches issue transitions', async () => {
|
||||
if (!existingIssueKey) {
|
||||
console.warn('No existing issues found, skipping test');
|
||||
return;
|
||||
}
|
||||
const result = await mockBaseRequest(conn, 'GET', `/issue/${existingIssueKey}/transitions`);
|
||||
expect(result).toHaveProperty('transitions');
|
||||
expect(Array.isArray(result.transitions)).toBe(true);
|
||||
});
|
||||
|
||||
if (conn.apiVersion === '3') {
|
||||
it('uses approximate count endpoint (v3 specific)', async () => {
|
||||
const result = await mockBaseRequest(
|
||||
conn,
|
||||
'POST',
|
||||
'/search/approximate-count',
|
||||
JSON.stringify({ jql: '' }),
|
||||
);
|
||||
expect(result).toHaveProperty('count');
|
||||
expect(typeof result.count).toBe('number');
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
describe('Integration tests', () => {
|
||||
it('are skipped unless RUN_INTEGRATION_TESTS=true and tests/test-config.local.json exists', () => {
|
||||
if (config) {
|
||||
console.info('Test config found. Set RUN_INTEGRATION_TESTS=true to run integration tests.');
|
||||
} else {
|
||||
console.info(
|
||||
'No test config found. Create tests/test-config.local.json (see test-config.example.json).',
|
||||
);
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
75
tests/__tests__/file_operations/getIssue.test.ts
Normal file
75
tests/__tests__/file_operations/getIssue.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeFileName } from '../../../src/tools/sanitizers';
|
||||
|
||||
function generateFilenameFromTemplate(template: string, issue: { key: string; fields: { summary?: string } }): string {
|
||||
let filename = template;
|
||||
const summary = issue.fields?.summary || '';
|
||||
const sanitizedSummary = sanitizeFileName(summary);
|
||||
filename = filename.replace(/\{summary\}/g, sanitizedSummary);
|
||||
const key = issue.key || '';
|
||||
filename = filename.replace(/\{key\}/g, key);
|
||||
filename = sanitizeFileName(filename);
|
||||
if (!filename || filename.trim() === '') {
|
||||
if (key) {
|
||||
filename = key;
|
||||
} else if (sanitizedSummary) {
|
||||
filename = sanitizedSummary;
|
||||
} else {
|
||||
filename = 'jira-issue';
|
||||
}
|
||||
}
|
||||
return filename.trim();
|
||||
}
|
||||
|
||||
describe('generateFilenameFromTemplate', () => {
|
||||
it('generates filename with summary and key default template', () => {
|
||||
const issue = { key: 'PROJ-123', fields: { summary: 'Fix login bug' } };
|
||||
const result = generateFilenameFromTemplate('{summary} ({key})', issue);
|
||||
expect(result).toBe('Fix login bug (PROJ-123)');
|
||||
});
|
||||
|
||||
it('handles template with only key', () => {
|
||||
const issue = { key: 'PROJ-123', fields: { summary: 'Fix login bug' } };
|
||||
const result = generateFilenameFromTemplate('{key}', issue);
|
||||
expect(result).toBe('PROJ-123');
|
||||
});
|
||||
|
||||
it('handles template with only summary', () => {
|
||||
const issue = { key: 'PROJ-123', fields: { summary: 'Fix login bug' } };
|
||||
const result = generateFilenameFromTemplate('{summary}', issue);
|
||||
expect(result).toBe('Fix login bug');
|
||||
});
|
||||
|
||||
it('sanitizes summary in filename', () => {
|
||||
const issue = { key: 'PROJ-1', fields: { summary: 'bug: fix "critical" issue?' } };
|
||||
const result = generateFilenameFromTemplate('{summary}', issue);
|
||||
expect(result).not.toContain(':');
|
||||
expect(result).not.toContain('"');
|
||||
expect(result).not.toContain('?');
|
||||
expect(result).toContain('-');
|
||||
});
|
||||
|
||||
it('falls back to key when summary is empty but template has non-template text', () => {
|
||||
const issue = { key: 'PROJ-1', fields: { summary: '' } };
|
||||
const result = generateFilenameFromTemplate('{summary}', issue);
|
||||
expect(result).toBe('PROJ-1');
|
||||
});
|
||||
|
||||
it('falls back to jira-issue when both key and summary are empty', () => {
|
||||
const issue = { key: '', fields: { summary: '' } };
|
||||
const result = generateFilenameFromTemplate('{summary}', issue);
|
||||
expect(result).toBe('jira-issue');
|
||||
});
|
||||
|
||||
it('preserves custom template structure', () => {
|
||||
const issue = { key: 'PROJ-42', fields: { summary: 'My Task' } };
|
||||
const result = generateFilenameFromTemplate('{key}-{summary}', issue);
|
||||
expect(result).toBe('PROJ-42-My Task');
|
||||
});
|
||||
|
||||
it('handles missing fields gracefully by showing non-template parts', () => {
|
||||
const issue = { key: 'PROJ-1', fields: {} };
|
||||
const result = generateFilenameFromTemplate('{summary} ({key})', issue);
|
||||
expect(result).toBe('(PROJ-1)');
|
||||
});
|
||||
});
|
||||
55
tests/__tests__/localization/translator.test.ts
Normal file
55
tests/__tests__/localization/translator.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { useTranslations, t } from '../../../src/localization/translator';
|
||||
|
||||
describe('translator', () => {
|
||||
const originalLanguage = window.localStorage.getItem('language');
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLanguage) {
|
||||
window.localStorage.setItem('language', originalLanguage);
|
||||
} else {
|
||||
window.localStorage.removeItem('language');
|
||||
}
|
||||
});
|
||||
|
||||
describe('useTranslations', () => {
|
||||
it('returns translate function with correct prefix', () => {
|
||||
const { t: translate } = useTranslations('modals.jql_search');
|
||||
const result = translate('desc');
|
||||
expect(result).toBe('Search issues by JQL query');
|
||||
});
|
||||
|
||||
it('returns locale string', () => {
|
||||
const { locale } = useTranslations();
|
||||
expect(locale).toBe('en');
|
||||
});
|
||||
|
||||
it('returns correct locale when language is set', () => {
|
||||
// ru locale may not be compiled; test with en
|
||||
const { locale } = useTranslations();
|
||||
expect(locale).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('t', () => {
|
||||
it('returns translation for valid key with prefix', () => {
|
||||
const result = t('jql.name', 'modals.jql_search');
|
||||
expect(result).toBe('JQL Query');
|
||||
});
|
||||
|
||||
it('returns translation key when not found', () => {
|
||||
const result = t('nonexistent.key', 'some_prefix');
|
||||
expect(result).toBe('some_prefix.nonexistent.key');
|
||||
});
|
||||
|
||||
it('replaces placeholders in translation', () => {
|
||||
const result = t('total', 'modals.jql_search.preview', { total: '42' });
|
||||
expect(result).toBe('Total 42 issues found');
|
||||
});
|
||||
|
||||
it('returns command translation', () => {
|
||||
const result = t('name', 'commands.batch_fetch_issues');
|
||||
expect(result).toBe('Batch Fetch Issues by JQL');
|
||||
});
|
||||
});
|
||||
});
|
||||
4
tests/__tests__/setup.ts
Normal file
4
tests/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(cb, 0));
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
|
||||
62
tests/__tests__/tools/convertFunctionString.test.ts
Normal file
62
tests/__tests__/tools/convertFunctionString.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { validateFunctionString } from '../../../src/tools/convertFunctionString';
|
||||
|
||||
describe('validateFunctionString', () => {
|
||||
it('accepts empty string', async () => {
|
||||
const result = await validateFunctionString('');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts whitespace-only string', async () => {
|
||||
const result = await validateFunctionString(' ');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects document reference', async () => {
|
||||
const result = await validateFunctionString('document.title');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorMessage).toContain('document');
|
||||
});
|
||||
|
||||
it('rejects window reference', async () => {
|
||||
const result = await validateFunctionString('window.location');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorMessage).toContain('window');
|
||||
});
|
||||
|
||||
it('rejects eval reference', async () => {
|
||||
const result = await validateFunctionString('eval("1+1")');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorMessage).toContain('eval');
|
||||
});
|
||||
|
||||
it('rejects Function reference', async () => {
|
||||
const result = await validateFunctionString('Function("return 1")');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorMessage).toContain('Function');
|
||||
});
|
||||
|
||||
it('rejects fetch reference', async () => {
|
||||
const result = await validateFunctionString('fetch("/api")');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorMessage).toContain('fetch');
|
||||
});
|
||||
|
||||
it('accepts simple property access', async () => {
|
||||
const result = await validateFunctionString('issue.fields.summary', ['issue']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts simple expressions', async () => {
|
||||
const result = await validateFunctionString('value + "!"', ['value']);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts arrow function syntax', async () => {
|
||||
const result = await validateFunctionString('(issue, api_version) => issue.fields.summary', [
|
||||
'issue',
|
||||
'api_version',
|
||||
]);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
56
tests/__tests__/tools/sanitizers.test.ts
Normal file
56
tests/__tests__/tools/sanitizers.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeFileName } from '../../../src/tools/sanitizers';
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('replaces backslash with dash', () => {
|
||||
expect(sanitizeFileName('foo\\bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces forward slash with dash', () => {
|
||||
expect(sanitizeFileName('foo/bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces colon with dash', () => {
|
||||
expect(sanitizeFileName('foo: bar')).toBe('foo- bar');
|
||||
});
|
||||
|
||||
it('replaces asterisk with dash', () => {
|
||||
expect(sanitizeFileName('foo*bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces question mark with dash', () => {
|
||||
expect(sanitizeFileName('foo?bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces double quote with dash', () => {
|
||||
expect(sanitizeFileName('foo"bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces left angle bracket with dash', () => {
|
||||
expect(sanitizeFileName('foo<bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces right angle bracket with dash', () => {
|
||||
expect(sanitizeFileName('foo>bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces pipe with dash', () => {
|
||||
expect(sanitizeFileName('foo|bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('replaces all invalid characters', () => {
|
||||
expect(sanitizeFileName('a<b>c"d:e/f\\g*h?i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(sanitizeFileName('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves valid characters', () => {
|
||||
expect(sanitizeFileName('hello-world 123')).toBe('hello-world 123');
|
||||
});
|
||||
|
||||
it('handles unicode characters', () => {
|
||||
expect(sanitizeFileName('привет мир')).toBe('привет мир');
|
||||
});
|
||||
});
|
||||
112
tests/__tests__/tools/sectionTools.test.ts
Normal file
112
tests/__tests__/tools/sectionTools.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseFileContent,
|
||||
extractAllJiraSyncValuesFromContent,
|
||||
updateJiraSyncContent,
|
||||
} from '../../../src/tools/sectionTools';
|
||||
|
||||
describe('parseFileContent', () => {
|
||||
it('parses a sync section (stops at next heading)', () => {
|
||||
const content = '`jira-sync-section-status`\nIn Progress\n\n## Next Section\nmore text';
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('section');
|
||||
expect(blocks[0].name).toBe('status');
|
||||
expect(blocks[0].content).toBe('In Progress');
|
||||
});
|
||||
|
||||
it('parses a sync section without heading captures everything', () => {
|
||||
const content = '`jira-sync-section-status`\nIn Progress\n\nmore text';
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks[0].type).toBe('section');
|
||||
expect(blocks[0].content).toContain('In Progress');
|
||||
expect(blocks[0].content).toContain('more text');
|
||||
});
|
||||
|
||||
it('parses a sync line (captures rest of line)', () => {
|
||||
const content = 'text `jira-sync-line-assignee`johndoe more text';
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks[0].type).toBe('line');
|
||||
expect(blocks[0].name).toBe('assignee');
|
||||
expect(blocks[0].content).toBe('johndoe more text');
|
||||
});
|
||||
|
||||
it('parses an inline block', () => {
|
||||
const content = 'hello `jira-sync-inline-start-summary`my issue`jira-sync-end` world';
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('inline');
|
||||
expect(blocks[0].name).toBe('summary');
|
||||
expect(blocks[0].content).toBe('my issue');
|
||||
});
|
||||
|
||||
it('parses a block', () => {
|
||||
const content = 'before\n`jira-sync-block-start-description`\nline1\nline2\n`jira-sync-end`\nafter';
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('block');
|
||||
expect(blocks[0].name).toBe('description');
|
||||
expect(blocks[0].content).toBe('line1\nline2');
|
||||
});
|
||||
|
||||
it('returns empty array for content without sync markers', () => {
|
||||
const content = 'just plain text\nno markers here';
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses multiple blocks of different types', () => {
|
||||
const content = [
|
||||
'`jira-sync-section-status`',
|
||||
'Done',
|
||||
'',
|
||||
'`jira-sync-line-assignee`john',
|
||||
'`jira-sync-inline-start-priority`High`jira-sync-end`',
|
||||
].join('\n');
|
||||
const blocks = parseFileContent(content);
|
||||
expect(blocks).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAllJiraSyncValuesFromContent', () => {
|
||||
it('extracts values into a dictionary', () => {
|
||||
const content = '`jira-sync-section-status`\nDone\n`jira-sync-line-assignee`john';
|
||||
const result = extractAllJiraSyncValuesFromContent(content);
|
||||
expect(result).toEqual({ status: 'Done', assignee: 'john' });
|
||||
});
|
||||
|
||||
it('returns empty object for content without sync markers', () => {
|
||||
const result = extractAllJiraSyncValuesFromContent('plain text');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateJiraSyncContent', () => {
|
||||
it('updates a section with new content', () => {
|
||||
const content = '`jira-sync-section-status`\nIn Progress';
|
||||
const result = updateJiraSyncContent(content, { status: 'Done' });
|
||||
expect(result).toContain('Done');
|
||||
});
|
||||
|
||||
it('does not modify content for unknown field names', () => {
|
||||
const content = '`jira-sync-section-status`\nIn Progress';
|
||||
const result = updateJiraSyncContent(content, { assignee: 'john' });
|
||||
expect(result).toBe(content);
|
||||
});
|
||||
|
||||
it('updates only the specified fields', () => {
|
||||
const content = ['`jira-sync-section-status`', 'In Progress', '`jira-sync-line-assignee`jane'].join('\n');
|
||||
const result = updateJiraSyncContent(content, { status: 'Done' });
|
||||
expect(result).toContain('Done');
|
||||
expect(result).toContain('jane');
|
||||
});
|
||||
|
||||
it('preserves content after section when a heading boundary is present', () => {
|
||||
const content = 'some header\n\n`jira-sync-section-status`\nold value\n\n## Next\nfooter';
|
||||
const result = updateJiraSyncContent(content, { status: 'new value' });
|
||||
expect(result).toContain('`jira-sync-section-status`');
|
||||
expect(result).toContain('new value');
|
||||
expect(result).toContain('some header');
|
||||
expect(result).toContain('footer');
|
||||
});
|
||||
});
|
||||
19
tests/test-config.example.json
Normal file
19
tests/test-config.example.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"connections": [
|
||||
{
|
||||
"name": "Test Jira v2",
|
||||
"jiraUrl": "http://jira-test.local:8080",
|
||||
"apiVersion": "2",
|
||||
"authMethod": "bearer",
|
||||
"apiToken": ""
|
||||
},
|
||||
{
|
||||
"name": "Test Jira v3",
|
||||
"jiraUrl": "https://your-domain.atlassian.net",
|
||||
"apiVersion": "3",
|
||||
"authMethod": "basic",
|
||||
"apiToken": "",
|
||||
"email": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
15
tests/vitest.config.ts
Normal file
15
tests/vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/__tests__/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['tests/__tests__/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: path.resolve(__dirname, '__mocks__/obsidian.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -14,5 +14,6 @@
|
|||
"lib": ["DOM", "ES2017", "ES2018", "ES2019", "ES2020"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
||||
"exclude": ["tests/", "node_modules/"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue