-
Notifications
You must be signed in to change notification settings - Fork 26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Mermaid and Kroki support #41
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -80,6 +80,26 @@ def main() -> None: | |
const=None, | ||
help="Do not add 'generated by a tool' prompt to pages.", | ||
) | ||
parser.add_argument( | ||
"--render-mermaid", | ||
dest="render_mermaid", | ||
action="store_true", | ||
default=True, | ||
help="Render Mermaid diagrams as image files and add as attachments.", | ||
) | ||
parser.add_argument( | ||
"--no-render-mermaid", | ||
dest="render_mermaid", | ||
action="store_false", | ||
help="Inline mermaid diagram in the confluence page.", | ||
) | ||
parser.add_argument( | ||
"--render-mermaid-format", | ||
dest="kroki_output_format", | ||
choices=["png", "svg"], | ||
default="png", | ||
help="Format for rendering mermaid diagrams (default: 'png').", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you get Confluence work with SVG? I have had trouble embedding SVG in wiki pages, which is why md2conf is currently referencing a PNG image instead of an SVG image when both formats are found. |
||
) | ||
parser.add_argument( | ||
"--ignore-invalid-url", | ||
action="store_true", | ||
|
@@ -109,6 +129,8 @@ def main() -> None: | |
ignore_invalid_url=args.ignore_invalid_url, | ||
generated_by=args.generated_by, | ||
root_page_id=args.root_page, | ||
render_mermaid=args.render_mermaid, | ||
kroki_output_format=args.kroki_output_format, | ||
) | ||
properties = ConfluenceProperties( | ||
args.domain, args.path, args.username, args.apikey, args.space | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
import io | ||
import json | ||
import logging | ||
import mimetypes | ||
|
@@ -61,7 +62,6 @@ def removeprefix(string: str, prefix: str) -> str: | |
else: | ||
return string | ||
|
||
|
||
LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
|
@@ -186,24 +186,30 @@ def upload_attachment( | |
page_id: str, | ||
attachment_path: Path, | ||
attachment_name: str, | ||
raw_data: Optional[bytes] = None, | ||
comment: Optional[str] = None, | ||
*, | ||
space_key: Optional[str] = None, | ||
force: bool = False, | ||
) -> None: | ||
content_type = mimetypes.guess_type(attachment_path, strict=True)[0] | ||
|
||
if not attachment_path.is_file(): | ||
if not raw_data and not attachment_path.is_file(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
raise ConfluenceError(f"file not found: {attachment_path}") | ||
|
||
try: | ||
attachment = self.get_attachment_by_name( | ||
page_id, attachment_name, space_key=space_key | ||
) | ||
|
||
if not force and attachment.file_size == attachment_path.stat().st_size: | ||
LOGGER.info("Up-to-date attachment: %s", attachment_name) | ||
return | ||
if not raw_data: | ||
if not force and attachment.file_size == attachment_path.stat().st_size: | ||
LOGGER.info("Up-to-date attachment: %s", attachment_name) | ||
return | ||
else: | ||
if not force and attachment.file_size == len(raw_data): | ||
LOGGER.info("Up-to-date embedded image: %s", attachment_name) | ||
return | ||
|
||
id = removeprefix(attachment.id, "att") | ||
path = f"/content/{page_id}/child/attachment/{id}/data" | ||
|
@@ -213,17 +219,36 @@ def upload_attachment( | |
|
||
url = self._build_url(path) | ||
|
||
with open(attachment_path, "rb") as attachment_file: | ||
if not raw_data: | ||
with open(attachment_path, "rb") as attachment_file: | ||
file_to_upload = { | ||
"comment": comment, | ||
"file": ( | ||
attachment_name, # will truncate path component | ||
attachment_file, | ||
content_type, | ||
{"Expires": "0"}, | ||
), | ||
} | ||
LOGGER.info("Uploading attachment: %s", attachment_name) | ||
response = self.session.post( | ||
url, | ||
files=file_to_upload, # type: ignore | ||
headers={"X-Atlassian-Token": "no-check"}, | ||
) | ||
Comment on lines
+224
to
+238
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Much of this looks like a repetition of the block below. Perhaps we might want to encapsulate this into a function? |
||
else: | ||
LOGGER.info("Uploading raw data: %s", attachment_name) | ||
|
||
file_to_upload = { | ||
"comment": comment, | ||
"file": ( | ||
attachment_name, # will truncate path component | ||
attachment_file, | ||
io.BytesIO(raw_data), # type: ignore | ||
content_type, | ||
{"Expires": "0"}, | ||
), | ||
} | ||
LOGGER.info("Uploading attachment: %s", attachment_name) | ||
|
||
response = self.session.post( | ||
url, | ||
files=file_to_upload, # type: ignore | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,30 @@ | ||
# mypy: disable-error-code="dict-item" | ||
|
||
import hashlib | ||
import importlib.resources as resources | ||
import logging | ||
import os.path | ||
import pathlib | ||
import re | ||
import sys | ||
import uuid | ||
from dataclasses import dataclass | ||
from typing import Dict, List, Optional, Tuple | ||
from typing import Dict, List, Optional, Tuple, Literal | ||
from urllib.parse import ParseResult, urlparse, urlunparse | ||
|
||
import lxml.etree as ET | ||
import markdown | ||
from lxml.builder import ElementMaker | ||
|
||
from md2conf import kroki | ||
|
||
namespaces = { | ||
"ac": "http://atlassian.com/content", | ||
"ri": "http://atlassian.com/resource/identifier", | ||
} | ||
for key, value in namespaces.items(): | ||
ET.register_namespace(key, value) | ||
|
||
|
||
HTML = ElementMaker() | ||
AC = ElementMaker(namespace=namespaces["ac"]) | ||
RI = ElementMaker(namespace=namespaces["ri"]) | ||
|
@@ -142,6 +145,7 @@ def elements_from_strings(items: List[str]) -> ET._Element: | |
"kotlin", | ||
"livescript", | ||
"lua", | ||
"mermaid", | ||
"mathematica", | ||
"matlab", | ||
"objectivec", | ||
|
@@ -222,6 +226,8 @@ class ConfluenceConverterOptions: | |
""" | ||
|
||
ignore_invalid_url: bool = False | ||
render_mermaid: bool = False | ||
kroki_output_format: Literal['png', 'svg'] = 'png' | ||
|
||
|
||
class ConfluenceStorageFormatConverter(NodeVisitor): | ||
|
@@ -232,6 +238,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor): | |
base_path: pathlib.Path | ||
links: List[str] | ||
images: List[str] | ||
embedded_images: Dict[str, bytes] | ||
page_metadata: Dict[pathlib.Path, ConfluencePageMetadata] | ||
|
||
def __init__( | ||
|
@@ -246,6 +253,7 @@ def __init__( | |
self.base_path = path.parent | ||
self.links = [] | ||
self.images = [] | ||
self.embedded_images = {} | ||
self.page_metadata = page_metadata | ||
|
||
def _transform_link(self, anchor: ET._Element) -> None: | ||
|
@@ -317,8 +325,8 @@ def _transform_image(self, image: ET._Element) -> ET._Element: | |
if path and is_relative_url(path): | ||
relative_path = pathlib.Path(path) | ||
if ( | ||
relative_path.suffix == ".svg" | ||
and (self.base_path / relative_path.with_suffix(".png")).exists() | ||
relative_path.suffix == ".svg" | ||
and (self.base_path / relative_path.with_suffix(".png")).exists() | ||
): | ||
path = str(relative_path.with_suffix(".png")) | ||
|
||
|
@@ -349,19 +357,57 @@ def _transform_block(self, code: ET._Element) -> ET._Element: | |
language = "none" | ||
content: str = code.text or "" | ||
content = content.rstrip() | ||
return AC( | ||
"structured-macro", | ||
{ | ||
ET.QName(namespaces["ac"], "name"): "code", | ||
ET.QName(namespaces["ac"], "schema-version"): "1", | ||
}, | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "theme"}, "Midnight"), | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "language"}, language), | ||
AC( | ||
"parameter", {ET.QName(namespaces["ac"], "name"): "linenumbers"}, "true" | ||
), | ||
AC("plain-text-body", ET.CDATA(content)), | ||
) | ||
|
||
if language == "mermaid": | ||
if self.options.render_mermaid: | ||
image_data = kroki.render(content, output_format=self.options.kroki_output_format) | ||
image_hash = hashlib.md5(image_data).hexdigest() | ||
image_filename = attachment_name(f"embedded/{image_hash}.{self.options.kroki_output_format}") | ||
self.embedded_images[image_filename] = image_data | ||
return AC( | ||
"image", | ||
{ | ||
ET.QName(namespaces["ac"], "align"): "center", | ||
ET.QName(namespaces["ac"], "layout"): "center", | ||
}, | ||
RI( | ||
"attachment", | ||
{ET.QName(namespaces["ri"], "filename"): image_filename}, | ||
), | ||
) | ||
else: | ||
local_id = str(uuid.uuid4()) | ||
macro_id = str(uuid.uuid4()) | ||
return AC( | ||
"structured-macro", | ||
{ | ||
ET.QName(namespaces["ac"], "name"): "macro-diagram", | ||
ET.QName(namespaces["ac"], "schema-version"): "1", | ||
ET.QName(namespaces["ac"], "data-layout"): "default", | ||
ET.QName(namespaces["ac"], "local-id"): local_id, | ||
ET.QName(namespaces["ac"], "macro-id"): macro_id, | ||
}, | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "sourceType"}, "MacroBody"), | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "attachmentPageId"}), | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "syntax"}, "Mermaid"), | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "attachmentId"}), | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "url"}), | ||
AC("plain-text-body", ET.CDATA(content)), | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you put a |
||
else: | ||
return AC( | ||
"structured-macro", | ||
{ | ||
ET.QName(namespaces["ac"], "name"): "code", | ||
ET.QName(namespaces["ac"], "schema-version"): "1", | ||
}, | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "theme"}, "Midnight"), | ||
AC("parameter", {ET.QName(namespaces["ac"], "name"): "language"}, language), | ||
AC( | ||
"parameter", {ET.QName(namespaces["ac"], "name"): "linenumbers"}, "true" | ||
), | ||
AC("plain-text-body", ET.CDATA(content)), | ||
) | ||
Comment on lines
+398
to
+410
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you remove the nesting in an |
||
|
||
def _transform_toc(self, code: ET._Element) -> ET._Element: | ||
return AC( | ||
|
@@ -567,6 +613,8 @@ class ConfluenceDocumentOptions: | |
ignore_invalid_url: bool = False | ||
generated_by: Optional[str] = "This page has been generated with a tool." | ||
root_page_id: Optional[str] = None | ||
render_mermaid: bool = False | ||
kroki_output_format: str = 'png' | ||
|
||
|
||
class ConfluenceDocument: | ||
|
@@ -624,14 +672,17 @@ def __init__( | |
|
||
converter = ConfluenceStorageFormatConverter( | ||
ConfluenceConverterOptions( | ||
ignore_invalid_url=self.options.ignore_invalid_url | ||
ignore_invalid_url=self.options.ignore_invalid_url, | ||
render_mermaid=self.options.render_mermaid, | ||
kroki_output_format=self.options.kroki_output_format, | ||
), | ||
path, | ||
page_metadata, | ||
) | ||
converter.visit(self.root) | ||
self.links = converter.links | ||
self.images = converter.images | ||
self.embedded_images = converter.embedded_images | ||
|
||
def xhtml(self) -> str: | ||
return _content_to_string(self.root) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import base64 | ||
from typing import Literal | ||
|
||
import requests | ||
import zlib | ||
|
||
import os | ||
|
||
|
||
def get_kroki_server() -> str: | ||
return os.getenv('KROKI_SERVER_URL', 'https://kroki.io') | ||
|
||
|
||
def render(source: str, output_format: Literal['png', 'svg'] = 'png') -> bytes: | ||
compressed_source = zlib.compress(source.encode('utf-8'), 9) | ||
encoded_source = base64.urlsafe_b64encode(compressed_source).decode('ascii') | ||
kroki_server = get_kroki_server() | ||
kroki_url = f"{kroki_server}/mermaid/{output_format}/{encoded_source}" | ||
response = requests.get(kroki_url) | ||
|
||
if response.status_code == 200: | ||
if output_format == 'png': | ||
return response.content | ||
else: | ||
return response.text.encode('utf-8') | ||
else: | ||
raise Exception(f"Failed to render Mermaid diagram. Status code: {response.status_code}") | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would talk about Mermaid and Kroki in a separate section (e.g. Embedding Mermaid diagrams). This would keep setup instructions simple for basic use, and advanced users could explore additional features as they read along.