wikinator 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.
wikinator/__about__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2025-present Paul Philion <philion@acmerocket.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """project version"""
5
+ __version__ = "0.1.0"
wikinator/__init__.py ADDED
File without changes
wikinator/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env python
2
+
3
+ from wikinator.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
wikinator/app.py ADDED
@@ -0,0 +1,151 @@
1
+ import os
2
+ from pathlib import Path, PurePath
3
+ import logging
4
+ import json
5
+
6
+ from markitdown import MarkItDown
7
+
8
+ import typer
9
+
10
+ class Page:
11
+ """
12
+ Specific class for page, to help with validation, as graphql is strict about params
13
+ """
14
+ content: str
15
+ editor: str
16
+ isPublished: bool
17
+ isPrivate: bool
18
+ locale: str
19
+ path: str
20
+ tags: list[str]
21
+ title: str
22
+ description: str
23
+
24
+ def __init__(self, content: str, editor: str, isPublished: bool, isPrivate: bool,
25
+ locale: str, path: str, tags: list[str], title: str, description: str):
26
+ self.content = content
27
+ self.editor = editor
28
+ self.isPublished = isPublished
29
+ self.isPrivate = isPrivate
30
+ self.locale = locale
31
+ self.path = path
32
+ self.tags = tags
33
+ self.title = title
34
+ self.description = description
35
+
36
+ @classmethod
37
+ def load(cls, params: dict[str,any]):
38
+ return cls(
39
+ content = params["content"],
40
+ editor = params["editor"],
41
+ isPublished = params["isPublished"],
42
+ isPrivate = params["isPrivate"],
43
+ locale = params["locale"],
44
+ path = params["path"],
45
+ tags = params["tags"],
46
+ title = params["title"],
47
+ description = params["description"],
48
+ )
49
+
50
+ @classmethod
51
+ def load_json(cls, json_str:str):
52
+ return cls.load(cls, json.loads(json_str))
53
+
54
+ def write(self, root:str) -> None:
55
+ """
56
+ Output the converted document to the specified directory `root`.
57
+ Use the stored path to output relative to the provided root.
58
+ """
59
+ filename = self.path + '.md'
60
+ target = Path(root, filename)
61
+ # assure required dirs exist
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ # write the content
64
+ with open(target, 'w') as output_file:
65
+ # TODO write yaml-based meta data?
66
+ output_file.write(self.content)
67
+
68
+
69
+ class Converter:
70
+ root: Path # Root for file walk, and to resolve rol paths
71
+
72
+ def _convert(self, infile:Path) -> Page:
73
+ raise NotImplementedError
74
+
75
+ def convert(self, infile:Path, outroot:Path) -> Page:
76
+ # translate with subclass
77
+ new_doc = self._convert(infile)
78
+
79
+ #print("converted", infile, new_doc.path)
80
+
81
+ # write!
82
+ new_doc.write(outroot)
83
+
84
+ return new_doc
85
+
86
+ def convert_directory(self, inpath:str, outroot:str):
87
+ # load queues
88
+ #docx_queue: list[Path] = []
89
+ self.root = Path(inpath)
90
+
91
+ for root, dirs, files in os.walk(self.root):
92
+ for file in files:
93
+ full_path = Path(root, file)
94
+ ext = full_path.suffix.lower() # TODO strip first char, '.'
95
+
96
+ # TODO: generic mapping to queue based on ext.
97
+ if ext == ".docx":
98
+ #docx_queue.append(full_path) # TODO async!
99
+ self.convert(full_path, outroot)
100
+ else:
101
+ logging.debug(f"No processor for {ext}, skipping {full_path}")
102
+
103
+
104
+ class MarkitdownConverter(Converter):
105
+ def _convert(self, filepath:Path) -> Page:
106
+ """
107
+ Converts a markdown file into a struct to add to the wiki
108
+ """
109
+ md = MarkItDown(enable_plugins=False)
110
+ result = md.convert(filepath)
111
+
112
+ # determine the relative file path
113
+ # this *includes* the top-level directly name
114
+ rel_path = PurePath(os.path.relpath(filepath, self.root.parent)) # remove .parent to remove top dirname
115
+ rel_file = PurePath(rel_path.parent, filepath.stem)
116
+
117
+ return Page(
118
+ title = result.title if result.title else filepath.stem,
119
+ path = str(rel_file),
120
+ content = result.text_content,
121
+ editor = "markdown",
122
+ locale = "en",
123
+ tags = None,
124
+ description = f"generated from: {filepath}",
125
+ isPublished = False,
126
+ isPrivate = True,
127
+ )
128
+
129
+ # trying to make everything async
130
+ # skipping for now
131
+ # start workers
132
+ # with multiprocessing.Pool(3) as worker_pool:
133
+ # # create a list the same size for starmap
134
+ # args = [(doc, outroot) for doc in docx_queue]
135
+ # # run the workers
136
+ # results = worker_pool.starmap(convert_docx, args)
137
+ # # check result success
138
+ # for result in results:
139
+ # print(result)
140
+
141
+
142
+ def do_convert(src: str, dest: str) -> None:
143
+ MarkitdownConverter().convert_directory(src, dest)
144
+
145
+
146
+ def main() -> None:
147
+ typer.run(do_convert)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,66 @@
1
+ import os.path
2
+
3
+ from google.auth.transport.requests import Request
4
+ from google.oauth2.credentials import Credentials
5
+ from google_auth_oauthlib.flow import InstalledAppFlow
6
+ from googleapiclient.discovery import build
7
+ from googleapiclient.errors import HttpError
8
+
9
+ # If modifying these scopes, delete the file token.json.
10
+ SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
11
+
12
+
13
+ def main():
14
+ """Shows basic usage of the Drive v3 API.
15
+ Prints the names and ids of the first 10 files the user has access to.
16
+ """
17
+ creds = None
18
+ # The file token.json stores the user's access and refresh tokens, and is
19
+ # created automatically when the authorization flow completes for the first
20
+ # time.
21
+ if os.path.exists("token.json"):
22
+ creds = Credentials.from_authorized_user_file("token.json", SCOPES)
23
+ # If there are no (valid) credentials available, let the user log in.
24
+ if not creds or not creds.valid:
25
+ if creds and creds.expired and creds.refresh_token:
26
+ creds.refresh(Request())
27
+ else:
28
+ flow = InstalledAppFlow.from_client_secrets_file(
29
+ "credentials.json", SCOPES
30
+ )
31
+ creds = flow.run_local_server(port=0)
32
+ # Save the credentials for the next run
33
+ with open("token.json", "w") as token:
34
+ token.write(creds.to_json())
35
+
36
+ try:
37
+ service = build("drive", "v3", credentials=creds)
38
+
39
+ # Call the Drive v3 API
40
+ files = service.files()
41
+ request = files.list(pageSize=1, fields="nextPageToken, files(*)")
42
+
43
+ #while request is not None:
44
+ results = request.execute()
45
+
46
+ items = results.get("files", [])
47
+ if not items:
48
+ print("No files found.")
49
+ return
50
+ for item in items:
51
+ #print(json.dumps(item, indent=4))
52
+ print(item['name'], item['mimeType'])
53
+
54
+ # download
55
+ md_doc = files.export(fileId=item['id'], mimeType="text/markdown").execute()
56
+ print(md_doc)
57
+
58
+ # request = files.list_next(request, results)
59
+
60
+ except HttpError as error:
61
+ # TODO(developer) - Handle errors from drive API.
62
+ print(f"An error occurred: {error}")
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
wikinator/wiki.py ADDED
@@ -0,0 +1,281 @@
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+
6
+ from gql import Client, gql
7
+ from gql.transport.aiohttp import AIOHTTPTransport
8
+
9
+
10
+
11
+ def init_client() -> Client:
12
+ """
13
+ Initialize the GraphQL client with the credentials found in the system ENV:
14
+ - GRAPH_DB : The full URL for requests to the graph DB
15
+ - AUTH_TOKEN : Security token to authorize session
16
+ """
17
+ load_dotenv()
18
+ db_url = os.getenv("GRAPH_DB")
19
+ token = os.getenv("AUTH_TOKEN")
20
+ transport = AIOHTTPTransport(url=db_url, headers={'Authorization': f'Bearer {token}'}, ssl=True)
21
+ return Client(transport=transport)
22
+
23
+
24
+ def store_page(client: Client, params: dict):
25
+ # store in file?
26
+ query = gql(
27
+ '''
28
+ mutation Page (
29
+ $content: String!,
30
+ $description: String!,
31
+ $editor:String!,
32
+ $isPublished:Boolean!,
33
+ $isPrivate:Boolean!,
34
+ $locale:String!,
35
+ $path:String!,
36
+ $tags:[String]!,
37
+ $title:String!) {
38
+ pages {
39
+ create (
40
+ content:$content,
41
+ description:$description,
42
+ editor: $editor,
43
+ isPublished: $isPublished,
44
+ isPrivate: $isPrivate,
45
+ locale: $locale,
46
+ path:$path,
47
+ tags: $tags,
48
+ title:$title
49
+ ) {
50
+ responseResult {
51
+ succeeded
52
+ errorCode
53
+ slug
54
+ message
55
+ }
56
+ page {
57
+ id
58
+ path
59
+ title
60
+ }
61
+ }
62
+ }
63
+ }
64
+ '''
65
+ )
66
+ print('---', params['path'])
67
+ result = client.execute(query, variable_values=params)
68
+ print(result)
69
+ return result
70
+
71
+ # EXTS = {
72
+ # ".docx":
73
+ # 32
74
+ # .pdf 87
75
+ # .xlsx 15
76
+ # .rsc 4
77
+ # .drawio 4
78
+ # .deb 8
79
+ # .bin 1
80
+ # .mp4 28
81
+ # .png 49
82
+ # .PDF 7
83
+ # .pptx 1
84
+ # .apk 10
85
+ # .jpg 5
86
+ # .HEIC 21
87
+ # .tgz 1
88
+ # .php 2
89
+ # .yaml 2
90
+ # .gz 1
91
+ # .toml 1
92
+ # .service 1
93
+ # .jpeg 2
94
+ # .txt 1
95
+ # .csv 1
96
+ # }
97
+
98
+ ### Design Notes
99
+ # multiprocessing.JoinableQueue for queues, using both get() and task_done()
100
+
101
+ #running = False
102
+ #docx_queue = multiprocessing.JoinableQueue()
103
+
104
+ # def docx_worker(client: Client):
105
+ # while running:
106
+ # try:
107
+ # item = docx_queue.get(timeout=0.01)
108
+ # if item is None:
109
+ # continue
110
+
111
+ # try:
112
+ # process_docx(client, item)
113
+ # finally:
114
+ # docx_queue.task_done()
115
+
116
+ # except queue.Empty:
117
+ # pass
118
+ # except:
119
+ # logging.exception('error while processing item')
120
+
121
+ CLIENT: Client = init_client()
122
+
123
+
124
+
125
+ #def main():
126
+ #CLIENT = init_client()
127
+ #process_directory("./data")
128
+
129
+ # client = init_client()
130
+
131
+ # with open("sample.adoc", 'r') as file:
132
+ # file_content = file.read()
133
+
134
+ # params = {
135
+ # "title": "This is a test",
136
+ # "path": "test/test-43",
137
+ # "content": file_content,
138
+ # "editor": "asciidoc",
139
+ # "locale": "en",
140
+ # "tags": "",
141
+ # "description": "generated from googledoc/...",
142
+ # "isPublished": False,
143
+ # "isPrivate": True,
144
+ # }
145
+
146
+ # page = create_page(client, params)
147
+ # print(json.dumps(page, indent=4))
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+ ### other queries. remove when stable
156
+ # query = gql(
157
+ # """
158
+ # {
159
+ # pages {
160
+ # list (orderBy: TITLE) {
161
+ # id
162
+ # path
163
+ # title
164
+ # contentType
165
+ # createdAt
166
+ # description
167
+ # isPrivate
168
+ # isPublished
169
+ # updatedAt
170
+ # }
171
+ # }
172
+ # }
173
+ # """
174
+ # )
175
+
176
+ # query = gql(
177
+ # """
178
+ # {
179
+ # users {
180
+ # list {
181
+ # id
182
+ # name
183
+ # email
184
+ # }
185
+ # }
186
+ # }
187
+ # """
188
+ # )
189
+
190
+ # insert into "pages" (
191
+ # "authorId",
192
+ # "content",
193
+ # "contentType",
194
+ # "createdAt",
195
+ # "creatorId",
196
+ # "description",
197
+ # "editorKey",
198
+ # "extra",
199
+ # "hash",
200
+ # "isPrivate",
201
+ # "isPublished",
202
+ # "localeCode",
203
+ # "path",
204
+ # "publishEndDate",
205
+ # "publishStartDate",
206
+ # "title",
207
+ # "toc",
208
+ # "updatedAt")
209
+
210
+ # query = gql(
211
+ # """
212
+ # mutation Page ($content: String!, $description: String!, $editor:String!, $isPublished:Boolean!, $isPrivate:Boolean!, $locale:String!, $path:String!,$tags:[String]!, $title:String!) {
213
+ # pages {
214
+ # create (content:$content, description:$description, editor: $editor, isPublished: $isPublished, isPrivate: $isPrivate, locale: $locale, path:$path, tags: $tags, title:$title) {
215
+ # responseResult {
216
+ # succeeded,
217
+ # errorCode,
218
+ # slug,
219
+ # message
220
+ # },
221
+ # page {
222
+ # id,
223
+ # path,
224
+ # title
225
+ # }
226
+ # }
227
+ # }
228
+ # }
229
+ # """
230
+ # )
231
+
232
+ # query = gql(
233
+ # '''
234
+ # mutation Page (
235
+ # $content: String!,
236
+ # $description: String!,
237
+ # $editor:String!,
238
+ # $isPublished:Boolean!,
239
+ # $isPrivate:Boolean!,
240
+ # $locale:String!,
241
+ # $path:String!,
242
+ # $tags:[String]!,
243
+ # $title:String!) {
244
+ # pages {
245
+ # create (
246
+ # content:$content,
247
+ # description:$description,
248
+ # editor: $editor,
249
+ # isPublished: $isPublished,
250
+ # isPrivate: $isPrivate,
251
+ # locale: $locale,
252
+ # path:$path,
253
+ # tags: $tags,
254
+ # title:$title
255
+ # ) {
256
+ # responseResult {
257
+ # succeeded
258
+ # errorCode
259
+ # slug
260
+ # message
261
+ # }
262
+ # page {
263
+ # id
264
+ # path
265
+ # title
266
+ # }
267
+ # }
268
+ # }
269
+ # }
270
+ # '''
271
+ # )
272
+
273
+ # query = gql(
274
+ # """
275
+ # query getContinentName ($code: ID!) {
276
+ # continent (code: $code) {
277
+ # name
278
+ # }
279
+ # }
280
+ # """
281
+ # )
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: wikinator
3
+ Version: 0.1.0
4
+ Summary: Convert Google docs to markdown
5
+ Project-URL: Documentation, https://github.com/philion/wikinator#readme
6
+ Project-URL: Issues, https://github.com/philion/wikinator/issues
7
+ Project-URL: Source, https://github.com/philion/wikinator
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.13
10
+ Requires-Dist: dotenv>=0.9.9
11
+ Requires-Dist: google-api-python-client>=2.175.0
12
+ Requires-Dist: google-auth-httplib2>=0.2.0
13
+ Requires-Dist: google-auth-oauthlib>=1.2.2
14
+ Requires-Dist: gql[all]>=3.5.3
15
+ Requires-Dist: markitdown[all]>=0.1.2
16
+ Requires-Dist: typer-slim>=0.16.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # wikinator
20
+
21
+ Convert a Google drive download into a markdown-based wiki.
22
+
23
+ **Note**: This is a work in progress, and not all features will be supported or working properly.
24
+
25
+ ## tl;dr
26
+ ```
27
+ uvx wikinator some/dir another_dir
28
+ uvx wikinator some/dir -graphql https://wiki.example.com/graphql -token 'graphql-auth-token'
29
+ ```
30
+
31
+ Given a directory, convert supported file types into markdown-based files while maintaining names and directory structure. This can then be uploaded into various wiki systems.
32
+
33
+ ### Supported File Types
34
+ - DOCX files (default for GDocs) are converted to markdown
35
+ - images are extracted, uploaded and embedded in the markdown
36
+ - `text` and code file types are wrapped in markdown code blocks
37
+ - CSV and XSLT are converted to markdown tables
38
+ - for any document that is converted to markdown, a copy of the original is uploaded and attached
39
+
40
+ ### Supported Wiki Import
41
+ - wiki.js (and other GraphQL-based wikis)
42
+ - Obsidian
43
+
44
+ The development log will be kept here until the 1.0 release.
45
+
46
+ ## Usage
47
+ ```
48
+ uvx wikinator some/dir another_dir
49
+ uvx wikinator some/dir -graphql https://wiki.example.com/graphql -token 'graphql-auth-token'
50
+ ```
51
+
52
+ ## Build & Test
53
+ 1. Clone
54
+ ```
55
+ git clone ...
56
+ cd wikinator
57
+ ```
58
+ 2. Run, with uv
59
+ ```
60
+ uv run -m wikinator [options]
61
+ ```
62
+ 3. Test, with pytest
63
+ ```
64
+ uv run pytest
65
+ ```
66
+
67
+ ## Development Log
68
+
69
+ ### 2025-07-04
70
+ Let's make a project! Today's goals:
71
+ - [x] clean up code and README
72
+ - [x] add CLI options, using type (not all implemented)
73
+ - [x] initial commit to github
74
+ - [ ] add image handling
75
+ - [ ] upload to pypi and confirm uvx commands
76
+
77
+ Cruft removed. README updated. (author waves, breaking 4th wall)
78
+
79
+ Moving on the main() cleanup and adding support for https://github.com/fastapi/typer
80
+
81
+ Added simple CLI options for src and dest. Got end-to-end tree processing.
82
+
83
+ Let's go for git and call it a day!
84
+
85
+ ### 2025-07-03
86
+ Next steps are testing different document converters and accessing google drive via API.
87
+
88
+ #### Markdown conversion libraries
89
+ - pandoc, see https://docs.asciidoctor.org/asciidoctor/latest/migrate/ms-word/
90
+ - markitdown, https://github.com/microsoft/markitdown
91
+ - docx2markdown, https://github.com/haesleinhuepf/docx2markdown
92
+ - docx2md, https://github.com/mattn/docx2md
93
+
94
+ Reference:
95
+ - https://www.docstomarkdown.pro/convert-word-or-docs-to-markdown-using-pandoc/
96
+
97
+ #### Google Drive API
98
+ Starting with https://developers.google.com/workspace/drive/api/quickstart/python
99
+
100
+ > Note: Follow those Google directions for setting up everything. It's complicated compared to simply generating a service token. Your intrepid author made different tokens in different accounts and couldn't access anything! And get permissions right! Document specific needs in intstall docs.
101
+
102
+ Further aside: There are two versions of the tool: file-based and google-takeout. The google related stuff will always be a bear to setup.
103
+
104
+ Made suffienct progress to feel like there a seperate CLI tool here. Set aside for now, and focus on:
105
+ 1. Build file-based output
106
+ 2. Generate and link images
107
+ 3. Clean up for initial 0.1 version
108
+
109
+ ### 2025-07-02
110
+ Initial time-boxed work started to examine what would be required to migrate our existing GoogleDocs-based info repo into a wiki, with wiki.js being targeted.
111
+
112
+ Initial proof-of-concept goals:
113
+ - Convert a docx page to md or asciidoc
114
+ - Upload test pages to wiki.js
115
+
116
+ I was able to get this working in sample code in a few hours.
@@ -0,0 +1,11 @@
1
+ wikinator/__about__.py,sha256=IdVXYY7Z3ydcf0n-hgfyqAau2-a22qBsSNMM5FZtHes,154
2
+ wikinator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ wikinator/__main__.py,sha256=TRhJB-hjf9CD9mFn4TlXG2qPRpVNZ_wArjpvm6jiZdQ,93
4
+ wikinator/app.py,sha256=JzHYmgLLWoTJCBsw-u71qLzJZTS7nTZo8FjcfwvSnCU,4628
5
+ wikinator/quickstart.py,sha256=FHx_b1mPA8H2-5Pw0FbnOP6UCmARAs0DsxHJ6y3BmFc,2255
6
+ wikinator/wiki.py,sha256=bdM9nGtylfywlWkGD1dAqTdVnPEQPwR20Y62wM6yPVM,6905
7
+ wikinator-0.1.0.dist-info/METADATA,sha256=xrVcgY4sH8-8gs2LbUBqy9TyxsQcMm_RaB9be40_0Ng,4059
8
+ wikinator-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ wikinator-0.1.0.dist-info/entry_points.txt,sha256=6NGOQQ5sXSESmFtd4j0Dy_Yv6fcNwE-NUxPp10xG4cI,49
10
+ wikinator-0.1.0.dist-info/licenses/LICENSE,sha256=Mmlntf1TLxyV5T4YFPW_x1oKKyb9-OC2pT4dJHC4ebQ,1069
11
+ wikinator-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wikinator = wikinator.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Paul Philion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.