├── .coveragerc ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── LICENSE ├── README.md ├── codecov.yml ├── mkdocs_with_confluence ├── __init__.py └── plugin.py ├── requirements.txt ├── requirements_dev.txt ├── setup.py └── tests └── test.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = mkdocs_with_confluence 4 | include = */mkdocs_with_confluence/* 5 | 6 | [report] 7 | exclude_lines = 8 | if self.debug: 9 | pragma: no cover 10 | raise NotImplementedError 11 | if __name__ == .__main__.: 12 | ignore_errors = True 13 | omit = 14 | tests/* 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mkdocs_with_confluence/__pycache__/* 2 | mkdocs_with_confluence.egg-info/* 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | entry: black 7 | language: python 8 | types: [python] 9 | language_version: python3.7 10 | args: [--line-length=120] 11 | - repo: local 12 | hooks: 13 | - id: mypy 14 | name: mypy 15 | entry: mypy 16 | language: system 17 | types: [python] 18 | args: [--ignore-missing-imports, --namespace-packages, --show-error-codes, --pretty] 19 | - repo: local 20 | hooks: 21 | - id: flake8 22 | name: flake8 23 | entry: flake8 24 | language: system 25 | types: [python] 26 | args: [--max-line-length=120, "--ignore=D101,D104,D212,D200,E203,W293,D412,W503"] 27 | # D100 requires all Python files (modules) to have a "public" docstring even if all functions within have a docstring. 28 | # D104 requires __init__ files to have a docstring 29 | # D212 30 | # D200 31 | # D412 No blank lines allowed between a section header and its content 32 | # E203 33 | # W293 blank line contains whitespace 34 | # W503 line break before binary operator (for compatibility with black) 35 | 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | script: 3 | - pip install -r requirements_dev.txt 4 | - python setup.py install 5 | - flake8 --max-line-length=120 --ignore=D101,D104,D212,D200,E203,W293,D412,W503 mkdocs_with_confluence/ 6 | - black --check --line-length=120 mkdocs_with_confluence/ 7 | - nosetests --with-coverage 8 | after_success: 9 | - bash <(curl -s https://codecov.io/bash) 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Pawel Sikora 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PyPI](https://img.shields.io/pypi/v/mkdocs-with-confluence) 2 | [![Build Status](https://app.travis-ci.com/pawelsikora/mkdocs-with-confluence.svg?token=Nxwjs6L2kEPqZeJARZzo&branch=main)](https://app.travis-ci.com/pawelsikora/mkdocs-with-confluence) 3 | [![codecov](https://codecov.io/gh/pawelsikora/mkdocs-with-confluence/branch/master/graph/badge.svg)](https://codecov.io/gh/pawelsikora/mkdocs-with-confluence) 4 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/mkdocs-with-confluence) 5 | ![GitHub contributors](https://img.shields.io/github/contributors/pawelsikora/mkdocs-with-confluence) 6 | ![PyPI - License](https://img.shields.io/pypi/l/mkdocs-with-confluence) 7 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mkdocs-with-confluence) 8 | # mkdocs-with-confluence 9 | 10 | MkDocs plugin that converts markdown pages into confluence markup 11 | and export it to the Confluence page 12 | 13 | ## Setup 14 | Install the plugin using pip: 15 | 16 | `pip install mkdocs-with-confluence` 17 | 18 | Activate the plugin in `mkdocs.yml`: 19 | 20 | ```yaml 21 | plugins: 22 | - search 23 | - mkdocs-with-confluence 24 | ``` 25 | 26 | More information about plugins in the [MkDocs documentation: mkdocs-plugins](https://www.mkdocs.org/user-guide/plugins/). 27 | 28 | ## Usage 29 | 30 | Use following config and adjust it according to your needs: 31 | 32 | ```yaml 33 | - mkdocs-with-confluence: 34 | host_url: https:///rest/api/content 35 | space: 36 | parent_page_name: 37 | username: 38 | password: 39 | enabled_if_env: MKDOCS_TO_CONFLUENCE 40 | #verbose: true 41 | #debug: true 42 | dryrun: true 43 | ``` 44 | 45 | ## Parameters: 46 | 47 | ### Requirements 48 | - md2cf 49 | - mimetypes 50 | - mistune 51 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | # Commits pushed to master should not make the overall 8 | # project coverage decrease by more than x%: 9 | target: auto 10 | threshold: 20% 11 | patch: 12 | default: 13 | # Be tolerant on slight code coverage diff on PRs to limit 14 | # noisy red coverage status on github PRs. 15 | # Note: The coverage stats are still uploaded 16 | # to codecov so that PR reviewers can see uncovered lines 17 | target: auto 18 | threshold: 20% 19 | 20 | codecov: 21 | notify: 22 | # Prevent coverage status to upload multiple times for parallel and long 23 | # running CI pipelines. This configuration is particularly useful on PRs 24 | # to avoid confusion. Note that this value is set to the number of Azure 25 | # Pipeline jobs uploading coverage reports. 26 | after_n_builds: 6 27 | token: 21ccebfb-1239-4ec6-a01d-039067b9ab30 28 | 29 | -------------------------------------------------------------------------------- /mkdocs_with_confluence/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawelsikora/mkdocs-with-confluence/ac0b21edda0d295a1c50aac7606a80437c84deba/mkdocs_with_confluence/__init__.py -------------------------------------------------------------------------------- /mkdocs_with_confluence/plugin.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | import hashlib 4 | import sys 5 | import re 6 | import tempfile 7 | import shutil 8 | import requests 9 | import mimetypes 10 | import mistune 11 | import contextlib 12 | from time import sleep 13 | from mkdocs.config import config_options 14 | from mkdocs.plugins import BasePlugin 15 | from md2cf.confluence_renderer import ConfluenceRenderer 16 | from os import environ 17 | from pathlib import Path 18 | 19 | TEMPLATE_BODY = "

TEMPLATE

" 20 | 21 | 22 | @contextlib.contextmanager 23 | def nostdout(): 24 | save_stdout = sys.stdout 25 | sys.stdout = DummyFile() 26 | yield 27 | sys.stdout = save_stdout 28 | 29 | 30 | class DummyFile(object): 31 | def write(self, x): 32 | pass 33 | 34 | 35 | class MkdocsWithConfluence(BasePlugin): 36 | _id = 0 37 | config_scheme = ( 38 | ("host_url", config_options.Type(str, default=None)), 39 | ("space", config_options.Type(str, default=None)), 40 | ("parent_page_name", config_options.Type(str, default=None)), 41 | ("username", config_options.Type(str, default=environ.get("JIRA_USERNAME", None))), 42 | ("api_token", config_options.Type(str, default=environ.get("CONFLUENCE_API_TOKEN", None))), # If specified, password is ignored 43 | ("password", config_options.Type(str, default=environ.get("JIRA_PASSWORD", None))), 44 | ("enabled_if_env", config_options.Type(str, default=None)), 45 | ("verbose", config_options.Type(bool, default=False)), 46 | ("debug", config_options.Type(bool, default=False)), 47 | ("dryrun", config_options.Type(bool, default=False)), 48 | ) 49 | 50 | def __init__(self): 51 | self.enabled = True 52 | self.confluence_renderer = ConfluenceRenderer(use_xhtml=True) 53 | self.confluence_mistune = mistune.Markdown(renderer=self.confluence_renderer) 54 | self.simple_log = False 55 | self.flen = 1 56 | self.session = requests.Session() 57 | self.page_attachments = {} 58 | 59 | def on_nav(self, nav, config, files): 60 | MkdocsWithConfluence.tab_nav = [] 61 | navigation_items = nav.__repr__() 62 | 63 | for n in navigation_items.split("\n"): 64 | leading_spaces = len(n) - len(n.lstrip(" ")) 65 | spaces = leading_spaces * " " 66 | if "Page" in n: 67 | try: 68 | self.page_title = self.__get_page_title(n) 69 | if self.page_title is None: 70 | raise AttributeError 71 | except AttributeError: 72 | self.page_local_path = self.__get_page_url(n) 73 | print( 74 | f"WARN - Page from path {self.page_local_path} has no" 75 | f" entity in the mkdocs.yml nav section. It will be uploaded" 76 | f" to the Confluence, but you may not see it on the web server!" 77 | ) 78 | self.page_local_name = self.__get_page_name(n) 79 | self.page_title = self.page_local_name 80 | 81 | p = spaces + self.page_title 82 | MkdocsWithConfluence.tab_nav.append(p) 83 | if "Section" in n: 84 | try: 85 | self.section_title = self.__get_section_title(n) 86 | if self.section_title is None: 87 | raise AttributeError 88 | except AttributeError: 89 | self.section_local_path = self.__get_page_url(n) 90 | print( 91 | f"WARN - Section from path {self.section_local_path} has no" 92 | f" entity in the mkdocs.yml nav section. It will be uploaded" 93 | f" to the Confluence, but you may not see it on the web server!" 94 | ) 95 | self.section_local_name = self.__get_section_title(n) 96 | self.section_title = self.section_local_name 97 | s = spaces + self.section_title 98 | MkdocsWithConfluence.tab_nav.append(s) 99 | 100 | def on_files(self, files, config): 101 | pages = files.documentation_pages() 102 | try: 103 | self.flen = len(pages) 104 | print(f"Number of Files in directory tree: {self.flen}") 105 | except 0: 106 | print("ERR: You have no documentation pages" "in the directory tree, please add at least one!") 107 | 108 | def on_post_template(self, output_content, template_name, config): 109 | if self.config["verbose"] is False and self.config["debug"] is False: 110 | self.simple_log = True 111 | print("INFO - Mkdocs With Confluence: Start exporting markdown pages... (simple logging)") 112 | else: 113 | self.simple_log = False 114 | 115 | def on_config(self, config): 116 | if "enabled_if_env" in self.config: 117 | env_name = self.config["enabled_if_env"] 118 | if env_name: 119 | self.enabled = os.environ.get(env_name) == "1" 120 | if not self.enabled: 121 | print( 122 | "WARNING - Mkdocs With Confluence: Exporting MKDOCS pages to Confluence turned OFF: " 123 | f"(set environment variable {env_name} to 1 to enable)" 124 | ) 125 | return 126 | else: 127 | print( 128 | "INFO - Mkdocs With Confluence: Exporting MKDOCS pages to Confluence " 129 | f"turned ON by var {env_name}==1!" 130 | ) 131 | self.enabled = True 132 | else: 133 | print( 134 | "WARNING - Mkdocs With Confluence: Exporting MKDOCS pages to Confluence turned OFF: " 135 | f"(set environment variable {env_name} to 1 to enable)" 136 | ) 137 | return 138 | else: 139 | print("INFO - Mkdocs With Confluence: Exporting MKDOCS pages to Confluence turned ON by default!") 140 | self.enabled = True 141 | 142 | if self.config["dryrun"]: 143 | print("WARNING - Mkdocs With Confluence - DRYRUN MODE turned ON") 144 | self.dryrun = True 145 | else: 146 | self.dryrun = False 147 | 148 | def on_page_markdown(self, markdown, page, config, files): 149 | MkdocsWithConfluence._id += 1 150 | if self.config["api_token"]: 151 | self.session.auth = (self.config["username"], self.config["api_token"]) 152 | else: 153 | self.session.auth = (self.config["username"], self.config["password"]) 154 | 155 | if self.enabled: 156 | if self.simple_log is True: 157 | print("INFO - Mkdocs With Confluence: Page export progress: [", end="", flush=True) 158 | for i in range(MkdocsWithConfluence._id): 159 | print("#", end="", flush=True) 160 | for j in range(self.flen - MkdocsWithConfluence._id): 161 | print("-", end="", flush=True) 162 | print(f"] ({MkdocsWithConfluence._id} / {self.flen})", end="\r", flush=True) 163 | 164 | if self.config["debug"]: 165 | print(f"\nDEBUG - Handling Page '{page.title}' (And Parent Nav Pages if necessary):\n") 166 | if not all(self.config_scheme): 167 | print("DEBUG - ERR: YOU HAVE EMPTY VALUES IN YOUR CONFIG. ABORTING") 168 | return markdown 169 | 170 | try: 171 | if self.config["debug"]: 172 | print("DEBUG - Get section first parent title...: ") 173 | try: 174 | 175 | parent = self.__get_section_title(page.ancestors[0].__repr__()) 176 | except IndexError as e: 177 | if self.config["debug"]: 178 | print( 179 | f"DEBUG - WRN({e}): No first parent! Assuming " 180 | f"DEBUG - {self.config['parent_page_name']}..." 181 | ) 182 | parent = None 183 | if self.config["debug"]: 184 | print(f"DEBUG - {parent}") 185 | if not parent: 186 | parent = self.config["parent_page_name"] 187 | 188 | if self.config["parent_page_name"] is not None: 189 | main_parent = self.config["parent_page_name"] 190 | else: 191 | main_parent = self.config["space"] 192 | 193 | if self.config["debug"]: 194 | print("DEBUG - Get section second parent title...: ") 195 | try: 196 | parent1 = self.__get_section_title(page.ancestors[1].__repr__()) 197 | except IndexError as e: 198 | if self.config["debug"]: 199 | print( 200 | f"DEBUG - ERR({e}) No second parent! Assuming " 201 | f"second parent is main parent: {main_parent}..." 202 | ) 203 | parent1 = None 204 | if self.config["debug"]: 205 | print(f"{parent}") 206 | 207 | if not parent1: 208 | parent1 = main_parent 209 | if self.config["debug"]: 210 | print( 211 | f"DEBUG - ONLY ONE PARENT FOUND. ASSUMING AS A " 212 | f"FIRST NODE after main parent config {main_parent}" 213 | ) 214 | 215 | if self.config["debug"]: 216 | print(f"DEBUG - PARENT0: {parent}, PARENT1: {parent1}, MAIN PARENT: {main_parent}") 217 | 218 | tf = tempfile.NamedTemporaryFile(delete=False) 219 | f = open(tf.name, "w") 220 | 221 | attachments = [] 222 | try: 223 | for match in re.finditer(r'img src="file://(.*)" s', markdown): 224 | if self.config["debug"]: 225 | print(f"DEBUG - FOUND IMAGE: {match.group(1)}") 226 | attachments.append(match.group(1)) 227 | for match in re.finditer(r"!\[[\w\. -]*\]\((?!http|file)([^\s,]*).*\)", markdown): 228 | file_path = match.group(1).lstrip("./\\") 229 | attachments.append(file_path) 230 | 231 | if self.config["debug"]: 232 | print(f"DEBUG - FOUND IMAGE: {file_path}") 233 | attachments.append("docs/" + file_path.replace("../", "")) 234 | 235 | except AttributeError as e: 236 | if self.config["debug"]: 237 | print(f"DEBUG - WARN(({e}): No images found in markdown. Proceed..") 238 | new_markdown = re.sub( 239 | r'', '"/>

', new_markdown) 242 | confluence_body = self.confluence_mistune(new_markdown) 243 | f.write(confluence_body) 244 | if self.config["debug"]: 245 | print(confluence_body) 246 | page_name = page.title 247 | new_name = "confluence_page_" + page_name.replace(" ", "_") + ".html" 248 | shutil.copy(f.name, new_name) 249 | f.close() 250 | 251 | if self.config["debug"]: 252 | print( 253 | f"\nDEBUG - UPDATING PAGE TO CONFLUENCE, DETAILS:\n" 254 | f"DEBUG - HOST: {self.config['host_url']}\n" 255 | f"DEBUG - SPACE: {self.config['space']}\n" 256 | f"DEBUG - TITLE: {page.title}\n" 257 | f"DEBUG - PARENT: {parent}\n" 258 | f"DEBUG - BODY: {confluence_body}\n" 259 | ) 260 | 261 | page_id = self.find_page_id(page.title) 262 | if page_id is not None: 263 | if self.config["debug"]: 264 | print( 265 | f"DEBUG - JUST ONE STEP FROM UPDATE OF PAGE '{page.title}' \n" 266 | f"DEBUG - CHECKING IF PARENT PAGE ON CONFLUENCE IS THE SAME AS HERE" 267 | ) 268 | 269 | parent_name = self.find_parent_name_of_page(page.title) 270 | 271 | if parent_name == parent: 272 | if self.config["debug"]: 273 | print("DEBUG - Parents match. Continue...") 274 | else: 275 | if self.config["debug"]: 276 | print(f"DEBUG - ERR, Parents does not match: '{parent}' =/= '{parent_name}' Aborting...") 277 | return markdown 278 | self.update_page(page.title, confluence_body) 279 | for i in MkdocsWithConfluence.tab_nav: 280 | if page.title in i: 281 | print(f"INFO - Mkdocs With Confluence: {i} *UPDATE*") 282 | else: 283 | if self.config["debug"]: 284 | print( 285 | f"DEBUG - PAGE: {page.title}, PARENT0: {parent}, " 286 | f"PARENT1: {parent1}, MAIN PARENT: {main_parent}" 287 | ) 288 | parent_id = self.find_page_id(parent) 289 | self.wait_until(parent_id, 1, 20) 290 | second_parent_id = self.find_page_id(parent1) 291 | self.wait_until(second_parent_id, 1, 20) 292 | main_parent_id = self.find_page_id(main_parent) 293 | if not parent_id: 294 | if not second_parent_id: 295 | main_parent_id = self.find_page_id(main_parent) 296 | if not main_parent_id: 297 | print("ERR: MAIN PARENT UNKNOWN. ABORTING!") 298 | return markdown 299 | 300 | if self.config["debug"]: 301 | print( 302 | f"DEBUG - Trying to ADD page '{parent1}' to " 303 | f"main parent({main_parent}) ID: {main_parent_id}" 304 | ) 305 | body = TEMPLATE_BODY.replace("TEMPLATE", parent1) 306 | self.add_page(parent1, main_parent_id, body) 307 | for i in MkdocsWithConfluence.tab_nav: 308 | if parent1 in i: 309 | print(f"INFO - Mkdocs With Confluence: {i} *NEW PAGE*") 310 | time.sleep(1) 311 | 312 | if self.config["debug"]: 313 | print( 314 | f"DEBUG - Trying to ADD page '{parent}' " 315 | f"to parent1({parent1}) ID: {second_parent_id}" 316 | ) 317 | body = TEMPLATE_BODY.replace("TEMPLATE", parent) 318 | self.add_page(parent, second_parent_id, body) 319 | for i in MkdocsWithConfluence.tab_nav: 320 | if parent in i: 321 | print(f"INFO - Mkdocs With Confluence: {i} *NEW PAGE*") 322 | time.sleep(1) 323 | 324 | if parent_id is None: 325 | for i in range(11): 326 | while parent_id is None: 327 | try: 328 | self.add_page(page.title, parent_id, confluence_body) 329 | except requests.exceptions.HTTPError: 330 | print( 331 | f"ERR - HTTP error on adding page. It probably occured due to " 332 | f"parent ID('{parent_id}') page is not YET synced on server. Retry nb {i}/10..." 333 | ) 334 | sleep(5) 335 | parent_id = self.find_page_id(parent) 336 | break 337 | 338 | self.add_page(page.title, parent_id, confluence_body) 339 | 340 | print(f"Trying to ADD page '{page.title}' to parent0({parent}) ID: {parent_id}") 341 | for i in MkdocsWithConfluence.tab_nav: 342 | if page.title in i: 343 | print(f"INFO - Mkdocs With Confluence: {i} *NEW PAGE*") 344 | 345 | if attachments: 346 | self.page_attachments[page.title] = attachments 347 | 348 | except IndexError as e: 349 | if self.config["debug"]: 350 | print(f"DEBUG - ERR({e}): Exception error!") 351 | return markdown 352 | 353 | return markdown 354 | 355 | def on_post_page(self, output, page, config): 356 | site_dir = config.get("site_dir") 357 | attachments = self.page_attachments.get(page.title, []) 358 | 359 | if self.config["debug"]: 360 | print(f"\nDEBUG - UPLOADING ATTACHMENTS TO CONFLUENCE FOR {page.title}, DETAILS:") 361 | print(f"FILES: {attachments} \n") 362 | for attachment in attachments: 363 | if self.config["debug"]: 364 | print(f"DEBUG - looking for {attachment} in {site_dir}") 365 | for p in Path(site_dir).rglob(f"*{attachment}"): 366 | self.add_or_update_attachment(page.title, p) 367 | return output 368 | 369 | def on_page_content(self, html, page, config, files): 370 | return html 371 | 372 | def __get_page_url(self, section): 373 | return re.search("url='(.*)'\\)", section).group(1)[:-1] + ".md" 374 | 375 | def __get_page_name(self, section): 376 | return os.path.basename(re.search("url='(.*)'\\)", section).group(1)[:-1]) 377 | 378 | def __get_section_name(self, section): 379 | if self.config["debug"]: 380 | print(f"DEBUG - SECTION name: {section}") 381 | return os.path.basename(re.search("url='(.*)'\\/", section).group(1)[:-1]) 382 | 383 | def __get_section_title(self, section): 384 | if self.config["debug"]: 385 | print(f"DEBUG - SECTION title: {section}") 386 | try: 387 | r = re.search("Section\\(title='(.*)'\\)", section) 388 | return r.group(1) 389 | except AttributeError: 390 | name = self.__get_section_name(section) 391 | print(f"WRN - Section '{name}' doesn't exist in the mkdocs.yml nav section!") 392 | return name 393 | 394 | def __get_page_title(self, section): 395 | try: 396 | r = re.search("\\s*Page\\(title='(.*)',", section) 397 | return r.group(1) 398 | except AttributeError: 399 | name = self.__get_page_url(section) 400 | print(f"WRN - Page '{name}' doesn't exist in the mkdocs.yml nav section!") 401 | return name 402 | 403 | # Adapted from https://stackoverflow.com/a/3431838 404 | def get_file_sha1(self, file_path): 405 | hash_sha1 = hashlib.sha1() 406 | with open(file_path, "rb") as f: 407 | for chunk in iter(lambda: f.read(4096), b""): 408 | hash_sha1.update(chunk) 409 | return hash_sha1.hexdigest() 410 | 411 | def add_or_update_attachment(self, page_name, filepath): 412 | print(f"INFO - Mkdocs With Confluence * {page_name} *ADD/Update ATTACHMENT if required* {filepath}") 413 | if self.config["debug"]: 414 | print(f" * Mkdocs With Confluence: Add Attachment: PAGE NAME: {page_name}, FILE: {filepath}") 415 | page_id = self.find_page_id(page_name) 416 | if page_id: 417 | file_hash = self.get_file_sha1(filepath) 418 | attachment_message = f"MKDocsWithConfluence [v{file_hash}]" 419 | existing_attachment = self.get_attachment(page_id, filepath) 420 | if existing_attachment: 421 | file_hash_regex = re.compile(r"\[v([a-f0-9]{40})]$") 422 | existing_match = file_hash_regex.search(existing_attachment["version"]["message"]) 423 | if existing_match is not None and existing_match.group(1) == file_hash: 424 | if self.config["debug"]: 425 | print(f" * Mkdocs With Confluence * {page_name} * Existing attachment skipping * {filepath}") 426 | else: 427 | self.update_attachment(page_id, filepath, existing_attachment, attachment_message) 428 | else: 429 | self.create_attachment(page_id, filepath, attachment_message) 430 | else: 431 | if self.config["debug"]: 432 | print("PAGE DOES NOT EXISTS") 433 | 434 | def get_attachment(self, page_id, filepath): 435 | name = os.path.basename(filepath) 436 | if self.config["debug"]: 437 | print(f" * Mkdocs With Confluence: Get Attachment: PAGE ID: {page_id}, FILE: {filepath}") 438 | 439 | url = self.config["host_url"] + "/" + page_id + "/child/attachment" 440 | headers = {"X-Atlassian-Token": "no-check"} # no content-type here! 441 | if self.config["debug"]: 442 | print(f"URL: {url}") 443 | 444 | r = self.session.get(url, headers=headers, params={"filename": name, "expand": "version"}) 445 | r.raise_for_status() 446 | with nostdout(): 447 | response_json = r.json() 448 | if response_json["size"]: 449 | return response_json["results"][0] 450 | 451 | def update_attachment(self, page_id, filepath, existing_attachment, message): 452 | if self.config["debug"]: 453 | print(f" * Mkdocs With Confluence: Update Attachment: PAGE ID: {page_id}, FILE: {filepath}") 454 | 455 | url = self.config["host_url"] + "/" + page_id + "/child/attachment/" + existing_attachment["id"] + "/data" 456 | headers = {"X-Atlassian-Token": "no-check"} # no content-type here! 457 | 458 | if self.config["debug"]: 459 | print(f"URL: {url}") 460 | 461 | filename = os.path.basename(filepath) 462 | 463 | # determine content-type 464 | content_type, encoding = mimetypes.guess_type(filepath) 465 | if content_type is None: 466 | content_type = "multipart/form-data" 467 | files = {"file": (filename, open(Path(filepath), "rb"), content_type), "comment": message} 468 | 469 | if not self.dryrun: 470 | r = self.session.post(url, headers=headers, files=files) 471 | r.raise_for_status() 472 | print(r.json()) 473 | if r.status_code == 200: 474 | print("OK!") 475 | else: 476 | print("ERR!") 477 | 478 | def create_attachment(self, page_id, filepath, message): 479 | if self.config["debug"]: 480 | print(f" * Mkdocs With Confluence: Create Attachment: PAGE ID: {page_id}, FILE: {filepath}") 481 | 482 | url = self.config["host_url"] + "/" + page_id + "/child/attachment" 483 | headers = {"X-Atlassian-Token": "no-check"} # no content-type here! 484 | 485 | if self.config["debug"]: 486 | print(f"URL: {url}") 487 | 488 | filename = os.path.basename(filepath) 489 | 490 | # determine content-type 491 | content_type, encoding = mimetypes.guess_type(filepath) 492 | if content_type is None: 493 | content_type = "multipart/form-data" 494 | files = {"file": (filename, open(filepath, "rb"), content_type), "comment": message} 495 | if not self.dryrun: 496 | r = self.session.post(url, headers=headers, files=files) 497 | print(r.json()) 498 | r.raise_for_status() 499 | if r.status_code == 200: 500 | print("OK!") 501 | else: 502 | print("ERR!") 503 | 504 | def find_page_id(self, page_name): 505 | if self.config["debug"]: 506 | print(f"INFO - * Mkdocs With Confluence: Find Page ID: PAGE NAME: {page_name}") 507 | name_confl = page_name.replace(" ", "+") 508 | url = self.config["host_url"] + "?title=" + name_confl + "&spaceKey=" + self.config["space"] + "&expand=history" 509 | if self.config["debug"]: 510 | print(f"URL: {url}") 511 | r = self.session.get(url) 512 | r.raise_for_status() 513 | with nostdout(): 514 | response_json = r.json() 515 | if response_json["results"]: 516 | if self.config["debug"]: 517 | print(f"ID: {response_json['results'][0]['id']}") 518 | return response_json["results"][0]["id"] 519 | else: 520 | if self.config["debug"]: 521 | print("PAGE DOES NOT EXIST") 522 | return None 523 | 524 | def add_page(self, page_name, parent_page_id, page_content_in_storage_format): 525 | print(f"INFO - * Mkdocs With Confluence: {page_name} - *NEW PAGE*") 526 | 527 | if self.config["debug"]: 528 | print(f" * Mkdocs With Confluence: Adding Page: PAGE NAME: {page_name}, parent ID: {parent_page_id}") 529 | url = self.config["host_url"] + "/" 530 | if self.config["debug"]: 531 | print(f"URL: {url}") 532 | headers = {"Content-Type": "application/json"} 533 | space = self.config["space"] 534 | data = { 535 | "type": "page", 536 | "title": page_name, 537 | "space": {"key": space}, 538 | "ancestors": [{"id": parent_page_id}], 539 | "body": {"storage": {"value": page_content_in_storage_format, "representation": "storage"}}, 540 | } 541 | if self.config["debug"]: 542 | print(f"DATA: {data}") 543 | if not self.dryrun: 544 | r = self.session.post(url, json=data, headers=headers) 545 | r.raise_for_status() 546 | if r.status_code == 200: 547 | if self.config["debug"]: 548 | print("OK!") 549 | else: 550 | if self.config["debug"]: 551 | print("ERR!") 552 | 553 | def update_page(self, page_name, page_content_in_storage_format): 554 | page_id = self.find_page_id(page_name) 555 | print(f"INFO - * Mkdocs With Confluence: {page_name} - *UPDATE*") 556 | if self.config["debug"]: 557 | print(f" * Mkdocs With Confluence: Update PAGE ID: {page_id}, PAGE NAME: {page_name}") 558 | if page_id: 559 | page_version = self.find_page_version(page_name) 560 | page_version = page_version + 1 561 | url = self.config["host_url"] + "/" + page_id 562 | if self.config["debug"]: 563 | print(f"URL: {url}") 564 | headers = {"Content-Type": "application/json"} 565 | space = self.config["space"] 566 | data = { 567 | "id": page_id, 568 | "title": page_name, 569 | "type": "page", 570 | "space": {"key": space}, 571 | "body": {"storage": {"value": page_content_in_storage_format, "representation": "storage"}}, 572 | "version": {"number": page_version}, 573 | } 574 | 575 | if not self.dryrun: 576 | r = self.session.put(url, json=data, headers=headers) 577 | r.raise_for_status() 578 | if r.status_code == 200: 579 | if self.config["debug"]: 580 | print("OK!") 581 | else: 582 | if self.config["debug"]: 583 | print("ERR!") 584 | else: 585 | if self.config["debug"]: 586 | print("PAGE DOES NOT EXIST YET!") 587 | 588 | def find_page_version(self, page_name): 589 | if self.config["debug"]: 590 | print(f"INFO - * Mkdocs With Confluence: Find PAGE VERSION, PAGE NAME: {page_name}") 591 | name_confl = page_name.replace(" ", "+") 592 | url = self.config["host_url"] + "?title=" + name_confl + "&spaceKey=" + self.config["space"] + "&expand=version" 593 | r = self.session.get(url) 594 | r.raise_for_status() 595 | with nostdout(): 596 | response_json = r.json() 597 | if response_json["results"] is not None: 598 | if self.config["debug"]: 599 | print(f"VERSION: {response_json['results'][0]['version']['number']}") 600 | return response_json["results"][0]["version"]["number"] 601 | else: 602 | if self.config["debug"]: 603 | print("PAGE DOES NOT EXISTS") 604 | return None 605 | 606 | def find_parent_name_of_page(self, name): 607 | if self.config["debug"]: 608 | print(f"INFO - * Mkdocs With Confluence: Find PARENT OF PAGE, PAGE NAME: {name}") 609 | idp = self.find_page_id(name) 610 | url = self.config["host_url"] + "/" + idp + "?expand=ancestors" 611 | 612 | r = self.session.get(url) 613 | r.raise_for_status() 614 | with nostdout(): 615 | response_json = r.json() 616 | if response_json: 617 | if self.config["debug"]: 618 | print(f"PARENT NAME: {response_json['ancestors'][-1]['title']}") 619 | return response_json["ancestors"][-1]["title"] 620 | else: 621 | if self.config["debug"]: 622 | print("PAGE DOES NOT HAVE PARENT") 623 | return None 624 | 625 | def wait_until(self, condition, interval=0.1, timeout=1): 626 | start = time.time() 627 | while not condition and time.time() - start < timeout: 628 | time.sleep(interval) 629 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | mime 3 | mistune 4 | md2cf 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | nose 4 | pytest-cov 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="mkdocs-with-confluence", 5 | version="0.2.7", 6 | description="MkDocs plugin for uploading markdown documentation to Confluence via Confluence REST API", 7 | keywords="mkdocs markdown confluence documentation rest python", 8 | url="https://github.com/pawelsikora/mkdocs-with-confluence/", 9 | author="Pawel Sikora", 10 | author_email="sikor6@gmail.com", 11 | license="MIT", 12 | python_requires=">=3.6", 13 | install_requires=["mkdocs>=1.1", "jinja2", "mistune", "md2cf", "requests"], 14 | packages=find_packages(), 15 | entry_points={"mkdocs.plugins": ["mkdocs-with-confluence = mkdocs_with_confluence.plugin:MkdocsWithConfluence"]}, 16 | ) 17 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import mimetypes 3 | 4 | # ----------------------------------------------------------------------------- 5 | # Globals 6 | 7 | BASE_URL = "/rest/api/content" 8 | SPACE_NAME = "" 9 | USERNAME = "" 10 | PASSWORD = "" 11 | 12 | 13 | def upload_attachment(page_id, filepath): 14 | url = BASE_URL + "/" + page_id + "/child/attachment/" 15 | headers = {"X-Atlassian-Token": "no-check"} # no content-type here! 16 | print(f"URL: {url}") 17 | filename = filepath 18 | 19 | # determine content-type 20 | content_type, encoding = mimetypes.guess_type(filename) 21 | if content_type is None: 22 | content_type = "multipart/form-data" 23 | 24 | # provide content-type explicitly 25 | files = {"file": (filename, open(filename, "rb"), content_type)} 26 | print(f"FILES: {files}") 27 | 28 | auth = (USERNAME, PASSWORD) 29 | r = requests.post(url, headers=headers, files=files, auth=auth) 30 | r.raise_for_status() 31 | 32 | 33 | def find_parent_name_of_page(name): 34 | idp = find_page_id(name) 35 | url = BASE_URL + "/" + idp + "?expand=ancestors" 36 | print(f"URL: {url}") 37 | 38 | auth = (USERNAME, PASSWORD) 39 | r = requests.get(url, auth=auth) 40 | r.raise_for_status() 41 | response_json = r.json() 42 | if response_json: 43 | print(f"ID: {response_json['ancestors'][0]['title']}") 44 | return response_json 45 | else: 46 | print("PAGE DOES NOT EXIST") 47 | return None 48 | 49 | 50 | def find_page_id(name): 51 | name_confl = name.replace(" ", "+") 52 | url = BASE_URL + "?title=" + name_confl + "&spaceKey=" + SPACE_NAME + "&expand=history" 53 | print(f"URL: {url}") 54 | 55 | auth = (USERNAME, PASSWORD) 56 | r = requests.get(url, auth=auth) 57 | r.raise_for_status() 58 | response_json = r.json() 59 | if response_json["results"]: 60 | print(f"ID: {response_json['results']}") 61 | return response_json["results"] 62 | else: 63 | print("PAGE DOES NOT EXIST") 64 | return None 65 | 66 | 67 | def add_page(page_name, parent_page_id): 68 | url = BASE_URL + "/" 69 | print(f"URL: {url}") 70 | headers = {"Content-Type": "application/json"} 71 | auth = (USERNAME, PASSWORD) 72 | data = { 73 | "type": "page", 74 | "title": page_name, 75 | "space": {"key": SPACE_NAME}, 76 | "ancestors": [{"id": parent_page_id}], 77 | "body": {"storage": {"value": "

This is a new page

", "representation": "storage"}}, 78 | } 79 | 80 | r = requests.post(url, json=data, headers=headers, auth=auth) 81 | r.raise_for_status() 82 | print(r.json()) 83 | 84 | 85 | def update_page(page_name): 86 | page_id = find_page_id(page_name) 87 | if page_id: 88 | page_version = find_page_version(page_name) 89 | page_version = page_version + 1 90 | print(f"PAGE ID: {page_id}, PAGE NAME: {page_name}") 91 | url = BASE_URL + "/" + page_id 92 | print(f"URL: {url}") 93 | headers = {"Content-Type": "application/json"} 94 | auth = (USERNAME, PASSWORD) 95 | data = { 96 | "type": "page", 97 | "space": {"key": SPACE_NAME}, 98 | "body": {"storage": {"value": "

Let the dragons out!

", "representation": "storage"}}, 99 | "version": {"number": page_version}, 100 | } 101 | 102 | data["id"] = page_id 103 | data["title"] = page_name 104 | print(data) 105 | 106 | r = requests.put(url, json=data, headers=headers, auth=auth) 107 | r.raise_for_status() 108 | print(r.json()) 109 | else: 110 | print("PAGE DOES NOT EXIST. CREATING WITH DEFAULT BODY") 111 | add_page(page_name) 112 | 113 | 114 | def find_page_version(name): 115 | name_confl = name.replace(" ", "+") 116 | url = BASE_URL + "?title=" + name_confl + "&spaceKey=" + SPACE_NAME + "&expand=version" 117 | 118 | print(f"URL: {url}") 119 | 120 | auth = (USERNAME, PASSWORD) 121 | r = requests.get(url, auth=auth) 122 | r.raise_for_status() 123 | response_json = r.json() 124 | if response_json["results"]: 125 | print(f"VERSION: {response_json['results'][0]['version']['number']}") 126 | return response_json["results"][0]["version"]["number"] 127 | else: 128 | print("PAGE DOES NOT EXISTS") 129 | return None 130 | 131 | 132 | # add_page() 133 | # update_page("Test Page") 134 | # find_page_version("Test Page") 135 | # find_parent_name_of_page("Test Parent Page") 136 | # find_page_id("Test Page") 137 | # upload_attachment() 138 | --------------------------------------------------------------------------------