adf-parse 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adf_parse/__init__.py +18 -0
- adf_parse/enums.py +53 -0
- adf_parse/models.py +13 -0
- adf_parse/parser.py +150 -0
- adf_parse-0.1.0.dist-info/METADATA +68 -0
- adf_parse-0.1.0.dist-info/RECORD +7 -0
- adf_parse-0.1.0.dist-info/WHEEL +4 -0
adf_parse/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""adf-parse: convert Atlassian Document Format (ADF) to plain text / Markdown."""
|
|
2
|
+
|
|
3
|
+
from .enums import ADFMarkType, ADFNodeType, MediaRefType
|
|
4
|
+
from .models import MediaRef, NodeCollection
|
|
5
|
+
from .parser import create_md_doc, parse_content, parse_doc
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ADFMarkType",
|
|
11
|
+
"ADFNodeType",
|
|
12
|
+
"MediaRefType",
|
|
13
|
+
"MediaRef",
|
|
14
|
+
"NodeCollection",
|
|
15
|
+
"create_md_doc",
|
|
16
|
+
"parse_content",
|
|
17
|
+
"parse_doc",
|
|
18
|
+
]
|
adf_parse/enums.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
class ADFNodeType(StrEnum):
|
|
4
|
+
"""Node types in Atlassian Document Format.
|
|
5
|
+
Split by how the parser handles them."""
|
|
6
|
+
|
|
7
|
+
# --- Root ---
|
|
8
|
+
DOC = "doc"
|
|
9
|
+
|
|
10
|
+
# The only node with a "text" key
|
|
11
|
+
TEXT = "text"
|
|
12
|
+
|
|
13
|
+
# --- Containers you format specially ---
|
|
14
|
+
PARAGRAPH = "paragraph" # join inner text into one line
|
|
15
|
+
HEADING = "heading" # prefix with # based on attrs.level
|
|
16
|
+
BULLET_LIST = "bulletList" # children are listItems, prefix with -
|
|
17
|
+
ORDERED_LIST = "orderedList" # children are listItems, prefix with 1. 2. 3.
|
|
18
|
+
LIST_ITEM = "listItem" # wrapper inside lists, just recurse
|
|
19
|
+
CODE_BLOCK = "codeBlock" # grab attrs.language, wrap in ```
|
|
20
|
+
TABLE = "table" # contains tableRows
|
|
21
|
+
TABLE_ROW = "tableRow" # contains cells, join with |
|
|
22
|
+
TABLE_HEADER = "tableHeader" # like a cell but bold/header
|
|
23
|
+
TABLE_CELL = "tableCell" # grab text, separate with |
|
|
24
|
+
BLOCKQUOTE = "blockquote" # prefix lines with >
|
|
25
|
+
|
|
26
|
+
# --- Containers you just recurse into (treat as blob) ---
|
|
27
|
+
PANEL = "panel" # attrs.panelType = warning/info/error/success/note
|
|
28
|
+
EXPAND = "expand" # attrs.title = collapsible section title
|
|
29
|
+
TASK_LIST = "taskList" # contains taskItems
|
|
30
|
+
TASK_ITEM = "taskItem" # attrs.state = TODO/DONE
|
|
31
|
+
|
|
32
|
+
# --- Leaves: no content, no text. Info in attrs ---
|
|
33
|
+
HARD_BREAK = "hardBreak" # just insert \n
|
|
34
|
+
RULE = "rule" # horizontal line, insert \n---\n or skip
|
|
35
|
+
MENTION = "mention" # attrs.text = "@John Smith"
|
|
36
|
+
STATUS = "status" # attrs.text = "IN PROGRESS", attrs.color
|
|
37
|
+
DATE = "date" # attrs.timestamp (unix millis string)
|
|
38
|
+
INLINE_CARD = "inlineCard" # attrs.url = smart link URL
|
|
39
|
+
MEDIA_SINGLE = "mediaSingle" # container for media node
|
|
40
|
+
MEDIA = "media" # attrs.id, attrs.type, attrs.collection
|
|
41
|
+
MEDIA_INLINE = "mediaInline" # Inline Media
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ADFMarkType(StrEnum):
|
|
45
|
+
"""Mark types you actually care about during parsing."""
|
|
46
|
+
|
|
47
|
+
LINK = "link"
|
|
48
|
+
CODE = "code"
|
|
49
|
+
|
|
50
|
+
class MediaRefType(StrEnum):
|
|
51
|
+
URL = "url"
|
|
52
|
+
ID = "id"
|
|
53
|
+
|
adf_parse/models.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass ,field
|
|
2
|
+
from .enums import MediaRefType
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class MediaRef:
|
|
6
|
+
ref_type:MediaRefType
|
|
7
|
+
ref :str
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class NodeCollection:
|
|
11
|
+
collected_text:list = field(default_factory=list)
|
|
12
|
+
links:list[tuple[str,str]] = field(default_factory=list)
|
|
13
|
+
media_ref:list[MediaRef] = field(default_factory=list)
|
adf_parse/parser.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
|
|
2
|
+
from datetime import datetime ,timezone
|
|
3
|
+
from .enums import ADFMarkType,ADFNodeType,MediaRefType
|
|
4
|
+
from .models import MediaRef, NodeCollection
|
|
5
|
+
|
|
6
|
+
def format_list_item(prefix: str, item_text: str) -> str:
|
|
7
|
+
# first line gets the prefix, the rest (nested lists etc.) get indented under it
|
|
8
|
+
lines = item_text.split("\n")
|
|
9
|
+
pad = " " * len(prefix)
|
|
10
|
+
return "\n".join([prefix + lines[0]] + [pad + line for line in lines[1:]])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_content(content: list[dict]) -> NodeCollection:
|
|
14
|
+
collection = NodeCollection()
|
|
15
|
+
|
|
16
|
+
for node in content:
|
|
17
|
+
node_type = node["type"]
|
|
18
|
+
attrs = node.get("attrs") or {}
|
|
19
|
+
|
|
20
|
+
parts: list[str] = []
|
|
21
|
+
if node.get("content"):
|
|
22
|
+
resp = parse_content(node["content"])
|
|
23
|
+
collection.links.extend(resp.links)
|
|
24
|
+
collection.media_ref.extend(resp.media_ref)
|
|
25
|
+
parts = resp.collected_text
|
|
26
|
+
|
|
27
|
+
# text (the only node with a "text" key)
|
|
28
|
+
if node_type == ADFNodeType.TEXT:
|
|
29
|
+
text = node["text"]
|
|
30
|
+
for mark in node.get("marks", []):
|
|
31
|
+
if mark["type"] == ADFMarkType.LINK:
|
|
32
|
+
ref = mark["attrs"]["href"]
|
|
33
|
+
collection.links.append((text,ref))
|
|
34
|
+
text = f"{text} ({ref})"
|
|
35
|
+
elif mark["type"] == ADFMarkType.CODE:
|
|
36
|
+
text = f"`{text}`"
|
|
37
|
+
collection.collected_text.append(text)
|
|
38
|
+
|
|
39
|
+
# leaves: no content, info is in attrs
|
|
40
|
+
elif node_type == ADFNodeType.HARD_BREAK:
|
|
41
|
+
collection.collected_text.append("\n")
|
|
42
|
+
|
|
43
|
+
elif node_type == ADFNodeType.MENTION:
|
|
44
|
+
collection.collected_text.append(attrs.get("text") or f"@{attrs.get('id', 'unknown')}")
|
|
45
|
+
|
|
46
|
+
elif node_type == ADFNodeType.INLINE_CARD:
|
|
47
|
+
url = attrs.get("url", "")
|
|
48
|
+
if url:
|
|
49
|
+
collection.links.append((url,url))
|
|
50
|
+
collection.collected_text.append(url)
|
|
51
|
+
|
|
52
|
+
# ---- containers ----
|
|
53
|
+
elif node_type == ADFNodeType.HEADING:
|
|
54
|
+
level = int(attrs.get("level", 1))
|
|
55
|
+
collection.collected_text.append(f"{'#' * level} {''.join(parts)}")
|
|
56
|
+
|
|
57
|
+
elif node_type == ADFNodeType.PARAGRAPH:
|
|
58
|
+
collection.collected_text.append("".join(parts)) # empty paragraph -> "" -> blank line
|
|
59
|
+
|
|
60
|
+
elif node_type == ADFNodeType.LIST_ITEM:
|
|
61
|
+
collection.collected_text.append("\n".join(parts))
|
|
62
|
+
|
|
63
|
+
elif node_type == ADFNodeType.BULLET_LIST:
|
|
64
|
+
items = [format_list_item("- ", item) for item in parts]
|
|
65
|
+
collection.collected_text.append("\n".join(items))
|
|
66
|
+
|
|
67
|
+
elif node_type == ADFNodeType.ORDERED_LIST:
|
|
68
|
+
start = int(attrs.get("order", 1))
|
|
69
|
+
items = [format_list_item(f"{start + i}. ", item) for i, item in enumerate(parts)]
|
|
70
|
+
collection.collected_text.append("\n".join(items))
|
|
71
|
+
|
|
72
|
+
elif node_type == ADFNodeType.CODE_BLOCK:
|
|
73
|
+
language = attrs.get("language", "")
|
|
74
|
+
collection.collected_text.append(f"```{language}\n{''.join(parts)}\n```")
|
|
75
|
+
|
|
76
|
+
elif node_type == ADFNodeType.PANEL:
|
|
77
|
+
panel_type = attrs.get("panelType", "info")
|
|
78
|
+
collection.collected_text.append(f"[{panel_type}] " + "\n".join(parts))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# leaves ----
|
|
82
|
+
elif node_type == ADFNodeType.RULE:
|
|
83
|
+
collection.collected_text.append("---")
|
|
84
|
+
|
|
85
|
+
elif node_type == ADFNodeType.STATUS:
|
|
86
|
+
if attrs.get("text"):
|
|
87
|
+
collection.collected_text.append(f"[status: {attrs['text']}]")
|
|
88
|
+
|
|
89
|
+
elif node_type == ADFNodeType.DATE:
|
|
90
|
+
try:
|
|
91
|
+
ts = int(attrs["timestamp"]) / 1000
|
|
92
|
+
collection.collected_text.append(datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d"))
|
|
93
|
+
except (KeyError, ValueError, TypeError):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# containers
|
|
97
|
+
elif node_type == ADFNodeType.BLOCKQUOTE:
|
|
98
|
+
lines = "\n".join(parts).split("\n")
|
|
99
|
+
collection.collected_text.append("\n".join(f"> {line}" for line in lines))
|
|
100
|
+
|
|
101
|
+
elif node_type == ADFNodeType.EXPAND:
|
|
102
|
+
title = attrs.get("title") or "details"
|
|
103
|
+
collection.collected_text.append(f"[expand: {title}]\n" + "\n".join(parts))
|
|
104
|
+
|
|
105
|
+
elif node_type == ADFNodeType.TASK_ITEM:
|
|
106
|
+
box = "[x]" if attrs.get("state") == "DONE" else "[ ]"
|
|
107
|
+
collection.collected_text.append(f"- {box} {''.join(parts)}")
|
|
108
|
+
|
|
109
|
+
elif node_type == ADFNodeType.TASK_LIST:
|
|
110
|
+
collection.collected_text.append("\n".join(parts))
|
|
111
|
+
|
|
112
|
+
# media
|
|
113
|
+
elif node_type in (ADFNodeType.MEDIA, ADFNodeType.MEDIA_INLINE):
|
|
114
|
+
ref_type = MediaRefType.URL if attrs.get("url") else MediaRefType.ID
|
|
115
|
+
ref:str = attrs.get(ref_type.value,"")
|
|
116
|
+
if ref:
|
|
117
|
+
collection.media_ref.append(MediaRef(ref_type=ref_type,ref=ref))
|
|
118
|
+
|
|
119
|
+
# ---- table ----
|
|
120
|
+
elif node_type in (ADFNodeType.TABLE_HEADER, ADFNodeType.TABLE_CELL):
|
|
121
|
+
cell = " ".join(parts).replace("\n", " ").replace("|", "\\|") # keep each cell on one line
|
|
122
|
+
collection.collected_text.append(cell)
|
|
123
|
+
|
|
124
|
+
elif node_type == ADFNodeType.TABLE_ROW:
|
|
125
|
+
collection.collected_text.append("| " + " | ".join(parts) + " |")
|
|
126
|
+
|
|
127
|
+
elif node_type == ADFNodeType.TABLE:
|
|
128
|
+
rows = list(parts)
|
|
129
|
+
first_row_cells = node["content"][0].get("content", []) if node.get("content") else []
|
|
130
|
+
is_header = bool(first_row_cells) and all(
|
|
131
|
+
c["type"] == ADFNodeType.TABLE_HEADER for c in first_row_cells
|
|
132
|
+
)
|
|
133
|
+
if is_header:
|
|
134
|
+
rows.insert(1, "| " + " | ".join(["---"] * len(first_row_cells)) + " |")
|
|
135
|
+
collection.collected_text.append("\n".join(rows))
|
|
136
|
+
|
|
137
|
+
elif parts:
|
|
138
|
+
collection.collected_text.append("\n".join(parts))
|
|
139
|
+
|
|
140
|
+
return collection
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_doc(adf: dict) -> NodeCollection:
|
|
144
|
+
"""Parse a full ADF document and return the collected text, links and media refs."""
|
|
145
|
+
return parse_content(adf.get("content") or [])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_md_doc(adf: dict) -> str:
|
|
149
|
+
"""Render a full ADF document as a Markdown string."""
|
|
150
|
+
return "\n".join(parse_doc(adf).collected_text)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adf-parse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Atlassian Document Format (ADF) from Jira/Confluence into plain text or Markdown
|
|
5
|
+
Keywords: adf,atlassian,jira,confluence,markdown,parser
|
|
6
|
+
Author: Abhishek Hazra
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Project-URL: Homepage, https://github.com/hazra1991/adf-parse
|
|
18
|
+
Project-URL: Repository, https://github.com/hazra1991/adf-parse
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# adf-parse
|
|
22
|
+
|
|
23
|
+
Convert [Atlassian Document Format (ADF)](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) — the JSON format used by Jira and Confluence — into plain text / Markdown.
|
|
24
|
+
|
|
25
|
+
Zero dependencies. Supports paragraphs, headings, bullet/ordered lists (with nesting), code blocks, tables (with header detection), blockquotes, panels, expands, task lists, mentions, dates, statuses, inline cards, hard breaks, rules, and media references.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install adf-parse
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.11+.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from adf_parse import create_md_doc, parse_doc
|
|
39
|
+
|
|
40
|
+
adf = {
|
|
41
|
+
"type": "doc",
|
|
42
|
+
"version": 1,
|
|
43
|
+
"content": [
|
|
44
|
+
{"type": "heading", "attrs": {"level": 2},
|
|
45
|
+
"content": [{"type": "text", "text": "Release notes"}]},
|
|
46
|
+
{"type": "paragraph",
|
|
47
|
+
"content": [{"type": "text", "text": "See the docs",
|
|
48
|
+
"marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]}]},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
print(create_md_doc(adf))
|
|
53
|
+
# ## Release notes
|
|
54
|
+
# See the docs (https://example.com)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To also get the extracted links and media references, use `parse_doc`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
result = parse_doc(adf)
|
|
61
|
+
result.collected_text # list of rendered blocks
|
|
62
|
+
result.links # list of (text, href) tuples
|
|
63
|
+
result.media_ref # list of MediaRef(ref_type, ref)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
adf_parse/__init__.py,sha256=64Tg1WZiQ0q5D8pXUI7t_W36fL6gWqLyghntORfWhwI,440
|
|
2
|
+
adf_parse/enums.py,sha256=8PxFno0LDOTZ7_CnaisN7AMIcwptIneU8Bncehstu1A,2197
|
|
3
|
+
adf_parse/models.py,sha256=be_254oCZ3Cd2EIChxW0TElXDZK3VVBF3SarHpjE47I,348
|
|
4
|
+
adf_parse/parser.py,sha256=f8sGPxlHSwwat23pRzsgdlxmiYUkxXzea4hrU11iq4k,6140
|
|
5
|
+
adf_parse-0.1.0.dist-info/WHEEL,sha256=Sb1dMJuf3wy6TqB8bzcqZpk8WSKCV8HbGz39HaP5dwE,81
|
|
6
|
+
adf_parse-0.1.0.dist-info/METADATA,sha256=akTgGAJzosGEEBspc0xPuuy8ruKCNLjqrJH_I8UCNF8,2209
|
|
7
|
+
adf_parse-0.1.0.dist-info/RECORD,,
|