wikinator 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {wikinator-0.2.0 → wikinator-0.4.0}/PKG-INFO +41 -3
- {wikinator-0.2.0 → wikinator-0.4.0}/README.md +39 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/pyproject.toml +2 -6
- wikinator-0.4.0/src/wikinator/__init__.py +3 -0
- wikinator-0.4.0/src/wikinator/app.py +148 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/src/wikinator/docxit.py +50 -16
- wikinator-0.4.0/tests/resources/test.docx +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/uv.lock +3 -691
- wikinator-0.2.0/src/wikinator/__about__.py +0 -5
- wikinator-0.2.0/src/wikinator/__init__.py +0 -0
- wikinator-0.2.0/src/wikinator/app.py +0 -241
- {wikinator-0.2.0 → wikinator-0.4.0}/.github/workflows/release.yml +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/.gitignore +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/LICENSE +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/Makefile +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/src/wikinator/__main__.py +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/src/wikinator/quickstart.py +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/src/wikinator/wiki.py +0 -0
- {wikinator-0.2.0 → wikinator-0.4.0}/tests/smoke_test.py +0 -0
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wikinator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Convert Google docs to markdown
|
|
5
5
|
Project-URL: Documentation, https://github.com/philion/wikinator#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/philion/wikinator/issues
|
|
7
7
|
Project-URL: Source, https://github.com/philion/wikinator
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Requires-Python: >=3.13
|
|
10
|
-
Requires-Dist: docx2markdown>=0.1.1
|
|
11
10
|
Requires-Dist: dotenv>=0.9.9
|
|
12
11
|
Requires-Dist: google-api-python-client>=2.175.0
|
|
13
12
|
Requires-Dist: google-auth-httplib2>=0.2.0
|
|
14
13
|
Requires-Dist: google-auth-oauthlib>=1.2.2
|
|
15
14
|
Requires-Dist: gql[all]>=3.5.3
|
|
16
|
-
Requires-Dist:
|
|
15
|
+
Requires-Dist: python-docx>=1.2.0
|
|
17
16
|
Requires-Dist: typer-slim>=0.16.0
|
|
18
17
|
Description-Content-Type: text/markdown
|
|
19
18
|
|
|
@@ -93,7 +92,46 @@ https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessi
|
|
|
93
92
|
Added detection for strikethru and Courier New (as "code font").
|
|
94
93
|
|
|
95
94
|
This is good enough for v0.2!
|
|
95
|
+
|
|
96
|
+
Oops. minor bug. fixing with v0.3
|
|
97
|
+
|
|
98
|
+
Noticed when working on strikethru that nested lists didn't seem to be working.
|
|
99
|
+
|
|
100
|
+
Next tasks:
|
|
101
|
+
- [x] List handling
|
|
102
|
+
- [x] Code cleanup (remove unused libs)
|
|
103
|
+
- [ ] restructure docxit for in-memory
|
|
104
|
+
- [ ] simple testing.
|
|
105
|
+
- [x] recognize and handle single file
|
|
106
|
+
- [x] default output to local dir
|
|
107
|
+
|
|
108
|
+
Looking over the raw XML, it looks like...
|
|
109
|
+
```xml
|
|
110
|
+
<w:numPr>
|
|
111
|
+
<w:ilvl w:val="0" />
|
|
112
|
+
<w:numId w:val="2" />
|
|
113
|
+
</w:numPr>
|
|
96
114
|
```
|
|
115
|
+
It looks like:
|
|
116
|
+
* `w:ilvl` is the zero-based indent level
|
|
117
|
+
* `w:numId` is an ID from numbering.xml, roughly mapping to:
|
|
118
|
+
- val=1 ordered list: `1. `
|
|
119
|
+
- val=2 checklist: `- [ ]`
|
|
120
|
+
- val=3 bullet: `* `
|
|
121
|
+
|
|
122
|
+
Restructured and cleaned up. Removed unneeded code and libraried.
|
|
123
|
+
|
|
124
|
+
Created a simple docx doc for testing.
|
|
125
|
+
|
|
126
|
+
Far enough that a new release feels right. v0.4!
|
|
127
|
+
|
|
128
|
+
Let's combine testing lists with a simple test:
|
|
129
|
+
- load a file with a list
|
|
130
|
+
- convert
|
|
131
|
+
- confirm it contains the correct list
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
97
135
|
|
|
98
136
|
|
|
99
137
|
### 2025-07-04
|
|
@@ -74,7 +74,46 @@ https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessi
|
|
|
74
74
|
Added detection for strikethru and Courier New (as "code font").
|
|
75
75
|
|
|
76
76
|
This is good enough for v0.2!
|
|
77
|
+
|
|
78
|
+
Oops. minor bug. fixing with v0.3
|
|
79
|
+
|
|
80
|
+
Noticed when working on strikethru that nested lists didn't seem to be working.
|
|
81
|
+
|
|
82
|
+
Next tasks:
|
|
83
|
+
- [x] List handling
|
|
84
|
+
- [x] Code cleanup (remove unused libs)
|
|
85
|
+
- [ ] restructure docxit for in-memory
|
|
86
|
+
- [ ] simple testing.
|
|
87
|
+
- [x] recognize and handle single file
|
|
88
|
+
- [x] default output to local dir
|
|
89
|
+
|
|
90
|
+
Looking over the raw XML, it looks like...
|
|
91
|
+
```xml
|
|
92
|
+
<w:numPr>
|
|
93
|
+
<w:ilvl w:val="0" />
|
|
94
|
+
<w:numId w:val="2" />
|
|
95
|
+
</w:numPr>
|
|
77
96
|
```
|
|
97
|
+
It looks like:
|
|
98
|
+
* `w:ilvl` is the zero-based indent level
|
|
99
|
+
* `w:numId` is an ID from numbering.xml, roughly mapping to:
|
|
100
|
+
- val=1 ordered list: `1. `
|
|
101
|
+
- val=2 checklist: `- [ ]`
|
|
102
|
+
- val=3 bullet: `* `
|
|
103
|
+
|
|
104
|
+
Restructured and cleaned up. Removed unneeded code and libraried.
|
|
105
|
+
|
|
106
|
+
Created a simple docx doc for testing.
|
|
107
|
+
|
|
108
|
+
Far enough that a new release feels right. v0.4!
|
|
109
|
+
|
|
110
|
+
Let's combine testing lists with a simple test:
|
|
111
|
+
- load a file with a list
|
|
112
|
+
- convert
|
|
113
|
+
- confirm it contains the correct list
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
78
117
|
|
|
79
118
|
|
|
80
119
|
### 2025-07-04
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wikinator"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Convert Google docs to markdown"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
dependencies = [
|
|
8
|
-
"docx2markdown>=0.1.1",
|
|
9
8
|
"dotenv>=0.9.9",
|
|
10
9
|
"google-api-python-client>=2.175.0",
|
|
11
10
|
"google-auth-httplib2>=0.2.0",
|
|
12
11
|
"google-auth-oauthlib>=1.2.2",
|
|
13
12
|
"gql[all]>=3.5.3",
|
|
14
|
-
"
|
|
13
|
+
"python-docx>=1.2.0",
|
|
15
14
|
"typer-slim>=0.16.0",
|
|
16
15
|
]
|
|
17
16
|
|
|
@@ -34,9 +33,6 @@ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
|
|
|
34
33
|
|
|
35
34
|
[tool.hatch]
|
|
36
35
|
|
|
37
|
-
[tool.hatch.version]
|
|
38
|
-
path = "src/wikinator/__about__.py"
|
|
39
|
-
|
|
40
36
|
[build-system]
|
|
41
37
|
requires = ["hatchling"]
|
|
42
38
|
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path, PurePath
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from . import docxit, log
|
|
8
|
+
|
|
9
|
+
class Page:
|
|
10
|
+
"""
|
|
11
|
+
Specific class for page, to help with validation, as graphql is strict about params
|
|
12
|
+
"""
|
|
13
|
+
content: str
|
|
14
|
+
editor: str
|
|
15
|
+
isPublished: bool
|
|
16
|
+
isPrivate: bool
|
|
17
|
+
locale: str
|
|
18
|
+
path: str
|
|
19
|
+
tags: list[str]
|
|
20
|
+
title: str
|
|
21
|
+
description: str
|
|
22
|
+
|
|
23
|
+
def __init__(self, content: str, editor: str, isPublished: bool, isPrivate: bool,
|
|
24
|
+
locale: str, path: str, tags: list[str], title: str, description: str):
|
|
25
|
+
self.content = content
|
|
26
|
+
self.editor = editor
|
|
27
|
+
self.isPublished = isPublished
|
|
28
|
+
self.isPrivate = isPrivate
|
|
29
|
+
self.locale = locale
|
|
30
|
+
self.path = path
|
|
31
|
+
self.tags = tags
|
|
32
|
+
self.title = title
|
|
33
|
+
self.description = description
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def load(cls, params: dict[str,any]):
|
|
37
|
+
return cls(
|
|
38
|
+
content = params["content"],
|
|
39
|
+
editor = params["editor"],
|
|
40
|
+
isPublished = params["isPublished"],
|
|
41
|
+
isPrivate = params["isPrivate"],
|
|
42
|
+
locale = params["locale"],
|
|
43
|
+
path = params["path"],
|
|
44
|
+
tags = params["tags"],
|
|
45
|
+
title = params["title"],
|
|
46
|
+
description = params["description"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load_json(cls, json_str:str):
|
|
51
|
+
return cls.load(cls, json.loads(json_str))
|
|
52
|
+
|
|
53
|
+
def write(self, root:str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Output the converted document to the specified directory `root`.
|
|
56
|
+
Use the stored path to output relative to the provided root.
|
|
57
|
+
"""
|
|
58
|
+
filename = self.path + '.md'
|
|
59
|
+
target = Path(root, filename)
|
|
60
|
+
# assure required dirs exist
|
|
61
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
# write the content
|
|
63
|
+
with open(target, 'w') as output_file:
|
|
64
|
+
# TODO write yaml-based meta data?
|
|
65
|
+
output_file.write(self.content)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Converter:
|
|
69
|
+
root: Path # Root for file walk, and to resolve rol paths
|
|
70
|
+
|
|
71
|
+
def _convert(self, infile:Path) -> Page:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
def convert(self, infile:Path, outroot:Path) -> Page:
|
|
75
|
+
# translate with subclass
|
|
76
|
+
new_doc = self._convert(infile)
|
|
77
|
+
|
|
78
|
+
#print("converted", infile, new_doc.path)
|
|
79
|
+
|
|
80
|
+
# write!
|
|
81
|
+
new_doc.write(outroot)
|
|
82
|
+
|
|
83
|
+
return new_doc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def convert_file(self, full_path:Path, outroot:str):
|
|
87
|
+
ext = full_path.suffix.lower() # TODO strip first char, '.'
|
|
88
|
+
|
|
89
|
+
# TODO: generic mapping to queue based on ext.
|
|
90
|
+
if ext == ".docx":
|
|
91
|
+
#docx_queue.append(full_path) # TODO async!
|
|
92
|
+
self.convert(full_path, outroot)
|
|
93
|
+
else:
|
|
94
|
+
log.debug(f"No processor for {ext}, skipping {full_path}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def convert_directory(self, inpath:str, outroot:str):
|
|
98
|
+
# load queues
|
|
99
|
+
#docx_queue: list[Path] = []
|
|
100
|
+
|
|
101
|
+
self.root = Path(inpath)
|
|
102
|
+
|
|
103
|
+
if os.path.isfile(inpath):
|
|
104
|
+
return self.convert_file(Path(inpath), outroot)
|
|
105
|
+
|
|
106
|
+
for root, dirs, files in os.walk(self.root):
|
|
107
|
+
for file in files:
|
|
108
|
+
full_path = Path(root, file)
|
|
109
|
+
self.convert_file(full_path, outroot)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DocxitConverter(Converter):
|
|
113
|
+
def convert(self, infile:Path, outroot:Path) -> Page:
|
|
114
|
+
"""
|
|
115
|
+
Converts a docx file into markdown using docxit
|
|
116
|
+
"""
|
|
117
|
+
rel_path = PurePath(os.path.relpath(infile, self.root.parent)) # remove .parent to remove top dirname
|
|
118
|
+
rel_file = PurePath(rel_path.parent, infile.stem + ".md")
|
|
119
|
+
out_file = Path(outroot, rel_file)
|
|
120
|
+
|
|
121
|
+
# FIXME deconstruct docx_to_markdown to seperate convert from write
|
|
122
|
+
# assure required dirs exist
|
|
123
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
docxit.docx_to_markdown(infile, out_file)
|
|
125
|
+
|
|
126
|
+
return Page(
|
|
127
|
+
title = infile.stem,
|
|
128
|
+
path = str(rel_file),
|
|
129
|
+
content = "", ## FIXME
|
|
130
|
+
editor = "markdown",
|
|
131
|
+
locale = "en",
|
|
132
|
+
tags = None,
|
|
133
|
+
description = f"generated from: {infile}",
|
|
134
|
+
isPublished = False,
|
|
135
|
+
isPrivate = True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def do_convert(src: str, dest: str = ".") -> None:
|
|
140
|
+
DocxitConverter().convert_directory(src, dest)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> None:
|
|
144
|
+
typer.run(do_convert)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -35,6 +35,8 @@ import os
|
|
|
35
35
|
from lxml import etree
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
|
|
38
|
+
from . import log
|
|
39
|
+
|
|
38
40
|
def docx_to_markdown(docx_file, output_md):
|
|
39
41
|
"""Convert a .docx file to a Markdown file and a subfolder of images."""
|
|
40
42
|
|
|
@@ -55,7 +57,6 @@ def docx_to_markdown(docx_file, output_md):
|
|
|
55
57
|
images[rel.rId] = image_filename[len(folder)+1:]
|
|
56
58
|
#print("image file", image_filename, "-->", images[rel.rId])
|
|
57
59
|
|
|
58
|
-
#print("images", images)
|
|
59
60
|
|
|
60
61
|
for block in doc.element.body:
|
|
61
62
|
if block.tag.endswith('p'): # Handle paragraphs
|
|
@@ -63,13 +64,10 @@ def docx_to_markdown(docx_file, output_md):
|
|
|
63
64
|
md_paragraph = ""
|
|
64
65
|
|
|
65
66
|
### switching on paragraph.style.name
|
|
66
|
-
|
|
67
67
|
style_name = paragraph.style.name
|
|
68
|
+
#print("STYLE:", style_name)
|
|
68
69
|
|
|
69
|
-
if "
|
|
70
|
-
prefix = get_bullet_point_prefix(paragraph)
|
|
71
|
-
md_paragraph = prefix # Markdown syntax for bullet points
|
|
72
|
-
elif "Heading 1" in style_name:
|
|
70
|
+
if "Heading 1" in style_name:
|
|
73
71
|
md_paragraph = "# "
|
|
74
72
|
elif "Heading 2" in style_name:
|
|
75
73
|
md_paragraph = "## "
|
|
@@ -79,14 +77,19 @@ def docx_to_markdown(docx_file, output_md):
|
|
|
79
77
|
md_paragraph = "#### "
|
|
80
78
|
elif "Heading 5" in style_name:
|
|
81
79
|
md_paragraph = "##### "
|
|
82
|
-
|
|
83
80
|
elif "Normal" or "normal" in style_name:
|
|
84
81
|
md_paragraph = ""
|
|
82
|
+
if is_list(paragraph):
|
|
83
|
+
md_paragraph = get_bullet_point_prefix(paragraph)
|
|
85
84
|
else:
|
|
86
85
|
print("Unsupported style:", style_name)
|
|
87
86
|
|
|
88
|
-
|
|
87
|
+
content = parse_run(paragraph, images)
|
|
89
88
|
|
|
89
|
+
#print("---> ", md_paragraph, content[:40])
|
|
90
|
+
|
|
91
|
+
#if len(content.strip()) > 0:
|
|
92
|
+
md_paragraph += content
|
|
90
93
|
markdown.append(md_paragraph)
|
|
91
94
|
|
|
92
95
|
elif block.tag.endswith('tbl'): # Handle tables (if present)
|
|
@@ -107,7 +110,7 @@ def docx_to_markdown(docx_file, output_md):
|
|
|
107
110
|
|
|
108
111
|
# Write to Markdown file
|
|
109
112
|
with open(output_md, "w", encoding="utf-8") as md_file:
|
|
110
|
-
md_file.write("\n
|
|
113
|
+
md_file.write("\n".join(markdown))
|
|
111
114
|
|
|
112
115
|
|
|
113
116
|
def extract_r_embed(xml_string):
|
|
@@ -135,6 +138,7 @@ def extract_r_embed(xml_string):
|
|
|
135
138
|
return blip.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed")
|
|
136
139
|
return None
|
|
137
140
|
|
|
141
|
+
|
|
138
142
|
def save_image(image_part, output_folder):
|
|
139
143
|
"""Save an image to the output folder and return the filename."""
|
|
140
144
|
os.makedirs(output_folder, exist_ok=True)
|
|
@@ -155,10 +159,42 @@ def get_list_level(paragraph):
|
|
|
155
159
|
return int(ilvl.get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"))
|
|
156
160
|
return 0
|
|
157
161
|
|
|
162
|
+
|
|
163
|
+
def get_list_marker(paragraph):
|
|
164
|
+
p = paragraph._element
|
|
165
|
+
numPr = p.find(".//w:numPr", namespaces=p.nsmap)
|
|
166
|
+
ilvl = numPr.find(".//w:numId", namespaces=p.nsmap)
|
|
167
|
+
type_id = int(ilvl.get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"))
|
|
168
|
+
|
|
169
|
+
match type_id:
|
|
170
|
+
case 1:
|
|
171
|
+
return '1. '
|
|
172
|
+
case 2:
|
|
173
|
+
return '- [ ] '
|
|
174
|
+
case 3:
|
|
175
|
+
return '* '
|
|
176
|
+
case _:
|
|
177
|
+
log.info(f"Unknown list type id: {type_id}")
|
|
178
|
+
|
|
179
|
+
# by default
|
|
180
|
+
return "* "
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_list(paragraph) -> bool:
|
|
184
|
+
p = paragraph._element
|
|
185
|
+
numPr = p.find(".//w:numPr", namespaces=p.nsmap)
|
|
186
|
+
return numPr is not None
|
|
187
|
+
|
|
188
|
+
|
|
158
189
|
def get_bullet_point_prefix(paragraph):
|
|
159
|
-
"""
|
|
190
|
+
"""
|
|
191
|
+
Determine the Markdown prefix for a bullet point
|
|
192
|
+
based on its indentation level.
|
|
193
|
+
"""
|
|
160
194
|
level = get_list_level(paragraph)
|
|
161
|
-
|
|
195
|
+
marker = get_list_marker(paragraph)
|
|
196
|
+
return " " * level + marker
|
|
197
|
+
|
|
162
198
|
|
|
163
199
|
def parse_run(run, images):
|
|
164
200
|
"""Go through document objects recursively and return markdown."""
|
|
@@ -176,7 +212,7 @@ def parse_run(run, images):
|
|
|
176
212
|
image_url = images[rId]
|
|
177
213
|
text += f""
|
|
178
214
|
else:
|
|
179
|
-
|
|
215
|
+
log.warning("unknown run type", s)
|
|
180
216
|
|
|
181
217
|
if isinstance(run, docx.text.run.Run):
|
|
182
218
|
if run.bold:
|
|
@@ -189,9 +225,7 @@ def parse_run(run, images):
|
|
|
189
225
|
text = f"~~{text}~~"
|
|
190
226
|
# check .font for monospacing
|
|
191
227
|
# check style
|
|
192
|
-
if run.font.name == "Courier New":
|
|
193
|
-
|
|
194
|
-
text = f"`{text}`"
|
|
195
|
-
#print(run.font.name)
|
|
228
|
+
if run.font.name == "Courier New": # more fonts!
|
|
229
|
+
text = f"`{text}`<br>"
|
|
196
230
|
|
|
197
231
|
return text
|
|
Binary file
|