diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/.eslintrc.js b/plugins/scaffolder-backend-module-confluence-to-markdowwn/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/README.md b/plugins/scaffolder-backend-module-confluence-to-markdowwn/README.md new file mode 100644 index 0000000000..61cc1efe13 --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/README.md @@ -0,0 +1,126 @@ +# @backstage/plugin-scaffolder-backend-module-confluence-to-markdowwn + +Welcome to the `transform:confluence-to-markdown` action for the `scaffolder-backend`. + +## Getting started + +You need to configure the action in your backend: + +## From your Backstage root directory + +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-scaffolder-backend-module-confluence-to-markdowwn +``` + +Configure the action: +(you can check the [docs](https://backstage.io/docs/features/software-templates/writing-custom-actions#registering-custom-actions) to see all options): + +```typescript +// packages/backend/src/plugins/scaffolder.ts + +import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend'; +import { ScmIntegrations } from '@backstage/integration'; +import { createConfluenceToMarkdownAction } from '@backstage/plugin-scaffolder-backend-module-confluence-to-markdowwn'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const catalogClient = new CatalogClient({ discoveryApi: env.discovery }); + const integrations = ScmIntegrations.fromConfig(env.config); + + const builtInActions = createBuiltinActions({ + integrations, + catalogClient, + config: env.config, + reader: env.reader, + }); + + const actions = [ + ...builtInActions, + createConfluenceToMarkdownAction({ + integrations, + config: env.config, + reader: env.reader, + }), + ]; + + return createRouter({ + actions, + catalogClient: catalogClient, + logger: env.logger, + config: env.config, + database: env.database, + reader: env.reader, + }); +} +``` + +You will also need an access token for authorization with `Read` permissions. You can create a Personal Access Token (PAT) in confluence and add the PAT to your `app-config.yaml` + +``` +confluence: + baseUrl: ${CONFLUENCE_BASE_URL} + token: ${CONFLUENCE_TOKEN} + +``` + +After that you can use the action in your template: + +``` +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: confluence-to-markdown + title: Confluence to Markdown + description: This template converts a single confluence document to Markdown for Techdocs and adds it to a given GitHub repo. + tags: + - do-not-use + - poc +spec: + owner: + type: service + parameters: + - title: Confluence and Github Repo Information + properties: + confluenceUrls: + type: array + description: Urls for confluence doc to be converted to markdown. In format /display// or /pages/viewpage.action?spacekey=&title= + items: + type: string + default: confluence url + ui:options: + addable: true + minItems: 1 + maxItems: 5 + repoUrl: + type: string + title: GitHub url mkdocs.yaml link + description: The GitHub repo url to your mkdocs.yaml file. Example + steps: + - id: create-docs + name: Get markdown file created and update markdown.yaml file + action: transform:confluence-to-markdown + input: + confluenceUrls: ${{ parameters.confluenceUrls }} + repoUrl: ${{ parameters.repoUrl }} + - id: publish + name: Publish PR to GitHub + action: publish:github:pull-request + input: + repoUrl: ?repo=${{ steps['create-docs'].output.repo }}&owner=${{ steps['create-docs'].output.owner }} + branchName: confluence-to-markdown + title: Confluence to Markdown + description: PR for converting confluence page to mkdocs + - id: merge + name: Merge PR + action: publish:github:merge-pull-request + input: + prUrl: ${{ steps['publish'].output.remoteUrl }} + forceAdmin: true + +``` + +Replace `` with your GitHub url without `https://`. + +You can find a list of all registred actions including their parameters at the /create/actions route in your Backstage application. diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/package.json b/plugins/scaffolder-backend-module-confluence-to-markdowwn/package.json new file mode 100644 index 0000000000..cc445108c3 --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/plugin-scaffolder-backend-module-confluence-to-markdowwn", + "description": "The confluence-to-markdowwn module for @backstage/plugin-scaffolder-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "help": "backstage-cli help" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/integration": "workspace:^", + "@backstage/plugin-scaffolder-backend": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", + "@backstage/types": "workspace:^", + "fs": "^0.0.1-security", + "fs-extra": "^11.1.0", + "git-url-parse": "^13.1.0", + "node-fetch": "2.6.7", + "node-html-markdown": "^1.3.0", + "yaml": "^2.2.1" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "mock-fs": "^5.2.0", + "os": "^0.1.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/confluenceToMarkdown.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/confluenceToMarkdown.test.ts new file mode 100644 index 0000000000..6a3a497d99 --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/confluenceToMarkdown.test.ts @@ -0,0 +1,316 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { PassThrough } from 'stream'; +import { createConfluenceToMarkdownAction } from './confluenceToMarkdown'; +import { getVoidLogger } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import mockFs from 'mock-fs'; +import os from 'os'; +import fetch from 'node-fetch'; +import type { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { readFile, writeFile } from 'fs-extra'; +import { Readable } from 'stream'; + +jest.mock('node-fetch'); +jest.mock('fs-extra', () => ({ + mkdirSync: jest.fn(), + readFile: jest.fn().mockResolvedValue('File contents'), + writeFile: jest.fn().mockImplementation(() => { + return Promise.resolve(); + }), + outputFile: jest.fn(), + openSync: jest.fn(), + createWriteStream: jest.fn().mockReturnValue(new PassThrough()), + writeStream: jest.fn(), +})); +const { Response } = jest.requireActual('node-fetch'); + +describe('transform:confluence-to-markdown', () => { + const config = new ConfigReader({ + confluence: { + baseUrl: 'https://nodomain.confluence.com/', + token: 'fake_token', + }, + }); + + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'token' }], + }, + }), + ); + + let reader: UrlReader; + let mockContext: ActionContext<{ + confluenceUrls: string[]; + repoUrl: string; + }>; + + const logger = getVoidLogger(); + jest.spyOn(logger, 'info'); + + const mockTmpDir = os.tmpdir(); + + beforeEach(() => { + reader = { + readUrl: jest.fn(), + readTree: jest.fn().mockResolvedValue({ + dir: jest.fn(), + }), + search: jest.fn(), + }; + mockContext = { + input: { + confluenceUrls: [ + 'https://nodomain.confluence.com/display/testing/mkdocs', + ], + repoUrl: 'https://notreal.github.com/space/backstage/mkdocs.yml', + }, + workspacePath: '/tmp', + logger, + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), + }; + mockFs({ [`${mockTmpDir}/src/docs`]: {} }); + }); + afterEach(() => { + jest.clearAllMocks(); + mockFs.restore(); + }); + + it('should call confluence to markdown action successfully with results array', async () => { + const options = { + reader, + integrations, + config, + }; + const responseBody = { + results: [ + { + id: '4444444', + type: 'page', + title: 'Testing', + body: { + export_view: { + value: '

hello world

', + }, + }, + }, + ], + }; + const responseBodyTwo = { + results: [ + { + id: '4444444', + type: 'attachment', + title: 'testing.pdf', + metadata: { + mediaType: 'application/pdf', + }, + _links: { + download: '/download/attachments/4444444/testing.pdf', + }, + }, + ], + }; + + (fetch as jest.MockedFunction) + .mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { + status: 200, + statusText: 'OK', + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(responseBodyTwo), { + status: 200, + statusText: 'OK', + }), + ) + .mockResolvedValueOnce( + new Response(Readable.from('Hello world'), { + status: 200, + statusText: 'OK', + }), + ); + + const mockCreateWriteStream = jest.fn().mockReturnValue({ + on: jest.fn(), + once: jest.fn((_event, callback) => { + callback(); + }), + }); + jest.mock('fs', () => ({ + openSync: jest.fn(), + createWriteStream: jest.fn().mockReturnValue(mockCreateWriteStream), + })); + const action = createConfluenceToMarkdownAction(options); + + await action.handler(mockContext); + + expect(logger.info).toHaveBeenCalledWith( + `Fetching the mkdocs.yml catalog from https://notreal.github.com/space/backstage/mkdocs.yml`, + ); + expect(logger.info).toHaveBeenCalledTimes(5); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(readFile).toHaveBeenCalledTimes(1); + expect(writeFile).toHaveBeenCalledTimes(1); + }); + + it('should call confluence to markdown action successfully with empty results array', async () => { + const options = { + reader, + integrations, + config, + }; + const responseBody = { + results: [ + { + id: '4444444', + type: 'page', + title: 'Testing', + body: { + export_view: { + value: '

hello world

', + }, + }, + }, + ], + }; + const responseBodyTwo = { + results: [], + }; + + (fetch as jest.MockedFunction) + .mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { + status: 200, + statusText: 'OK', + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(responseBodyTwo), { + status: 200, + statusText: 'OK', + }), + ); + + jest.mock('fs-extra', () => ({ + readFile: jest.fn().mockResolvedValue('File contents'), + })); + const action = createConfluenceToMarkdownAction(options); + + await action.handler(mockContext); + + expect(logger.info).toHaveBeenCalledWith( + `Fetching the mkdocs.yml catalog from https://notreal.github.com/space/backstage/mkdocs.yml`, + ); + expect(logger.info).toHaveBeenCalledTimes(5); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(readFile).toHaveBeenCalledTimes(1); + expect(writeFile).toHaveBeenCalledTimes(1); + }); + + it('shoud fail on the first fetch call with response.ok set to false', async () => { + const options = { + reader, + integrations, + config, + }; + const responseBody = {}; + (fetch as jest.MockedFunction).mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { + status: 401, + statusText: 'npot', + ok: false, + }), + ); + const action = createConfluenceToMarkdownAction(options); + await expect(async () => { + await action.handler(mockContext); + }).rejects.toThrow('Request failed with 401 Error'); + }); + + it('should return nothing in results from the first api call and fail', async () => { + const options = { + reader, + integrations, + config, + }; + const responseBody = { + results: [], + }; + (fetch as jest.MockedFunction).mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { + status: 200, + statusText: 'ok', + }), + ); + const action = createConfluenceToMarkdownAction(options); + await expect(async () => { + await action.handler(mockContext); + }).rejects.toThrow( + 'Could not find document https://nodomain.confluence.com/display/testing/mkdocs. Please check your input.', + ); + }); + + it('should fail on the second fetch call to confluence', async () => { + const options = { + reader, + integrations, + config, + }; + const responseBody = { + results: [ + { + id: '4444444', + type: 'page', + title: 'Testing', + body: { + export_view: { + value: '

hello world

', + }, + }, + }, + ], + }; + const responseBodyTwo = {}; + + (fetch as jest.MockedFunction) + .mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { + status: 200, + statusText: 'OK', + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(responseBodyTwo), { + status: 404, + statusText: 'notok', + }), + ); + const action = createConfluenceToMarkdownAction(options); + await expect(async () => { + await action.handler(mockContext); + }).rejects.toThrow('Request failed with 404 Error'); + }); +}); diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/confluenceToMarkdown.ts b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/confluenceToMarkdown.ts new file mode 100644 index 0000000000..792d1f088b --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/confluenceToMarkdown.ts @@ -0,0 +1,187 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Config } from '@backstage/config'; +import { UrlReader } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { createFetchPlainAction } from '@backstage/plugin-scaffolder-backend'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { InputError, ConflictError } from '@backstage/errors'; +import { NodeHtmlMarkdown } from 'node-html-markdown'; +import fs from 'fs-extra'; +import parseGitUrl from 'git-url-parse'; +import { + parseFromYaml, + readFileAsString, + transformToYaml, + updateFile, + fetchConfluence, + getAndWriteAttachments, + createConfluenceVariables, +} from './helpers'; + +export const createConfluenceToMarkdownAction = (options: { + reader: UrlReader; + integrations: ScmIntegrations; + config: Config; +}) => { + const { config, reader, integrations } = options; + const fetchPlainAction = createFetchPlainAction({ reader, integrations }); + type Obj = { + [key: string]: string; + }; + + return createTemplateAction<{ + confluenceUrls: string[]; + repoUrl: string; + }>({ + id: 'transform:confluence-to-markdown', + schema: { + input: { + properties: { + confluenceUrls: { + type: 'array', + title: 'Confluence URL', + description: + 'Paste your confluence url. Ensure it follows this format: https://{confluence+base+url}/display/{spacekey}/{page+title}', + items: { + type: 'string', + default: 'Confluence URL', + }, + }, + repoUrl: { + type: 'string', + title: 'GitHub Repo Url', + description: + 'mkdocs.yml file location inside the github repo you want to store the document', + }, + }, + }, + }, + async handler(ctx) { + const { confluenceUrls, repoUrl } = ctx.input; + const parsedRepoUrl = parseGitUrl(`${repoUrl}`); + const filePathToMkdocs = parsedRepoUrl.filepath.substring( + 0, + parsedRepoUrl.filepath.lastIndexOf('/') + 1, + ); + const dirPath = ctx.workspacePath; + let productArray: string[][] = []; + + ctx.logger.info(`Fetching the mkdocs.yml catalog from ${repoUrl}`); + ctx.logger.info(confluenceUrls); + const params = new URL(confluenceUrls[0]); + ctx.logger.info(params.searchParams); + // This grabs the files from Github + const repoFileDir = `${dirPath}/${parsedRepoUrl.filepath}`; + await fetchPlainAction.handler({ + ...ctx, + input: { + url: `https://${parsedRepoUrl.resource}/${parsedRepoUrl.owner}/${parsedRepoUrl.name}`, + targetPath: dirPath, + }, + }); + + for (let p = 0; p < confluenceUrls.length; p++) { + const confluenceUrl = confluenceUrls[p]; + + const { spacekey, title, titleWithSpaces } = + await createConfluenceVariables(confluenceUrl); + ctx.logger.info(spacekey); + ctx.logger.info(title); + ctx.logger.info(titleWithSpaces); + + // This calls confluence to get the page html and page id + const getConfluenceDoc = await fetchConfluence( + `/rest/api/content?title=${title}&spaceKey=${spacekey}&expand=body.export_view`, + config, + ); + if (getConfluenceDoc.results.length === 0) { + throw new InputError( + `Could not find document ${confluenceUrl}. Please check your input.`, + ); + } + // This gets attachements for the confluence page if they exist + const getDocAttachments = await fetchConfluence( + `/rest/api/content/${getConfluenceDoc.results[0].id}/child/attachment`, + config, + ); + + if (getDocAttachments.results.length) { + fs.mkdirSync(`${dirPath}/${filePathToMkdocs}docs/img`, { + recursive: true, + }); + productArray = await getAndWriteAttachments( + getDocAttachments, + dirPath, + config, + filePathToMkdocs, + ); + } + + ctx.logger.info( + `starting action for converting ${titleWithSpaces} from Confluence To Markdown`, + ); + + // This reads mkdocs.yml file + const mkdocsFileContent = await readFileAsString(repoFileDir); + const mkdocsFile = parseFromYaml(mkdocsFileContent); + ctx.logger.info( + `Adding new file - ${titleWithSpaces} to the current mkdocs.yml file`, + ); + + // This modifies the mkdocs.yml file + if (mkdocsFile !== undefined && mkdocsFile.hasOwnProperty('nav')) { + const { nav } = mkdocsFile; + if (!nav.some((i: Obj) => i.hasOwnProperty(titleWithSpaces))) { + nav.push({ + [titleWithSpaces]: `${titleWithSpaces.replace(/\s+/g, '-')}.md`, + }); + mkdocsFile.nav = nav; + } else { + throw new ConflictError( + 'This document looks to exist inside the GitHub repo. Will end the action.', + ); + } + } + const newTemplateFileContent = transformToYaml(mkdocsFile); + await updateFile(newTemplateFileContent, repoFileDir); + + // This grabs the confluence html and converts it to markdown and adds attachments + const html = getConfluenceDoc.results[0].body.export_view.value; + const markdownToPublish = NodeHtmlMarkdown.translate(html); + let newString: string = markdownToPublish; + productArray.map((product: string[]) => { + const regex = product[1].includes('pdf') + ? new RegExp(`(\\[.*?\\]\\()(.*?${product[0]}.*?)(\\))`, 'gi') + : new RegExp(`(\\!\\[.*?\\]\\()(.*?${product[0]}.*?)(\\))`, 'gi'); + newString = newString.replace(regex, `$1./img/${product[1]}$3`); + }); + + ctx.logger.info(`Adding new file to repo.`); + await fs.outputFile( + `${dirPath}/${filePathToMkdocs}docs/${titleWithSpaces.replace( + /\s+/g, + '-', + )}.md`, + newString, + ); + } + + ctx.output('repo', parsedRepoUrl.name); + ctx.output('owner', parsedRepoUrl.owner); + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/helpers.ts b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/helpers.ts new file mode 100644 index 0000000000..d0125b5a0d --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/helpers.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { ResponseError, ConflictError, InputError } from '@backstage/errors'; +import fs from 'fs-extra'; +import YAML from 'yaml'; +import fetch, { Response } from 'node-fetch'; + +interface Links { + webui: string; + download: string; + thumbnail: string; + self: string; +} + +interface Metadata { + mediaType: string; +} + +export interface Result { + id: string; + type: string; + status: string; + title: string; + metadata: Metadata; + _links: Links; +} + +export interface Results { + results: Result[]; +} + +export const readFileAsString = async (fileDir: string) => { + const content = await fs.readFile(fileDir, 'utf-8'); + return content.toString(); +}; + +export const parseFromYaml = (content: string) => { + return YAML.parse(content); +}; + +export const transformToYaml = (content: any) => { + return YAML.stringify(content); +}; + +export const updateFile = async (content: string, file: string) => { + await fs.writeFile(file, content); +}; + +export const fetchConfluence = async (relativeUrl: string, config: Config) => { + const baseUrl = config.getString('confluence.baseUrl'); + const token = config.getString('confluence.token'); + const response: Response = await fetch(`${baseUrl}${relativeUrl}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json() as Promise; +}; + +export const getAndWriteAttachments = async ( + arr: Results, + workspace: string, + config: Config, + mkdocsDir: string, +) => { + const productArr: string[][] = []; + const baseUrl = config.getString('confluence.baseUrl'); + const token = config.getString('confluence.token'); + await Promise.all( + await arr.results.map(async (result: Result) => { + const downloadLink = result._links.download; + const downloadTitle = result.title.replace(/ /g, '-'); + if (result.metadata.mediaType !== 'application/gliffy+json') { + productArr.push([result.title.replace(/ /g, '%20'), downloadTitle]); + } + + const res = await fetch(`${baseUrl}${downloadLink}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + // console.log(`${workspace}/${mkdocsDir}docs/img/${downloadTitle}`) + if (!res.ok) { + throw ResponseError.fromResponse(res); + } else if (res.body !== null) { + fs.openSync(`${workspace}/${mkdocsDir}docs/img/${downloadTitle}`, 'w'); + const writeStream = fs.createWriteStream( + `${workspace}/${mkdocsDir}docs/img/${downloadTitle}`, + ); + res.body.pipe(writeStream); + await new Promise((resolve, reject) => { + writeStream.on('finish', () => { + resolve(`${workspace}/${mkdocsDir}docs/img/${downloadTitle}`); + }); + writeStream.on('error', reject); + }); + } else { + throw new ConflictError( + 'No Body on the response. Can not save images from Confluence Doc', + ); + } + }), + ); + return productArr; +}; + +export const createConfluenceVariables = async (url: string) => { + let spacekey: string | null = null; + let title: string | null = null; + let titleWithSpaces: string | null = ''; + const params = new URL(url); + if (url.includes('display')) { + spacekey = params.pathname.split('/')[2]; + title = params.pathname.split('/')[3]; + titleWithSpaces = title?.replace(/\+/g, ' '); + return { spacekey, title, titleWithSpaces }; + } + throw new InputError( + 'The Url format for Confluence is incorrect. Acceptable format is `/display//`', + ); +}; diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/index.ts b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/index.ts new file mode 100644 index 0000000000..00f00eabda --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/confluence/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { createConfluenceToMarkdownAction } from './confluenceToMarkdown'; diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/index.ts b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/index.ts new file mode 100644 index 0000000000..3d3a6c73a0 --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/actions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './confluence'; diff --git a/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/index.ts b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/index.ts new file mode 100644 index 0000000000..e38cee3557 --- /dev/null +++ b/plugins/scaffolder-backend-module-confluence-to-markdowwn/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The confluence-to-markdowwn module for @backstage/plugin-scaffolder-backend. + * + * @packageDocumentation + */ + +export * from './actions'; diff --git a/yarn.lock b/yarn.lock index b1b60ba15e..3585d449fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7651,6 +7651,29 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-scaffolder-backend-module-confluence-to-markdowwn@workspace:plugins/scaffolder-backend-module-confluence-to-markdowwn": + version: 0.0.0-use.local + resolution: "@backstage/plugin-scaffolder-backend-module-confluence-to-markdowwn@workspace:plugins/scaffolder-backend-module-confluence-to-markdowwn" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/integration": "workspace:^" + "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/types": "workspace:^" + fs: ^0.0.1-security + fs-extra: ^11.1.0 + git-url-parse: ^13.1.0 + mock-fs: ^5.2.0 + node-fetch: 2.6.7 + node-html-markdown: ^1.3.0 + os: ^0.1.2 + yaml: ^2.2.1 + languageName: unknown + linkType: soft + "@backstage/plugin-scaffolder-backend-module-cookiecutter@workspace:plugins/scaffolder-backend-module-cookiecutter": version: 0.0.0-use.local resolution: "@backstage/plugin-scaffolder-backend-module-cookiecutter@workspace:plugins/scaffolder-backend-module-cookiecutter" @@ -20175,6 +20198,19 @@ __metadata: languageName: node linkType: hard +"css-select@npm:^5.1.0": + version: 5.1.0 + resolution: "css-select@npm:5.1.0" + dependencies: + boolbase: ^1.0.0 + css-what: ^6.1.0 + domhandler: ^5.0.2 + domutils: ^3.0.1 + nth-check: ^2.0.1 + checksum: 2772c049b188d3b8a8159907192e926e11824aea525b8282981f72ba3f349cf9ecd523fdf7734875ee2cb772246c22117fc062da105b6d59afe8dcd5c99c9bda + languageName: node + linkType: hard + "css-to-react-native@npm:^3.0.0": version: 3.0.0 resolution: "css-to-react-native@npm:3.0.0" @@ -20229,6 +20265,13 @@ __metadata: languageName: node linkType: hard +"css-what@npm:^6.1.0": + version: 6.1.0 + resolution: "css-what@npm:6.1.0" + checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe + languageName: node + linkType: hard + "css.escape@npm:1.5.1, css.escape@npm:^1.5.1": version: 1.5.1 resolution: "css.escape@npm:1.5.1" @@ -23898,6 +23941,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^11.1.0": + version: 11.1.0 + resolution: "fs-extra@npm:11.1.0" + dependencies: + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: 5ca476103fa1f5ff4a9b3c4f331548f8a3c1881edaae323a4415d3153b5dc11dc6a981c8d1dd93eec8367ceee27b53f8bd27eecbbf66ffcdd04927510c171e7f + languageName: node + linkType: hard + "fs-extra@npm:^7.0.1, fs-extra@npm:~7.0.1": version: 7.0.1 resolution: "fs-extra@npm:7.0.1" @@ -23955,6 +24009,13 @@ __metadata: languageName: node linkType: hard +"fs@npm:^0.0.1-security": + version: 0.0.1-security + resolution: "fs@npm:0.0.1-security" + checksum: 53c6230e1faae9fa32c1df82c16a84b51b1243d20f3da2b64bd110bb472b73b9185169b703e008356e3cdc92d155088b617d9d39a63b5227a30b3621baad7f5d + languageName: node + linkType: hard + "fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -24228,7 +24289,7 @@ __metadata: languageName: node linkType: hard -"git-url-parse@npm:^13.0.0": +"git-url-parse@npm:^13.0.0, git-url-parse@npm:^13.1.0": version: 13.1.0 resolution: "git-url-parse@npm:13.1.0" dependencies: @@ -24900,7 +24961,7 @@ __metadata: languageName: node linkType: hard -"he@npm:^1.2.0": +"he@npm:1.2.0, he@npm:^1.2.0": version: 1.2.0 resolution: "he@npm:1.2.0" bin: @@ -30598,6 +30659,25 @@ __metadata: languageName: node linkType: hard +"node-html-markdown@npm:^1.3.0": + version: 1.3.0 + resolution: "node-html-markdown@npm:1.3.0" + dependencies: + node-html-parser: ^6.1.1 + checksum: 303affd75bf11e92f004047e283aa05da5c8ae8696c1f28f811e9f61762c55b4480e0e5986a9a4b399791485b835380af8ab0554ebf867d6cf9be362abedcf5d + languageName: node + linkType: hard + +"node-html-parser@npm:^6.1.1": + version: 6.1.5 + resolution: "node-html-parser@npm:6.1.5" + dependencies: + css-select: ^5.1.0 + he: 1.2.0 + checksum: b54257b31954be17d473c86a2fede53d5e238708f3d88577c98881c28ab66013fd37044903f6f3cd662b56d882681fb511bca75a80a80b62f7111b32b39eebae + languageName: node + linkType: hard + "node-int64@npm:^0.4.0": version: 0.4.0 resolution: "node-int64@npm:0.4.0" @@ -30898,12 +30978,12 @@ __metadata: languageName: node linkType: hard -"nth-check@npm:^2.0.0": - version: 2.0.1 - resolution: "nth-check@npm:2.0.1" +"nth-check@npm:^2.0.0, nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" dependencies: boolbase: ^1.0.0 - checksum: 5386d035c48438ff304fe687704d93886397349d1bed136de97aeae464caba10e8ffac55a04b215b86b3bc8897f33e0a5aa1045a9d8b2f251ae61b2a3ad3e450 + checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3 languageName: node linkType: hard @@ -31278,6 +31358,13 @@ __metadata: languageName: node linkType: hard +"os@npm:^0.1.2": + version: 0.1.2 + resolution: "os@npm:0.1.2" + checksum: dc2d99759eef13f5dc47ddb12c67b9760a7196fd83a35a7aec2d75b82f91163ca1d4e8872238f8c2a35f4cddd5adf5ce6638a234c0563c748d3cd1d69a9f7153 + languageName: node + linkType: hard + "ospath@npm:^1.2.2": version: 1.2.2 resolution: "ospath@npm:1.2.2" @@ -39460,10 +39547,10 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.0.0, yaml@npm:^2.1.1, yaml@npm:^2.1.3": - version: 2.1.3 - resolution: "yaml@npm:2.1.3" - checksum: 91316062324a93f9cb547469092392e7d004ff8f70c40fecb420f042a4870b2181557350da56c92f07bd44b8f7a252b0be26e6ade1f548e1f4351bdd01c9d3c7 +"yaml@npm:^2.0.0, yaml@npm:^2.1.1, yaml@npm:^2.1.3, yaml@npm:^2.2.1": + version: 2.2.1 + resolution: "yaml@npm:2.2.1" + checksum: 84f68cbe462d5da4e7ded4a8bded949ffa912bc264472e5a684c3d45b22d8f73a3019963a32164023bdf3d83cfb6f5b58ff7b2b10ef5b717c630f40bd6369a23 languageName: node linkType: hard