diff --git a/collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js b/collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js
new file mode 100644
index 00000000000..644ce7c48fb
--- /dev/null
+++ b/collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js
@@ -0,0 +1,92 @@
+const { JiraIssueLoader } = require("../../../../../utils/extensions/Jira/JiraLoader");
+
+describe("JiraIssueLoader", () => {
+ const baseUrl = "https://example.atlassian.net";
+ const projectKey = "TEST";
+ const username = "user";
+ const accessToken = "token";
+ const personalAccessToken = "pat";
+
+ let loader;
+
+ beforeEach(() => {
+ loader = new JiraIssueLoader({ baseUrl, projectKey, username, accessToken });
+ });
+
+ test("generates Basic auth header with username and token", () => {
+ const expected = `Basic ${Buffer.from(`${username}:${accessToken}`).toString("base64")}`;
+ expect(loader.authorizationHeader).toBe(expected);
+ });
+
+ test("generates Bearer auth header with personal access token", () => {
+ const patLoader = new JiraIssueLoader({ baseUrl, projectKey, personalAccessToken });
+ expect(patLoader.authorizationHeader).toBe(`Bearer ${personalAccessToken}`);
+ });
+
+ test("createDocumentFromIssue extracts code blocks correctly", () => {
+ const issue = {
+ id: "1",
+ key: "TEST-1",
+ fields: {
+ summary: "Test Issue",
+ description: {
+ storage: {
+ value: `js `
+ }
+ },
+ status: { name: "Open" },
+ issuetype: { name: "Task" },
+ creator: { displayName: "Alice" },
+ created: "2025-01-01T00:00:00.000Z",
+ },
+ };
+
+ const doc = loader.createDocumentFromIssue(issue);
+
+ expect(doc.pageContent).toContain("```js\nconsole.log(\"Hello World\");\n```");
+ expect(doc.metadata.id).toBe("1");
+ expect(doc.metadata.key).toBe("TEST-1");
+ expect(doc.metadata.status).toBe("Open");
+ expect(doc.metadata.title).toBe("Test Issue");
+ expect(doc.metadata.created_by).toBe("Alice");
+ expect(doc.metadata.url).toBe(`${baseUrl}/jira/browse/TEST-1`);
+ });
+
+ test("load returns empty array on fetch failure", async () => {
+ // Suppress console.error output
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ // Mock fetchJiraData to throw an error
+ jest.spyOn(loader, "fetchJiraData").mockImplementation(async () => {
+ throw new Error("Network error");
+ });
+
+ const result = await loader.load();
+ expect(result).toEqual([]);
+ expect(consoleSpy).toHaveBeenCalledWith("Error:", expect.any(Error));
+
+ consoleSpy.mockRestore();
+ });
+
+ test("fetchAllIssuesInProject handles paginated results", async () => {
+ const totalIssues = 4;
+
+ // Mock fetchJiraData to simulate paginated API
+ jest.spyOn(loader, "fetchJiraData").mockImplementation(async (url) => {
+ const urlObj = new URL(url);
+ const startAt = parseInt(urlObj.searchParams.get("startAt") || "0", 10);
+ const limit = parseInt(urlObj.searchParams.get("maxResults") || "25", 10);
+
+ const issues = [];
+ for (let i = startAt + 1; i <= Math.min(startAt + limit, totalIssues); i++) {
+ issues.push({ id: i, key: `TEST-${i}`, fields: {} });
+ }
+
+ return { issues, total: totalIssues };
+ });
+
+ const issues = await loader.fetchAllIssuesInProject();
+ expect(issues).toHaveLength(totalIssues);
+ expect(issues.map(i => i.key)).toEqual(["TEST-1", "TEST-2", "TEST-3", "TEST-4"]);
+ });
+});
diff --git a/collector/__tests__/utils/extensions/JIra/jira-serivice.test.js b/collector/__tests__/utils/extensions/JIra/jira-serivice.test.js
new file mode 100644
index 00000000000..815f9d9d4f6
--- /dev/null
+++ b/collector/__tests__/utils/extensions/JIra/jira-serivice.test.js
@@ -0,0 +1,118 @@
+process.env.NODE_ENV = "development";
+process.env.STORAGE_DIR = "C:/temp";
+
+const fs = require("fs");
+const path = require("path");
+const { v4 } = require("uuid");
+const { loadJira, fetchJiraIssue } = require("../../../../utils/extensions/Jira");
+const { JiraIssueLoader } = require("../../../../utils/extensions/Jira/JiraLoader");
+const { writeToServerDocuments, sanitizeFileName } = require("../../../../utils/files");
+const { tokenizeString } = require("../../../../utils/tokenizer");
+
+jest.mock("../../../../utils/extensions/Jira/JiraLoader");
+jest.mock("../../../../utils/files");
+jest.mock("../../../../utils/tokenizer");
+jest.mock("fs");
+
+describe("Jira Service", () => {
+ const mockResponse = { locals: { encryptionWorker: { encrypt: jest.fn((s) => `encrypted:${s}`) } } };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("loadJira", () => {
+ test("fails when no credentials provided", async () => {
+ const result = await loadJira({ baseUrl: "https://example.atlassian.net", projectKey: "TEST" }, mockResponse);
+ expect(result.success).toBe(false);
+ expect(result.reason).toContain("You need either a personal access token");
+ });
+
+ test("fails when invalid baseUrl", async () => {
+ const result = await loadJira({ baseUrl: "invalid-url", projectKey: "TEST", personalAccessToken: "pat" }, mockResponse);
+ expect(result.success).toBe(false);
+ expect(result.reason).toContain("Provided base URL is not a valid URL");
+ });
+
+ test("saves documents correctly when Jira returns issues", async () => {
+ const mockDocs = [
+ {
+ pageContent: "Test content",
+ metadata: { title: "Issue 1", url: "https://example.atlassian.net/browse/TEST-1", source: "Test source" },
+ },
+ ];
+
+ JiraIssueLoader.mockImplementation(() => ({
+ load: jest.fn().mockResolvedValue(mockDocs),
+ }));
+
+ fs.existsSync.mockReturnValue(false);
+ fs.mkdirSync.mockImplementation(() => {});
+
+ sanitizeFileName.mockImplementation((s) => s);
+ tokenizeString.mockReturnValue(5);
+ writeToServerDocuments.mockImplementation(() => {});
+
+ const result = await loadJira(
+ { baseUrl: "https://example.atlassian.net", projectKey: "TEST", personalAccessToken: "pat" },
+ mockResponse
+ );
+
+ expect(result.success).toBe(true);
+ expect(writeToServerDocuments).toHaveBeenCalled();
+ expect(tokenizeString).toHaveBeenCalledWith("Test content");
+ expect(mockResponse.locals.encryptionWorker.encrypt).toHaveBeenCalled();
+ });
+ });
+
+ describe("fetchJiraIssue", () => {
+ test("fails when required params are missing", async () => {
+ const result = await fetchJiraIssue({ baseUrl: null, pageUrl: "url", projectKey: "TEST", username: "user", accessToken: "token" });
+ expect(result.success).toBe(false);
+ expect(result.reason).toContain("You need either a username and access token");
+ });
+
+ test("returns content when Jira issue found", async () => {
+ const mockDocs = [
+ { pageContent: "Issue content", metadata: { url: "url" } },
+ ];
+
+ JiraIssueLoader.mockImplementation(() => ({
+ load: jest.fn().mockResolvedValue(mockDocs),
+ }));
+
+ const result = await fetchJiraIssue({
+ baseUrl: "https://example.atlassian.net",
+ pageUrl: "url",
+ projectKey: "TEST",
+ username: "user",
+ accessToken: "token",
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.content).toBe("Issue content");
+ });
+
+ test("returns failure when issue not found", async () => {
+ const mockDocs = [
+ { pageContent: "Other content", metadata: { url: "other-url" } },
+ ];
+
+ JiraIssueLoader.mockImplementation(() => ({
+ load: jest.fn().mockResolvedValue(mockDocs),
+ }));
+
+ const result = await fetchJiraIssue({
+ baseUrl: "https://example.atlassian.net",
+ pageUrl: "url",
+ projectKey: "TEST",
+ username: "user",
+ accessToken: "token",
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.content).toBeNull();
+ expect(result.reason).toContain("Target page could not be found");
+ });
+ });
+});
diff --git a/collector/extensions/index.js b/collector/extensions/index.js
index 76c5aafd334..2ca1bd7ee41 100644
--- a/collector/extensions/index.js
+++ b/collector/extensions/index.js
@@ -201,7 +201,32 @@ function extensions(app) {
return;
}
);
-}
+ app.post(
+ "/ext/jira",
+ [verifyPayloadIntegrity, setDataSigner],
+ async function (request, response) {
+ try {
+ const { loadJira } = require("../utils/extensions/Jira");
+ const { success, reason, data } = await loadJira(
+ reqBody(request),
+ response
+ );
+ response.status(200).json({ success, reason, data });
+ } catch (e) {
+ console.error(e);
+ response.status(400).json({
+ success: false,
+ reason: e.message,
+ data: {
+ title: null,
+ author: null,
+ },
+ });
+ }
+ return;
+ }
+ );
+}
-module.exports = extensions;
\ No newline at end of file
+module.exports = extensions;
diff --git a/collector/extensions/resync/index.js b/collector/extensions/resync/index.js
index 3ca1f44ab6e..8b8d3189b3c 100644
--- a/collector/extensions/resync/index.js
+++ b/collector/extensions/resync/index.js
@@ -144,10 +144,43 @@ async function resyncDrupalWiki({ chunkSource }, response) {
}
}
+/**
+ * Fetches the content of a specific jira issue via its chunkSource.
+ * Returns the content as a text string of the page in question and only that issue.
+ * @param {object} data - metadata from a document (e.g.: chunkSource)
+ * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
+ */
+async function resyncJira({ chunkSource }, response) {
+ if (!chunkSource) throw new Error('Invalid source property provided');
+ try {
+ // Jira data is `payload` encrypted. So we need to expand its
+ // encrypted payload back into query params so we can reFetch the page with same access token/params.
+ const source = response.locals.encryptionWorker.expandPayload(chunkSource);
+ const { fetchJiraIssue } = require("../../utils/extensions/Jira");
+ const { success, reason, content } = await fetchJiraIssue({
+ pageUrl: `https:${source.pathname}`,
+ baseUrl: source.searchParams.get('baseUrl'),
+ spaceKey: source.searchParams.get('projectKey'),
+ accessToken: source.searchParams.get('token'),
+ username: source.searchParams.get('username'),
+ });
+
+ if (!success) throw new Error(`Failed to sync Jira issue content. ${reason}`);
+ response.status(200).json({ success, content });
+ } catch (e) {
+ console.error(e);
+ response.status(200).json({
+ success: false,
+ content: null,
+ });
+ }
+}
+
module.exports = {
link: resyncLink,
youtube: resyncYouTube,
confluence: resyncConfluence,
github: resyncGithub,
drupalwiki: resyncDrupalWiki,
+ jira: resyncJira,
}
diff --git a/collector/utils/extensions/Jira/JiraLoader/index.js b/collector/utils/extensions/Jira/JiraLoader/index.js
new file mode 100644
index 00000000000..1a447b93efb
--- /dev/null
+++ b/collector/utils/extensions/Jira/JiraLoader/index.js
@@ -0,0 +1,162 @@
+const { htmlToText } = require("html-to-text");
+
+class JiraIssueLoader {
+ constructor({
+ baseUrl,
+ projectKey,
+ username,
+ accessToken,
+ limit = 25,
+ expand = "changelog",
+ personalAccessToken,
+ cloud = true,
+ }) {
+ this.baseUrl = baseUrl;
+ this.projectKey = projectKey;
+ this.username = username;
+ this.accessToken = accessToken;
+ this.limit = limit;
+ this.expand = expand;
+ this.personalAccessToken = personalAccessToken;
+ this.cloud = cloud;
+ }
+
+ get authorizationHeader() {
+ if (this.personalAccessToken) {
+ return `Bearer ${this.personalAccessToken}`;
+ } else if (this.username && this.accessToken) {
+ const authToken = Buffer.from(
+ `${this.username}:${this.accessToken}`
+ ).toString("base64");
+ return `Basic ${authToken}`;
+ }
+ return undefined;
+ }
+
+ async load(options) {
+ try {
+ const issues = await this.fetchAllIssuesInProject(
+ options?.start,
+ options?.limit
+ );
+ return issues.map((issue) => this.createDocumentFromIssue(issue));
+ } catch (error) {
+ console.error("Error:", error);
+ return [];
+ }
+ }
+
+ async fetchJiraData(url) {
+ try {
+ const initialHeaders = {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ };
+ const authHeader = this.authorizationHeader;
+ if (authHeader) {
+ initialHeaders.Authorization = authHeader;
+ }
+ const response = await fetch(url, {
+ headers: initialHeaders,
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${url} from Jira: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ throw new Error(`Failed to fetch ${url} from Jira: ${error}`);
+ }
+ }
+
+ // https://developer.atlassian.com/rest/api/3/search?jql=project=ABC&startAt=0&maxResults=100
+ async fetchAllIssuesInProject(start = 0, limit = this.limit) {
+ const base = this.baseUrl.replace(/\/+$/, "");
+ const apiVersion = this.cloud ? "3" : "2";
+
+ const url = new URL(`/jira/rest/api/${apiVersion}/search`, base);
+ url.searchParams.set("jql", `project=${this.projectKey}`);
+ url.searchParams.set("startAt", String(start));
+ url.searchParams.set("maxResults", String(limit));
+ if (this.expand) url.searchParams.set("expand", String(this.expand));
+ //url.searchParams.set('fields', 'summary,issuetype,status,assignee,priority,updated');
+
+ const data = await this.fetchJiraData(url);
+
+ const issues = Array.isArray(data?.issues) ? data.issues : [];
+ if (!issues.length) return [];
+
+ const total = Number.isFinite(data?.total) ? data.total : issues.length;
+ const nextStart = start + issues.length;
+
+ if (nextStart >= total) {
+ return issues;
+ }
+
+ const nextIssueResults = await this.fetchAllIssuesInProject(
+ nextStart,
+ limit
+ );
+ return issues.concat(nextIssueResults);
+ }
+
+ createDocumentFromIssue(issue) {
+ const extractCodeBlocks = (content) => {
+ const codeBlockRegex =
+ /]*>[\s\S]*?<\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g;
+ const languageRegex =
+ /(.*?)<\/ac:parameter>/;
+
+ return content.replace(codeBlockRegex, (match) => {
+ const language = match.match(languageRegex)?.[1] || "";
+ const code =
+ match.match(
+ /<\/ac:plain-text-body>/
+ )?.[1] || "";
+ return `\n\`\`\`${language}\n${code.trim()}\n\`\`\`\n`;
+ });
+ };
+
+ let rawDescription = "";
+ if (issue.fields?.description) {
+ if (issue.fields.description.storage?.value) {
+ rawDescription = extractCodeBlocks(
+ issue.fields.description.storage.value
+ );
+ } else if (typeof issue.fields.description === "string") {
+ rawDescription = issue.fields.description;
+ } else if (issue.fields.description.content) {
+ rawDescription = issue.fields.description.content
+ .map((block) => block.content?.map((c) => c.text || "").join(" "))
+ .join("\n");
+ }
+ }
+
+ const plainTextContent = htmlToText(rawDescription, {
+ wordwrap: false,
+ preserveNewlines: true,
+ });
+ const textWithPreservedStructure = plainTextContent.replace(
+ /\n{3,}/g,
+ "\n\n"
+ );
+ const issueUrl = `${this.baseUrl}/jira/browse/${issue.key}`;
+
+ return {
+ pageContent: textWithPreservedStructure,
+ metadata: {
+ id: issue.id,
+ key: issue.key,
+ status: issue.fields?.status?.name || "",
+ title: issue.fields?.summary || "",
+ type: issue.fields?.issuetype?.name || "",
+ url: issueUrl,
+ created_by: issue.fields?.creator?.displayName || "",
+ created_at: issue.fields?.created || "",
+ updated_by: issue.fields?.updated_by?.displayName || "",
+ updated_at: issue.fields?.updated || "",
+ },
+ };
+ }
+}
+
+module.exports = { JiraIssueLoader };
diff --git a/collector/utils/extensions/Jira/index.js b/collector/utils/extensions/Jira/index.js
new file mode 100644
index 00000000000..9baf5a777cd
--- /dev/null
+++ b/collector/utils/extensions/Jira/index.js
@@ -0,0 +1,259 @@
+const fs = require("fs");
+const path = require("path");
+const { default: slugify } = require("slugify");
+const { v4 } = require("uuid");
+const { writeToServerDocuments, sanitizeFileName } = require("../../files");
+const { tokenizeString } = require("../../tokenizer");
+const { JiraIssueLoader } = require("./JiraLoader");
+
+/**
+ * Load Jira tickets from a projectID and Jira credentials
+ * @param {object} args - forwarded request body params
+ * @param {import("../../../middleware/setDataSigner").ResponseWithSigner} response - Express response object with encryptionWorker
+ * @returns
+ */
+async function loadJira(
+ {
+ baseUrl = null,
+ projectKey = null,
+ username = null,
+ accessToken = null,
+ cloud = true,
+ personalAccessToken = null,
+ },
+ response
+) {
+ if (!personalAccessToken && (!username || !accessToken)) {
+ return {
+ success: false,
+ reason:
+ "You need either a personal access token (PAT), or a username and access token to use the Jira connector.",
+ };
+ }
+
+ if (!baseUrl || !validBaseUrl(baseUrl)) {
+ return {
+ success: false,
+ reason: "Provided base URL is not a valid URL.",
+ };
+ }
+
+ if (!projectKey) {
+ return {
+ success: false,
+ reason: "You need to provide a Jira project key.",
+ };
+ }
+
+ const { origin, hostname } = new URL(baseUrl);
+ console.log(`-- Working Jira ${origin} --`);
+ const loader = new JiraIssueLoader({
+ baseUrl: origin,
+ projectKey,
+ username,
+ accessToken,
+ cloud,
+ personalAccessToken,
+ });
+
+ const { docs, error } = await loader
+ .load()
+ .then((docs) => {
+ return { docs, error: null };
+ })
+ .catch((e) => {
+ return {
+ docs: [],
+ error: e.message?.split("Error:")?.[1] || e.message,
+ };
+ });
+
+ if (!docs.length || !!error) {
+ return {
+ success: false,
+ reason: error ?? "No issue found for that Jira project.",
+ };
+ }
+ const outFolder = slugify(
+ `jira-${hostname}-${v4().slice(0, 4)}`
+ ).toLowerCase();
+
+ const outFolderPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(
+ __dirname,
+ `../../../../server/storage/documents/${outFolder}`
+ )
+ : path.resolve(process.env.STORAGE_DIR, `documents/${outFolder}`);
+
+ if (!fs.existsSync(outFolderPath))
+ fs.mkdirSync(outFolderPath, { recursive: true });
+
+ docs.forEach((doc) => {
+ if (!doc.pageContent) return;
+
+ const data = {
+ id: v4(),
+ url: doc.metadata.url + ".page",
+ title: doc.metadata.title || doc.metadata.source,
+ docAuthor: origin,
+ description: doc.metadata.title,
+ docSource: `${origin} Jira`,
+ chunkSource: generateChunkSource(
+ { doc, baseUrl: origin, projectKey, accessToken, username, cloud },
+ response.locals.encryptionWorker
+ ),
+ published: new Date().toLocaleString(),
+ wordCount: doc.pageContent.split(" ").length,
+ pageContent: doc.pageContent,
+ token_count_estimate: tokenizeString(doc.pageContent),
+ };
+
+ console.log(`[Jira Loader]: Saving ${doc.metadata.title} to ${outFolder}`);
+
+ const fileName = sanitizeFileName(
+ `${slugify(doc.metadata.title)}-${data.id}`
+ );
+ writeToServerDocuments({
+ data,
+ filename: fileName,
+ destinationOverride: outFolderPath,
+ });
+ });
+
+ return {
+ success: true,
+ reason: null,
+ data: {
+ projectKey,
+ destination: outFolder,
+ },
+ };
+}
+
+/**
+ * Gets the issue content from a specific Jira issue, not all issues in a workspace.
+ * @returns
+ */
+async function fetchJiraIssue({
+ pageUrl,
+ baseUrl,
+ projectKey,
+ username,
+ accessToken,
+ cloud = true,
+}) {
+ if (!pageUrl || !baseUrl || !projectKey || !username || !accessToken) {
+ return {
+ success: false,
+ content: null,
+ reason:
+ "You need either a username and access token, or a personal access token (PAT), to use the Jira connector.",
+ };
+ }
+
+ if (!validBaseUrl(baseUrl)) {
+ return {
+ success: false,
+ content: null,
+ reason: "Provided base URL is not a valid URL.",
+ };
+ }
+
+ if (!projectKey) {
+ return {
+ success: false,
+ content: null,
+ reason: "You need to provide a Jira project key.",
+ };
+ }
+
+ console.log(`-- Working Jira Issue ${pageUrl} --`);
+ const loader = new JiraIssueLoader({
+ baseUrl, // Should be the origin of the baseUrl
+ projectKey,
+ username,
+ accessToken,
+ cloud,
+ });
+
+ const { docs, error } = await loader
+ .load()
+ .then((docs) => {
+ return { docs, error: null };
+ })
+ .catch((e) => {
+ return {
+ docs: [],
+ error: e.message?.split("Error:")?.[1] || e.message,
+ };
+ });
+
+ if (!docs.length || !!error) {
+ return {
+ success: false,
+ reason: error ?? "No pages found for that Jira project.",
+ content: null,
+ };
+ }
+
+ const targetDocument = docs.find(
+ (doc) => doc.pageContent && doc.metadata.url === pageUrl
+ );
+ if (!targetDocument) {
+ return {
+ success: false,
+ reason: "Target page could not be found in Jira project.",
+ content: null,
+ };
+ }
+
+ return {
+ success: true,
+ reason: null,
+ content: targetDocument.pageContent,
+ };
+}
+
+/**
+ * Validates if the provided baseUrl is a valid URL at all.
+ * @param {string} baseUrl
+ * @returns {boolean}
+ */
+function validBaseUrl(baseUrl) {
+ try {
+ new URL(baseUrl);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+/**
+ * Generate the full chunkSource for a specific Jira issue so that we can resync it later.
+ * This data is encrypted into a single `payload` query param so we can replay credentials later
+ * since this was encrypted with the systems persistent password and salt.
+ * @param {object} chunkSourceInformation
+ * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker
+ * @returns {string}
+ */
+function generateChunkSource(
+ { doc, baseUrl, projectKey, accessToken, username, cloud },
+ encryptionWorker
+) {
+ const payload = {
+ baseUrl,
+ projectKey,
+ token: accessToken,
+ username,
+ cloud,
+ };
+ return `jira://${doc.metadata.url}?payload=${encryptionWorker.encrypt(
+ JSON.stringify(payload)
+ )}`;
+}
+
+module.exports = {
+ loadJira,
+ fetchJiraIssue,
+};
diff --git a/frontend/src/components/DataConnectorOption/media/index.js b/frontend/src/components/DataConnectorOption/media/index.js
index b0fba91bc4e..5a38117bd90 100644
--- a/frontend/src/components/DataConnectorOption/media/index.js
+++ b/frontend/src/components/DataConnectorOption/media/index.js
@@ -5,6 +5,7 @@ import Link from "./link.svg";
import Confluence from "./confluence.jpeg";
import DrupalWiki from "./drupalwiki.jpg";
import Obsidian from "./obsidian.png";
+import Jira from "./jira.svg";
const ConnectorImages = {
github: GitHub,
@@ -14,6 +15,7 @@ const ConnectorImages = {
confluence: Confluence,
drupalwiki: DrupalWiki,
obsidian: Obsidian,
+ jira: Jira,
};
export default ConnectorImages;
diff --git a/frontend/src/components/DataConnectorOption/media/jira.svg b/frontend/src/components/DataConnectorOption/media/jira.svg
new file mode 100644
index 00000000000..1f2a3d292fe
--- /dev/null
+++ b/frontend/src/components/DataConnectorOption/media/jira.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx
new file mode 100644
index 00000000000..61f5d6d10c0
--- /dev/null
+++ b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx
@@ -0,0 +1,271 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { Warning } from "@phosphor-icons/react";
+import { Tooltip } from "react-tooltip";
+
+export default function JiraOptions() {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [accessType, setAccessType] = useState("username");
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = new FormData(e.target);
+
+ try {
+ setLoading(true);
+ showToast(
+ "Fetching all issues for Jira project - this may take a while.",
+ "info",
+ {
+ clear: true,
+ autoClose: false,
+ }
+ );
+ const { data, error } = await System.dataConnectors.jira.collect({
+ baseUrl: form.get("baseUrl"),
+ projectKey: form.get("projectKey"),
+ username: form.get("username"),
+ accessToken: form.get("accessToken"),
+ cloud: form.get("isCloud") === "true",
+ personalAccessToken: form.get("personalAccessToken"),
+ });
+
+ if (!!error) {
+ showToast(error, "error", { clear: true });
+ setLoading(false);
+ return;
+ }
+
+ showToast(
+ `Issues collected from Jira space ${data.projectKey}. Output folder is ${data.destination}.`,
+ "success",
+ { clear: true }
+ );
+ e.target.reset();
+ setLoading(false);
+ } catch (e) {
+ console.error(e);
+ showToast(e.message, "error", { clear: true });
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx
index 1e1d268525f..2f8e1a0e42a 100644
--- a/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx
+++ b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx
@@ -10,6 +10,7 @@ import { useState } from "react";
import ConnectorOption from "./ConnectorOption";
import WebsiteDepthOptions from "./Connectors/WebsiteDepth";
import ObsidianOptions from "./Connectors/Obsidian";
+import JiraOptions from "./Connectors/Jira";
export const getDataConnectors = (t) => ({
github: {
@@ -54,6 +55,12 @@ export const getDataConnectors = (t) => ({
description: "Import Obsidian vault in a single click.",
options: ,
},
+ jira: {
+ name: t("connectors.jira.name"),
+ image: ConnectorImages.jira,
+ description: t("connectors.jira.description"),
+ options: ,
+ },
});
export default function DataConnectors() {
diff --git a/frontend/src/locales/ar/common.js b/frontend/src/locales/ar/common.js
index cc85ea8d77b..41aa0da0717 100644
--- a/frontend/src/locales/ar/common.js
+++ b/frontend/src/locales/ar/common.js
@@ -575,6 +575,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/da/common.js b/frontend/src/locales/da/common.js
index 51089ea38b2..88bd5f4b3bd 100644
--- a/frontend/src/locales/da/common.js
+++ b/frontend/src/locales/da/common.js
@@ -601,6 +601,29 @@ const TRANSLATIONS = {
task_explained:
"Når færdig, vil sideindholdet være tilgængeligt for indlejring i arbejdsområder i dokumentvælgeren.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Dokumenter",
"data-connectors": "Datakonnektorer",
diff --git a/frontend/src/locales/de/common.js b/frontend/src/locales/de/common.js
index 4106d401cbe..300ebfb6939 100644
--- a/frontend/src/locales/de/common.js
+++ b/frontend/src/locales/de/common.js
@@ -806,6 +806,35 @@ const TRANSLATIONS = {
task_explained:
"Sobald der Vorgang abgeschlossen ist, ist der Seiteninhalt im Dokumenten-Picker zur Einbettung in Workspaces verfügbar.",
},
+ jira: {
+ name: "Jira",
+ description:
+ "Importieren Sie ein komplettes Jira-Issue mit einem einzigen Klick.",
+ deployment_type: "Jira Bereitstellungstyp",
+ deployment_type_explained:
+ "Bestimmen Sie, ob Ihre Jira-Instanz in der Atlassian Cloud oder selbst gehostet ist.",
+ base_url: "Jira Basis-URL",
+ base_url_explained: "Dies ist die Basis-URL Ihres Jira-Bereichs.",
+ project_key: "Jira Project-Key",
+ project_key_explained:
+ "Dies ist der Project-Key Ihrer Jira-Instanz, der verwendet wird. Beginnt normalerweise mit ~",
+ username: "Jira Benutzername",
+ username_explained: "Ihr Jira Benutzername.",
+ auth_type: "Jira Authentifizierungstyp",
+ auth_type_explained:
+ "Wählen Sie den Authentifizierungstyp, den Sie verwenden möchten, um auf Ihre Jira-Issuen zuzugreifen.",
+ auth_type_username: "Benutzername und Zugriffstoken",
+ auth_type_personal: "Persönliches Zugriffstoken",
+ token: "Confluence API-Token",
+ token_explained_start:
+ "Sie müssen ein Zugriffstoken für die Authentifizierung bereitstellen. Sie können ein Zugriffstoken",
+ token_explained_link: "hier",
+ token_desc: "Zugriffstoken für die Authentifizierung.",
+ pat_token: "Confluence persönliches Zugriffstoken",
+ pat_token_explained: "Ihr Confluence persönliches Zugriffstoken.",
+ task_explained:
+ "Sobald der Vorgang abgeschlossen ist, ist der Seiteninhalt im Dokumenten-Picker zur Einbettung in Workspaces verfügbar.",
+ },
manage: {
documents: "Dokumente",
"data-connectors": "Datenverbindungen",
diff --git a/frontend/src/locales/en/common.js b/frontend/src/locales/en/common.js
index 345b38324ba..7935c432614 100644
--- a/frontend/src/locales/en/common.js
+++ b/frontend/src/locales/en/common.js
@@ -862,7 +862,34 @@ const TRANSLATIONS = {
task_explained:
"Once complete, the page content will be available for embedding into workspaces in the document picker.",
},
-
+ jira: {
+ name: "Jira",
+ description: "Import an entire Jira issue in a single click.",
+ deployment_type: "Jira deployment type",
+ deployment_type_explained:
+ "Determine if your Jira instance is hosted on Atlassian cloud or self-hosted.",
+ base_url: "Jira base URL",
+ base_url_explained: "This is the base URL of your Jira project.",
+ project_key: "Jira project key",
+ project_key_explained:
+ "This is the project key of your Jira instance that will be used. Usually begins with ~",
+ username: "Jira Username",
+ username_explained: "Your Jira username",
+ auth_type: "Jira Auth Type",
+ auth_type_explained:
+ "Select the authentication type you want to use to access your Jira project.",
+ auth_type_username: "Username and Access Token",
+ auth_type_personal: "Personal Access Token",
+ token: "Jira Access Token",
+ token_explained_start:
+ "You need to provide an access token for authentication. You can generate an access token",
+ token_explained_link: "here",
+ token_desc: "Access token for authentication",
+ pat_token: "Jira Personal Access Token",
+ pat_token_explained: "Your Jira personal access token.",
+ task_explained:
+ "Once complete, the issue content will be available for embedding into workspaces in the document picker.",
+ },
manage: {
documents: "Documents",
"data-connectors": "Data Connectors",
diff --git a/frontend/src/locales/es/common.js b/frontend/src/locales/es/common.js
index 3d7e830be6b..469a84be8fa 100644
--- a/frontend/src/locales/es/common.js
+++ b/frontend/src/locales/es/common.js
@@ -819,6 +819,29 @@ const TRANSLATIONS = {
task_explained:
"Una vez completado, el contenido de la página estará disponible para incrustar en los espacios de trabajo en el selector de documentos.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Documentos",
"data-connectors": "Conectores de datos",
diff --git a/frontend/src/locales/et/common.js b/frontend/src/locales/et/common.js
index c6d04c4631d..d54d58a0001 100644
--- a/frontend/src/locales/et/common.js
+++ b/frontend/src/locales/et/common.js
@@ -768,6 +768,29 @@ const TRANSLATIONS = {
task_explained:
"Kui valmis, on lehe sisu dokumentide valijas tööruumidesse põimimiseks saadaval.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Dokumendid",
"data-connectors": "Andmepistikud",
diff --git a/frontend/src/locales/fa/common.js b/frontend/src/locales/fa/common.js
index 1f0cd82647b..32dc823c674 100644
--- a/frontend/src/locales/fa/common.js
+++ b/frontend/src/locales/fa/common.js
@@ -567,6 +567,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/fr/common.js b/frontend/src/locales/fr/common.js
index f12183466f3..623c5fe0797 100644
--- a/frontend/src/locales/fr/common.js
+++ b/frontend/src/locales/fr/common.js
@@ -575,6 +575,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/he/common.js b/frontend/src/locales/he/common.js
index 84ffe023fa9..d75c7e925b8 100644
--- a/frontend/src/locales/he/common.js
+++ b/frontend/src/locales/he/common.js
@@ -775,6 +775,29 @@ const TRANSLATIONS = {
task_explained:
"לאחר השלמה, תוכן העמוד יהיה זמין להטמעה בסביבות עבודה בבורר המסמכים.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "מסמכים",
"data-connectors": "מחברי נתונים",
diff --git a/frontend/src/locales/it/common.js b/frontend/src/locales/it/common.js
index 442a5f2c7ad..9d724b32317 100644
--- a/frontend/src/locales/it/common.js
+++ b/frontend/src/locales/it/common.js
@@ -573,6 +573,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/ja/common.js b/frontend/src/locales/ja/common.js
index 45e47e4d0bd..3c1c6bfe407 100644
--- a/frontend/src/locales/ja/common.js
+++ b/frontend/src/locales/ja/common.js
@@ -593,6 +593,29 @@ const TRANSLATIONS = {
task_explained:
"完了後、ページ内容がドキュメントピッカーからワークスペースに埋め込めるようになります。",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "ドキュメント",
"data-connectors": "データコネクタ",
diff --git a/frontend/src/locales/ko/common.js b/frontend/src/locales/ko/common.js
index 28fd58d4e4a..c88bfd55baa 100644
--- a/frontend/src/locales/ko/common.js
+++ b/frontend/src/locales/ko/common.js
@@ -783,6 +783,29 @@ const TRANSLATIONS = {
task_explained:
"가져오기가 완료되면 페이지 내용이 문서 선택기에서 워크스페이스에 임베딩할 수 있도록 제공됩니다.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "문서 관리",
"data-connectors": "데이터 커넥터",
diff --git a/frontend/src/locales/lv/common.js b/frontend/src/locales/lv/common.js
index 9786bce5c1c..fca3fbb6342 100644
--- a/frontend/src/locales/lv/common.js
+++ b/frontend/src/locales/lv/common.js
@@ -798,6 +798,29 @@ const TRANSLATIONS = {
task_explained:
"Kad tas būs pabeigts, lapas saturs būs pieejams iegulšanai darba vietās dokumentu atlasītājā.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Dokumenti",
"data-connectors": "Datu savienotāji",
diff --git a/frontend/src/locales/nl/common.js b/frontend/src/locales/nl/common.js
index 3d300d0af24..564bc6b698e 100644
--- a/frontend/src/locales/nl/common.js
+++ b/frontend/src/locales/nl/common.js
@@ -570,6 +570,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/pl/common.js b/frontend/src/locales/pl/common.js
index d7673520a14..39750cf4db5 100644
--- a/frontend/src/locales/pl/common.js
+++ b/frontend/src/locales/pl/common.js
@@ -803,6 +803,29 @@ const TRANSLATIONS = {
task_explained:
"Po zakończeniu zawartość strony będzie dostępna do osadzenia w obszarach roboczych w selektorze dokumentów.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Dokumenty",
"data-connectors": "Źródła danych",
diff --git a/frontend/src/locales/pt_BR/common.js b/frontend/src/locales/pt_BR/common.js
index 7795a3e98d4..25fac5f81e6 100644
--- a/frontend/src/locales/pt_BR/common.js
+++ b/frontend/src/locales/pt_BR/common.js
@@ -781,6 +781,29 @@ const TRANSLATIONS = {
task_explained:
"Após conclusão, o conteúdo da página estará disponível para vínculo.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Documentos",
"data-connectors": "Conectores de Dados",
diff --git a/frontend/src/locales/ro/common.js b/frontend/src/locales/ro/common.js
index 35556747d4b..2c533855bb6 100644
--- a/frontend/src/locales/ro/common.js
+++ b/frontend/src/locales/ro/common.js
@@ -542,6 +542,29 @@ const TRANSLATIONS = {
task_explained:
"Odată complet, conținutul paginii va fi disponibil pentru embedding în spații de lucru în selectorul de documente.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Documente",
"data-connectors": "Conectori de date",
diff --git a/frontend/src/locales/ru/common.js b/frontend/src/locales/ru/common.js
index aebe5b68a98..2f5116c7112 100644
--- a/frontend/src/locales/ru/common.js
+++ b/frontend/src/locales/ru/common.js
@@ -602,6 +602,29 @@ const TRANSLATIONS = {
task_explained:
"После завершения содержимое страницы будет доступно для внедрения в рабочие пространства через выбор документов.",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "Документы",
"data-connectors": "Коннекторы данных",
diff --git a/frontend/src/locales/tr/common.js b/frontend/src/locales/tr/common.js
index cadf6f929c4..5b98c3134bf 100644
--- a/frontend/src/locales/tr/common.js
+++ b/frontend/src/locales/tr/common.js
@@ -570,6 +570,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/vn/common.js b/frontend/src/locales/vn/common.js
index 761c6e51a37..a94661ae1de 100644
--- a/frontend/src/locales/vn/common.js
+++ b/frontend/src/locales/vn/common.js
@@ -569,6 +569,29 @@ const TRANSLATIONS = {
pat_token_explained: null,
task_explained: null,
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: null,
"data-connectors": null,
diff --git a/frontend/src/locales/zh/common.js b/frontend/src/locales/zh/common.js
index 95872cd59f7..5ef0bd0b7bb 100644
--- a/frontend/src/locales/zh/common.js
+++ b/frontend/src/locales/zh/common.js
@@ -732,6 +732,29 @@ const TRANSLATIONS = {
pat_token_explained: "您的 Confluence 个人访问令牌。",
task_explained: "完成后,页面内容将可用于在文档选择器中嵌入至工作区。",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "文档",
"data-connectors": "数据连接器",
diff --git a/frontend/src/locales/zh_TW/common.js b/frontend/src/locales/zh_TW/common.js
index f3acd752cec..7e2bd1851a0 100644
--- a/frontend/src/locales/zh_TW/common.js
+++ b/frontend/src/locales/zh_TW/common.js
@@ -565,6 +565,29 @@ const TRANSLATIONS = {
pat_token_explained: "您的 Confluence 個人存取權杖。",
task_explained: "完成後,頁面內容將可供嵌入到工作區中的檔案選擇器。",
},
+ jira: {
+ name: null,
+ description: null,
+ deployment_type: null,
+ deployment_type_explained: null,
+ base_url: null,
+ base_url_explained: null,
+ project_key: null,
+ project_key_explained: null,
+ username: null,
+ username_explained: null,
+ auth_type: null,
+ auth_type_explained: null,
+ auth_type_username: null,
+ auth_type_personal: null,
+ token: null,
+ token_explained_start: null,
+ token_explained_link: null,
+ token_desc: null,
+ pat_token: null,
+ pat_token_explained: null,
+ task_explained: null,
+ },
manage: {
documents: "文件",
"data-connectors": "資料連接器",
diff --git a/frontend/src/media/dataConnectors/jira.png b/frontend/src/media/dataConnectors/jira.png
new file mode 100644
index 00000000000..87c62ea2532
Binary files /dev/null and b/frontend/src/media/dataConnectors/jira.png differ
diff --git a/frontend/src/models/dataConnector.js b/frontend/src/models/dataConnector.js
index c76afaa2c00..03a168ca17d 100644
--- a/frontend/src/models/dataConnector.js
+++ b/frontend/src/models/dataConnector.js
@@ -207,6 +207,38 @@ const DataConnector = {
});
},
},
+ jira: {
+ collect: async function ({
+ baseUrl,
+ projectKey,
+ username,
+ accessToken,
+ cloud,
+ personalAccessToken,
+ }) {
+ return await fetch(`${API_BASE}/ext/jira`, {
+ method: "POST",
+ headers: baseHeaders(),
+ body: JSON.stringify({
+ baseUrl,
+ projectKey,
+ username,
+ accessToken,
+ cloud,
+ personalAccessToken,
+ }),
+ })
+ .then((res) => res.json())
+ .then((res) => {
+ if (!res.success) throw new Error(res.reason);
+ return { data: res.data, error: null };
+ })
+ .catch((e) => {
+ console.error(e);
+ return { data: null, error: e.message };
+ });
+ },
+ },
};
export default DataConnector;
diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx
index 91024635c0f..dc2121779c4 100644
--- a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx
+++ b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx
@@ -58,7 +58,7 @@ export default function LiveSyncToggle({ enabled = false, onToggle }) {
This feature only applies to web-based content, such as websites,
- Confluence, YouTube, and GitHub files.
+ Confluence, YouTube, GitHub and Jira files.
diff --git a/server/endpoints/extensions/index.js b/server/endpoints/extensions/index.js
index 0a4acea6cc0..8cf91d78dd9 100644
--- a/server/endpoints/extensions/index.js
+++ b/server/endpoints/extensions/index.js
@@ -170,6 +170,50 @@ function extensionEndpoints(app) {
}
}
);
+
+ app.post(
+ "/ext/obsidian/vault",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/obsidian/vault",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "obsidian_vault",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/ext/jira",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/jira",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "jira",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
}
module.exports = { extensionEndpoints };
diff --git a/server/jobs/sync-watched-documents.js b/server/jobs/sync-watched-documents.js
index 0b3a72d1d3d..7975b58b6db 100644
--- a/server/jobs/sync-watched-documents.js
+++ b/server/jobs/sync-watched-documents.js
@@ -1,152 +1,213 @@
-const { Document } = require('../models/documents.js');
-const { DocumentSyncQueue } = require('../models/documentSyncQueue.js');
-const { CollectorApi } = require('../utils/collectorApi');
+const { Document } = require("../models/documents.js");
+const { DocumentSyncQueue } = require("../models/documentSyncQueue.js");
+const { CollectorApi } = require("../utils/collectorApi");
const { fileData } = require("../utils/files");
-const { log, conclude, updateSourceDocument } = require('./helpers/index.js');
-const { getVectorDbClass } = require('../utils/helpers/index.js');
-const { DocumentSyncRun } = require('../models/documentSyncRun.js');
+const { log, conclude, updateSourceDocument } = require("./helpers/index.js");
+const { getVectorDbClass } = require("../utils/helpers/index.js");
+const { DocumentSyncRun } = require("../models/documentSyncRun.js");
(async () => {
try {
const queuesToProcess = await DocumentSyncQueue.staleDocumentQueues();
if (queuesToProcess.length === 0) {
- log('No outstanding documents to sync. Exiting.');
+ log("No outstanding documents to sync. Exiting.");
return;
}
const collector = new CollectorApi();
if (!(await collector.online())) {
- log('Could not reach collector API. Exiting.');
+ log("Could not reach collector API. Exiting.");
return;
}
- log(`${queuesToProcess.length} watched documents have been found to be stale and will be updated now.`)
+ log(
+ `${queuesToProcess.length} watched documents have been found to be stale and will be updated now.`
+ );
for (const queue of queuesToProcess) {
let newContent = null;
const document = queue.workspaceDoc;
const workspace = document.workspace;
- const { metadata, type, source } = Document.parseDocumentTypeAndSource(document);
+ const { metadata, type, source } =
+ Document.parseDocumentTypeAndSource(document);
if (!metadata || !DocumentSyncQueue.validFileTypes.includes(type)) {
// Document is either broken, invalid, or not supported so drop it from future queues.
- log(`Document ${document.filename} has no metadata, is broken, or invalid and has been removed from all future runs.`)
+ log(
+ `Document ${document.filename} has no metadata, is broken, or invalid and has been removed from all future runs.`
+ );
await DocumentSyncQueue.unwatch(document);
continue;
}
- if (['link', 'youtube'].includes(type)) {
+ if (["link", "youtube"].includes(type)) {
const response = await collector.forwardExtensionRequest({
endpoint: "/ext/resync-source-document",
method: "POST",
body: JSON.stringify({
type,
- options: { link: source }
- })
+ options: { link: source },
+ }),
});
newContent = response?.content;
}
- if (['confluence', 'github', 'gitlab', 'drupalwiki'].includes(type)) {
+ if (
+ ["confluence", "github", "gitlab", "drupalwiki", "jira"].includes(type)
+ ) {
const response = await collector.forwardExtensionRequest({
endpoint: "/ext/resync-source-document",
method: "POST",
body: JSON.stringify({
type,
- options: { chunkSource: metadata.chunkSource }
- })
+ options: { chunkSource: metadata.chunkSource },
+ }),
});
newContent = response?.content;
}
if (!newContent) {
// Check if the last "x" runs were all failures (not exits!). If so - remove the job entirely since it is broken.
- const failedRunCount = (await DocumentSyncRun.where({ queueId: queue.id }, DocumentSyncQueue.maxRepeatFailures, { createdAt: 'desc' })).filter((run) => run.status === DocumentSyncRun.statuses.failed).length;
+ const failedRunCount = (
+ await DocumentSyncRun.where(
+ { queueId: queue.id },
+ DocumentSyncQueue.maxRepeatFailures,
+ { createdAt: "desc" }
+ )
+ ).filter(
+ (run) => run.status === DocumentSyncRun.statuses.failed
+ ).length;
if (failedRunCount >= DocumentSyncQueue.maxRepeatFailures) {
- log(`Document ${document.filename} has failed to refresh ${failedRunCount} times continuously and will now be removed from the watched document set.`)
+ log(
+ `Document ${document.filename} has failed to refresh ${failedRunCount} times continuously and will now be removed from the watched document set.`
+ );
await DocumentSyncQueue.unwatch(document);
continue;
}
- log(`Failed to get a new content response from collector for source ${source}. Skipping, but will retry next worker interval. Attempt ${failedRunCount === 0 ? 1 : failedRunCount}/${DocumentSyncQueue.maxRepeatFailures}`);
- await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.failed, { filename: document.filename, workspacesModified: [], reason: 'No content found.' })
+ log(
+ `Failed to get a new content response from collector for source ${source}. Skipping, but will retry next worker interval. Attempt ${failedRunCount === 0 ? 1 : failedRunCount}/${DocumentSyncQueue.maxRepeatFailures}`
+ );
+ await DocumentSyncQueue.saveRun(
+ queue.id,
+ DocumentSyncRun.statuses.failed,
+ {
+ filename: document.filename,
+ workspacesModified: [],
+ reason: "No content found.",
+ }
+ );
continue;
}
- const currentDocumentData = await fileData(document.docpath)
+ const currentDocumentData = await fileData(document.docpath);
if (currentDocumentData.pageContent === newContent) {
- const nextSync = DocumentSyncQueue.calcNextSync(queue)
- log(`Source ${source} is unchanged and will be skipped. Next sync will be ${nextSync.toLocaleString()}.`);
- await DocumentSyncQueue._update(
+ const nextSync = DocumentSyncQueue.calcNextSync(queue);
+ log(
+ `Source ${source} is unchanged and will be skipped. Next sync will be ${nextSync.toLocaleString()}.`
+ );
+ await DocumentSyncQueue._update(queue.id, {
+ lastSyncedAt: new Date().toISOString(),
+ nextSyncAt: nextSync.toISOString(),
+ });
+ await DocumentSyncQueue.saveRun(
queue.id,
+ DocumentSyncRun.statuses.exited,
{
- lastSyncedAt: new Date().toISOString(),
- nextSyncAt: nextSync.toISOString(),
+ filename: document.filename,
+ workspacesModified: [],
+ reason: "Content unchanged.",
}
);
- await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.exited, { filename: document.filename, workspacesModified: [], reason: 'Content unchanged.' })
continue;
}
// update the defined document and workspace vectorDB with the latest information
// it will skip cache and create a new vectorCache file.
const vectorDatabase = getVectorDbClass();
- await vectorDatabase.deleteDocumentFromNamespace(workspace.slug, document.docId);
- await vectorDatabase.addDocumentToNamespace(
+ await vectorDatabase.deleteDocumentFromNamespace(
workspace.slug,
- { ...currentDocumentData, pageContent: newContent, docId: document.docId },
- document.docpath,
- true
+ document.docId
);
- updateSourceDocument(
- document.docpath,
+ await vectorDatabase.addDocumentToNamespace(
+ workspace.slug,
{
...currentDocumentData,
pageContent: newContent,
docId: document.docId,
- published: (new Date).toLocaleString(),
- // Todo: Update word count and token_estimate?
- }
- )
- log(`Workspace "${workspace.name}" vectors of ${source} updated. Document and vector cache updated.`)
-
+ },
+ document.docpath,
+ true
+ );
+ updateSourceDocument(document.docpath, {
+ ...currentDocumentData,
+ pageContent: newContent,
+ docId: document.docId,
+ published: new Date().toLocaleString(),
+ // Todo: Update word count and token_estimate?
+ });
+ log(
+ `Workspace "${workspace.name}" vectors of ${source} updated. Document and vector cache updated.`
+ );
// Now we can bloom the results to all matching documents in all other workspaces
const workspacesModified = [workspace.slug];
- const moreReferences = await Document.where({
- id: { not: document.id },
- filename: document.filename
- }, null, null, { workspace: true });
+ const moreReferences = await Document.where(
+ {
+ id: { not: document.id },
+ filename: document.filename,
+ },
+ null,
+ null,
+ { workspace: true }
+ );
if (moreReferences.length !== 0) {
- log(`${source} is referenced in ${moreReferences.length} other workspaces. Updating those workspaces as well...`)
+ log(
+ `${source} is referenced in ${moreReferences.length} other workspaces. Updating those workspaces as well...`
+ );
for (const additionalDocumentRef of moreReferences) {
const additionalWorkspace = additionalDocumentRef.workspace;
workspacesModified.push(additionalWorkspace.slug);
- await vectorDatabase.deleteDocumentFromNamespace(additionalWorkspace.slug, additionalDocumentRef.docId);
+ await vectorDatabase.deleteDocumentFromNamespace(
+ additionalWorkspace.slug,
+ additionalDocumentRef.docId
+ );
await vectorDatabase.addDocumentToNamespace(
additionalWorkspace.slug,
- { ...currentDocumentData, pageContent: newContent, docId: additionalDocumentRef.docId },
- additionalDocumentRef.docpath,
+ {
+ ...currentDocumentData,
+ pageContent: newContent,
+ docId: additionalDocumentRef.docId,
+ },
+ additionalDocumentRef.docpath
+ );
+ log(
+ `Workspace "${additionalWorkspace.name}" vectors for ${source} was also updated with the new content from cache.`
);
- log(`Workspace "${additionalWorkspace.name}" vectors for ${source} was also updated with the new content from cache.`)
}
}
const nextRefresh = DocumentSyncQueue.calcNextSync(queue);
- log(`${source} has been refreshed in all workspaces it is currently referenced in. Next refresh will be ${nextRefresh.toLocaleString()}.`)
- await DocumentSyncQueue._update(
+ log(
+ `${source} has been refreshed in all workspaces it is currently referenced in. Next refresh will be ${nextRefresh.toLocaleString()}.`
+ );
+ await DocumentSyncQueue._update(queue.id, {
+ lastSyncedAt: new Date().toISOString(),
+ nextSyncAt: nextRefresh.toISOString(),
+ });
+ await DocumentSyncQueue.saveRun(
queue.id,
+ DocumentSyncRun.statuses.success,
{
- lastSyncedAt: new Date().toISOString(),
- nextSyncAt: nextRefresh.toISOString(),
+ filename: document.filename,
+ workspacesModified,
}
);
- await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.success, { filename: document.filename, workspacesModified })
}
} catch (e) {
- console.error(e)
- log(`errored with ${e.message}`)
+ console.error(e);
+ log(`errored with ${e.message}`);
} finally {
conclude();
}
diff --git a/server/models/documentSyncQueue.js b/server/models/documentSyncQueue.js
index b4e9790ce1c..ceb1ef87d77 100644
--- a/server/models/documentSyncQueue.js
+++ b/server/models/documentSyncQueue.js
@@ -4,7 +4,7 @@ const { SystemSettings } = require("./systemSettings");
const { Telemetry } = require("./telemetry");
/**
- * @typedef {('link'|'youtube'|'confluence'|'github'|'gitlab')} validFileType
+ * @typedef {('link'|'youtube'|'confluence'|'github'|'gitlab'|'jira')} validFileType
*/
const DocumentSyncQueue = {
@@ -17,6 +17,7 @@ const DocumentSyncQueue = {
"github",
"gitlab",
"drupalwiki",
+ "jira",
],
defaultStaleAfter: 604800000,
maxRepeatFailures: 5, // How many times a run can fail in a row before pruning.
@@ -62,6 +63,7 @@ const DocumentSyncQueue = {
if (chunkSource.startsWith("github://")) return true; // If is a GitHub file reference
if (chunkSource.startsWith("gitlab://")) return true; // If is a GitLab file reference
if (chunkSource.startsWith("drupalwiki://")) return true; // If is a DrupalWiki document link
+ if (chunkSource.startsWith("jira://")) return true; // If is a Jira document link
return false;
},
diff --git a/server/models/documents.js b/server/models/documents.js
index a283311537e..6968c5c77bc 100644
--- a/server/models/documents.js
+++ b/server/models/documents.js
@@ -243,7 +243,7 @@ const Document = {
// Some data sources have encoded params in them we don't want to log - so strip those details.
_stripSource: function (sourceString, type) {
- if (["confluence", "github"].includes(type)) {
+ if (["confluence", "github", "jira"].includes(type)) {
const _src = new URL(sourceString);
_src.search = ""; // remove all search params that are encoded for resync.
return _src.toString();