diff --git a/.vscode/settings.json b/.vscode/settings.json index 30bf8c2..ec20fef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off" + "typescript.tsc.autoDetect": "off", + "cSpell.words": ["mermaidchart"] } \ No newline at end of file diff --git a/package.json b/package.json index 2658714..af4db37 100644 --- a/package.json +++ b/package.json @@ -514,8 +514,9 @@ "typescript": "^4.9.5" }, "dependencies": { - "axios": "^1.4.0", "uuid": "^9.0.0", + "@mermaidchart/sdk": "^0.2.0", "yaml": "^2.7.0" - } + }, + "packageManager": "pnpm@9.7.1+sha512.faf344af2d6ca65c4c5c8c2224ea77a81a5e8859cbc4e06b1511ddce2f0151512431dd19e6aff31f2c6a8f5f2aced9bd2273e1fed7dd4de1868984059d2c4247" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc03314..711e608 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ importers: .: dependencies: - axios: - specifier: ^1.4.0 - version: 1.7.9 + '@mermaidchart/sdk': + specifier: ^0.2.0 + version: 0.2.0 uuid: specifier: ^9.0.0 version: 9.0.1 @@ -164,6 +164,10 @@ packages: resolution: {integrity: sha512-An7l1hEr0w1HMMh1LU+rtDtqL7/jw74ORlc9Wnh06v7TU/xpG39/Zdr1ZJu3QpjUfKJ+E0/OXMW8DRSWTlh7qQ==} engines: {node: '>=16'} + '@badgateway/oauth2-client@2.4.2': + resolution: {integrity: sha512-70Fmzlmn8EfCjjssls8N6E94quBUWnLhu4inPZU2pkwpc6ZvbErkLRvtkYl81KFCvVcuVC0X10QPZVNwjXo2KA==} + engines: {node: '>= 14'} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -546,6 +550,10 @@ packages: '@mermaid-js/parser@0.3.1-rc.1': resolution: {integrity: sha512-gYXEGLui3Cfp+P37TBz2no4LuoEY2fEnK1MTh9YPbuAta7kVbZXPpTeay9ahtV7Zi6GkfW3yAUGM9fJ1KkoiWA==} + '@mermaidchart/sdk@0.2.0': + resolution: {integrity: sha512-ghIeLxw9FH6WAmw3lDjwGdk0ZCtd08DSEti5OJVKCDi1iIFa/MT8M0tnzBiQvVs/YOeBwW7lzqU+rkT0lxh43g==} + engines: {node: ^18.18.0 || >= 20.0.0} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2756,6 +2764,8 @@ snapshots: jsonwebtoken: 9.0.2 uuid: 8.3.2 + '@badgateway/oauth2-client@2.4.2': {} + '@braintree/sanitize-url@7.1.1': {} '@chevrotain/cst-dts-gen@11.0.3': @@ -3036,6 +3046,14 @@ snapshots: dependencies: langium: 3.0.0 + '@mermaidchart/sdk@0.2.0': + dependencies: + '@badgateway/oauth2-client': 2.4.2 + axios: 1.7.9 + uuid: 9.0.1 + transitivePeerDependencies: + - debug + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/extension.ts b/src/extension.ts index 4169223..626af54 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { MermaidChartProvider, MCTreeItem, getAllTreeViewProjectsCache } from "./mermaidChartProvider"; +import { MermaidChartProvider, MCTreeItem, getAllTreeViewProjectsCache, getProjectIdForDocument } from "./mermaidChartProvider"; import { MermaidChartVSCode } from "./mermaidChartVSCode"; import { applyMermaidChartTokenHighlighting, @@ -193,7 +193,7 @@ context.subscriptions.push( ); context.subscriptions.push( - vscode.commands.registerCommand('mermaid.connectDiagram',async(uri:vscode.Uri, range:vscode.Range)=>{ + vscode.commands.registerCommand('mermaid.connectDiagram', async (uri: vscode.Uri, range: vscode.Range) => { const document = await vscode.workspace.openTextDocument(uri); const content = document.getText(); const fileExt = path.extname(document.fileName); @@ -205,31 +205,57 @@ context.subscriptions.push( projects.map((p) => ({ label: p.title, description: p.title, projectId: p.uuid })), { placeHolder: "Select a project to save the diagram" } ); - + if (!selectedProject || !selectedProject?.projectId) { - vscode.window.showInformationMessage("Operation cancelled."); - return; + vscode.window.showInformationMessage("Operation cancelled."); + return; } - const response = await mcAPI.createDocumentWithDiagram(diagramCode, selectedProject.projectId) + try { + const newDocument = await mcAPI.createDocument(selectedProject.projectId); + + if (!newDocument || !newDocument.documentID) { + vscode.window.showErrorMessage("Failed to create a new document."); + return; + } + + await mcAPI.setDocument({ + documentID: newDocument.documentID, + projectID: selectedProject.projectId, + code: diagramCode, + }); + + const processedCode = ensureIdField(diagramCode, newDocument.documentID); + mermaidChartProvider.refresh(); + const editor = await createMermaidFile(context, processedCode, true); - const processedCode = ensureIdField(diagramCode, response.documentID); - const editor= await await createMermaidFile(context, processedCode, true); - if(editor){ - syncAuxFile(editor.document.uri.toString(), uri,range); - } + if (editor) { + syncAuxFile(editor.document.uri.toString(), uri, range); + } + } catch (error) { + vscode.window.showErrorMessage(`Error creating or connecting document: ${error instanceof Error ? error.message : "Unknown error occurred."}`); + } }) -) - vscode.workspace.onWillSaveTextDocument(async (event) => { - if (event.document.languageId.startsWith("mermaid")) { - event.waitUntil(Promise.resolve([])); - const content = event.document.getText(); - const diagramId = extractIdFromCode(content); - if (diagramId) { - await mcAPI.saveDocumentCode(content, diagramId); +); + +vscode.workspace.onWillSaveTextDocument(async (event) => { + if (event.document.languageId.startsWith("mermaid")) { + event.waitUntil(Promise.resolve([])); + const content = event.document.getText(); + const diagramId = extractIdFromCode(content); + if (diagramId) { + const projectId = getProjectIdForDocument(diagramId); + + if (projectId) { + await mcAPI.setDocument({ + documentID: diagramId, + projectID: projectId, + code: content, + }); } } - }); + } +}); context.subscriptions.push( vscode.commands.registerCommand('mermaidChart.syncDiagramWithMermaid', async () => { @@ -245,13 +271,29 @@ context.subscriptions.push( try { const diagramId = extractIdFromCode(content); if (TempFileCache.hasTempUri(context, document.uri.toString()) && diagramId) { - await mcAPI.saveDocumentCode(content, diagramId); + const projectId = getProjectIdForDocument(diagramId); + + if (projectId) { + await mcAPI.setDocument({ + documentID: diagramId, + projectID: projectId, + code: content, + }); + } vscode.window.showInformationMessage(`Diagram synced successfully with Mermaid chart. Diagram ID: ${diagramId}`); } else if (TempFileCache.hasTempUri(context, document.uri.toString())){ vscode.window.showInformationMessage('This is temporary buffer, this can not be saved locally'); } else if (!TempFileCache.hasTempUri(context, document.uri.toString()) && diagramId) { await vscode.commands.executeCommand('workbench.action.files.save'); - await mcAPI.saveDocumentCode(content, diagramId); + const projectId = getProjectIdForDocument(diagramId); + + if (projectId) { + await mcAPI.setDocument({ + documentID: diagramId, + projectID: projectId, + code: content, + }); + } vscode.window.showInformationMessage(`Diagram synced successfully with Mermaid chart. Diagram ID: ${diagramId}`); } else { await vscode.commands.executeCommand('workbench.action.files.save'); diff --git a/src/mermaidAPI.ts b/src/mermaidAPI.ts deleted file mode 100644 index b85fd81..0000000 --- a/src/mermaidAPI.ts +++ /dev/null @@ -1,317 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { v4 as uuid } from "uuid"; -import defaultAxios, { AxiosInstance } from "axios"; -import { createHash } from "crypto"; - -const defaultBaseURL = "https://www.mermaidchart.com"; // "http://127.0.0.1:5174" -const authorizationURLTimeout = 60_000; - -export interface InitParams { - clientID: string; - redirectURI?: string; - baseURL?: string; -} - -export interface OAuthAuthorizationParams { - response_type: "code"; - client_id: string; - redirect_uri: string; - code_challenge_method: "S256"; - code_challenge: string; - state: string; - scope: string; -} - -interface AuthState { - codeVerifier: string; -} - -export interface MCUser { - fullName: string; - emailAddress: string; -} - -export interface MCProject { - id: string; - title: string; -} - -export interface MCDocument { - documentID: string; - projectID: string; - code: string; - major: string; - minor: string; - title: string; -} - -export interface AuthorizationData { - url: string; - state: string; - scope: string; -} -export class MermaidChart { - private clientID: string; - private baseURL!: string; - private axios!: AxiosInstance; - private pendingStates: Record = {}; - private redirectURI?: string; - private accessToken?: string; - - constructor({ clientID, baseURL, redirectURI }: InitParams) { - this.clientID = clientID; - this.setBaseURL(baseURL || defaultBaseURL); - if (redirectURI) { - this.setRedirectURI(redirectURI); - } - } - - public setRedirectURI(redirectURI: string) { - this.redirectURI = redirectURI; - } - - public setBaseURL(baseURL: string = defaultBaseURL) { - if (this.baseURL && this.baseURL === baseURL) { - return; - } - this.baseURL = baseURL; - this.accessToken = undefined; - this.axios = defaultAxios.create({ - baseURL: this.baseURL, - }); - - this.axios.interceptors.response.use((res) => { - // Reset token if a 401 is thrown - if (res.status === 401) { - this.resetAccessToken(); - } - return res; - }); - } - - public async getAuthorizationData({ - state, - scope, - }: { - state?: string; - scope?: string; - } = {}): Promise { - if (!this.redirectURI) { - throw new Error("redirectURI is not set"); - } - - const stateID = state ?? uuid(); - - this.pendingStates[stateID] = { - codeVerifier: uuid(), - }; - - const params: OAuthAuthorizationParams = { - client_id: this.clientID, - redirect_uri: this.redirectURI, - response_type: "code", - code_challenge_method: "S256", - code_challenge: getEncodedSHA256Hash( - this.pendingStates[stateID].codeVerifier - ), - state: stateID, - scope: scope ?? "email", - }; - - // Deletes the state after 60 seconds - setTimeout(() => { - delete this.pendingStates[stateID]; - }, authorizationURLTimeout); - - const url = `${this.baseURL}${this.URLS.oauth.authorize(params)}`; - return { - url, - state: stateID, - scope: params.scope, - }; - } - - public async handleAuthorizationResponse(query: URLSearchParams) { - const authorizationToken = query.get("code"); - const state = query.get("state"); - - if (!authorizationToken) { - throw new RequiredParameterMissingError("token"); - } - if (!state) { - throw new RequiredParameterMissingError("state"); - } - - const pendingState = this.pendingStates[state]; - // Check if it is a valid auth request started by the extension - if (!pendingState) { - throw new OAuthError("invalid_state"); - } - - const tokenResponse = await defaultAxios.post( - this.baseURL + this.URLS.oauth.token, - { - client_id: this.clientID, - redirect_uri: this.redirectURI, - code_verifier: pendingState.codeVerifier, - code: authorizationToken, - } - ); - - if (tokenResponse.status !== 200) { - throw new OAuthError("invalid_token"); - } - - const accessToken = tokenResponse.data.access_token; - await this.setAccessToken(accessToken); - } - - /** - * This method is used after authentication to save the access token. - * It should be called by the plugins if any update to access token is made outside this lib. - * @param accessToken access token to use for requests - */ - public async setAccessToken(accessToken: string): Promise { - this.axios.defaults.headers.common[ - "Authorization" - ] = `Bearer ${accessToken}`; - // This is to verify that the token is valid - await this.getUser(); - this.accessToken = accessToken; - } - - public async resetAccessToken(): Promise { - this.accessToken = undefined; - this.axios.defaults.headers.common["Authorization"] = `Bearer none`; - } - - /** - * This function will be called before every request to get the access token to use for the request. - * It should be overridden by the plugins to return the access token. - * @returns the access token to use for requests - */ - public async getAccessToken(): Promise { - if (!this.accessToken) { - throw new Error("No access token set. Please authenticate first."); - } - return this.accessToken; - } - - public async getUser(): Promise { - const user = await this.axios.get(this.URLS.rest.users.self); - return user.data; - } - - public async getProjects(): Promise { - const projects = await this.axios.get( - this.URLS.rest.projects.list - ); - return projects.data; - } - - public async getDocuments(projectID: string): Promise { - const projects = await this.axios.get( - this.URLS.rest.projects.get(projectID).documents - ); - return projects.data; - } - - public async getEditURL(document: Pick) { - const url = `${this.baseURL}${this.URLS.diagram(document).edit}`; - return url; - } - - public async getRawDocument( - document: Pick, - theme: "light" | "dark" - ) { - const raw = await this.axios.get( - this.URLS.raw(document, theme).svg - ); - return raw.data; - } - - public async saveDocumentCode(code: string, documentID: string): Promise { - const response = await this.axios.patch(this.URLS.rest.documents.patch(documentID), { - code: code, - }); - return response.data; - } - public async createDocumentWithDiagram(code: string, projectID: string): Promise { - const response = await this.axios.post(this.URLS.rest.projects.get(projectID).documents, { - code : code - }); - return response.data; -} - - private URLS = { - oauth: { - authorize: (params: OAuthAuthorizationParams) => - `/oauth/authorize?${new URLSearchParams( - Object.entries(params) - ).toString()}`, - token: `/oauth/token`, - }, - rest: { - users: { - self: `/rest-api/users/me`, - }, - documents: { - get: (documentID: string) => { - return `/rest-api/documents/${documentID}`; - }, - patch: (documentID : string) => { - return `/rest-api/documents/${documentID}`; - } - }, - projects: { - list: `/rest-api/projects`, - get: (projectID: string) => { - return { - documents: `/rest-api/projects/${projectID}/documents`, - }; - }, - }, - }, - raw: ( - document: Pick, - theme: "light" | "dark" - ) => { - const base = `/raw/${document.documentID}?version=v${document.major}.${document.minor}&theme=${theme}&format=`; - return { - html: base + "html", - svg: base + "svg", - png: base + "png", - }; - }, - diagram: (d: Pick) => { - // const base = `/app/projects/${d.projectID}/diagrams/${d.documentID}/version/v${d.major}.${d.minor}`; - return { - // self: base, - edit: `/app/diagrams/${d.documentID}`, - // view: base + "/view", - } as const; - }, - } as const; -} - -export class RequiredParameterMissingError extends Error { - constructor(parameterName: string) { - super(`Required parameter ${parameterName} is missing`); - } -} -export class OAuthError extends Error { - constructor(message: string) { - super(message); - } -} - -export const getEncodedSHA256Hash = (str: string) => { - const hash = createHash("sha256").update(str).digest(); - - return Buffer.from(hash) - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -}; diff --git a/src/mermaidChartAuthenticationProvider.ts b/src/mermaidChartAuthenticationProvider.ts index 6f25134..f93d1b8 100644 --- a/src/mermaidChartAuthenticationProvider.ts +++ b/src/mermaidChartAuthenticationProvider.ts @@ -182,15 +182,14 @@ export class MermaidChartAuthenticationProvider const uri = Uri.parse(authData.url); await env.openExternal(uri); - let codeExchangePromise = this._codeExchangePromises.get( - authData.scope - ); + const scope = authData.scope.join(" "); + let codeExchangePromise = this._codeExchangePromises.get(scope); if (!codeExchangePromise) { codeExchangePromise = promiseFromEvent( this._uriHandler.event, this.handleUri(scopes) ); - this._codeExchangePromises.set(authData.scope, codeExchangePromise); + this._codeExchangePromises.set(scope, codeExchangePromise); } try { @@ -208,7 +207,7 @@ export class MermaidChartAuthenticationProvider ]); } finally { codeExchangePromise?.cancel.fire(); - this._codeExchangePromises.delete(authData.scope); + this._codeExchangePromises.delete(scope); } } ); @@ -223,9 +222,7 @@ export class MermaidChartAuthenticationProvider scopes: readonly string[] ) => PromiseAdapter = (scopes) => async (uri, resolve, reject) => { - await this.mcAPI.handleAuthorizationResponse( - new URLSearchParams(uri.query) - ); + await this.mcAPI.handleAuthorizationResponse(`?${uri.query}`); resolve("done"); }; diff --git a/src/mermaidChartProvider.ts b/src/mermaidChartProvider.ts index e3d7917..1bcd49f 100644 --- a/src/mermaidChartProvider.ts +++ b/src/mermaidChartProvider.ts @@ -14,6 +14,13 @@ export function setAllTreeViewProjectsCache(projects: Project[]): void { export function getAllTreeViewProjectsCache(): Project[] { return allTreeViewProjectsCache; } +export function getProjectIdForDocument(diagramId: string): string { + return ( + allTreeViewProjectsCache.find((project) => + project?.children?.some((child) => child.uuid === diagramId) + )?.uuid || "" + ); +} export class MCTreeItem extends vscode.TreeItem { uuid: string; @@ -196,7 +203,7 @@ export class MermaidChartProvider document.documentID, new vscode.Range(0, 0, 0, 1), document.title, - document.code, + document.code || "", vscode.TreeItemCollapsibleState.None ); projectDocuments.push(treeViewDocument); diff --git a/src/mermaidChartVSCode.ts b/src/mermaidChartVSCode.ts index 92d21e6..dc48d85 100644 --- a/src/mermaidChartVSCode.ts +++ b/src/mermaidChartVSCode.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { MermaidChart } from "./mermaidAPI"; +import { MermaidChart } from "@mermaidchart/sdk"; import { MermaidChartAuthenticationProvider } from "./mermaidChartAuthenticationProvider"; export class MermaidChartVSCode extends MermaidChart { diff --git a/src/util.ts b/src/util.ts index 9eff98a..0fdbc00 100644 --- a/src/util.ts +++ b/src/util.ts @@ -210,8 +210,8 @@ export async function viewMermaidChart( const svgContent = await mcAPI.getRawDocument( { documentID: uuid, - major: "0", - minor: "1", + major: 0, + minor: 1, }, themeParameter ); @@ -235,17 +235,23 @@ export async function editMermaidChart( uuid: string, provider: MermaidChartProvider ) { - // const project = provider.getProjectOfDocument(uuid); - // const projectUuid = project?.uuid; - // if (!projectUuid) { - // vscode.window.showErrorMessage( - // "Diagram not found in project. Diagram might have moved to a different project." - // ); - // return; - // } + // Retrieve the document details to get the required fields + const document = await mcAPI.getDocument({ documentID: uuid }); + + if (!document || !document.projectID) { + vscode.window.showErrorMessage( + "Document details not found. Unable to edit the chart." + ); + return; + } + const editUrl = await mcAPI.getEditURL({ - documentID: uuid, + documentID: document.documentID, + major: document.major, + minor: document.minor, + projectID: document.projectID, }); + vscode.env.openExternal(vscode.Uri.parse(editUrl)); }