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 ( +
+
+
+
+
+
+
+ +

+ {t("connectors.jira.deployment_type_explained")} +

+
+ +
+ +
+
+ +

+ {t("connectors.jira.base_url_explained")} +

+
+ +
+
+
+ +

+ {t("connectors.jira.project_key_explained")} +

+
+ +
+
+
+ +

+ {t("connectors.jira.auth_type_explained")} +

+
+ +
+ {accessType === "username" && ( + <> +
+
+ +

+ {t("connectors.jira.username_explained")} +

+
+ +
+
+
+ +

+ {t("connectors.jira.token_desc")} +

+
+ +
+ + )} + {accessType === "personalToken" && ( +
+
+ +

+ {t("connectors.jira.pat_token_explained")} +

+
+ +
+ )} +
+
+ +
+ + {loading && ( +

+ {t("connectors.jira.task_explained")} +

+ )} +
+
+
+
+ ); +} 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();