inital commit

This commit is contained in:
2025-05-30 22:21:59 +03:00
commit 67374bf430
19 changed files with 3265 additions and 0 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
main.js

24
.eslintrc Normal file
View File

@@ -0,0 +1,24 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

34
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
main.js manifest.json styles.css

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.continue
examples

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
tag-version-prefix=""

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Maksim Syomochkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

80
README.md Normal file
View File

@@ -0,0 +1,80 @@
# Obsidian Jira Plugin
A plugin for [Obsidian](https://obsidian.md/) that enables you to create Markdown notes from Jira issues and keep them up to date automatically. Easily generate notes from your Jira tickets and ensure your vault always reflects the latest issue status.
## Features
- Fetch Jira issues and save them as Markdown notes using a customizable template.
- Update individual or all tracked issues with a single click.
- Create new issue notes from selected text in the editor.
- Manage your Jira connection and tracked issues from the settings tab.
- Status bar button for quick updates of the current issue note.
- Supports ignoring TLS certificate errors (for self-signed Jira servers).
## Installation
1. **Manual Installation**
- Download the plugin files (`main.ts`, `settings.ts`, `styles.css`) and build them using your preferred TypeScript build process.
- Place the compiled files in your Obsidian vault's `.obsidian/plugins/your-plugin-folder/` directory.
2. **Via Community Plugins**
- (Not yet available in the community plugins list.)
## Usage
1. **Configure Settings**
- Go to `Settings → Jira Plugin`.
- Enter your Jira API URL, username, and password.
- Set the path where issue notes will be saved (e.g., `jira`).
- Optionally, customize the Markdown template for issue notes.
- Toggle "Ignore TLS certificate errors" if your Jira server uses a self-signed certificate.
2. **Creating an Issue Note**
- Select an issue key (e.g., `PROJ-123`) in the editor.
- Run the command palette and select `Jira: Create issue`.
- The plugin will fetch the issue from Jira, create a note using your template, and link it.
3. **Updating Issues**
- Open an issue note and click the status bar button to update it.
- Or, use the command palette:
- `Jira: Update issue` updates the current issue note.
- `Jira: Update all issues` updates all tracked issues.
4. **Managing Issues**
- In the settings tab, view, add, or remove tracked issues.
## Template
The template is filled with data received from Jira.
You can explore the available data fields by performing a `curl` request to your Jira API, for example:
```sh
curl -u your-username:password https://jira.example.com/rest/api/2/issue/PROJ-123
```
The template uses [Nunjucks](https://mozilla.github.io/nunjucks/) syntax. Example:
### Example template
```nunjucks
---
jira_assignee: "[[{{ fields.assignee.displayName }}]]"
jira_reporter: "[[{{ fields.reporter.displayName }}]]"
jira_type: {{ fields.issuetype.name }}
jira_project: {{fields.project.key}}
jira_status: {{fields.status.name}}
jira_summary: {{ fields.summary }}
jira_priority: {{fields.priority.name}}
jira_link: https://jira.example.com.com/browse/{{key}}
jira_components: [{% for comp in fields.components %}"{{ comp.name }}"{% if not loop.last %}, {% endif %}{% endfor %}]
---
# [[{{ key }}]]
{{ fields.description }}
```
## License
MIT. See LICENSE for details.
## Support & Feedback
For questions, suggestions, or bug reports, please open an issue on the GitHub repository.

50
esbuild.config.mjs Normal file
View File

@@ -0,0 +1,50 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
platform: "node",
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

10
manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"id": "jira-issue-notes",
"name": "Jira Issue Notes",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Plugin for fetching and managing Jira issues directly from your notes",
"author": "Maxim Syomochkin",
"authorUrl": "https://mak-sim.ru",
"isDesktopOnly": true
}

2546
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "jira-issue-notes",
"version": "1.0.0",
"description": "Plugin for fetching and managing Jira issues directly from your notes.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@types/nunjucks": "^3.2.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"punycode": "^2.3.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"node-fetch": "^3.3.2",
"nunjucks": "^3.2.4"
}
}

47
src/jiraApi.ts Executable file
View File

@@ -0,0 +1,47 @@
import fetch from "node-fetch";
import https from "https";
import { Notice } from "obsidian";
export class JiraApi {
constructor(
url: string,
username: string,
password: string,
ignoreTLS = true
) {
this.headers = {
Accept: "application/json",
Authorization:
"Basic " +
Buffer.from(`${username}:${password}`).toString("base64"),
};
this.url = `${url}/rest/api/2/issue/`;
this.ignoreTLS = ignoreTLS;
}
private url: string;
private headers: any;
private ignoreTLS: boolean;
public async fetchIssue(issueKey: string): Promise<any> {
try {
const options: any = { headers: this.headers };
if (this.ignoreTLS) {
options.agent = new https.Agent({ rejectUnauthorized: false });
}
const response = await fetch(this.url + issueKey, options);
if (!response.ok) {
const errorDetail = await response.text();
throw new Error(
`Error fetching issue: ${response.statusText}. Details: ${errorDetail}`
);
}
const data: any = await response.json();
return data;
} catch (error) {
console.error("Error fetching issue:", error);
new Notice("Error fetching issue: " + error.message);
throw error;
}
}
}

136
src/main.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Editor, Notice, Plugin } from "obsidian";
import { renderString } from "nunjucks";
import { JiraSettings, JiraSettingTab, DEFAULT_SETTINGS } from "./settings";
import { JiraApi } from "./jiraApi";
export default class Jira extends Plugin {
settings: JiraSettings;
statusBarItemEl: HTMLElement;
jiraApi: JiraApi;
async onload() {
await this.loadSettings();
this.createJiraApi();
this.addCommand({
id: "create-issue",
name: "Create issue",
editorCallback: this.createCallback.bind(this),
});
this.addCommand({
id: "update-issue",
name: "Update issue",
editorCallback: this.UpdateCallback.bind(this),
});
this.addCommand({
id: "update-all-issues",
name: "Update all issues",
editorCallback: this.UpdateAllCallback.bind(this),
});
this.statusBarItemEl = this.addStatusBarItem();
this.statusBarItemEl.addClass("jira-statusbar-btn");
this.statusBarItemEl.addEventListener("click", () => {
const file = this.app.workspace.getActiveFile();
if (file?.basename) {
this.UpdateIssue(file.basename);
} else {
new Notice("No active file to update.");
}
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => {
const file = this.app.workspace.getActiveFile();
if (file && this.settings.issues.includes(file.basename)) {
this.statusBarItemEl.show();
this.statusBarItemEl.setText(`Update: ${file.basename}`);
} else {
this.statusBarItemEl.hide();
}
})
);
this.addSettingTab(new JiraSettingTab(this.app, this));
}
private createJiraApi() {
this.jiraApi = new JiraApi(
this.settings.apiUrl,
this.settings.username,
this.settings.password,
this.settings.ignoreTLS
);
}
private async saveIssueToFile(issueKey: string, data: any) {
const filePath = `${this.settings.path}/${issueKey}.md`;
const content = renderString(this.settings.template, data);
await this.app.vault.adapter.write(filePath, content);
}
private async UpdateIssue(issueKey: string) {
console.log(`Updating ${issueKey}`);
new Notice(`Updating ${issueKey}`);
const data = await this.jiraApi.fetchIssue(issueKey);
await this.saveIssueToFile(issueKey, data);
new Notice(`Issue ${issueKey} updated`);
}
private async UpdateCallback() {
const file = this.app.workspace.getActiveFile();
if (file && this.settings.issues.includes(file.basename)) {
const data = await this.jiraApi.fetchIssue(file.basename);
await this.saveIssueToFile(file.basename, data);
new Notice(`Issue ${file.basename} updated`);
} else {
new Notice("This note is not linked to a Jira issue.");
}
}
private async UpdateAllCallback() {
for (const issue of this.settings.issues) {
this.UpdateIssue(issue);
}
}
private async createCallback(editor: Editor) {
const issueKey = editor.getSelection();
const data = await this.jiraApi.fetchIssue(issueKey);
await this.saveIssueToFile(issueKey, data);
editor.replaceSelection(`[[${issueKey}]]`);
if (!this.settings.issues.includes(issueKey)) {
this.settings.issues.push(issueKey);
this.saveSettings();
}
new Notice(`Note ${issueKey} created`);
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
this.createJiraApi();
}
}

190
src/settings.ts Normal file
View File

@@ -0,0 +1,190 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import Jira from "./main";
export interface JiraSettings {
apiUrl: string;
username: string;
password: string;
path: string;
template: string;
issues: string[];
ignoreTLS: boolean;
}
export const DEFAULT_SETTINGS: JiraSettings = {
apiUrl: "https://jira.example.com",
username: "",
password: "",
path: "jira",
template: "",
issues: [],
ignoreTLS: false,
};
export class JiraSettingTab extends PluginSettingTab {
plugin: Jira;
constructor(app: App, plugin: Jira) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName("apiUrl").addText((text) =>
text
.setValue(this.plugin.settings.apiUrl)
.onChange(async (value) => {
this.plugin.settings.apiUrl = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("username").addText((text) =>
text
.setValue(this.plugin.settings.username)
.onChange(async (value) => {
this.plugin.settings.username = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("password").addText((text) => {
text.setValue(this.plugin.settings.password).onChange(
async (value) => {
this.plugin.settings.password = value;
await this.plugin.saveSettings();
}
);
text.inputEl.type = "password";
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.style.marginLeft = "8px";
toggleBtn.style.background = "none";
toggleBtn.style.border = "none";
toggleBtn.style.cursor = "pointer";
let visible = false;
const eyeIcon = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`;
const eyeOffIcon = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-11-7-11-7a21.81 21.81 0 0 1 5.06-6.06"/>
<path d="M1 1l22 22"/>
<path d="M9.53 9.53A3 3 0 0 0 12 15a3 3 0 0 0 2.47-5.47"/>
<path d="M14.47 14.47A3 3 0 0 1 12 9a3 3 0 0 1-2.47 5.47"/>
</svg>
`;
toggleBtn.innerHTML = eyeIcon;
toggleBtn.onclick = () => {
visible = !visible;
text.inputEl.type = visible ? "text" : "password";
toggleBtn.innerHTML = visible ? eyeOffIcon : eyeIcon;
};
text.inputEl.parentElement?.appendChild(toggleBtn);
});
new Setting(containerEl).setName("path").addText((text) =>
text.setValue(this.plugin.settings.path).onChange(async (value) => {
this.plugin.settings.path = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Ignore TLS certificate errors")
.setDesc("Disable TLS certificate validation (not secure)")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ignoreTLS)
.onChange(async (value) => {
this.plugin.settings.ignoreTLS = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("template").addTextArea((text) => {
text.setValue(this.plugin.settings.template).onChange(
async (value) => {
this.plugin.settings.template = value;
await this.plugin.saveSettings();
}
);
text.inputEl.setAttr("rows", 25);
text.inputEl.setAttr("cols", 50);
});
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "Issues (expand to view/edit)";
details.appendChild(summary);
const issuesList = document.createElement("ul");
this.plugin.settings.issues.forEach((issue, idx) => {
const li = document.createElement("li");
li.textContent = issue;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
`;
removeBtn.style.background = "none";
removeBtn.style.border = "none";
removeBtn.style.cursor = "pointer";
removeBtn.style.marginLeft = "8px";
removeBtn.title = "Remove";
removeBtn.onclick = async () => {
this.plugin.settings.issues.splice(idx, 1);
await this.plugin.saveSettings();
this.display();
};
li.appendChild(removeBtn);
issuesList.appendChild(li);
});
details.appendChild(issuesList);
// Форма для добавления нового issue
const addDiv = document.createElement("div");
const input = document.createElement("input");
input.type = "text";
input.placeholder = "Add issue...";
addDiv.appendChild(input);
const addBtn = document.createElement("button");
addBtn.textContent = "Add";
addBtn.onclick = async () => {
const value = input.value.trim();
if (value && !this.plugin.settings.issues.includes(value)) {
this.plugin.settings.issues.push(value);
await this.plugin.saveSettings();
this.display();
}
input.value = "";
};
addDiv.appendChild(addBtn);
details.appendChild(addDiv);
containerEl.appendChild(details);
}
}

17
styles.css Normal file
View File

@@ -0,0 +1,17 @@
.jira-statusbar-btn {
padding: 2px 10px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
color: var(--text-normal);
font-size: 13px;
margin: 2px 0;
user-select: none;
}
.jira-statusbar-btn:hover {
background: var(--background-modifier-hover);
border-color: var(--interactive-accent);
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

14
version-bump.mjs Normal file
View File

@@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View File

@@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}