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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.10
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any