├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── images ├── commands.png ├── fav_spaces.png ├── hotkeys.png ├── search_spaces.png └── settings_tab.png ├── lib ├── adaptors │ ├── file.ts │ └── properties.ts ├── builder │ ├── adf.ts │ └── types.ts ├── confluence │ ├── attachements.ts │ ├── base.ts │ ├── client.ts │ ├── label.ts │ ├── page.ts │ ├── parameters.ts │ ├── search.ts │ ├── space.ts │ └── types.ts ├── directors │ ├── label.ts │ ├── link.ts │ ├── list.ts │ ├── media.ts │ ├── paragraph.ts │ └── table.ts ├── modal.ts ├── settings.ts └── utils.ts ├── main.ts ├── manifest.json ├── package.json ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": 12, 11 | "sourceType": "module", 12 | "project": "./tsconfig.json" // Ensure ESLint uses your TypeScript config 13 | }, 14 | "plugins": ["@typescript-eslint"] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | check-tag: 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Check if tag exists 17 | id: check_tag 18 | run: | 19 | git fetch --tags 20 | $env:APP_VERSION = (node -p "require('./package.json').version") 21 | $TAG_EXISTS = git tag -l "${env:APP_VERSION}" 22 | if ($TAG_EXISTS) { 23 | echo "Tag already exists for version $env:APP_VERSION. Stopping workflow." 24 | exit 1 25 | } 26 | 27 | build: 28 | runs-on: ubuntu-latest 29 | needs: check-tag 30 | 31 | steps: 32 | - uses: actions/checkout@v3 33 | 34 | - name: Use Node.js 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: "18.x" 38 | 39 | - name: Build plugin 40 | run: | 41 | npm install 42 | npm run build 43 | 44 | - name: Get version from package.json 45 | id: get_version 46 | run: echo "app_version=$(node -p "require('./package.json').version")" >> $GITHUB_ENV 47 | 48 | - name: Create Release 49 | id: create_release 50 | uses: actions/create-release@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 53 | with: 54 | tag_name: ${{ env.app_version }} 55 | release_name: Release v${{ env.app_version }} 56 | draft: false 57 | prerelease: false 58 | 59 | - name: Upload Release Assets 60 | if: ${{ steps.create_release.outputs.upload_url }} 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 63 | TAG_NAME: ${{ env.app_version }} 64 | run: | 65 | gh release upload $TAG_NAME ./main.js ./manifest.json ./styles.css 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | package-lock.json 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Confluence Link 2 | 3 | Welcome to the `Confluence Link` project. The objective of this project is to make it easy to write documentation on Obsidian and quicky create a Confluence page to share with you team members. 4 | 5 | ## Setting things up 6 | 7 | 1. Open the plugin settings and configure the following fields: 8 | 9 | - `Confluence Domain`: The URL of your Atlassian Confluence instance 10 | - `Atlassian User Name`: Your Atlassian account's email address 11 | - `Atlassian API Token`: Your Atlassian API token. You can generate one from your [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens). 12 | - (Optional) `Confluence Default Space`: The space identifier where all you confluence pages will be created 13 | 14 | ![Settings](./images/settings_tab.png) 15 | 16 | 2. (Optional) Open the default obsidian hotkeys settings: 17 | 18 | - search for `confluence-link` 19 | - add hotkeys 20 | 21 | ![Hotkeys](./images/hotkeys.png) 22 | 23 | ## Usage 24 | 25 | 1. Open a md file 26 | 2. Press the hotkey set at step 2 in [Settings things up](#Setting-things-up) section or use the command pallet (`Ctrl/Cmd + P` ) and search for `Confluence Link` commands to execute 27 | 28 | ![Commands](./images/commands.png) 29 | 30 | ## Nice to know 31 | 32 | While the spaces modal is opened you can mark or unmark spaces as favorites by clicking the star icon. This will make them appear as the first results the next time you open this modal. 33 | 34 | ![Favorite_Spaces](./images/fav_spaces.png) 35 | 36 | If a space is not in the initial list you can type `??` followed by the space title for a "fuzzy search" using all the spaces you have access to, not just the up to 250 that the confluence API can return in one request. 37 | 38 | ![Search](./images/search_spaces.png) 39 | 40 | ## Issues 41 | 42 | Please log issues or feature requests to https://github.com/BungaRazvan/confluence-link/issues as this is where the code is being developed 43 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /images/commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BungaRazvan/confluence-link/80e06734f8b55612ce9b3e7674fc732d84ed07d3/images/commands.png -------------------------------------------------------------------------------- /images/fav_spaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BungaRazvan/confluence-link/80e06734f8b55612ce9b3e7674fc732d84ed07d3/images/fav_spaces.png -------------------------------------------------------------------------------- /images/hotkeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BungaRazvan/confluence-link/80e06734f8b55612ce9b3e7674fc732d84ed07d3/images/hotkeys.png -------------------------------------------------------------------------------- /images/search_spaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BungaRazvan/confluence-link/80e06734f8b55612ce9b3e7674fc732d84ed07d3/images/search_spaces.png -------------------------------------------------------------------------------- /images/settings_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BungaRazvan/confluence-link/80e06734f8b55612ce9b3e7674fc732d84ed07d3/images/settings_tab.png -------------------------------------------------------------------------------- /lib/adaptors/file.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, MarkdownRenderer, Notice, TFile } from "obsidian"; 2 | import ADFBuilder from "lib/builder/adf"; 3 | import { AdfElement } from "lib/builder/types"; 4 | import ConfluenceClient from "lib/confluence/client"; 5 | import PropertiesAdaptor from "./properties"; 6 | import ParagraphDirector from "lib/directors/paragraph"; 7 | import { ConfluenceLinkSettings } from "lib/confluence/types"; 8 | import TableDirector from "lib/directors/table"; 9 | import { MardownLgToConfluenceLgMap } from "lib/utils"; 10 | import { find } from "lodash"; 11 | import ListDirector from "lib/directors/list"; 12 | import LabelDirector from "lib/directors/label"; 13 | 14 | export default class FileAdaptor { 15 | constructor( 16 | private readonly app: App, 17 | private readonly client: ConfluenceClient, 18 | private readonly spaceId: string, 19 | private readonly settings: ConfluenceLinkSettings 20 | ) {} 21 | 22 | async convertObs2Adf( 23 | text: string, 24 | path: string, 25 | propertiesAdaptor: PropertiesAdaptor 26 | ): Promise { 27 | const container = document.createElement("div"); 28 | 29 | MarkdownRenderer.render( 30 | this.app, 31 | text, 32 | container, 33 | path, 34 | new Component() 35 | ); 36 | const adf = await this.htmlToAdf(container, path, propertiesAdaptor); 37 | return adf; 38 | } 39 | 40 | async htmlToAdf( 41 | container: HTMLElement, 42 | filePath: string, 43 | propertiesAdaptor: PropertiesAdaptor 44 | ): Promise { 45 | const builder = new ADFBuilder(); 46 | const labelDirector = new LabelDirector(this.client, propertiesAdaptor); 47 | 48 | for (const node of Array.from(container.childNodes)) { 49 | await this.traverse( 50 | node as HTMLElement, 51 | builder, 52 | filePath, 53 | labelDirector 54 | ); 55 | } 56 | 57 | if (this.settings.uploadTags && labelDirector.allTags.length > 0) { 58 | await labelDirector.updateConfluencePage(); 59 | } 60 | 61 | return builder.build(); 62 | } 63 | 64 | async getConfluenceLink(path: string): Promise { 65 | const file = this.app.metadataCache.getFirstLinkpathDest(path, "."); 66 | 67 | if (!(file instanceof TFile)) { 68 | return "#"; 69 | } 70 | const fileData = await this.app.vault.read(file); 71 | const propAdaptor = new PropertiesAdaptor().loadProperties(fileData); 72 | let { confluenceUrl } = propAdaptor.properties; 73 | 74 | if (confluenceUrl) { 75 | return confluenceUrl as string; 76 | } 77 | 78 | const response = await this.client.page.createPage({ 79 | spaceId: this.spaceId, 80 | pageTitle: file.basename, 81 | }); 82 | confluenceUrl = response._links.base + response._links.webui; 83 | 84 | propAdaptor.addProperties({ 85 | pageId: response.id, 86 | spaceId: response.spaceId, 87 | confluenceUrl, 88 | }); 89 | await this.app.vault.modify(file, propAdaptor.toFile(fileData)); 90 | 91 | const adf = await this.convertObs2Adf(fileData, path, propAdaptor); 92 | 93 | await this.client.page.updatePage({ 94 | pageId: propAdaptor.properties.pageId as string, 95 | pageTitle: file.basename, 96 | adf, 97 | }); 98 | 99 | new Notice(`Page Created: ${file.basename}`); 100 | return confluenceUrl as string; 101 | } 102 | 103 | async traverse( 104 | node: HTMLElement, 105 | builder: ADFBuilder, 106 | filePath: string, 107 | labelDirector: LabelDirector 108 | ) { 109 | switch (node.nodeName) { 110 | case "H1": 111 | case "H2": 112 | case "H3": 113 | case "H4": 114 | case "H5": 115 | case "H6": 116 | builder.addItem( 117 | builder.headingItem( 118 | Number(node.nodeName[1]), 119 | node.textContent! 120 | ) 121 | ); 122 | break; 123 | case "TABLE": 124 | const tableRows = Array.from(node.querySelectorAll("tr")); 125 | const tableContent = await Promise.all( 126 | tableRows.map(async (row) => { 127 | const cells = await Promise.all( 128 | Array.from(row.querySelectorAll("td, th")).map( 129 | async (cell) => { 130 | const cellAdf = new ADFBuilder(); 131 | const director = new TableDirector( 132 | cellAdf, 133 | this, 134 | this.app, 135 | this.client, 136 | this.settings, 137 | labelDirector 138 | ); 139 | 140 | await director.addItems( 141 | cell as HTMLTableCellElement, 142 | filePath 143 | ); 144 | 145 | return cellAdf.build(); 146 | } 147 | ) 148 | ); 149 | return builder.tableRowItem(cells); 150 | }) 151 | ); 152 | builder.addItem(builder.tableItem(tableContent)); 153 | break; 154 | case "PRE": 155 | const codeElement = node.querySelector("code"); 156 | 157 | // skip if pre is for file properties or no code element 158 | if (node.classList.contains("frontmatter") || !codeElement) { 159 | break; 160 | } 161 | 162 | if (codeElement.classList.contains("language-mermaid")) { 163 | // TODO figure out mermaid 164 | break; 165 | } 166 | 167 | const codeText = codeElement.textContent || ""; 168 | const codeLg = find( 169 | Array.from(codeElement.classList.values()), 170 | (cls: string) => { 171 | return cls.startsWith("language-"); 172 | } 173 | ); 174 | const confluenceLg = codeLg 175 | ? MardownLgToConfluenceLgMap[ 176 | codeLg.replace("language-", "") 177 | ] 178 | : ""; 179 | 180 | builder.addItem(builder.codeBlockItem(codeText, confluenceLg)); 181 | 182 | break; 183 | case "P": 184 | const paragraphDirector = new ParagraphDirector( 185 | builder, 186 | this, 187 | this.app, 188 | this.client, 189 | this.settings, 190 | labelDirector 191 | ); 192 | await paragraphDirector.addItems( 193 | node as HTMLParagraphElement, 194 | filePath 195 | ); 196 | 197 | break; 198 | case "OL": 199 | case "UL": 200 | const listDirector = new ListDirector( 201 | builder, 202 | this, 203 | this.app, 204 | this.client, 205 | this.settings, 206 | labelDirector 207 | ); 208 | 209 | await listDirector.addList( 210 | node as HTMLUListElement | HTMLOListElement, 211 | filePath 212 | ); 213 | 214 | break; 215 | case "BLOCKQUOTE": 216 | builder.addItem(builder.blockquoteItem(node.textContent!)); 217 | break; 218 | case "HR": 219 | builder.addItem(builder.horizontalRuleItem()); 220 | break; 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /lib/adaptors/properties.ts: -------------------------------------------------------------------------------- 1 | import { difference, isEmpty } from "lodash"; 2 | import { stringify, parse } from "yaml"; 3 | 4 | export interface PropsType { 5 | tags?: Array; 6 | pageId?: string; 7 | spaceId?: string; 8 | confluenceUrl?: string; 9 | [key: string]: 10 | | boolean 11 | | Array 12 | | number 13 | | string 14 | | Date 15 | | undefined; 16 | } 17 | 18 | export default class PropertiesAdaptor { 19 | properties: PropsType; 20 | 21 | constructor() { 22 | this.properties = {}; 23 | } 24 | 25 | loadProperties(str: string): PropertiesAdaptor { 26 | const frontMatterMatch = str.match(/^---\n([\s\S]+?)\n---\n/); 27 | 28 | if (!frontMatterMatch || frontMatterMatch.length < 2) { 29 | return this; 30 | } 31 | 32 | const existingFrontMatter = frontMatterMatch[1] 33 | .trim() 34 | .replaceAll("}", ""); 35 | // Parse the existing YAML front matter into an object 36 | const existingFrontMatterObject = parse(existingFrontMatter); 37 | 38 | if (existingFrontMatterObject) { 39 | this.properties = existingFrontMatterObject; 40 | } 41 | 42 | return this; 43 | } 44 | 45 | addProperties(props: PropsType): PropertiesAdaptor { 46 | this.properties = { 47 | ...this.properties, 48 | ...props, 49 | }; 50 | 51 | return this; 52 | } 53 | 54 | addTags(tags: Array): PropertiesAdaptor { 55 | const props = { ...this.properties }; 56 | const propTags = props.tags; 57 | 58 | if (isEmpty(propTags)) { 59 | this.addProperties({ tags: tags }); 60 | return this; 61 | } 62 | 63 | const newTags = difference(tags, propTags!); 64 | 65 | this.addProperties({ tags: propTags!.concat(newTags) }); 66 | return this; 67 | } 68 | 69 | toFile(str: string): string { 70 | const frontMatterMatch = str.match(/^---\n([\s\S]+?)\n---\n/); 71 | 72 | if (isEmpty(this.properties)) { 73 | return ""; 74 | } 75 | 76 | if (!frontMatterMatch) { 77 | return `---\n${stringify(this.properties)}\n---\n${str}`; 78 | } 79 | 80 | return str.replace( 81 | frontMatterMatch[0], 82 | `---\n${stringify(this.properties)}\n---\n` 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/builder/adf.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextElement, 3 | LinkElement, 4 | ParagraphElement, 5 | TableElement, 6 | HeadingElement, 7 | CodeBlockElement, 8 | TaskListItemElement, 9 | ListItemElement, 10 | BlockquoteElement, 11 | TableRowElement, 12 | TaskItemElement, 13 | BulletListItemElement, 14 | OrderedListElement, 15 | AdfElement, 16 | EmphasisElement, 17 | LinkMarkElement, 18 | StrikeMarkElement, 19 | UnderlineMarkElement, 20 | CodeMarkElement, 21 | EmMarkElement, 22 | StrongMarkElement, 23 | CardElementLink, 24 | MediaSingleItemElement, 25 | MediaItemElement, 26 | Layout, 27 | } from "./types"; 28 | 29 | export default class ADFBuilder { 30 | private adf: AdfElement[]; 31 | 32 | constructor() { 33 | this.adf = []; 34 | } 35 | 36 | headingItem(level: number, text: string): HeadingElement { 37 | const heading = { 38 | type: "heading", 39 | content: [{ type: "text", text: text }], 40 | attrs: { level: level, id: text }, 41 | }; 42 | return heading; 43 | } 44 | 45 | horizontalRuleItem(): { type: "rule" } { 46 | return { 47 | type: "rule", 48 | }; 49 | } 50 | 51 | paragraphItem(text?: string): ParagraphElement { 52 | const paragraph = { 53 | type: "paragraph", 54 | content: text ? [{ type: "text", text: text }] : [], 55 | }; 56 | return paragraph; 57 | } 58 | 59 | tableItem(tableContent: Array): TableElement { 60 | return { 61 | type: "table", 62 | content: tableContent, 63 | }; 64 | } 65 | 66 | tableRowItem(cells: AdfElement[][]): TableRowElement { 67 | const tableRow = { 68 | type: "tableRow", 69 | content: cells.map((cell) => ({ 70 | type: "tableCell", 71 | attrs: { 72 | background: "", 73 | colwidth: [], 74 | colspan: 1, 75 | rowspan: 1, 76 | }, 77 | content: cell, 78 | })), 79 | }; 80 | 81 | return tableRow; 82 | } 83 | 84 | codeItem(codeText: string): TextElement { 85 | return { 86 | type: "text", 87 | text: codeText, 88 | marks: [this.markCode()], 89 | }; 90 | } 91 | 92 | underlineItem(text: string): TextElement { 93 | return { 94 | type: "text", 95 | text: text, 96 | marks: [this.markUnderline()], 97 | }; 98 | } 99 | 100 | strikeItem(text: string): TextElement { 101 | return { 102 | type: "text", 103 | text: text, 104 | marks: [this.markStrike()], 105 | }; 106 | } 107 | 108 | codeBlockItem(codeText: string, language: string = ""): CodeBlockElement { 109 | return { 110 | type: "codeBlock", 111 | attrs: { language }, 112 | content: [{ type: "text", text: codeText }], 113 | }; 114 | } 115 | 116 | taskListItem(taskListItems: Array): TaskListItemElement { 117 | return { 118 | type: "taskList", 119 | content: taskListItems, 120 | attrs: { localId: "Task List" }, 121 | }; 122 | } 123 | 124 | textItem(text: string): TextElement { 125 | return { 126 | type: "text", 127 | text: text, 128 | }; 129 | } 130 | 131 | strongItem(text: string): TextElement { 132 | return { 133 | type: "text", 134 | text: text, 135 | marks: [this.markStrong()], 136 | }; 137 | } 138 | 139 | bulletListItem(listItems: Array): BulletListItemElement { 140 | return { 141 | type: "bulletList", 142 | content: listItems, 143 | }; 144 | } 145 | 146 | orderedListItem(listItems: Array): OrderedListElement { 147 | return { 148 | type: "orderedList", 149 | content: listItems, 150 | }; 151 | } 152 | 153 | linkItem(linkText: string, href: string): LinkElement { 154 | return { 155 | type: "text", 156 | text: linkText, 157 | marks: [this.markLink(href)], 158 | }; 159 | } 160 | 161 | cardItem(href: string): CardElementLink { 162 | return { 163 | type: "inlineCard", 164 | attrs: { 165 | url: href, 166 | }, 167 | }; 168 | } 169 | 170 | blockquoteItem(blockquoteText: string): BlockquoteElement { 171 | return { 172 | type: "blockquote", 173 | content: [ 174 | { 175 | type: "paragraph", 176 | content: [{ type: "text", text: blockquoteText }], 177 | }, 178 | ], 179 | }; 180 | } 181 | 182 | emphasisItem(emText: string): EmphasisElement { 183 | return { 184 | type: "text", 185 | text: emText, 186 | marks: [this.markEm()], 187 | }; 188 | } 189 | 190 | listItem(content: AdfElement[]): ListItemElement { 191 | return { 192 | type: "listItem", 193 | content, 194 | }; 195 | } 196 | 197 | taskItem(text: string, isChecked: boolean): TaskItemElement { 198 | return { 199 | type: "taskItem", 200 | attrs: { localId: text, state: isChecked ? "DONE" : "TODO" }, 201 | content: [ 202 | { 203 | type: "text", 204 | text: text, 205 | marks: [], 206 | }, 207 | ], 208 | }; 209 | } 210 | 211 | mediaItem( 212 | id: string, 213 | collection: string, 214 | width: number | null = null, 215 | height: number | null = null 216 | ): MediaItemElement { 217 | const media: MediaItemElement = { 218 | type: "media", 219 | attrs: { 220 | type: "file", 221 | id, 222 | collection, 223 | }, 224 | }; 225 | 226 | if (width) { 227 | media.attrs.width = width; 228 | media.attrs.widthType = "pixel"; 229 | } 230 | 231 | if (height) { 232 | media.attrs.height = height; 233 | } 234 | 235 | return media; 236 | } 237 | 238 | mediaSingleItem( 239 | id: string, 240 | collection: string, 241 | layout: Layout = "center", 242 | width: number | null = null, 243 | height: number | null = null 244 | ): MediaSingleItemElement { 245 | return { 246 | type: "mediaSingle", 247 | content: [this.mediaItem(id, collection, width, height)], 248 | attrs: { 249 | layout, 250 | }, 251 | }; 252 | } 253 | 254 | markLink(href: string): LinkMarkElement { 255 | return { 256 | type: "link", 257 | attrs: { 258 | href, 259 | }, 260 | }; 261 | } 262 | 263 | markStrong(): StrongMarkElement { 264 | return { type: "strong" }; 265 | } 266 | 267 | markEm(): EmMarkElement { 268 | return { type: "em" }; 269 | } 270 | 271 | markCode(): CodeMarkElement { 272 | return { type: "code" }; 273 | } 274 | 275 | markUnderline(): UnderlineMarkElement { 276 | return { 277 | type: "underline", 278 | }; 279 | } 280 | 281 | markStrike(): StrikeMarkElement { 282 | return { 283 | type: "strike", 284 | }; 285 | } 286 | 287 | addItem(item: AdfElement): this { 288 | this.adf.push(item); 289 | return this; 290 | } 291 | 292 | build(): AdfElement[] { 293 | return this.adf; 294 | } 295 | 296 | clear(): this { 297 | this.adf = []; 298 | return this; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /lib/builder/types.ts: -------------------------------------------------------------------------------- 1 | export type LinkMarkElement = { 2 | type: "link"; 3 | attrs: { 4 | href: string; 5 | }; 6 | }; 7 | 8 | export type CodeMarkElement = { 9 | type: "code"; 10 | }; 11 | 12 | export type StrongMarkElement = { 13 | type: "strong"; 14 | }; 15 | 16 | export type UnderlineMarkElement = { 17 | type: "underline"; 18 | }; 19 | 20 | export type StrikeMarkElement = { 21 | type: "strike"; 22 | }; 23 | 24 | export type EmMarkElement = { 25 | type: "em"; 26 | }; 27 | 28 | export type MarksList = ( 29 | | LinkMarkElement 30 | | CodeMarkElement 31 | | StrongMarkElement 32 | | UnderlineMarkElement 33 | | StrikeMarkElement 34 | | EmMarkElement 35 | )[]; 36 | 37 | export interface MarkElement { 38 | marks?: MarksList; 39 | } 40 | 41 | export interface TextElement extends MarkElement { 42 | type: string; 43 | text: string; 44 | } 45 | 46 | export interface MarkedElement extends TextElement { 47 | mark: MarksList; 48 | } 49 | 50 | export interface LinkElement { 51 | type: string; 52 | text: string; 53 | marks: LinkMarkElement[]; 54 | } 55 | 56 | export interface CardElementLink { 57 | type: string; 58 | attrs: { 59 | url: string; 60 | }; 61 | } 62 | 63 | export interface ParagraphElement { 64 | type: string; 65 | content: (TextElement | LinkElement | EmphasisElement | CardElementLink)[]; 66 | } 67 | export interface EmphasisElement { 68 | type: "text"; 69 | text: string; 70 | marks: [EmMarkElement]; 71 | } 72 | 73 | export interface TableCellElement { 74 | type: string; 75 | attrs: { 76 | background: string; 77 | colwidth: any[]; 78 | colspan: number; 79 | rowspan: number; 80 | }; 81 | content: any; 82 | } 83 | 84 | export interface TableRowElement { 85 | type: string; 86 | content: TableCellElement[]; 87 | } 88 | 89 | export interface HeadingElement { 90 | type: string; 91 | content: TextElement[]; 92 | attrs: { 93 | level: number; 94 | }; 95 | } 96 | 97 | export interface RuleElement { 98 | type: string; 99 | } 100 | 101 | export interface TableElement { 102 | type: string; 103 | content: TableRowElement[]; 104 | } 105 | 106 | export interface CodeBlockElement { 107 | type: string; 108 | attrs: { 109 | language: string; 110 | }; 111 | content: TextElement[]; 112 | } 113 | 114 | export interface TaskItemElement { 115 | type: string; 116 | attrs: { 117 | localId: string; 118 | state: "DONE" | "TODO"; 119 | }; 120 | content: TextElement[]; 121 | } 122 | 123 | export interface TaskListItemElement { 124 | type: string; 125 | content: TaskItemElement[]; 126 | attrs: { 127 | localId: string; 128 | }; 129 | } 130 | 131 | export interface MediaItemElement { 132 | type: string; 133 | attrs: { 134 | type: string; 135 | id: string; 136 | collection: string; 137 | accessLevel?: "NONE" | "SITE" | "APPLICATION" | "CONTAINER"; 138 | text?: string; 139 | userType?: "DEFAULT" | "SPECIAL" | "APP"; 140 | width?: number; 141 | height?: number; 142 | widthType?: "pixel" | "percentage"; 143 | }; 144 | } 145 | 146 | export type Layout = 147 | | "wrap-left" 148 | | "center" 149 | | "wrap-right" 150 | | "wide" 151 | | "full-width" 152 | | "align-start" 153 | | "align-end"; 154 | 155 | export interface MediaSingleItemElement { 156 | type: string; 157 | content: [MediaItemElement]; 158 | attrs: { 159 | layout?: Layout; 160 | }; 161 | } 162 | 163 | export interface BulletListItemElement { 164 | type: string; 165 | content: ListItemElement[]; 166 | } 167 | 168 | export interface OrderedListElement { 169 | type: string; 170 | content: ListItemElement[]; 171 | } 172 | 173 | export interface BlockquoteElement { 174 | type: string; 175 | content: ParagraphElement[]; 176 | } 177 | 178 | export interface ADFNode { 179 | type: string; 180 | [key: string]: any; 181 | } 182 | 183 | export type ListItemElement = { 184 | type: "listItem"; 185 | content: AdfElement[]; 186 | }; 187 | 188 | export type AdfElement = 189 | | HeadingElement 190 | | ParagraphElement 191 | | TableElement 192 | | CodeBlockElement 193 | | TextElement 194 | | TaskListItemElement 195 | | ListItemElement 196 | | LinkElement 197 | | BlockquoteElement 198 | | RuleElement 199 | | EmphasisElement; 200 | -------------------------------------------------------------------------------- /lib/confluence/attachements.ts: -------------------------------------------------------------------------------- 1 | import { concatenateUint8Arrays } from "lib/utils"; 2 | import { Client, RequestConfig, UploadResponse } from "./types"; 3 | import { requestUrl } from "obsidian"; 4 | 5 | export default class Attachements { 6 | ATLASSIAN_TOKEN_CHECK_FLAG: string; 7 | ATLASSIAN_TOKEN_CHECK_NOCHECK_VALUE: string; 8 | 9 | constructor(private readonly client: Client) { 10 | this.ATLASSIAN_TOKEN_CHECK_FLAG = "X-Atlassian-Token"; 11 | this.ATLASSIAN_TOKEN_CHECK_NOCHECK_VALUE = "no-check"; 12 | } 13 | 14 | async uploadFile( 15 | pageId: string, 16 | formData: FormData 17 | ): Promise { 18 | formData.append("minorEdit", "true"); 19 | 20 | const config: RequestConfig = { 21 | url: `rest/api/content/${pageId}/child/attachment`, 22 | method: "PUT", 23 | }; 24 | 25 | return await this.sendRequest(config, formData); 26 | } 27 | 28 | async sendRequest( 29 | requestConfig: RequestConfig, 30 | formData: FormData 31 | ): Promise { 32 | const clientConfig = this.client.config; 33 | 34 | const creds = Buffer.from( 35 | `${clientConfig.authentication.email}:${clientConfig.authentication.apiToken}` 36 | ).toString("base64"); 37 | const url = new URL(`/wiki/${requestConfig.url}`, clientConfig.host); 38 | 39 | const boundary = `----WebKitFormBoundary${Math.random() 40 | .toString(36) 41 | .substring(7)}`; 42 | 43 | const file = formData.get("file")! as File; 44 | 45 | // Create the multipart form data manually 46 | const formDataParts = []; 47 | 48 | // Add file part 49 | formDataParts.push( 50 | `--${boundary}\r\n` + 51 | `Content-Disposition: form-data; name="file"; filename="${file.name}"\r\n\r\n` 52 | // `Content-Type: ${file.type}\r\n\r\n` 53 | ); 54 | formDataParts.push(new Uint8Array(await file.arrayBuffer())); 55 | formDataParts.push(`\r\n`); 56 | 57 | // Add minorEdit part 58 | formDataParts.push( 59 | `--${boundary}\r\n` + 60 | `Content-Disposition: form-data; name="minorEdit"\r\n\r\n` + 61 | `${formData.get("minorEdit")}\r\n` 62 | ); 63 | 64 | // Add comment part 65 | formDataParts.push( 66 | `--${boundary}\r\n` + 67 | `Content-Disposition: form-data; name="comment"\r\n\r\n` + 68 | `Content uploaded using Obsidian\r\n` 69 | ); 70 | 71 | // End boundary 72 | formDataParts.push(`--${boundary}--\r\n`); 73 | 74 | // Convert form data parts to Uint8Array 75 | const bodyArray = formDataParts.map((part) => { 76 | if (typeof part === "string") { 77 | return new TextEncoder().encode(part); 78 | } else { 79 | return new Uint8Array(part); 80 | } 81 | }); 82 | 83 | // Concatenate all parts into a single Uint8Array 84 | const bodyUint8Array = concatenateUint8Arrays(bodyArray); 85 | 86 | const response = await requestUrl({ 87 | url: url.toString(), 88 | method: requestConfig.method, 89 | body: bodyUint8Array.buffer, 90 | headers: { 91 | Accept: "application/json", 92 | "User-Agent": "Obsidian.md", 93 | Authorization: `Basic ${creds}`, 94 | [this.ATLASSIAN_TOKEN_CHECK_FLAG]: 95 | this.ATLASSIAN_TOKEN_CHECK_NOCHECK_VALUE, 96 | "Content-Type": `multipart/form-data; boundary=${boundary}`, 97 | }, 98 | throw: false, 99 | }); 100 | 101 | if (response.status >= 200 && response.status < 300) { 102 | // If the response status is okay, parse JSON 103 | return response.json; 104 | } else { 105 | console.error(response.json); 106 | throw new Error(`HTTP error! Status: ${response.status}`); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/confluence/base.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "obsidian"; 2 | import { map, isEmpty, compact, isArray } from "lodash"; 3 | import { Config, RequestConfig, ObsidianRequestParams } from "./types"; 4 | import { removeUndefinedProperties } from "lib/utils"; 5 | 6 | export default class BaseClient { 7 | constructor(protected readonly config: Config) { 8 | this.config = config; 9 | } 10 | 11 | protected paramSerializer(parameters: Record): string { 12 | return compact( 13 | map(parameters, (value, key) => { 14 | if (value === null || typeof value === "undefined") { 15 | return null; 16 | } 17 | 18 | if (Array.isArray(value)) { 19 | value = value.join(","); 20 | } 21 | 22 | if (value instanceof Date) { 23 | value = value.toISOString(); 24 | } else if (value !== null && typeof value === "object") { 25 | value = JSON.stringify(value); 26 | } else if (value instanceof Function) { 27 | const part = value(); 28 | return part && this.encode(part); 29 | } 30 | 31 | return `${this.encode(key)}=${this.encode(value)}`; 32 | }) 33 | ).join("&"); 34 | } 35 | 36 | protected encode(value: string): string { 37 | return encodeURIComponent(value) 38 | .replace(/%3A/gi, ":") 39 | .replace(/%24/g, "$") 40 | .replace(/%2C/gi, ",") 41 | .replace(/%20/g, "+") 42 | .replace(/%5B/gi, "[") 43 | .replace(/%5D/gi, "]"); 44 | } 45 | 46 | async sendRequest(requestConfig: RequestConfig): Promise { 47 | const creds = Buffer.from( 48 | `${this.config.authentication.email}:${this.config.authentication.apiToken}` 49 | ).toString("base64"); 50 | 51 | const method = requestConfig.method; 52 | const url = new URL(`/wiki/${requestConfig.url}`, this.config.host); 53 | let params = requestConfig.params; 54 | 55 | if (!isArray(params)) { 56 | params = removeUndefinedProperties(params || {}); 57 | } 58 | 59 | const requestParams: ObsidianRequestParams = { 60 | url: url.toString(), 61 | method, 62 | }; 63 | 64 | if (!isEmpty(params)) { 65 | if (method != "GET") { 66 | requestParams["body"] = JSON.stringify(params); 67 | } else { 68 | requestParams["url"] += `?${this.paramSerializer(params)}`; 69 | } 70 | } 71 | 72 | const response = await requestUrl({ 73 | ...requestParams, 74 | headers: { 75 | "Content-Type": "application/json", 76 | Authorization: `Basic ${creds}`, 77 | }, 78 | throw: false, 79 | }); 80 | 81 | if (response.status >= 200 && response.status < 300) { 82 | // If the response status is okay, parse JSON 83 | return response.json; 84 | } else { 85 | console.error(response.json); 86 | throw new Error(`HTTP error! Status: ${response.status}`); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/confluence/client.ts: -------------------------------------------------------------------------------- 1 | import BaseClient from "./base"; 2 | import Search from "./search"; 3 | import Page from "./page"; 4 | import Space from "./space"; 5 | import { Client, Config } from "./types"; 6 | import Attachements from "./attachements"; 7 | import Label from "./label"; 8 | 9 | export default class ConfluenceClient extends BaseClient { 10 | config: Config; 11 | 12 | constructor(config: Config) { 13 | super(config); 14 | } 15 | 16 | search = new Search(this as Client); 17 | page = new Page(this as Client); 18 | space = new Space(this as Client); 19 | attachement = new Attachements(this as Client); 20 | label = new Label(this as Client); 21 | } 22 | -------------------------------------------------------------------------------- /lib/confluence/label.ts: -------------------------------------------------------------------------------- 1 | import { Client, RequestConfig } from "./types"; 2 | import { map } from "lodash"; 3 | 4 | export default class Label { 5 | constructor(private client: Client) {} 6 | 7 | async addLabel(pageId: string, labels: Array) { 8 | const labelObjects = map(labels, (label) => { 9 | return { prefix: "global", name: label.replaceAll("#", "") }; 10 | }); 11 | const config: RequestConfig = { 12 | url: `rest/api/content/${pageId}/label`, 13 | method: "POST", 14 | params: labelObjects, 15 | }; 16 | 17 | return await this.client.sendRequest(config); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/confluence/page.ts: -------------------------------------------------------------------------------- 1 | import { Client, PageResponse, GetPagesResponse, RequestConfig } from "./types"; 2 | import { CreatePage, UpdatePage, GetPageById } from "./parameters"; 3 | 4 | export default class Page { 5 | constructor(private client: Client) {} 6 | 7 | async createPage(parameters: CreatePage): Promise { 8 | const config: RequestConfig = { 9 | url: "api/v2/pages", 10 | method: "POST", 11 | params: { 12 | spaceId: parameters.spaceId, 13 | status: "current", 14 | title: parameters.pageTitle, 15 | parentId: parameters.parentId, 16 | }, 17 | }; 18 | 19 | if (parameters.adf) { 20 | const adf_body = { 21 | version: 1, 22 | type: "doc", 23 | content: parameters.adf, 24 | }; 25 | 26 | config.params = { 27 | ...config.params, 28 | body: { 29 | representation: "atlas_doc_format", 30 | value: JSON.stringify(adf_body), 31 | }, 32 | }; 33 | } 34 | 35 | return await this.client.sendRequest(config); 36 | } 37 | 38 | async updatePage(parameters: UpdatePage): Promise { 39 | const pageResponse = await this.getPageById({ 40 | pageId: parameters.pageId as string, 41 | }); 42 | 43 | const config: RequestConfig = { 44 | url: `api/v2/pages/${parameters.pageId}`, 45 | method: "PUT", 46 | params: { 47 | id: parameters.pageId, 48 | status: "current", 49 | title: parameters.pageTitle, 50 | parentId: parameters.parentId, 51 | spaceId: parameters.spaceId, 52 | ownerId: parameters.ownerId, 53 | version: { 54 | number: pageResponse.version.number + 1, 55 | message: `Obsidian update ${new Date().toISOString()}`, 56 | }, 57 | }, 58 | }; 59 | 60 | if (parameters.adf) { 61 | const adf_body = { 62 | version: 1, 63 | type: "doc", 64 | content: parameters.adf, 65 | }; 66 | 67 | config.params = { 68 | ...config.params, 69 | body: { 70 | representation: "atlas_doc_format", 71 | value: JSON.stringify(adf_body), 72 | }, 73 | }; 74 | } 75 | 76 | return await this.client.sendRequest(config); 77 | } 78 | 79 | async getPageById(parameters: GetPageById): Promise { 80 | const config: RequestConfig = { 81 | url: `api/v2/pages/${parameters.pageId}`, 82 | method: "GET", 83 | }; 84 | 85 | return await this.client.sendRequest(config); 86 | } 87 | 88 | async getPages(): Promise { 89 | const config: RequestConfig = { 90 | url: "api/v2/pages", 91 | method: "GET", 92 | }; 93 | 94 | return await this.client.sendRequest(config); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/confluence/parameters.ts: -------------------------------------------------------------------------------- 1 | import { AdfElement } from "lib/builder/types"; 2 | 3 | export type SearchByCQL = { 4 | cql: string; 5 | cqlcontext?: string; 6 | cursor?: string; 7 | next?: string; 8 | prev?: string; 9 | limit?: string; 10 | start?: string; 11 | includeArchivedSpaces?: string; 12 | excludeCurrentSpaces?: string; 13 | excerpt?: string; 14 | sitePermissionTypeFilter?: string; 15 | expand?: string; 16 | }; 17 | 18 | export type CreatePage = { 19 | spaceId: string; 20 | pageTitle: string; 21 | parentId?: string; 22 | adf?: AdfElement; 23 | }; 24 | 25 | export type UpdatePage = { 26 | pageId: string; 27 | pageTitle: string; 28 | spaceId?: string; 29 | parentId?: string; 30 | ownerId?: string; 31 | adf?: AdfElement[]; 32 | }; 33 | 34 | export type GetPageById = { 35 | pageId: string; 36 | }; 37 | -------------------------------------------------------------------------------- /lib/confluence/search.ts: -------------------------------------------------------------------------------- 1 | import { Client, RequestConfig, SearchResponse } from "./types"; 2 | import { SearchByCQL } from "./parameters"; 3 | 4 | export default class Search { 5 | constructor(private client: Client) {} 6 | 7 | async searchByCQL(parameters: SearchByCQL): Promise { 8 | const config: RequestConfig = { 9 | url: "rest/api/search", 10 | method: "GET", 11 | params: { 12 | cql: parameters.cql, 13 | cqlcontext: parameters.cqlcontext, 14 | cursor: parameters.cursor, 15 | next: parameters.next, 16 | prev: parameters.prev, 17 | limit: parameters.limit, 18 | start: parameters.start, 19 | includeArchivedSpaces: parameters.includeArchivedSpaces, 20 | excludeCurrentSpaces: parameters.excludeCurrentSpaces, 21 | excerpt: parameters.excerpt, 22 | sitePermissionTypeFilter: parameters.sitePermissionTypeFilter, 23 | expand: parameters.expand, 24 | }, 25 | }; 26 | 27 | return await this.client.sendRequest(config); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/confluence/space.ts: -------------------------------------------------------------------------------- 1 | import { Client, RequestConfig, SpaceResponse } from "./types"; 2 | 3 | export default class Space { 4 | constructor(private client: Client) {} 5 | 6 | async getSpaces(limit: number = 250): Promise { 7 | const config: RequestConfig = { 8 | url: "api/v2/spaces", 9 | method: "GET", 10 | params: { 11 | limit, 12 | }, 13 | }; 14 | 15 | return await this.client.sendRequest(config); 16 | } 17 | 18 | async getSpacesByKeys(keys: string[] | string): Promise { 19 | const config: RequestConfig = { 20 | url: "api/v2/spaces", 21 | method: "GET", 22 | params: { 23 | limit: 250, 24 | keys, 25 | }, 26 | }; 27 | 28 | return await this.client.sendRequest(config); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/confluence/types.ts: -------------------------------------------------------------------------------- 1 | export interface ConfluenceLinkSettings { 2 | confluenceDomain: string; 3 | atlassianUsername: string; 4 | atlassianApiToken: string; 5 | confluenceDefaultSpaceId: string; 6 | followLinks: boolean; 7 | uploadTags: boolean; 8 | favSpaces: string[]; 9 | } 10 | 11 | export interface Config { 12 | host: string; 13 | authentication: { 14 | email: string; 15 | apiToken: string; 16 | }; 17 | } 18 | 19 | export interface UrlConfig { 20 | url: string; 21 | method: "GET" | "POST" | "PUT" | "DELETE"; 22 | } 23 | 24 | export interface RequestConfig extends UrlConfig { 25 | params?: object; 26 | } 27 | 28 | export interface ObsidianRequestParams extends UrlConfig { 29 | body?: string; 30 | } 31 | 32 | export interface Client { 33 | config: Config; 34 | sendRequest(requestConfig: RequestConfig): T; 35 | } 36 | 37 | export type PageResponse = { 38 | id: string; 39 | status: "current"; 40 | title: string; 41 | spaceId: string; 42 | parentId: string; 43 | parentType: "page"; 44 | position: number; 45 | authorId: string; 46 | ownerId: string; 47 | lastOwnerId: string; 48 | createdAt: string; 49 | version: { 50 | createdAt: string; 51 | message: string; 52 | number: number; 53 | minorEdit: boolean; 54 | authorId: string; 55 | }; 56 | body: { storage: {}; atlas_doc_format: {}; view: {} }; 57 | labels: { 58 | results: [{ id: string; name: string; prefix: string }]; 59 | meta: { hasMore: boolean; cursor: string }; 60 | _links: { self: string }; 61 | }; 62 | properties: { 63 | results: [{ id: string; key: string; version: {} }]; 64 | meta: { hasMore: boolean; cursor: string }; 65 | _links: { self: string }; 66 | }; 67 | operations: { 68 | results: [{ operation: string; targetType: string }]; 69 | meta: { hasMore: boolean; cursor: string }; 70 | _links: { self: string }; 71 | }; 72 | likes: { 73 | results: [{ accountId: string }]; 74 | meta: { hasMore: boolean; cursor: string }; 75 | _links: { self: string }; 76 | }; 77 | versions: { 78 | results: [ 79 | { 80 | createdAt: string; 81 | message: string; 82 | number: number; 83 | minorEdit: boolean; 84 | authorId: string; 85 | } 86 | ]; 87 | meta: { hasMore: boolean; cursor: string }; 88 | _links: { self: string }; 89 | }; 90 | isFavoritedByCurrentUser: boolean; 91 | _links: { base: string; webui: string }; 92 | }; 93 | 94 | export type GetPagesResponse = { 95 | results: [ 96 | { 97 | id: string; 98 | status: "current"; 99 | title: string; 100 | spaceId: string; 101 | parentId: string; 102 | parentType: "page"; 103 | position: number; 104 | authorId: string; 105 | ownerId: string; 106 | lastOwnerId: string; 107 | createdAt: string; 108 | version: { 109 | createdAt: string; 110 | message: string; 111 | number: number; 112 | minorEdit: boolean; 113 | authorId: string; 114 | }; 115 | body: { storage: {}; atlas_doc_format: {} }; 116 | _links: { 117 | webui: string; 118 | editui: string; 119 | tinyui: string; 120 | }; 121 | } 122 | ]; 123 | _links: { next: string; base: string }; 124 | }; 125 | 126 | export type SpaceResponse = { 127 | results: [ 128 | { 129 | id: string; 130 | key: string; 131 | name: string; 132 | type: "global"; 133 | status: "current"; 134 | authorId: string; 135 | createdAt: string; 136 | homepageId: string; 137 | description: { plain: {}; view: {} }; 138 | icon: { path: string; apiDownloadLink: string }; 139 | _links: { webui: string }; 140 | } 141 | ]; 142 | _links: { next: string; base: string }; 143 | }; 144 | 145 | export type SearchResponse = { 146 | results: [ 147 | { 148 | content: { 149 | id: string; 150 | type: string; 151 | status: string; 152 | title: string; 153 | space: { 154 | key: string; 155 | name: string; 156 | type: string; 157 | status: string; 158 | _expandable: {}; 159 | _links: {}; 160 | }; 161 | history: { latest: boolean }; 162 | version: { when: string; number: number; minorEdit: boolean }; 163 | ancestors: []; 164 | operations: [{ operation: "administer"; targetType: string }]; 165 | children: {}; 166 | childTypes: {}; 167 | descendants: {}; 168 | container: {}; 169 | body: { 170 | view: { value: string; representation: "view" }; 171 | export_view: { value: string; representation: "view" }; 172 | styled_view: { value: string; representation: "view" }; 173 | storage: { value: string; representation: "view" }; 174 | wiki: { value: string; representation: "view" }; 175 | editor: { value: string; representation: "view" }; 176 | editor2: { value: string; representation: "view" }; 177 | anonymous_export_view: { 178 | value: string; 179 | representation: "view"; 180 | }; 181 | atlas_doc_format: { 182 | value: string; 183 | representation: "view"; 184 | }; 185 | dynamic: { value: string; representation: "view" }; 186 | raw: { value: string; representation: "view" }; 187 | _expandable: { 188 | editor: string; 189 | view: string; 190 | export_view: string; 191 | styled_view: string; 192 | storage: string; 193 | editor2: string; 194 | anonymous_export_view: string; 195 | atlas_doc_format: string; 196 | wiki: string; 197 | dynamic: string; 198 | raw: string; 199 | }; 200 | }; 201 | restrictions: { 202 | read: { 203 | operation: "administer"; 204 | _expandable: {}; 205 | _links: {}; 206 | }; 207 | update: { 208 | operation: "administer"; 209 | _expandable: {}; 210 | _links: {}; 211 | }; 212 | _expandable: { read: string; update: string }; 213 | _links: {}; 214 | }; 215 | metadata: {}; 216 | macroRenderedOutput: {}; 217 | extensions: {}; 218 | _expandable: { 219 | childTypes: string; 220 | container: string; 221 | metadata: string; 222 | operations: string; 223 | children: string; 224 | restrictions: string; 225 | history: string; 226 | ancestors: string; 227 | body: string; 228 | version: string; 229 | descendants: string; 230 | space: string; 231 | extensions: string; 232 | schedulePublishDate: string; 233 | schedulePublishInfo: string; 234 | macroRenderedOutput: string; 235 | }; 236 | _links: {}; 237 | }; 238 | user: { 239 | type: "known"; 240 | username: string; 241 | userKey: string; 242 | accountId: string; 243 | accountType: "atlassian"; 244 | email: string; 245 | publicName: string; 246 | profilePicture: { 247 | path: string; 248 | width: number; 249 | height: number; 250 | isDefault: boolean; 251 | }; 252 | displayName: string; 253 | timeZone: string; 254 | isExternalCollaborator: boolean; 255 | externalCollaborator: boolean; 256 | operations: [{ operation: "administer"; targetType: string }]; 257 | details: {}; 258 | personalSpace: { 259 | key: string; 260 | name: string; 261 | type: string; 262 | status: string; 263 | _expandable: {}; 264 | _links: {}; 265 | }; 266 | _expandable: { 267 | operations: string; 268 | details: string; 269 | personalSpace: string; 270 | }; 271 | _links: {}; 272 | }; 273 | space: { 274 | id: string; 275 | key: string; 276 | name: string; 277 | icon: { 278 | path: string; 279 | width: number; 280 | height: number; 281 | isDefault: boolean; 282 | }; 283 | description: { 284 | plain: { 285 | value: string; 286 | representation: "plain"; 287 | embeddedContent: [{}]; 288 | }; 289 | view: { 290 | value: string; 291 | representation: "plain"; 292 | embeddedContent: [{}]; 293 | }; 294 | _expandable: { view: string; plain: string }; 295 | }; 296 | homepage: { type: string; status: string }; 297 | type: string; 298 | metadata: { 299 | labels: { 300 | results: [ 301 | { 302 | prefix: string; 303 | name: string; 304 | id: string; 305 | label: string; 306 | } 307 | ]; 308 | size: number; 309 | }; 310 | _expandable: {}; 311 | }; 312 | operations: [{ operation: "administer"; targetType: string }]; 313 | permissions: [ 314 | { 315 | operation: { 316 | operation: "administer"; 317 | targetType: string; 318 | }; 319 | anonymousAccess: boolean; 320 | unlicensedAccess: boolean; 321 | } 322 | ]; 323 | status: string; 324 | settings: { routeOverrideEnabled: boolean; _links: {} }; 325 | theme: { themeKey: string }; 326 | lookAndFeel: { 327 | headings: { color: string }; 328 | links: { color: string }; 329 | menus: { 330 | hoverOrFocus: { backgroundColor: string }; 331 | color: string; 332 | }; 333 | header: { 334 | backgroundColor: string; 335 | button: { 336 | backgroundColor: string; 337 | color: string; 338 | }; 339 | primaryNavigation: { 340 | color: string; 341 | hoverOrFocus: { 342 | backgroundColor: string; 343 | color: string; 344 | }; 345 | }; 346 | secondaryNavigation: { 347 | color: string; 348 | hoverOrFocus: { 349 | backgroundColor: string; 350 | color: string; 351 | }; 352 | }; 353 | search: { 354 | backgroundColor: string; 355 | color: string; 356 | }; 357 | }; 358 | content: {}; 359 | bordersAndDividers: { color: string }; 360 | }; 361 | history: { 362 | createdDate: string; 363 | createdBy: { type: "known" }; 364 | }; 365 | _expandable: { 366 | settings: string; 367 | metadata: string; 368 | operations: string; 369 | lookAndFeel: string; 370 | permissions: string; 371 | icon: string; 372 | description: string; 373 | theme: string; 374 | history: string; 375 | homepage: string; 376 | identifiers: string; 377 | }; 378 | _links: {}; 379 | }; 380 | title: string; 381 | excerpt: string; 382 | url: string; 383 | resultParentContainer: { 384 | title: string; 385 | displayUrl: string; 386 | }; 387 | resultGlobalContainer: { 388 | title: string; 389 | displayUrl: string; 390 | }; 391 | breadcrumbs: [{ label: string; url: string; separator: string }]; 392 | entityType: string; 393 | iconCssClass: string; 394 | lastModified: string; 395 | friendlyLastModified: string; 396 | score: number; 397 | } 398 | ]; 399 | start: number; 400 | limit: number; 401 | size: number; 402 | totalSize: number; 403 | cqlQuery: string; 404 | searchDuration: number; 405 | archivedResultCount: number; 406 | _links: {}; 407 | }; 408 | 409 | export type UploadResponse = { 410 | results: { 411 | id: string; 412 | type: string; 413 | status: string; 414 | title: string; 415 | version: { 416 | by: { 417 | type: "known"; 418 | accountId: string; 419 | accountType: "atlassian"; 420 | email: string; 421 | publicName: string; 422 | profilePicture: { 423 | path: string; 424 | width: number; 425 | height: number; 426 | isDefault: boolean; 427 | }; 428 | displayName: string; 429 | isExternalCollaborator: boolean; 430 | _expandable: { operations: string; personalSpace: string }; 431 | _links: { self: string }; 432 | }; 433 | when: string; 434 | friendlyWhen: string; 435 | message: string; 436 | number: number; 437 | minorEdit: boolean; 438 | contentTypeModified: boolean; 439 | _expandable: { collaborators: string; content: string }; 440 | _links: { self: string }; 441 | }; 442 | container: { 443 | id: string; 444 | type: string; 445 | status: string; 446 | title: string; 447 | macroRenderedOutput: {}; 448 | extensions: { position: number }; 449 | _expandable: { 450 | container: string; 451 | metadata: string; 452 | restrictions: string; 453 | history: string; 454 | body: string; 455 | version: string; 456 | descendants: string; 457 | space: string; 458 | childTypes: string; 459 | schedulePublishInfo: string; 460 | operations: string; 461 | schedulePublishDate: string; 462 | children: string; 463 | ancestors: string; 464 | }; 465 | _links: { 466 | self: string; 467 | tinyui: string; 468 | editui: string; 469 | webui: string; 470 | }; 471 | }; 472 | macroRenderedOutput: {}; 473 | metadata: { 474 | comment: string; 475 | mediaType: string; 476 | labels: { 477 | results: []; 478 | start: number; 479 | limit: number; 480 | size: number; 481 | _links: { next: string; self: string }; 482 | }; 483 | _expandable: { 484 | currentuser: string; 485 | comments: string; 486 | sourceTemplateEntityId: string; 487 | simple: string; 488 | properties: string; 489 | frontend: string; 490 | likes: string; 491 | }; 492 | }; 493 | extensions: { 494 | mediaType: string; 495 | fileSize: number; 496 | comment: string; 497 | mediaTypeDescription: string; 498 | fileId: string; 499 | collectionName: string; 500 | }; 501 | _expandable: { 502 | childTypes: string; 503 | schedulePublishInfo: string; 504 | operations: string; 505 | schedulePublishDate: string; 506 | children: string; 507 | restrictions: string; 508 | history: string; 509 | ancestors: string; 510 | body: string; 511 | descendants: string; 512 | space: string; 513 | }; 514 | _links: { 515 | webui: string; 516 | self: string; 517 | download: string; 518 | }; 519 | }[]; 520 | size: number; 521 | _links: { base: string; context: string }; 522 | }; 523 | -------------------------------------------------------------------------------- /lib/directors/label.ts: -------------------------------------------------------------------------------- 1 | import PropertiesAdaptor from "lib/adaptors/properties"; 2 | import ConfluenceClient from "lib/confluence/client"; 3 | import { cloneDeep, isEmpty } from "lodash"; 4 | import { App, TFile } from "obsidian"; 5 | 6 | export default class LabelDirector { 7 | allTags: Array; 8 | 9 | constructor( 10 | private readonly client: ConfluenceClient, 11 | private readonly propertiesAdaptor: PropertiesAdaptor 12 | ) { 13 | this.allTags = propertiesAdaptor.properties.tags 14 | ? cloneDeep(propertiesAdaptor.properties.tags) 15 | : []; 16 | } 17 | 18 | async addTags(htmlTags: Array = []) { 19 | for (const tag of htmlTags) { 20 | if (!this.allTags.includes(tag.textContent!)) { 21 | this.allTags.push(tag.textContent!); 22 | } 23 | } 24 | } 25 | 26 | async updateConfluencePage() { 27 | await this.client.label.addLabel( 28 | this.propertiesAdaptor.properties.pageId!, 29 | this.allTags 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/directors/link.ts: -------------------------------------------------------------------------------- 1 | import FileAdaptor from "lib/adaptors/file"; 2 | import ADFBuilder from "lib/builder/adf"; 3 | 4 | class LinkDirector { 5 | constructor( 6 | private readonly builder: ADFBuilder, 7 | private readonly fileAdaptor: FileAdaptor 8 | ) {} 9 | 10 | async build_item( 11 | node: HTMLAnchorElement, 12 | followLinks: boolean, 13 | filePath: string 14 | ) { 15 | const classList = node.classList; 16 | const isExternalLink = classList.contains("external-link"); 17 | const linkPath = node.getAttr("href")!; 18 | 19 | if (!followLinks) { 20 | if (isExternalLink) { 21 | if (node.getAttr("data-tooltip-position")) { 22 | return this.builder.cardItem(linkPath); 23 | } 24 | 25 | return this.builder.linkItem(node.textContent!, linkPath); 26 | } 27 | 28 | const paths = linkPath 29 | .split("#") 30 | .filter((string) => string.trim() != ""); 31 | const samePageLink = paths.length == 1; 32 | 33 | // check if we are linking to the same file 34 | if ( 35 | linkPath.includes(filePath.replace(".md", "")) || 36 | (samePageLink && linkPath.includes("#")) 37 | ) { 38 | const href = await this.findLink(node); 39 | return this.builder.cardItem(href); 40 | } 41 | 42 | return this.builder.linkItem(node.text, "#"); 43 | } 44 | 45 | const href = await this.findLink(node); 46 | 47 | if (href == "#") { 48 | return this.builder.linkItem(node.textContent!, "#"); 49 | } 50 | 51 | if ( 52 | classList.contains("internal-link") || 53 | (isExternalLink && node.getAttr("data-tooltip-position")) 54 | ) { 55 | return this.builder.cardItem(href); 56 | } 57 | 58 | return this.builder.linkItem(node.textContent!, href); 59 | } 60 | 61 | async findLink(linkEl: HTMLAnchorElement): Promise { 62 | let href = linkEl.href!; 63 | 64 | if (linkEl.classList.contains("internal-link")) { 65 | const dataLink = linkEl.getAttr("data-href")!; 66 | 67 | if (dataLink.contains("#")) { 68 | const paths = dataLink 69 | .split("#") 70 | .filter((string) => string.trim() != ""); 71 | const newPageLink = paths.length > 1; 72 | 73 | if (newPageLink) { 74 | href = 75 | (await this.fileAdaptor.getConfluenceLink( 76 | paths[0] + ".md" 77 | )) + 78 | "#" + 79 | paths[1]; 80 | 81 | href = href.replaceAll(" ", "-"); 82 | } else { 83 | href = dataLink.replaceAll(" ", "-"); 84 | } 85 | } else { 86 | href = await this.fileAdaptor.getConfluenceLink( 87 | linkEl.dataset.href! + ".md" 88 | ); 89 | } 90 | } 91 | 92 | return href; 93 | } 94 | } 95 | 96 | export default LinkDirector; 97 | -------------------------------------------------------------------------------- /lib/directors/list.ts: -------------------------------------------------------------------------------- 1 | import ADFBuilder from "lib/builder/adf"; 2 | import ParagraphDirector from "./paragraph"; 3 | import { 4 | BulletListItemElement, 5 | OrderedListElement, 6 | TaskListItemElement, 7 | } from "lib/builder/types"; 8 | 9 | class ListDirector extends ParagraphDirector { 10 | async addList(node: HTMLOListElement | HTMLUListElement, filePath: string) { 11 | this.builder.addItem(await this.buildList(node, filePath)); 12 | } 13 | 14 | async buildList( 15 | node: HTMLOListElement | HTMLUListElement, 16 | filePath: string 17 | ): Promise< 18 | BulletListItemElement | OrderedListElement | TaskListItemElement 19 | > { 20 | const isTaskList = this.isTasklist(node); 21 | let list = this.builder.bulletListItem([]); 22 | 23 | if (node.nodeName == "OL") { 24 | list = this.builder.orderedListItem([]); 25 | } 26 | 27 | if (isTaskList) { 28 | // @ts-ignore 29 | list = this.builder.taskListItem([]); 30 | } 31 | 32 | await this.buildListItems(node, isTaskList, filePath, list); 33 | 34 | return list; 35 | } 36 | 37 | async buildListItems( 38 | node: HTMLOListElement | HTMLUListElement, 39 | isTaskList: boolean, 40 | filePath: string, 41 | list: BulletListItemElement | OrderedListElement | TaskListItemElement 42 | ) { 43 | const items = await Promise.all( 44 | Array.from(node.children).map(async (li) => { 45 | const itemsAdfBuilder = new ADFBuilder(); 46 | const paragraphDirector = new ParagraphDirector( 47 | itemsAdfBuilder, 48 | this.fileAdaptor, 49 | this.app, 50 | this.client, 51 | this.settings, 52 | this.labelDirector 53 | ); 54 | 55 | if (isTaskList) { 56 | return this.builder.taskItem( 57 | li.textContent?.trim()!, 58 | Boolean(li.getAttr("data-task")) 59 | ); 60 | } 61 | 62 | let p = createEl("p"); 63 | let subList = null; 64 | 65 | for (const child of Array.from(li.childNodes)) { 66 | if ( 67 | child.nodeType === Node.ELEMENT_NODE && 68 | ["OL", "UL"].includes(child.nodeName) 69 | ) { 70 | subList = await this.buildList( 71 | child as HTMLOListElement | HTMLUListElement, 72 | filePath 73 | ); 74 | } else { 75 | if (child.textContent == "\n") { 76 | continue; 77 | } 78 | 79 | if ( 80 | child.nodeType == Node.ELEMENT_NODE && 81 | child.nodeName == "P" 82 | ) { 83 | p = child as HTMLParagraphElement; 84 | continue; 85 | } 86 | 87 | p.append(child); 88 | } 89 | } 90 | 91 | await paragraphDirector.addItems(p, filePath, true); 92 | const listItem = this.builder.listItem(itemsAdfBuilder.build()); 93 | 94 | if (subList) { 95 | listItem.content.push(subList); 96 | } 97 | 98 | return listItem; 99 | }) 100 | ); 101 | 102 | if (items) { 103 | // @ts-ignore 104 | list.content.push(...items); 105 | } 106 | } 107 | 108 | isTasklist(node: HTMLOListElement | HTMLUListElement): boolean { 109 | return ( 110 | node.querySelectorAll("li").length === 111 | node.querySelectorAll('input[type="checkbox"]').length 112 | ); 113 | } 114 | } 115 | 116 | export default ListDirector; 117 | -------------------------------------------------------------------------------- /lib/directors/media.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | 3 | import ADFBuilder from "lib/builder/adf"; 4 | import PropertiesAdaptor from "lib/adaptors/properties"; 5 | import ConfluenceClient from "lib/confluence/client"; 6 | import { toBlob } from "html-to-image"; 7 | import { Layout } from "lib/builder/types"; 8 | import { wait } from "lib/utils"; 9 | 10 | class MediaDirector { 11 | constructor( 12 | private readonly builder: ADFBuilder, 13 | private readonly app: App, 14 | private readonly client: ConfluenceClient 15 | ) {} 16 | 17 | async build_item(node: HTMLSpanElement, filePath: string) { 18 | const modEmpty = node.classList.contains("mod-empty-attachment"); 19 | 20 | if (modEmpty) { 21 | return null; 22 | } 23 | 24 | const file = this.app.metadataCache.getFirstLinkpathDest(filePath, "."); 25 | 26 | if (!(file instanceof TFile)) { 27 | return null; 28 | } 29 | 30 | const src = node.getAttr("src")!; 31 | 32 | const canvasEmbed = node.classList.contains("canvas-embed"); 33 | const imageEmbed = node.classList.contains("image-embed"); 34 | const pdfEmbed = node.classList.contains("pdf-embed"); 35 | const videoEmbed = node.classList.contains("video-embed"); 36 | 37 | const formData = new FormData(); 38 | const fileData = await this.app.vault.read(file); 39 | const props = new PropertiesAdaptor().loadProperties(fileData); 40 | const pageId = props.properties.pageId; 41 | 42 | let width = null; 43 | let height = null; 44 | let layout: Layout = "center"; 45 | 46 | if (canvasEmbed) { 47 | // TODO figure out canvas 48 | return null; 49 | } else if (imageEmbed) { 50 | const wrap = node.getAttr("alt"); 51 | 52 | width = node.getAttr("width") 53 | ? parseInt(node.getAttr("width")!) 54 | : null; 55 | height = node.getAttr("height") 56 | ? parseInt(node.getAttr("height")!) 57 | : null; 58 | layout = 59 | wrap == "inL" 60 | ? "wrap-left" 61 | : wrap == "inR" 62 | ? "wrap-right" 63 | : "center"; 64 | 65 | const imgFile = this.app.metadataCache.getFirstLinkpathDest( 66 | src, 67 | "." 68 | ); 69 | 70 | if (!imgFile) { 71 | console.error("not know path", node); 72 | return null; 73 | } 74 | 75 | formData.append( 76 | "file", 77 | new File( 78 | [await this.app.vault.readBinary(imgFile)], 79 | imgFile.name 80 | ) 81 | ); 82 | } else if (pdfEmbed || videoEmbed) { 83 | const fileSrc = src.split("#")[0]; 84 | const fileEmbed = this.app.metadataCache.getFirstLinkpathDest( 85 | fileSrc, 86 | "." 87 | ); 88 | 89 | if (!fileEmbed) { 90 | console.error("not know path", node); 91 | return null; 92 | } 93 | 94 | formData.append( 95 | "file", 96 | new File( 97 | [await this.app.vault.readBinary(fileEmbed)], 98 | fileEmbed.name 99 | ) 100 | ); 101 | } 102 | 103 | const attachmentResponse = await this.client.attachement.uploadFile( 104 | pageId as string, 105 | formData 106 | ); 107 | const { extensions } = attachmentResponse!.results[0]; 108 | 109 | return this.builder.mediaSingleItem( 110 | extensions.fileId, 111 | extensions.collectionName, 112 | layout, 113 | width, 114 | height 115 | ); 116 | } 117 | } 118 | 119 | export default MediaDirector; 120 | -------------------------------------------------------------------------------- /lib/directors/paragraph.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { MarksList } from "lib/builder/types"; 3 | 4 | import ADFBuilder from "lib/builder/adf"; 5 | import FileAdaptor from "lib/adaptors/file"; 6 | import ConfluenceClient from "lib/confluence/client"; 7 | 8 | import MediaDirector from "./media"; 9 | import LabelDirector from "./label"; 10 | import LinkDirector from "./link"; 11 | import { ConfluenceLinkSettings } from "lib/confluence/types"; 12 | 13 | class ParagraphDirector { 14 | constructor( 15 | readonly builder: ADFBuilder, 16 | readonly fileAdaptor: FileAdaptor, 17 | readonly app: App, 18 | readonly client: ConfluenceClient, 19 | readonly settings: ConfluenceLinkSettings, 20 | readonly labelDirector: LabelDirector 21 | ) {} 22 | 23 | async addItems( 24 | node: HTMLParagraphElement, 25 | filePath: string, 26 | ignoreTags = false 27 | ): Promise { 28 | const pItem = this.builder.paragraphItem(); 29 | 30 | if ( 31 | !ignoreTags && 32 | this.settings.uploadTags && 33 | this.isTagOnlyParagraph(node) 34 | ) { 35 | const tags = node.querySelectorAll('a[class="tag"]'); 36 | this.labelDirector.addTags( 37 | tags as unknown as Array 38 | ); 39 | 40 | return; 41 | } 42 | 43 | for (const innerNode of Array.from(node.childNodes)) { 44 | if ( 45 | innerNode.nodeType === Node.TEXT_NODE || 46 | this.isTagNode(innerNode as HTMLElement) 47 | ) { 48 | const textItem = this.builder.textItem(innerNode.textContent!); 49 | pItem.content.push(textItem); 50 | continue; 51 | } 52 | 53 | if ( 54 | innerNode.nodeType === Node.ELEMENT_NODE && 55 | innerNode.nodeName !== "SPAN" 56 | ) { 57 | const nestedItem = await this.findNestedItem( 58 | innerNode as HTMLElement, 59 | filePath 60 | ); 61 | 62 | if (nestedItem) { 63 | pItem.content.push(nestedItem); 64 | } 65 | 66 | continue; 67 | } 68 | 69 | if ( 70 | innerNode.nodeType === Node.ELEMENT_NODE && 71 | innerNode.nodeName == "SPAN" 72 | ) { 73 | const dir = new MediaDirector( 74 | this.builder, 75 | this.app, 76 | this.client 77 | ); 78 | const mediaItem = await dir.build_item( 79 | innerNode as HTMLSpanElement, 80 | filePath 81 | ); 82 | this.builder.addItem(pItem); 83 | 84 | if (mediaItem) { 85 | this.builder.addItem(mediaItem); 86 | } 87 | 88 | return; 89 | } 90 | } 91 | 92 | this.builder.addItem(pItem); 93 | } 94 | 95 | isTagNode(node: HTMLElement): boolean { 96 | return ( 97 | node.nodeType === Node.ELEMENT_NODE && 98 | node.nodeName === "A" && 99 | node.classList.contains("tag") 100 | ); 101 | } 102 | 103 | async findNestedItem(node: HTMLElement, filePath: string) { 104 | let item: any = null; 105 | 106 | switch (node.nodeName) { 107 | case "A": 108 | if (node.classList.contains("tag")) { 109 | break; 110 | } 111 | 112 | item = await new LinkDirector( 113 | this.builder, 114 | this.fileAdaptor 115 | ).build_item( 116 | node as HTMLAnchorElement, 117 | this.settings.followLinks, 118 | filePath 119 | ); 120 | break; 121 | case "STRONG": 122 | item = this.builder.strongItem(node.textContent!); 123 | break; 124 | case "EM": 125 | item = this.builder.emphasisItem(node.textContent!); 126 | break; 127 | case "CODE": 128 | item = this.builder.codeItem(node.textContent!); 129 | break; 130 | case "U": 131 | item = this.builder.underlineItem(node.textContent!); 132 | break; 133 | case "S": 134 | case "DEL": 135 | item = this.builder.strikeItem(node.textContent!); 136 | break; 137 | } 138 | 139 | if (item) { 140 | const marks = await this.findAllMarks(node); 141 | 142 | if (marks.length > 0) { 143 | item = { 144 | ...item, 145 | marks: [...item.marks, ...marks], 146 | }; 147 | } 148 | } 149 | 150 | return item; 151 | } 152 | 153 | async findAllMarks(node: HTMLElement) { 154 | let marks: MarksList = []; 155 | 156 | for (const _node of Array.from(node.childNodes)) { 157 | if (_node.nodeType == Node.TEXT_NODE) { 158 | break; 159 | } 160 | 161 | if (_node.nodeType == Node.ELEMENT_NODE) { 162 | switch (_node.nodeName) { 163 | case "A": 164 | const link = await new LinkDirector( 165 | this.builder, 166 | this.fileAdaptor 167 | ).findLink(_node as HTMLAnchorElement); 168 | marks.push(this.builder.markLink(link)); 169 | case "STRONG": 170 | marks.push(this.builder.markStrong()); 171 | break; 172 | case "EM": 173 | marks.push(this.builder.markEm()); 174 | break; 175 | case "CODE": 176 | marks.push(this.builder.markCode()); 177 | break; 178 | case "U": 179 | marks.push(this.builder.markUnderline()); 180 | break; 181 | case "S": 182 | marks.push(this.builder.markStrike()); 183 | break; 184 | } 185 | 186 | const moreMarks = await this.findAllMarks(_node as HTMLElement); 187 | 188 | if (moreMarks.length > 0) { 189 | marks = marks.concat(moreMarks); 190 | } 191 | } 192 | } 193 | 194 | return marks; 195 | } 196 | 197 | isTagOnlyParagraph(node: HTMLElement): boolean { 198 | const childNodes = Array.from(node.childNodes); 199 | 200 | let hasText = false; 201 | let hasTag = false; 202 | 203 | for (const child of childNodes) { 204 | if ( 205 | child.nodeType === Node.TEXT_NODE && 206 | child.textContent?.trim() !== "" 207 | ) { 208 | hasText = true; 209 | } else if ( 210 | child.nodeType === Node.ELEMENT_NODE && 211 | (child as HTMLElement).classList.contains("tag") 212 | ) { 213 | hasTag = true; 214 | } else if (child.nodeType === Node.ELEMENT_NODE) { 215 | hasText = true; // If it's another type of element, count it as text content 216 | } 217 | } 218 | 219 | // True if it contains only tags and no text content. 220 | return hasTag && !hasText; 221 | } 222 | } 223 | 224 | export default ParagraphDirector; 225 | -------------------------------------------------------------------------------- /lib/directors/table.ts: -------------------------------------------------------------------------------- 1 | import ParagraphDirector from "./paragraph"; 2 | 3 | class TableDirector extends ParagraphDirector { 4 | async addItems( 5 | node: HTMLTableCellElement, 6 | filePath: string 7 | ): Promise { 8 | if (node.children.length == 0) { 9 | this.builder.addItem(this.builder.paragraphItem(node.textContent!)); 10 | return; 11 | } 12 | 13 | const p = createEl("p"); 14 | 15 | for (const cellNode of Array.from(node.childNodes)) { 16 | if (cellNode.nodeName == "BR") { 17 | await super.addItems(p, filePath, true); 18 | p.empty(); 19 | continue; 20 | } 21 | 22 | p.appendChild(cellNode); 23 | } 24 | 25 | if (p.children.length > 0) { 26 | await super.addItems(p, filePath, true); 27 | } 28 | } 29 | } 30 | 31 | export default TableDirector; 32 | -------------------------------------------------------------------------------- /lib/modal.ts: -------------------------------------------------------------------------------- 1 | import { App, FuzzyMatch, FuzzySuggestModal, setIcon } from "obsidian"; 2 | import { map, filter } from "lodash"; 3 | 4 | import ConfluenceClient from "./confluence/client"; 5 | import ConfluenceLink from "main"; 6 | 7 | interface Space { 8 | title: string; 9 | id: string; 10 | key: string; 11 | } 12 | 13 | type Callback = (resilts: Space) => void; 14 | 15 | export default class SpaceSearchModal extends FuzzySuggestModal { 16 | client: ConfluenceClient; 17 | spaces: Space[]; 18 | plugin: ConfluenceLink; 19 | callback: Callback; 20 | 21 | constructor( 22 | app: App, 23 | plugin: ConfluenceLink, 24 | client: ConfluenceClient, 25 | callback: Callback 26 | ) { 27 | super(app); 28 | this.client = client; 29 | this.plugin = plugin; 30 | this.callback = callback; 31 | this.spaces = []; 32 | } 33 | 34 | async onOpen(): Promise { 35 | const favSpaces = this.plugin.settings.favSpaces; 36 | const spaces: Space[] = []; 37 | 38 | if (favSpaces.length) { 39 | const favSpacesResponse = await this.client.space.getSpacesByKeys( 40 | favSpaces 41 | ); 42 | 43 | map(favSpacesResponse.results, (item) => { 44 | spaces.push({ 45 | title: item.name, 46 | id: item.id, 47 | key: item.key, 48 | }); 49 | }); 50 | } 51 | 52 | const resp = await this.client.space.getSpaces(); 53 | const nonFavSpaces = map( 54 | filter(resp.results, (item) => !favSpaces.includes(item.key)), 55 | (item) => { 56 | return { 57 | title: item.name, 58 | id: item.id, 59 | key: item.key, 60 | }; 61 | } 62 | ); 63 | 64 | this.spaces = [...spaces, ...nonFavSpaces]; 65 | this.render(); 66 | } 67 | 68 | getItems(): Space[] { 69 | return this.spaces; 70 | } 71 | 72 | getItemText(space: Space): string { 73 | return space.title; 74 | } 75 | 76 | onChooseItem(space: Space): void { 77 | this.callback(space); 78 | this.close(); 79 | } 80 | 81 | // @ts-ignore 82 | async getSuggestions(query: string) { 83 | if (!query.startsWith("??")) { 84 | return super.getSuggestions(query); 85 | } 86 | 87 | const searchQ = query.replaceAll("??", "").trim(); 88 | if (!searchQ) { 89 | return []; 90 | } 91 | 92 | const fuzzySpacesSearch = await this.client.search.searchByCQL({ 93 | cql: `space.title~'${searchQ}' and type = 'space'`, 94 | }); 95 | const spaceKeys = map(fuzzySpacesSearch.results, "space.key"); 96 | 97 | if (spaceKeys.length == 0) { 98 | return []; 99 | } 100 | 101 | const spacesResponse = await this.client.space.getSpacesByKeys( 102 | spaceKeys 103 | ); 104 | 105 | const spaces = map(spacesResponse.results, (item) => { 106 | return { 107 | item: { 108 | title: item.name, 109 | id: item.id, 110 | key: item.key, 111 | }, 112 | }; 113 | }); 114 | 115 | return spaces; 116 | } 117 | 118 | renderSuggestion(item: FuzzyMatch, el: HTMLElement) { 119 | const { item: space } = item; 120 | 121 | const favSpaces = this.plugin.settings.favSpaces; 122 | const div = createDiv("suggestion-item space-container"); 123 | const icon = createSpan(); 124 | setIcon(icon, "star"); 125 | icon.classList.add("fav-icon"); 126 | 127 | if (favSpaces.includes(space.key)) { 128 | icon.classList.add("is-fav"); 129 | } 130 | 131 | const span = createSpan(); 132 | span.textContent = this.getItemText(space); 133 | 134 | div.appendChild(span); 135 | div.appendChild(icon); 136 | 137 | div.addEventListener("mouseenter", () => { 138 | div.classList.add("is-selected"); 139 | }); 140 | 141 | div.addEventListener("mouseleave", () => { 142 | div.classList.remove("is-selected"); 143 | }); 144 | 145 | div.addEventListener("click", (e) => { 146 | if (e.targetNode instanceof SVGElement) { 147 | e.stopPropagation(); 148 | icon.classList.toggle("is-fav"); 149 | 150 | if (icon.classList.contains("is-fav")) { 151 | favSpaces.push(space.key); 152 | } else { 153 | const spaceIdx = favSpaces.indexOf(space.key); 154 | favSpaces.splice(spaceIdx, 1); 155 | } 156 | 157 | this.plugin.saveSettings(); 158 | return; 159 | } 160 | 161 | this.onChooseItem(space); 162 | }); 163 | 164 | if (el.classList.contains("suggestion-item")) { 165 | el.replaceWith(div); 166 | } else { 167 | el.appendChild(div); 168 | } 169 | } 170 | 171 | render(): void { 172 | this.resultContainerEl.empty(); 173 | 174 | for (const space of this.spaces) { 175 | this.renderSuggestion( 176 | { item: space, match: { score: 0, matches: [] } }, 177 | this.resultContainerEl 178 | ); 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /lib/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, PluginSettingTab, Setting } from "obsidian"; 2 | import ConfluenceLinkPlugin from "main"; 3 | 4 | import ConfluenceClient from "./confluence/client"; 5 | import SpaceSearchModal from "./modal"; 6 | import { isFloat } from "./utils"; 7 | 8 | export class ConfluenceLinkSettingsTab extends PluginSettingTab { 9 | plugin: ConfluenceLinkPlugin; 10 | showToken: boolean; 11 | 12 | constructor(app: App, plugin: ConfluenceLinkPlugin) { 13 | super(app, plugin); 14 | this.plugin = plugin; 15 | this.showToken = false; 16 | } 17 | 18 | display() { 19 | const { containerEl } = this; 20 | containerEl.empty(); 21 | 22 | new Setting(containerEl) 23 | .setName("Confluence domain") 24 | .setDesc("eg: https://test.attlasian.net") 25 | .addText((text) => 26 | text 27 | .setValue(this.plugin.settings.confluenceDomain) 28 | .onChange(async (value) => { 29 | this.plugin.settings.confluenceDomain = value; 30 | await this.plugin.saveSettings(); 31 | }) 32 | ); 33 | 34 | new Setting(containerEl) 35 | .setName("Atlassian username") 36 | .setDesc("eg: user@domain.com") 37 | .addText((text) => { 38 | text.setValue(this.plugin.settings.atlassianUsername).onChange( 39 | async (value) => { 40 | this.plugin.settings.atlassianUsername = value; 41 | await this.plugin.saveSettings(); 42 | } 43 | ); 44 | }); 45 | 46 | new Setting(containerEl) 47 | .setName("Atlassian api token") 48 | .setDesc( 49 | createFragment((el) => { 50 | el.appendChild( 51 | createEl("a", { 52 | text: "Official documentation", 53 | href: "https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/", 54 | }) 55 | ); 56 | }) 57 | ) 58 | .addExtraButton((button) => 59 | button 60 | .setTooltip("Copy token") 61 | .setIcon("copy") 62 | .onClick(async () => { 63 | if (this.plugin.settings.atlassianApiToken) { 64 | await navigator.clipboard.writeText( 65 | this.plugin.settings.atlassianApiToken 66 | ); 67 | new Notice("Token copied"); 68 | } 69 | }) 70 | ) 71 | .addExtraButton((button) => 72 | button 73 | .setIcon(this.showToken ? "eye-off" : "eye") 74 | .onClick(() => { 75 | this.showToken = !this.showToken; 76 | this.display(); 77 | }) 78 | .setTooltip(this.showToken ? "Hide token" : "Show token") 79 | ) 80 | .addText((text) => { 81 | text.setValue(this.plugin.settings.atlassianApiToken).onChange( 82 | async (value) => { 83 | this.plugin.settings.atlassianApiToken = value; 84 | await this.plugin.saveSettings(); 85 | } 86 | ); 87 | 88 | text.inputEl.setAttr( 89 | "type", 90 | this.showToken ? "text" : "password" 91 | ); 92 | }); 93 | 94 | new Setting(containerEl).addButton((button) => 95 | button.setButtonText("Test Connection").onClick(async () => { 96 | button.setDisabled(true); 97 | button.setButtonText("Testing..."); 98 | 99 | const { 100 | confluenceDomain, 101 | atlassianUsername, 102 | atlassianApiToken, 103 | } = this.plugin.settings; 104 | 105 | if ( 106 | !confluenceDomain || 107 | !atlassianApiToken || 108 | !atlassianApiToken 109 | ) { 110 | return new Notice("Settings for connection not set up"); 111 | } 112 | 113 | const client = new ConfluenceClient({ 114 | host: confluenceDomain, 115 | authentication: { 116 | email: atlassianUsername, 117 | apiToken: atlassianApiToken, 118 | }, 119 | }); 120 | 121 | try { 122 | await client.search.searchByCQL({ 123 | cql: "id != 0 order by lastmodified desc", 124 | }); 125 | new Notice("Confluence Link: Connection established!"); 126 | } catch (e) { 127 | new Notice("Confluence Link: Connection failed!"); 128 | } 129 | 130 | button.setButtonText("Test Connection"); 131 | button.setDisabled(false); 132 | }) 133 | ); 134 | 135 | new Setting(containerEl) 136 | .setName("Confluence default space") 137 | .setDesc("Default spaceId to create the files") 138 | .addExtraButton((button) => { 139 | button 140 | // .setIcon() 141 | .setTooltip("Choose default spaceId") 142 | .onClick(() => { 143 | const { 144 | atlassianUsername, 145 | atlassianApiToken, 146 | confluenceDomain, 147 | } = this.plugin.settings; 148 | 149 | if ( 150 | !atlassianApiToken || 151 | !atlassianUsername || 152 | !confluenceDomain 153 | ) { 154 | new Notice( 155 | "Please set up the above settings first" 156 | ); 157 | return; 158 | } 159 | 160 | const client = new ConfluenceClient({ 161 | host: confluenceDomain, 162 | authentication: { 163 | email: atlassianUsername, 164 | apiToken: atlassianApiToken, 165 | }, 166 | }); 167 | new SpaceSearchModal( 168 | this.app, 169 | this.plugin, 170 | client, 171 | async (result) => { 172 | this.plugin.settings.confluenceDefaultSpaceId = 173 | result.id; 174 | await this.plugin.saveSettings(); 175 | 176 | this.display(); // Reload the settings tab 177 | } 178 | ).open(); 179 | }); 180 | }) 181 | .addText((text) => { 182 | let wait: number | null = null; 183 | 184 | text.setValue( 185 | this.plugin.settings.confluenceDefaultSpaceId 186 | ).onChange(async (value) => { 187 | if (Number(value) && !isFloat(Number(value))) { 188 | this.plugin.settings.confluenceDefaultSpaceId = value; 189 | await this.plugin.saveSettings(); 190 | } else { 191 | if (wait) { 192 | window.clearTimeout(wait); 193 | } 194 | 195 | wait = window.setTimeout(() => { 196 | this.display(); 197 | new Notice("Please enter a valid space id."); 198 | }, 500); 199 | } 200 | }); 201 | }); 202 | 203 | new Setting(containerEl) 204 | .setName("Follow links") 205 | .setDesc( 206 | "Enable to follow internal links and create those as confluence pages as well" 207 | ) 208 | .addToggle((cb) => { 209 | cb.setValue(this.plugin.settings.followLinks || false); 210 | 211 | cb.onChange((value) => { 212 | this.plugin.settings.followLinks = value; 213 | this.plugin.saveSettings(); 214 | }); 215 | }); 216 | 217 | new Setting(containerEl) 218 | .setName("Upload tags") 219 | .setDesc( 220 | "Enable to add the tags from the obsidian file to the confluence page as well" 221 | ) 222 | .addToggle((cb) => { 223 | cb.setValue(this.plugin.settings.uploadTags || false); 224 | 225 | cb.onChange((value) => { 226 | this.plugin.settings.uploadTags = value; 227 | this.plugin.saveSettings(); 228 | }); 229 | }); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { pickBy } from "lodash"; 2 | 3 | export function isFloat(n: number | string): boolean { 4 | return Number(n) === n && n % 1 !== 0; 5 | } 6 | 7 | export function removeUndefinedProperties( 8 | obj: Record 9 | ): Record { 10 | return pickBy(obj, (value) => typeof value !== "undefined"); 11 | } 12 | 13 | export function concatenateUint8Arrays(arrays: any[]): Uint8Array { 14 | const totalLength = arrays.reduce((acc, value) => acc + value.length, 0); 15 | const result = new Uint8Array(totalLength); 16 | 17 | let offset = 0; 18 | 19 | for (const array of arrays) { 20 | result.set(array, offset); 21 | offset += array.length; 22 | } 23 | 24 | return result; 25 | } 26 | 27 | export const MardownLgToConfluenceLgMap: { 28 | [key: string]: string; 29 | } = { 30 | js: "javascript", 31 | bash: "shell", 32 | abap: "abap", 33 | actionscript: "actionscript", 34 | ada: "ada", 35 | applescript: "applescript", 36 | arduino: "arduino", 37 | autoit: "autoit", 38 | c: "c", 39 | "c++": "cpp", 40 | clojure: "clojure", 41 | coffeescript: "coffeescript", 42 | coldfusion: "coldfusion", 43 | csharp: "csharp", 44 | css: "css", 45 | cuda: "cuda", 46 | d: "d", 47 | dart: "dart", 48 | diff: "diff", 49 | elixir: "elixir", 50 | erlang: "erlang", 51 | fortran: "fortran", 52 | foxpro: "foxpro", 53 | go: "go", 54 | graphql: "graphql", 55 | groovy: "groovy", 56 | haskell: "haskell", 57 | haxe: "haxe", 58 | html: "html", 59 | java: "java", 60 | javafx: "javafx", 61 | javascript: "javascript", 62 | json: "json", 63 | jsx: "jsx", 64 | julia: "julia", 65 | kotlin: "kotlin", 66 | livescript: "livescript", 67 | lua: "lua", 68 | mathematica: "mathematica", 69 | matlab: "matlab", 70 | "objective-c": "objective-c", 71 | "objective-j": "objective-j", 72 | ocaml: "ocaml", 73 | octave: "cctave", 74 | pascal: "pascal", 75 | perl: "perl", 76 | php: "php", 77 | plaintext: "text", 78 | powershell: "powershell", 79 | prolog: "prolog", 80 | puppet: "puppet", 81 | python: "python", 82 | qml: "qml", 83 | r: "r", 84 | racket: "racket", 85 | restructuredtext: "restructuredtext", 86 | ruby: "ruby", 87 | rust: "rust", 88 | sass: "sass", 89 | scala: "scala", 90 | scheme: "scheme", 91 | shell: "bash", 92 | smalltalk: "smalltalk", 93 | splunkspl: "splunkspl", 94 | sql: "sql", 95 | standardml: "standardml", 96 | swift: "swift", 97 | tcl: "tcl", 98 | tex: "tex", 99 | tsx: "tsx", 100 | typescript: "typescript", 101 | vala: "vala", 102 | vbnet: "vbnet", 103 | verilog: "verilog", 104 | vhdl: "vhdl", 105 | visualbasic: "visualbasic", 106 | xml: "xml", 107 | xquery: "xquery", 108 | yaml: "yaml", 109 | }; 110 | 111 | export const wait = async (ms: number = 1000) => { 112 | await new Promise((resolve) => setTimeout(resolve, ms)); 113 | }; 114 | 115 | export const isRecentlyModified = ( 116 | mtime: number, 117 | thresholdMs: number = 1000 118 | ): boolean => { 119 | return Date.now() - mtime <= thresholdMs; 120 | }; 121 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Editor, 3 | Notice, 4 | Plugin, 5 | TFile, 6 | FileView, 7 | MarkdownView, 8 | setIcon, 9 | } from "obsidian"; 10 | 11 | import { ConfluenceLinkSettingsTab } from "lib/settings"; 12 | import { ConfluenceLinkSettings } from "lib/confluence/types"; 13 | 14 | import ConfluenceClient from "lib/confluence/client"; 15 | import PropertiesAdaptor from "lib/adaptors/properties"; 16 | import FileAdaptor from "lib/adaptors/file"; 17 | import SpaceSearchModal from "lib/modal"; 18 | import LabelDirector from "lib/directors/label"; 19 | import { isRecentlyModified, wait } from "lib/utils"; 20 | 21 | export default class ConfluenceLink extends Plugin { 22 | settings: ConfluenceLinkSettings; 23 | 24 | async onload() { 25 | await this.loadSettings(); 26 | 27 | this.addSettingTab(new ConfluenceLinkSettingsTab(this.app, this)); 28 | 29 | // Register commands 30 | this.addCommand({ 31 | id: "upload-file-to-confluence", 32 | name: "Upload file to confluence using default space", 33 | editorCallback: (editor: Editor, ctx: MarkdownView) => { 34 | const { confluenceDefaultSpaceId } = this.settings; 35 | 36 | this.addProgress( 37 | async () => 38 | this.uploadFile( 39 | ctx.file?.path || "", 40 | confluenceDefaultSpaceId 41 | ), 42 | ctx.file?.basename! 43 | ); 44 | }, 45 | }); 46 | 47 | this.addCommand({ 48 | id: "upload-file-to-space", 49 | name: "Upload file to space", 50 | editorCallback: (editor: Editor, ctx: MarkdownView) => { 51 | this.addProgress( 52 | async () => this.uploadFile(ctx.file?.path || "", null), 53 | ctx.file?.basename! 54 | ); 55 | }, 56 | }); 57 | } 58 | 59 | async addProgress(callback: Function, filename: string) { 60 | const statusBar = this.addStatusBarItem(); 61 | 62 | setIcon(statusBar, "loader"); 63 | const loader = statusBar.querySelector("svg")!; 64 | 65 | statusBar.createEl("span", { 66 | text: `Uploading ${filename}`, 67 | attr: { style: "padding-rigth: 10px; padding-left: 5px" }, 68 | }); 69 | loader.animate( 70 | [ 71 | { 72 | // from 73 | transform: "rotate(0deg)", 74 | }, 75 | { 76 | // to 77 | transform: "rotate(360deg)", 78 | }, 79 | ], 80 | { 81 | duration: 2000, 82 | iterations: Infinity, // Repeat the animation infinitely 83 | } 84 | ); 85 | 86 | try { 87 | await callback(); 88 | } catch (e) { 89 | console.error(e); 90 | } 91 | 92 | statusBar.detach(); 93 | } 94 | 95 | getActiveCanvas(): any { 96 | let currentView = this.app.workspace?.getActiveViewOfType(FileView); 97 | 98 | if (currentView?.getViewType() !== "canvas") { 99 | return null; 100 | } 101 | 102 | return (currentView as any)["canvas"]; 103 | } 104 | 105 | async uploadFile(filePath: string, spaceId: string | null) { 106 | const { atlassianUsername, atlassianApiToken, confluenceDomain } = 107 | this.settings; 108 | 109 | if (!atlassianApiToken || !atlassianUsername || !confluenceDomain) { 110 | new Notice( 111 | "Settings not set up. Please open the settings page of the plugin" 112 | ); 113 | return; 114 | } 115 | 116 | const file = this.app.vault.getAbstractFileByPath(filePath || ""); 117 | 118 | if (!(file instanceof TFile)) { 119 | throw new Error("Not a TFile"); 120 | } 121 | 122 | // if the file was just modified 123 | // wait to make sure all the changes are on disc 124 | // before reading the files 125 | if (isRecentlyModified(file.stat.mtime)) { 126 | await wait(2000); 127 | } 128 | 129 | const fileData = await this.app.vault.read(file); 130 | const client = new ConfluenceClient({ 131 | host: confluenceDomain, 132 | authentication: { 133 | email: atlassianUsername, 134 | apiToken: atlassianApiToken, 135 | }, 136 | }); 137 | 138 | const propAdaptor = new PropertiesAdaptor().loadProperties(fileData); 139 | const { pageId } = propAdaptor.properties; 140 | let response = null; 141 | 142 | if (!spaceId && !pageId) { 143 | await new Promise((resolve) => { 144 | new SpaceSearchModal(this.app, this, client, (result) => { 145 | spaceId = result.id; 146 | resolve(); 147 | }).open(); 148 | }); 149 | } 150 | 151 | if (!pageId) { 152 | response = await client.page.createPage({ 153 | spaceId: spaceId as string, 154 | pageTitle: file.basename, 155 | }); 156 | 157 | propAdaptor.addProperties({ 158 | pageId: response.id, 159 | spaceId: response.spaceId, 160 | confluenceUrl: response._links.base + response._links.webui, 161 | }); 162 | } 163 | 164 | // Write the updated content back to the Obsidian file 165 | await this.app.vault.modify(file, propAdaptor.toFile(fileData)); 166 | 167 | const adf = await new FileAdaptor( 168 | this.app, 169 | client, 170 | spaceId as string, 171 | this.settings 172 | ).convertObs2Adf(fileData, filePath!, propAdaptor); 173 | 174 | client.page.updatePage({ 175 | pageId: propAdaptor.properties.pageId as string, 176 | pageTitle: file.basename, 177 | adf, 178 | }); 179 | 180 | new Notice(`${file.basename} file uploaded to confluence`); 181 | } 182 | 183 | async onunload() {} 184 | 185 | async loadSettings() { 186 | this.settings = Object.assign({ favSpaces: [] }, await this.loadData()); 187 | } 188 | 189 | async saveSettings() { 190 | await this.saveData(this.settings); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "confluence-link", 3 | "name": "Confluence Link", 4 | "version": "1.4.3", 5 | "minAppVersion": "0.15.0", 6 | "description": "Upload files to confluence pages", 7 | "author": "Razvan Bunga", 8 | "isDesktopOnly": true 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "confluence-link", 3 | "version": "1.4.3", 4 | "description": "Publish obsidian files to confluence pages", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "lint": "eslint . --ext .ts", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "keywords": [], 13 | "author": "Razvan Bunga", 14 | "license": "UNLICENSED", 15 | "devDependencies": { 16 | "@types/js-yaml": "4.0.9", 17 | "@types/lodash": "4.14.202", 18 | "@types/node": "16.11.6", 19 | "@typescript-eslint/eslint-plugin": "5.29.0", 20 | "@typescript-eslint/parser": "5.29.0", 21 | "builtin-modules": "3.3.0", 22 | "esbuild": "0.17.3", 23 | "html-to-image": "1.11.11", 24 | "lodash": "4.17.21", 25 | "obsidian": "latest", 26 | "tslib": "2.4.0", 27 | "typescript": "4.7.4", 28 | "yaml": "2.5.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .space-container { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | } 6 | 7 | .space-container .fav-icon { 8 | color: var(--color-yellow); 9 | cursor: pointer; 10 | } 11 | 12 | .space-container .fav-icon.is-fav > svg { 13 | fill: var(--color-yellow); 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "strictNullChecks": true, 15 | "lib": ["DOM", "ES5", "ES6", "ES7", "dom.iterable", "ES2021.String"] 16 | }, 17 | "include": ["**/*.ts"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.0.2": "0.15.0", 5 | "1.1.5": "0.15.0", 6 | "1.2.5": "0.15.0", 7 | "1.2.6": "0.15.0", 8 | "1.3.6": "0.15.0", 9 | "1.3.7": "0.15.0", 10 | "1.4.0": "0.15.0", 11 | "1.4.1": "0.15.0", 12 | "1.4.2": "0.15.0", 13 | "1.4.3": "0.15.0" 14 | } 15 | --------------------------------------------------------------------------------