convert-docs 0.1.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.
- convert_docs-0.1.0/.github/workflows/publish.yml +24 -0
- convert_docs-0.1.0/.gitignore +6 -0
- convert_docs-0.1.0/PKG-INFO +53 -0
- convert_docs-0.1.0/README.md +45 -0
- convert_docs-0.1.0/pyproject.toml +19 -0
- convert_docs-0.1.0/src/convert_docs/__init__.py +3 -0
- convert_docs-0.1.0/src/convert_docs/cli.py +174 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
workflow_dispatch: {}
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: astral-sh/setup-uv@v3
|
|
19
|
+
|
|
20
|
+
- name: Build
|
|
21
|
+
run: uv build
|
|
22
|
+
|
|
23
|
+
- name: Publish to PyPI (trusted publishing, no token needed)
|
|
24
|
+
run: uv publish --trusted-publishing automatic
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: convert-docs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Recursively convert documents (pdf, docx, pptx, xlsx, ...) to Markdown via markitdown, mirroring the source folder structure into a destination folder.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: markitdown[docx,outlook,pdf,pptx,xls,xlsx]>=0.1.5
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# convert-docs
|
|
10
|
+
|
|
11
|
+
Recursively convert documents (pdf, docx, pptx, xlsx, and more) to Markdown via
|
|
12
|
+
[markitdown](https://github.com/microsoft/markitdown), mirroring the source folder
|
|
13
|
+
structure into a destination folder.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv tool install convert-docs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
(Or, before it's published to PyPI: `uv tool install git+https://github.com/owenljy/convert-docs`)
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Run with no flags for an interactive prompt (asks for source, then destination):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
convert-docs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or pass flags to skip the prompts:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
convert-docs -s ~/Documents/Reports -o ~/Documents/Reports_MD
|
|
35
|
+
convert-docs -e pdf,docx # restrict which extensions get converted
|
|
36
|
+
convert-docs -f # force re-conversion, ignoring the up-to-date skip
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Flag | Description |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `-s, --source` | Source directory to scan (skips the interactive prompt) |
|
|
42
|
+
| `-o, --output` | Output directory, mirrors source structure (skips the interactive prompt) |
|
|
43
|
+
| `-e, --ext` | Comma-separated extensions to convert |
|
|
44
|
+
| `-f, --force` | Force re-conversion even if output `.md` already exists and is up to date |
|
|
45
|
+
|
|
46
|
+
Files with unsupported extensions are listed at the end instead of being silently
|
|
47
|
+
skipped, and any conversion failures are reported with the underlying error.
|
|
48
|
+
|
|
49
|
+
## Update
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv tool upgrade convert-docs
|
|
53
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# convert-docs
|
|
2
|
+
|
|
3
|
+
Recursively convert documents (pdf, docx, pptx, xlsx, and more) to Markdown via
|
|
4
|
+
[markitdown](https://github.com/microsoft/markitdown), mirroring the source folder
|
|
5
|
+
structure into a destination folder.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv tool install convert-docs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
(Or, before it's published to PyPI: `uv tool install git+https://github.com/owenljy/convert-docs`)
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Run with no flags for an interactive prompt (asks for source, then destination):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
convert-docs
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or pass flags to skip the prompts:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
convert-docs -s ~/Documents/Reports -o ~/Documents/Reports_MD
|
|
27
|
+
convert-docs -e pdf,docx # restrict which extensions get converted
|
|
28
|
+
convert-docs -f # force re-conversion, ignoring the up-to-date skip
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Flag | Description |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `-s, --source` | Source directory to scan (skips the interactive prompt) |
|
|
34
|
+
| `-o, --output` | Output directory, mirrors source structure (skips the interactive prompt) |
|
|
35
|
+
| `-e, --ext` | Comma-separated extensions to convert |
|
|
36
|
+
| `-f, --force` | Force re-conversion even if output `.md` already exists and is up to date |
|
|
37
|
+
|
|
38
|
+
Files with unsupported extensions are listed at the end instead of being silently
|
|
39
|
+
skipped, and any conversion failures are reported with the underlying error.
|
|
40
|
+
|
|
41
|
+
## Update
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool upgrade convert-docs
|
|
45
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "convert-docs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Recursively convert documents (pdf, docx, pptx, xlsx, ...) to Markdown via markitdown, mirroring the source folder structure into a destination folder."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"markitdown[pdf,docx,pptx,xlsx,xls,outlook]>=0.1.5",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
convert-docs = "convert_docs.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/convert_docs"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Recursively convert documents to Markdown via markitdown, mirroring folder structure."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_EXTENSIONS = [
|
|
8
|
+
"pdf", "docx", "pptx", "xlsx", "doc", "ppt", "xls", "csv",
|
|
9
|
+
"html", "htm", "txt", "epub", "json", "xml", "msg",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
SEPARATOR = "-" * 40
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def strip_quotes(text: str) -> str:
|
|
16
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
|
|
17
|
+
return text[1:-1]
|
|
18
|
+
return text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def prompt_source() -> Path:
|
|
22
|
+
while True:
|
|
23
|
+
raw = strip_quotes(input("Source folder path: ").strip())
|
|
24
|
+
candidate = Path(raw or ".").expanduser()
|
|
25
|
+
if candidate.is_dir():
|
|
26
|
+
return candidate.resolve()
|
|
27
|
+
print(f" '{raw}' is not a valid directory. Try again.", file=sys.stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prompt_destination(default: Path) -> Path:
|
|
31
|
+
print("Destination folder path (press Enter to use the default):")
|
|
32
|
+
print(f" default: {default}")
|
|
33
|
+
raw = strip_quotes(input("> ").strip())
|
|
34
|
+
if not raw:
|
|
35
|
+
return default
|
|
36
|
+
return Path(raw).expanduser().resolve()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_args(argv=None) -> argparse.Namespace:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="convert-docs",
|
|
42
|
+
description=__doc__,
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-s", "--source",
|
|
46
|
+
help="Source directory to scan (skips the interactive prompt)",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"-o", "--output",
|
|
50
|
+
help="Output directory, mirrors source structure (skips the interactive prompt)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"-e", "--ext",
|
|
54
|
+
default=",".join(DEFAULT_EXTENSIONS),
|
|
55
|
+
help="Comma-separated extensions to convert (default: %(default)s)",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"-f", "--force",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Force re-conversion even if output .md already exists and is up to date",
|
|
61
|
+
)
|
|
62
|
+
return parser.parse_args(argv)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_source(args: argparse.Namespace) -> Path:
|
|
66
|
+
if not args.source:
|
|
67
|
+
return prompt_source()
|
|
68
|
+
src_dir = Path(args.source).expanduser()
|
|
69
|
+
if not src_dir.is_dir():
|
|
70
|
+
print(f"Error: source directory '{args.source}' does not exist.", file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
return src_dir.resolve()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_destination(args: argparse.Namespace, src_dir: Path) -> Path:
|
|
76
|
+
default = src_dir / "Context"
|
|
77
|
+
if not args.output:
|
|
78
|
+
return prompt_destination(default)
|
|
79
|
+
return Path(args.output).expanduser().resolve()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def collect_files(src_dir: Path, dest_dir: Path, ext_set: set) -> tuple:
|
|
83
|
+
target_files = []
|
|
84
|
+
other_files = []
|
|
85
|
+
for path in sorted(src_dir.rglob("*")):
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
continue
|
|
88
|
+
if dest_dir == path or dest_dir in path.parents:
|
|
89
|
+
continue
|
|
90
|
+
suffix = path.suffix.lower().lstrip(".")
|
|
91
|
+
(target_files if suffix in ext_set else other_files).append(path)
|
|
92
|
+
return target_files, other_files
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def convert_files(target_files: list, src_dir: Path, dest_dir: Path, force: bool) -> tuple:
|
|
96
|
+
from markitdown import MarkItDown
|
|
97
|
+
|
|
98
|
+
md = MarkItDown()
|
|
99
|
+
total = len(target_files)
|
|
100
|
+
converted, failed, skipped = [], [], []
|
|
101
|
+
|
|
102
|
+
for i, path in enumerate(target_files, start=1):
|
|
103
|
+
rel_path = path.relative_to(src_dir)
|
|
104
|
+
out_path = (dest_dir / rel_path).with_suffix(".md")
|
|
105
|
+
print(f"[{i}/{total}] {rel_path} ... ", end="", flush=True)
|
|
106
|
+
|
|
107
|
+
if not force and out_path.exists() and out_path.stat().st_mtime >= path.stat().st_mtime:
|
|
108
|
+
print("skipped (up to date)")
|
|
109
|
+
skipped.append(str(rel_path))
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
try:
|
|
114
|
+
result = md.convert(str(path))
|
|
115
|
+
out_path.write_text(result.text_content, encoding="utf-8")
|
|
116
|
+
print("OK")
|
|
117
|
+
converted.append(str(rel_path))
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
print("FAILED")
|
|
120
|
+
failed.append(f"{rel_path} :: {exc}")
|
|
121
|
+
|
|
122
|
+
return converted, failed, skipped
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None) -> int:
|
|
126
|
+
args = parse_args(argv)
|
|
127
|
+
extensions = [e.strip().lower().lstrip(".") for e in args.ext.split(",") if e.strip()]
|
|
128
|
+
|
|
129
|
+
src_dir = resolve_source(args)
|
|
130
|
+
dest_dir = resolve_destination(args, src_dir)
|
|
131
|
+
|
|
132
|
+
if not dest_dir.exists():
|
|
133
|
+
print(f"Destination does not exist, creating: {dest_dir}")
|
|
134
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
dest_dir = dest_dir.resolve()
|
|
136
|
+
|
|
137
|
+
print(SEPARATOR)
|
|
138
|
+
print(f"Source folder: {src_dir}")
|
|
139
|
+
print(f"Destination folder: {dest_dir}")
|
|
140
|
+
print(f"Extensions: {', '.join(extensions)}")
|
|
141
|
+
print(SEPARATOR)
|
|
142
|
+
|
|
143
|
+
target_files, other_files = collect_files(src_dir, dest_dir, set(extensions))
|
|
144
|
+
print(f"Found {len(target_files)} convertible file(s), {len(other_files)} file(s) with unsupported extensions.")
|
|
145
|
+
print(SEPARATOR)
|
|
146
|
+
|
|
147
|
+
converted, failed, skipped = convert_files(target_files, src_dir, dest_dir, args.force)
|
|
148
|
+
|
|
149
|
+
print(SEPARATOR)
|
|
150
|
+
print("Summary:")
|
|
151
|
+
print(f" Converted: {len(converted)}")
|
|
152
|
+
print(f" Skipped (up to date): {len(skipped)}")
|
|
153
|
+
print(f" Failed: {len(failed)}")
|
|
154
|
+
print(f" Unsupported extension: {len(other_files)}")
|
|
155
|
+
|
|
156
|
+
if failed:
|
|
157
|
+
print()
|
|
158
|
+
print("Failed conversions:")
|
|
159
|
+
for item in failed:
|
|
160
|
+
print(f" - {item}")
|
|
161
|
+
|
|
162
|
+
if other_files:
|
|
163
|
+
print()
|
|
164
|
+
print("Unsupported / not attempted:")
|
|
165
|
+
for path in other_files:
|
|
166
|
+
print(f" - {path.relative_to(src_dir)}")
|
|
167
|
+
|
|
168
|
+
print(SEPARATOR)
|
|
169
|
+
print(f"Done. Output mirrored under: {dest_dir}")
|
|
170
|
+
return 1 if failed else 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
sys.exit(main())
|