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.
@@ -1,19 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikinator
3
- Version: 0.2.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: markitdown[all]>=0.1.2
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.2.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
- "markitdown[all]>=0.1.2",
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,3 @@
1
+ import logging
2
+
3
+ log = logging.getLogger(__name__)
@@ -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 "List" in style_name:
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
- md_paragraph += parse_run(paragraph, images)
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\n".join(markdown))
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
- """Determine the Markdown prefix for a bullet point based on its indentation level."""
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
- return " " * level + "- " # Use Markdown syntax for nested lists
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"![]({image_url})"
178
214
  else:
179
- print("unknown run type", s)
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
- print(text)
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