From 98c362133b2e740c0a32f5a4fa0bdcff3848de35 Mon Sep 17 00:00:00 2001 From: Andreas Ulm Date: Wed, 21 Sep 2022 23:24:43 +0200 Subject: [PATCH] implement placeholder replacement * implement argument "--replace-placeholders-file" to define YAML file which configures replacements for placeholder strings in MD files Signed-off-by: Andreas Ulm --- README.md | 50 ++++++++++++++++++++++- md2cf/__main__.py | 27 +++++++++++++ md2cf/api.py | 77 ++++++++++++++++++++++++++++++++++-- md2cf/confluence_renderer.py | 5 ++- sample_placeholders.yaml | 35 ++++++++++++++++ 5 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 sample_placeholders.yaml diff --git a/README.md b/README.md index 3f500b0..2a89a2f 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ usage: md2cf [-h] [-o HOST] [-u USERNAME] [-p PASSWORD] [--token TOKEN] [--insecure] [-s SPACE] [-a PARENT_TITLE | -A PARENT_ID] [-t TITLE] [-m MESSAGE] [-i PAGE_ID] [--prefix PREFIX] [--strip-top-header] [--remove-text-newlines] - [--replace-all-labels] [--preface-markdown [PREFACE_MARKDOWN] | - --preface-file PREFACE_FILE] + [--replace-all-labels] + [--replace-placeholders-file REPLACE_PLACEHOLDERS_FILE] + [--preface-markdown [PREFACE_MARKDOWN] | --preface-file PREFACE_FILE] [--postface-markdown [POSTFACE_MARKDOWN] | --postface-file POSTFACE_FILE] [--collapse-single-pages] [--no-gitignore] [--beautify-folders | --use-pages-file] @@ -119,6 +120,51 @@ This is a document with hardcoded newlines in its paragraphs. It's not that nice to read. ``` +### Repalcing text before upload + +The `--replace-placeholders-file` allows to specify a YAML formated configuration file that defines strings or regular expressions to be replaces by Confluence macros or other strings. +Example definitions can be found in [sample_placeholders.yaml](https://github.com/iamjackg/md2cf/blob/master/sample_placeholders.yaml). + +This configuration file supports the following replacements: +* replace string with Confluence macro: + + ```yaml + 'string-to-be-replaced': + type: 'macro' + name: 'name-of-confluence-macro' + parameters: + 'macro-parameter-name1': 'macro-parameter-value1' + 'macro-parameter-name2': 'macro-parameter-value2' + ``` + +* repalce string with an other string: + + ```yaml + 'long string to be replaced': + type: 'static' + text: 'new string' + ``` + +* replace regular expression with Confluence macro: + + ```yaml + # replace hugo-hint-macro with Confluence info-macro + '(?ms){{% hint [^%=]*(?!{{% /hint %}})info.*? %}}(.*?){{% /hint %}}': + type: 'macro' + name: 'info' + parameters: + icon: 'true' + # additions extend the macro definition as required by some Confluence macros + additions: + # \1 is replaced with group matched in regex + - '\1' + ``` + +The search string is always processed using [python-re](https://docs.python.org/3/library/re.html). +To test your regular expression you can use [Pythex](https://pythex.org/). +For syntax of Confluence macros you can check XHTML layout in [Confluence 5.7](https://confluence.atlassian.com/display/CONF57/Macros) documentation. +For a list of available Confluence macros see [latest Confluence macro list](https://confluence.atlassian.com/doc/macros-139387.html). + ### Adding a preface and/or postface The `--preface-markdown`, `--preface-file`, `--postface-markdown`, and `--postface-file` commands allow you to add some text at the top or bottom of each page. This is useful if you're mirroring documentation to Confluence and want people to know that it's going to be overwritten in an automated fashion. diff --git a/md2cf/__main__.py b/md2cf/__main__.py index 0dd2095..4cf19df 100644 --- a/md2cf/__main__.py +++ b/md2cf/__main__.py @@ -5,6 +5,7 @@ import pprint import re import sys +import yaml from collections import Counter from pathlib import Path from typing import List @@ -105,6 +106,12 @@ def get_parser(): action="store_true", help="replace all labels instead of only adding new ones", ) + page_group.add_argument( + "--replace-placeholders-file", + type=Path, + help="provide a YAML or JSON file with mapping of placeholders " + "in your markdown files and their replacements, e.g. Confluence Macro", + ) preface_group = page_group.add_mutually_exclusive_group() preface_group.add_argument( @@ -383,12 +390,32 @@ def main(): if args.password is None and args.token is None: args.password = getpass.getpass() + if args.replace_placeholders_file: + try: + placeholders = yaml.load( + args.replace_placeholders_file.open(), Loader=yaml.SafeLoader + ) + except yaml.YAMLError as error: + sys.stderr.write( + "Failed to load placeholders from file " + f"'{args.replace_placeholders_file}' due to\n{error}" + ) + exit(1) + if not isinstance(placeholders, dict): + sys.stderr.write( + "The content of given placeholders file " + f"({args.replace_placeholders_file.open}) is not a valid.\n" + "Please check github-link for the syntax." + ) + exit(1) + confluence = api.MinimalConfluence( host=args.host, username=args.username, password=args.password, token=args.token, verify=not args.insecure, + placeholders=placeholders, ) if (args.title or args.page_id) and ( diff --git a/md2cf/api.py b/md2cf/api.py index ed5b938..5f5022e 100644 --- a/md2cf/api.py +++ b/md2cf/api.py @@ -1,10 +1,21 @@ import tortilla from requests.auth import HTTPBasicAuth import requests.packages +import html +import re +from md2cf.confluence_renderer import ConfluenceRenderer, ConfluenceTag class MinimalConfluence: - def __init__(self, host, username=None, password=None, token=None, verify=True): + def __init__( + self, + host, + username=None, + password=None, + token=None, + verify=True, + placeholders=None, + ): if token is not None: self.api = tortilla.wrap( host, @@ -27,6 +38,8 @@ def __init__(self, host, username=None, password=None, token=None, verify=True): requests.packages.urllib3.exceptions.InsecureRequestWarning ) + self.placeholders = placeholders or {} + def get_page( self, title=None, space_key=None, page_id=None, additional_expansions=None ): @@ -66,6 +79,54 @@ def get_page( else: raise ValueError("At least one of title or page_id must not be None") + def generate_macro(self, name, parameters=None, additions=None): + """ + generate and return Confluence macro syntax + + based on https://community.atlassian.com/t5/Confluence-questions/Can-I-insert-the-History-macro-through-REST-API-call/qaq-p/1038703#M173031 + + Args: + name (str): short name of the Confluence macro + params (dict): optional. Parameters to configure the macro + additions (list of str): optional. Text to be added to the macro + + Returns: + String with Confluence XHTML syntax of macro + """ + parameters = parameters or {} + additions = additions or [] + cf_render = ConfluenceRenderer() + macro = cf_render.structured_macro(name) + for key, value in parameters.items(): + macro.append(cf_render.parameter(name=key, value=value)) + macro.append("".join(additions)) + macro_text = macro.render() + return macro_text + + def process_body(self, body): + """ + apply placeholder replacements to body + + Args: + body (str): generated body + + Returns: + String body with replacements + """ + for match, config in self.placeholders.items(): + replace = "" + match = html.escape(match) + if config.get("type") == "macro": + replace = self.generate_macro( + config.get("name"), + config.get("parameters"), + config.get("additions"), + ) + elif config.get("type") == "static": + replace = config.get("text") + body = re.sub(match, replace, body) + return body + def create_page( self, space, title, body, parent_id=None, update_message=None, labels=None ): @@ -88,7 +149,12 @@ def create_page( "title": title, "type": "page", "space": {"key": space}, - "body": {"storage": {"value": body, "representation": "storage"}}, + "body": { + "storage": { + "value": self.process_body(body), + "representation": "storage", + } + }, } if parent_id is not None: @@ -113,7 +179,12 @@ def update_page(self, page, body, parent_id=None, update_message=None, labels=No }, "title": page.title, "type": "page", - "body": {"storage": {"value": body, "representation": "storage"}}, + "body": { + "storage": { + "value": self.process_body(body), + "representation": "storage", + } + }, } if parent_id is not None: diff --git a/md2cf/confluence_renderer.py b/md2cf/confluence_renderer.py index 44be072..b8e1909 100644 --- a/md2cf/confluence_renderer.py +++ b/md2cf/confluence_renderer.py @@ -35,7 +35,10 @@ def render(self): ) if namespaced_attribs else "", - "".join([child.render() for child in self.children]), + "".join([child.render() + if isinstance(child, ConfluenceTag) + else child + for child in self.children]), "".format(self.text) if self.cdata else self.text, namespaced_name, ) diff --git a/sample_placeholders.yaml b/sample_placeholders.yaml new file mode 100644 index 0000000..9c56f8f --- /dev/null +++ b/sample_placeholders.yaml @@ -0,0 +1,35 @@ +--- +# yamllint disable rule:line-length +# for regex you can test using: +# https://pythex.org/ +# for macro syntax see: +# https://confluence.atlassian.com/doc/macros-139387.html +# https://confluence.atlassian.com/display/CONF57/Macros +# replace table-of-content macro of hugo theme geekdocs with Confluence macro. +'{{< toc >}}': + type: 'macro' + name: 'toc' + parameters: + printable: 'true' + style: 'disc' + maxLevel: '4' + minLevel: '1' + class: 'bigpink' + type: 'list' + outline: 'true' + include: '.*' +# replace placeholder with empty text +'long string to be replaced as example': + type: 'static' + text: '' +# replace hint-macro info with info-macro +'(?ms){{% hint [^%=]*(?!{{% /hint %}})info.*? %}}(.*?){{% /hint %}}': + type: 'macro' + name: 'info' + parameters: + icon: 'true' + title: '' + # additions extend the macro definition as required by some Confluence macros + additions: + # \1 is replaced with group matched in regex + - '\1'