hammerdown 3.1.4__tar.gz → 3.2.2__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.
- {hammerdown-3.1.4 → hammerdown-3.2.2}/PKG-INFO +7 -24
- {hammerdown-3.1.4 → hammerdown-3.2.2}/README.md +6 -23
- {hammerdown-3.1.4 → hammerdown-3.2.2}/pyproject.toml +3 -8
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/__init__.py +1 -1
- hammerdown-3.2.2/src/hammerdown/assets/icon_merge_darkred.ico +0 -0
- hammerdown-3.2.2/src/hammerdown/assets/icon_merge_orange.ico +0 -0
- hammerdown-3.2.2/src/hammerdown/batch/__init__.py +508 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/cli.py +7 -1
- hammerdown-3.2.2/src/hammerdown/core/__init__.py +325 -0
- hammerdown-3.2.2/src/hammerdown/core/description.py +6 -0
- hammerdown-3.2.2/src/hammerdown/core/discovery.py +143 -0
- hammerdown-3.2.2/src/hammerdown/core/estimates.py +126 -0
- hammerdown-3.2.2/src/hammerdown/core/file_selection.py +51 -0
- hammerdown-3.2.2/src/hammerdown/core/formatting.py +22 -0
- hammerdown-3.2.2/src/hammerdown/core/google_drive.py +161 -0
- hammerdown-3.2.2/src/hammerdown/core/ipc.py +170 -0
- hammerdown-3.2.2/src/hammerdown/core/merge.py +146 -0
- hammerdown-3.2.2/src/hammerdown/core/path_utils.py +47 -0
- hammerdown-3.2.2/src/hammerdown/core/platform_utils.py +16 -0
- hammerdown-3.2.2/src/hammerdown/core/resources.py +46 -0
- hammerdown-3.2.2/src/hammerdown/core/stdout_mode.py +117 -0
- hammerdown-3.2.2/src/hammerdown/core/updater.py +238 -0
- hammerdown-3.2.2/src/hammerdown/core/windows_console.py +67 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/windows.py +2 -2
- hammerdown-3.2.2/src/hammerdown/ui/batch_ui.py +285 -0
- hammerdown-3.2.2/src/hammerdown/ui/batch_ui_render.py +208 -0
- hammerdown-3.2.2/src/hammerdown/ui/ui_constants.py +27 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/PKG-INFO +7 -24
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/SOURCES.txt +22 -1
- {hammerdown-3.1.4 → hammerdown-3.2.2}/setup.cfg +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/__main__.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/assets/icon.ico +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/assets/icon.png +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/__init__.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/_common.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/linux.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/macos.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/windows_terminal.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/__init__.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/archive.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/charts.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/html.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/markdown_render.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/math/__init__.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/math/symbols.json +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/odt.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/__init__.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/legacy.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/presentation.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/spreadsheet.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/word.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/pdf.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/tables.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/text.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/utils.py +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/dependency_links.txt +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/entry_points.txt +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/requires.txt +0 -0
- {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hammerdown
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.2
|
|
4
4
|
Summary: Convert documents to Markdown with extracted images
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -192,37 +192,20 @@ hammerdown -m merged.md report.pdf notes.docx
|
|
|
192
192
|
Unknown desktop environments cannot be wired up automatically: every file manager exposes its own extension mechanism, and writing entries blindly could break existing associations with no reliable way to detect or undo it. If you need a context-menu action on a system that is not listed above, add a small adapter as described in [CONTRIBUTING.md](CONTRIBUTING.md#adding-support-for-another-platform) and open a pull request.
|
|
193
193
|
|
|
194
194
|
|
|
195
|
-
To update `hammerdown` to the latest version:
|
|
195
|
+
To update `hammerdown` to the latest version, use the built-in updater (which also refreshes launcher scripts and context menu entries):
|
|
196
196
|
|
|
197
197
|
```sh
|
|
198
|
-
hammerdown --
|
|
198
|
+
hammerdown --upgrade
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
-
> [!
|
|
202
|
-
> **
|
|
203
|
-
>
|
|
204
|
-
> ```sh
|
|
205
|
-
> pip uninstall hammerdown -y
|
|
206
|
-
> pip install --no-cache-dir hammerdown
|
|
207
|
-
> hammerdown --install
|
|
208
|
-
> ```
|
|
209
|
-
>
|
|
210
|
-
> Starting from version 1.5.2, `hammerdown --update` automatically refreshes all launcher scripts and file manager integrations without manual reinstallation.
|
|
201
|
+
> [!note]
|
|
202
|
+
> **Upgrading from versions prior to 3.2.1:** due to a packaging configuration issue in versions 3.1.0 through 3.1.4 on PyPI, use `pip install --upgrade hammerdown` to update to 3.2.1+. Starting with version 3.2.1, `hammerdown --upgrade` (or `hammerdown --update`) works automatically.
|
|
211
203
|
|
|
212
204
|
### Versioning and release tags
|
|
213
205
|
|
|
214
|
-
`hammerdown` follows semantic versioning. A tag `vX.Y.Z` (for example `v3.1
|
|
215
|
-
|
|
216
|
-
On every push to `main`, the CI workflow inspects the commit messages since the previous `v*` tag and bumps the version automatically, following Conventional Commits:
|
|
217
|
-
|
|
218
|
-
- `feat!:` or `fix!:` (breaking change) bumps the **major** version
|
|
219
|
-
- `feat:` bumps the **minor** version
|
|
220
|
-
- `fix:` or `perf:` bumps the **patch** version
|
|
221
|
-
- `docs`/`style`/`refactor`/`test`/`build`/`ci`/`chore` do not trigger a bump
|
|
222
|
-
|
|
223
|
-
The bump applies **once** to the whole range between two tags (the strongest single change wins), not per commit. The bot commit it creates is excluded from the next run, which keeps the workflow from looping. Preview tags (`-pre`, `-pre1`) are created manually when you want a testable build before the next stable release.
|
|
206
|
+
`hammerdown` follows semantic versioning. A tag `vX.Y.Z` (for example `v3.2.1`) is a **stable** release: it is marked as the latest and published to PyPI. A tag with a prerelease suffix (for example `v3.2.1-pre1`) is a **preview**: it is published as a GitHub release but is never marked latest and is not pushed to PyPI.
|
|
224
207
|
|
|
225
|
-
|
|
208
|
+
Releases are published directly by pushing version tags (`git tag v3.2.1 && git push origin v3.2.1`). The CI release workflow builds standalone binaries for Windows, Linux, and macOS, extracts release notes from `CHANGELOG.md`, and publishes the package to PyPI.
|
|
226
209
|
|
|
227
210
|
### Logging and Progress Tracking
|
|
228
211
|
|
|
@@ -175,37 +175,20 @@ hammerdown -m merged.md report.pdf notes.docx
|
|
|
175
175
|
Unknown desktop environments cannot be wired up automatically: every file manager exposes its own extension mechanism, and writing entries blindly could break existing associations with no reliable way to detect or undo it. If you need a context-menu action on a system that is not listed above, add a small adapter as described in [CONTRIBUTING.md](CONTRIBUTING.md#adding-support-for-another-platform) and open a pull request.
|
|
176
176
|
|
|
177
177
|
|
|
178
|
-
To update `hammerdown` to the latest version:
|
|
178
|
+
To update `hammerdown` to the latest version, use the built-in updater (which also refreshes launcher scripts and context menu entries):
|
|
179
179
|
|
|
180
180
|
```sh
|
|
181
|
-
hammerdown --
|
|
181
|
+
hammerdown --upgrade
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
> [!
|
|
185
|
-
> **
|
|
186
|
-
>
|
|
187
|
-
> ```sh
|
|
188
|
-
> pip uninstall hammerdown -y
|
|
189
|
-
> pip install --no-cache-dir hammerdown
|
|
190
|
-
> hammerdown --install
|
|
191
|
-
> ```
|
|
192
|
-
>
|
|
193
|
-
> Starting from version 1.5.2, `hammerdown --update` automatically refreshes all launcher scripts and file manager integrations without manual reinstallation.
|
|
184
|
+
> [!note]
|
|
185
|
+
> **Upgrading from versions prior to 3.2.1:** due to a packaging configuration issue in versions 3.1.0 through 3.1.4 on PyPI, use `pip install --upgrade hammerdown` to update to 3.2.1+. Starting with version 3.2.1, `hammerdown --upgrade` (or `hammerdown --update`) works automatically.
|
|
194
186
|
|
|
195
187
|
### Versioning and release tags
|
|
196
188
|
|
|
197
|
-
`hammerdown` follows semantic versioning. A tag `vX.Y.Z` (for example `v3.1
|
|
198
|
-
|
|
199
|
-
On every push to `main`, the CI workflow inspects the commit messages since the previous `v*` tag and bumps the version automatically, following Conventional Commits:
|
|
200
|
-
|
|
201
|
-
- `feat!:` or `fix!:` (breaking change) bumps the **major** version
|
|
202
|
-
- `feat:` bumps the **minor** version
|
|
203
|
-
- `fix:` or `perf:` bumps the **patch** version
|
|
204
|
-
- `docs`/`style`/`refactor`/`test`/`build`/`ci`/`chore` do not trigger a bump
|
|
205
|
-
|
|
206
|
-
The bump applies **once** to the whole range between two tags (the strongest single change wins), not per commit. The bot commit it creates is excluded from the next run, which keeps the workflow from looping. Preview tags (`-pre`, `-pre1`) are created manually when you want a testable build before the next stable release.
|
|
189
|
+
`hammerdown` follows semantic versioning. A tag `vX.Y.Z` (for example `v3.2.1`) is a **stable** release: it is marked as the latest and published to PyPI. A tag with a prerelease suffix (for example `v3.2.1-pre1`) is a **preview**: it is published as a GitHub release but is never marked latest and is not pushed to PyPI.
|
|
207
190
|
|
|
208
|
-
|
|
191
|
+
Releases are published directly by pushing version tags (`git tag v3.2.1 && git push origin v3.2.1`). The CI release workflow builds standalone binaries for Windows, Linux, and macOS, extracts release notes from `CHANGELOG.md`, and publishes the package to PyPI.
|
|
209
192
|
|
|
210
193
|
### Logging and Progress Tracking
|
|
211
194
|
|
|
@@ -26,16 +26,11 @@ hammerdown = "hammerdown.cli:main"
|
|
|
26
26
|
|
|
27
27
|
[tool.setuptools]
|
|
28
28
|
package-dir = {"" = "src"}
|
|
29
|
-
packages
|
|
30
|
-
|
|
31
|
-
"hammerdown.parsers",
|
|
32
|
-
"hammerdown.parsers.math",
|
|
33
|
-
"hammerdown.integrations",
|
|
34
|
-
"hammerdown.parsers.office",
|
|
35
|
-
]
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
36
31
|
|
|
37
32
|
[tool.setuptools.package-data]
|
|
38
|
-
hammerdown = ["assets
|
|
33
|
+
hammerdown = ["assets/*", "parsers/math/symbols.json"]
|
|
39
34
|
|
|
40
35
|
[tool.setuptools.dynamic]
|
|
41
36
|
version = {attr = "hammerdown.__version__"}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.error
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from ..core import (
|
|
14
|
+
TEXT_EXTENSIONS,
|
|
15
|
+
convert_file,
|
|
16
|
+
get_conversion_estimate_seconds,
|
|
17
|
+
)
|
|
18
|
+
from ..core.discovery import expand_paths, output_paths as compute_output_paths
|
|
19
|
+
from ..core.google_drive import download_google_url, extract_file_id, is_google_drive_url
|
|
20
|
+
from ..core.ipc import IPCCoordinator, try_delegate_to_coordinator
|
|
21
|
+
from ..core.merge import (
|
|
22
|
+
append_merged_document,
|
|
23
|
+
initialize_merge_file,
|
|
24
|
+
merge_asset_paths,
|
|
25
|
+
merge_root_for_inputs,
|
|
26
|
+
render_merged_markdown,
|
|
27
|
+
)
|
|
28
|
+
from ..ui.batch_ui import BatchUI
|
|
29
|
+
from ..core.stdout_mode import confirm_stdout, run_stdout_conversion
|
|
30
|
+
from ..core.formatting import format_duration
|
|
31
|
+
from ..core.path_utils import normalize_path
|
|
32
|
+
from ..core.platform_utils import is_windows
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("hammerdown")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_console_window() -> None:
|
|
38
|
+
"""Show the current console or allocate a fallback console on Windows."""
|
|
39
|
+
if os.name != "nt":
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
import ctypes
|
|
43
|
+
|
|
44
|
+
kernel32 = ctypes.windll.kernel32
|
|
45
|
+
user32 = ctypes.windll.user32
|
|
46
|
+
hwnd = kernel32.GetConsoleWindow()
|
|
47
|
+
if not hwnd:
|
|
48
|
+
if not kernel32.AllocConsole():
|
|
49
|
+
return
|
|
50
|
+
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
|
|
51
|
+
sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1)
|
|
52
|
+
sys.stdin = open("CONIN$", "r", encoding="utf-8")
|
|
53
|
+
for name in ("", "hammerdown"):
|
|
54
|
+
for handler in logging.getLogger(name).handlers:
|
|
55
|
+
if isinstance(handler, logging.StreamHandler) and getattr(handler, "stream", None) is None:
|
|
56
|
+
handler.setStream(sys.stderr)
|
|
57
|
+
hwnd = kernel32.GetConsoleWindow()
|
|
58
|
+
kernel32.SetConsoleTitleW("Hammerdown")
|
|
59
|
+
if hwnd:
|
|
60
|
+
user32.ShowWindow(hwnd, 5)
|
|
61
|
+
user32.SetForegroundWindow(hwnd)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def hand_off_to_windows_terminal(
|
|
65
|
+
files: Sequence[str],
|
|
66
|
+
args: argparse.Namespace,
|
|
67
|
+
merge_auto: bool,
|
|
68
|
+
) -> bool:
|
|
69
|
+
"""Run an Explorer selection in one Windows Terminal window.
|
|
70
|
+
|
|
71
|
+
Returns ``False`` when this is not a Windows context-menu run or when
|
|
72
|
+
Windows Terminal is unavailable, so the caller can fall back to a console.
|
|
73
|
+
"""
|
|
74
|
+
if not is_windows() or getattr(args, "context_worker", False):
|
|
75
|
+
return False
|
|
76
|
+
if not getattr(args, "coordinator", False):
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
from ..integrations.windows_terminal import launch_context_worker
|
|
80
|
+
|
|
81
|
+
return launch_context_worker(
|
|
82
|
+
files,
|
|
83
|
+
merge_auto=merge_auto,
|
|
84
|
+
include=getattr(args, "include", []),
|
|
85
|
+
exclude=getattr(args, "exclude", []),
|
|
86
|
+
overwrite=getattr(args, "overwrite", False),
|
|
87
|
+
skip_existing=getattr(args, "skip_existing", False),
|
|
88
|
+
output_dir=getattr(args, "output_dir", None),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_batch(args: argparse.Namespace) -> int:
|
|
93
|
+
raw_files = args.files
|
|
94
|
+
merge_requested = getattr(args, "merge", None)
|
|
95
|
+
merge_auto = getattr(args, "merge_auto", False)
|
|
96
|
+
if merge_requested and getattr(args, "overwrite", False):
|
|
97
|
+
logger.error("--merge cannot be combined with --overwrite")
|
|
98
|
+
return 1
|
|
99
|
+
if merge_requested and getattr(args, "output_dir", None):
|
|
100
|
+
logger.error("--merge cannot be combined with --output-dir")
|
|
101
|
+
return 1
|
|
102
|
+
if not raw_files:
|
|
103
|
+
logger.info("No supported documents found to convert")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
# Resolve Google Docs/Drive file URLs by downloading them first.
|
|
107
|
+
google_downloaded: set[str] = set()
|
|
108
|
+
google_failed: list[tuple[str, str]] = []
|
|
109
|
+
resolved_files: list[str] = []
|
|
110
|
+
google_staging = tempfile.TemporaryDirectory(prefix="hammerdown-remote-")
|
|
111
|
+
try:
|
|
112
|
+
google_staging_dir = Path(google_staging.name)
|
|
113
|
+
for raw in raw_files:
|
|
114
|
+
if is_google_drive_url(raw):
|
|
115
|
+
try:
|
|
116
|
+
cached = download_google_url(raw, google_staging_dir)
|
|
117
|
+
if cached is None:
|
|
118
|
+
google_failed.append((raw, "unsupported Google Drive link"))
|
|
119
|
+
continue
|
|
120
|
+
google_downloaded.add(str(cached.resolve()))
|
|
121
|
+
resolved_files.append(str(cached))
|
|
122
|
+
logger.info("Downloaded %s to %s", raw, cached.name)
|
|
123
|
+
except urllib.error.HTTPError as e:
|
|
124
|
+
reason = f"access denied (HTTP {e.code})" if e.code in (401, 403) else f"not found (HTTP {e.code})"
|
|
125
|
+
google_failed.append((raw, reason))
|
|
126
|
+
logger.error("Could not download Google Drive URL: %s (%s)", raw, reason)
|
|
127
|
+
except (urllib.error.URLError, OSError) as e:
|
|
128
|
+
google_failed.append((raw, f"network error: {e}"))
|
|
129
|
+
logger.error("Could not download Google Drive URL: %s (network error)", raw)
|
|
130
|
+
else:
|
|
131
|
+
resolved_files.append(raw)
|
|
132
|
+
raw_files = resolved_files
|
|
133
|
+
|
|
134
|
+
output_dir = getattr(args, "output_dir", None)
|
|
135
|
+
output_root = Path(output_dir).resolve() if output_dir else None
|
|
136
|
+
if output_root is not None and output_root.exists() and not output_root.is_dir():
|
|
137
|
+
logger.error("output directory path is not a directory: %s", output_root)
|
|
138
|
+
return 1
|
|
139
|
+
finally:
|
|
140
|
+
google_staging.cleanup()
|
|
141
|
+
|
|
142
|
+
include_patterns = [re.compile(pattern) for pattern in getattr(args, "include", [])]
|
|
143
|
+
exclude_patterns = [re.compile(pattern) for pattern in getattr(args, "exclude", [])]
|
|
144
|
+
targets = expand_paths(
|
|
145
|
+
raw_files,
|
|
146
|
+
excluded_dir=output_root,
|
|
147
|
+
include_patterns=include_patterns,
|
|
148
|
+
exclude_patterns=exclude_patterns,
|
|
149
|
+
)
|
|
150
|
+
# Surface Google links that could not be downloaded as explicit errors
|
|
151
|
+
for url, reason in google_failed:
|
|
152
|
+
# Give a readable label instead of just Path(url).name (which is often "edit")
|
|
153
|
+
label = url
|
|
154
|
+
file_id = extract_file_id(url)
|
|
155
|
+
if file_id:
|
|
156
|
+
label = f"Google Doc ({file_id[:8]}...)"
|
|
157
|
+
targets.append((label, reason))
|
|
158
|
+
if not targets:
|
|
159
|
+
logger.info("No supported documents found to convert")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
targets.sort(key=lambda item: item[0].lower())
|
|
163
|
+
files = [path for path, error in targets if error is None]
|
|
164
|
+
ui_files = [path for path, _ in targets]
|
|
165
|
+
estimates = [
|
|
166
|
+
get_conversion_estimate_seconds(Path(path)) if error is None else None
|
|
167
|
+
for path, error in targets
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
if getattr(args, "stdout", False):
|
|
171
|
+
initial_errors = [(path, error) for path, error in targets if error is not None]
|
|
172
|
+
for path, error in initial_errors:
|
|
173
|
+
print(f"[!] {Path(path).name}: {error}", file=sys.stderr)
|
|
174
|
+
file_estimates = [estimate for (_, error), estimate in zip(targets, estimates) if error is None]
|
|
175
|
+
if getattr(args, "confirm", False) and files and not confirm_stdout(files, file_estimates):
|
|
176
|
+
return 1
|
|
177
|
+
if not files:
|
|
178
|
+
return 1 if initial_errors else 0
|
|
179
|
+
conversion_status = run_stdout_conversion(files, raw_files)
|
|
180
|
+
return 1 if initial_errors or conversion_status else 0
|
|
181
|
+
|
|
182
|
+
context_worker = getattr(args, "context_worker", False)
|
|
183
|
+
use_ipc = (
|
|
184
|
+
(getattr(args, "coordinator", False) or os.environ.get("HAMMERDOWN_COORDINATOR") == "1")
|
|
185
|
+
and not context_worker
|
|
186
|
+
)
|
|
187
|
+
options = {
|
|
188
|
+
"overwrite": getattr(args, "overwrite", False),
|
|
189
|
+
"skip_existing": getattr(args, "skip_existing", False),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
coordinator = IPCCoordinator() if use_ipc else None
|
|
193
|
+
coordinator_started = coordinator.start() if coordinator else False
|
|
194
|
+
if use_ipc and not coordinator_started:
|
|
195
|
+
if files and not any(error for _, error in targets) and try_delegate_to_coordinator(files, options):
|
|
196
|
+
logger.info("Delegated %d files to active hammerdown coordinator", len(files))
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
if coordinator is not None and coordinator_started:
|
|
200
|
+
initial_errors = [target for target in targets if target[1] is not None]
|
|
201
|
+
collected_files = coordinator.collect_files(files, assembly_timeout=2.0)
|
|
202
|
+
targets = expand_paths(
|
|
203
|
+
[*raw_files, *collected_files],
|
|
204
|
+
excluded_dir=output_root,
|
|
205
|
+
include_patterns=include_patterns,
|
|
206
|
+
exclude_patterns=exclude_patterns,
|
|
207
|
+
)
|
|
208
|
+
targets.extend(
|
|
209
|
+
target for target in initial_errors if target not in targets
|
|
210
|
+
)
|
|
211
|
+
targets.sort(key=lambda item: item[0].lower())
|
|
212
|
+
files = [path for path, error in targets if error is None]
|
|
213
|
+
ui_files = [path for path, _ in targets]
|
|
214
|
+
estimates = [
|
|
215
|
+
get_conversion_estimate_seconds(Path(path)) if error is None else None
|
|
216
|
+
for path, error in targets
|
|
217
|
+
]
|
|
218
|
+
output_path_inputs = [*raw_files, *collected_files]
|
|
219
|
+
|
|
220
|
+
if hand_off_to_windows_terminal(list(dict.fromkeys(output_path_inputs)), args, merge_auto):
|
|
221
|
+
coordinator.stop()
|
|
222
|
+
return 0
|
|
223
|
+
_ensure_console_window()
|
|
224
|
+
elif use_ipc:
|
|
225
|
+
return 0
|
|
226
|
+
else:
|
|
227
|
+
output_path_inputs = raw_files
|
|
228
|
+
|
|
229
|
+
# Resolve default merge output for --right-click --merge-auto once the full
|
|
230
|
+
# selection is known, so the target does not depend on which process won
|
|
231
|
+
# the coordinator race.
|
|
232
|
+
if merge_auto and merge_requested is None and files:
|
|
233
|
+
merge_requested = str(Path(files[0]).parent / "hammerdowned.md")
|
|
234
|
+
|
|
235
|
+
merge_target = Path(normalize_path(merge_requested)) if merge_requested else None
|
|
236
|
+
merge_sources = [path for path, error in targets if error is None]
|
|
237
|
+
if merge_target is not None and any(Path(path).resolve() == merge_target.resolve() for path in merge_sources):
|
|
238
|
+
logger.error("merge target cannot be one of the source files: %s", merge_target)
|
|
239
|
+
if coordinator:
|
|
240
|
+
coordinator.stop()
|
|
241
|
+
return 1
|
|
242
|
+
|
|
243
|
+
merge_root = (
|
|
244
|
+
merge_root_for_inputs(output_path_inputs, [Path(path) for path in merge_sources])
|
|
245
|
+
if merge_target is not None
|
|
246
|
+
else None
|
|
247
|
+
)
|
|
248
|
+
merge_skipped = bool(merge_target is not None and args.skip_existing and merge_target.exists())
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
if merge_target is not None:
|
|
252
|
+
target_paths = [merge_target] * len(targets)
|
|
253
|
+
elif output_root is not None:
|
|
254
|
+
target_paths = compute_output_paths(output_path_inputs, targets, output_root)
|
|
255
|
+
else:
|
|
256
|
+
target_paths = []
|
|
257
|
+
for path, _ in targets:
|
|
258
|
+
p = Path(path)
|
|
259
|
+
# Google-downloaded HTML lives in a temporary staging folder; write
|
|
260
|
+
# its Markdown output next to the user (cwd) or in -o/--output-dir.
|
|
261
|
+
if str(p.resolve()) in google_downloaded:
|
|
262
|
+
base = output_root if output_root is not None else Path.cwd()
|
|
263
|
+
target_paths.append(
|
|
264
|
+
base / f"hm_{p.stem}_{p.suffix.lower().lstrip('.')}.md"
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
target_paths.append(
|
|
268
|
+
p.parent / f"hm_{p.stem}_{p.suffix.lower().lstrip('.')}.md"
|
|
269
|
+
)
|
|
270
|
+
except ValueError as exc:
|
|
271
|
+
logger.error("%s", exc)
|
|
272
|
+
if coordinator:
|
|
273
|
+
coordinator.stop()
|
|
274
|
+
return 1
|
|
275
|
+
|
|
276
|
+
results: list[bool] = []
|
|
277
|
+
merged_count = 0
|
|
278
|
+
merge_initialized = False
|
|
279
|
+
batch_ui = BatchUI(ui_files, estimates=estimates)
|
|
280
|
+
|
|
281
|
+
if batch_ui.can_redraw:
|
|
282
|
+
batch_ui.start()
|
|
283
|
+
|
|
284
|
+
conversion_finished = False
|
|
285
|
+
try:
|
|
286
|
+
for idx, (f, error) in enumerate(targets):
|
|
287
|
+
f_path = Path(f)
|
|
288
|
+
if error:
|
|
289
|
+
batch_ui.fail_file(idx, error)
|
|
290
|
+
results.append(False)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
estimate = estimates[idx]
|
|
294
|
+
hint = f"~{format_duration(estimate)}" if estimate is not None else ""
|
|
295
|
+
batch_ui.start_file(idx, hint=hint)
|
|
296
|
+
extension = f_path.suffix.lower()
|
|
297
|
+
output_path = target_paths[idx]
|
|
298
|
+
status_info = ""
|
|
299
|
+
if merge_target is not None:
|
|
300
|
+
if merge_skipped:
|
|
301
|
+
status_info = f"skipped, existing merge output: {merge_target.name}"
|
|
302
|
+
elif args.skip_existing and output_path.exists() and output_path != f_path:
|
|
303
|
+
status_info = f"skipped, existing output: {output_path.name}"
|
|
304
|
+
elif args.overwrite and extension in TEXT_EXTENSIONS:
|
|
305
|
+
status_info = "overwritten"
|
|
306
|
+
elif args.overwrite:
|
|
307
|
+
status_info = "overwrite ignored (binary file)"
|
|
308
|
+
|
|
309
|
+
if merge_skipped:
|
|
310
|
+
batch_ui.complete_file(idx, True, 0.0, info=status_info)
|
|
311
|
+
results.append(True)
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
# Directly skip if the output exists to avoid re-parsing
|
|
315
|
+
if args.skip_existing and output_path.exists() and output_path != f_path and merge_target is None:
|
|
316
|
+
batch_ui.complete_file(idx, True, 0.0, info=status_info)
|
|
317
|
+
results.append(True)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
def on_progress(current: int, total: int, unit: str = "pages") -> None:
|
|
321
|
+
batch_ui.update_progress(idx, current, total, unit)
|
|
322
|
+
|
|
323
|
+
def on_complete(success: bool, dur: float, imgs: int, tokens: int) -> None:
|
|
324
|
+
batch_ui.complete_file(idx, success, dur, imgs, tokens, status_info)
|
|
325
|
+
|
|
326
|
+
error_count_before = len(batch_ui.errors)
|
|
327
|
+
logger_was_disabled = logger.disabled
|
|
328
|
+
logger.disabled = True
|
|
329
|
+
is_google_cache = str(f_path.resolve()) in google_downloaded
|
|
330
|
+
try:
|
|
331
|
+
res = convert_file(
|
|
332
|
+
f_path,
|
|
333
|
+
overwrite=args.overwrite and not is_google_cache,
|
|
334
|
+
skip_existing=args.skip_existing if merge_target is None else False,
|
|
335
|
+
progress_callback=on_progress,
|
|
336
|
+
completion_callback=on_complete,
|
|
337
|
+
batch_mode=True,
|
|
338
|
+
target_dir=(
|
|
339
|
+
output_path.parent
|
|
340
|
+
if merge_target is None and (output_root is not None or is_google_cache)
|
|
341
|
+
else None
|
|
342
|
+
),
|
|
343
|
+
asset_dir=(
|
|
344
|
+
merge_asset_paths(f_path, merge_root, merge_target)[0]
|
|
345
|
+
if merge_target is not None and merge_root is not None
|
|
346
|
+
else None
|
|
347
|
+
),
|
|
348
|
+
write_markdown=merge_target is None,
|
|
349
|
+
)
|
|
350
|
+
finally:
|
|
351
|
+
logger.disabled = logger_was_disabled
|
|
352
|
+
succeeded = bool(res)
|
|
353
|
+
if not succeeded:
|
|
354
|
+
reason = getattr(res, "error", None) or "conversion failed"
|
|
355
|
+
if len(batch_ui.errors) > error_count_before:
|
|
356
|
+
name, previous_reason = batch_ui.errors[-1]
|
|
357
|
+
batch_ui.errors[-1] = (name, reason or previous_reason)
|
|
358
|
+
else:
|
|
359
|
+
batch_ui.errors.append((f_path.name, reason))
|
|
360
|
+
|
|
361
|
+
if merge_target is not None and merge_root is not None:
|
|
362
|
+
try:
|
|
363
|
+
if not merge_initialized:
|
|
364
|
+
initialize_merge_file(merge_target, merge_sources, merge_root)
|
|
365
|
+
merge_initialized = True
|
|
366
|
+
_, asset_prefix = merge_asset_paths(f_path, merge_root, merge_target)
|
|
367
|
+
append_merged_document(
|
|
368
|
+
merge_target,
|
|
369
|
+
f_path,
|
|
370
|
+
"",
|
|
371
|
+
merge_root,
|
|
372
|
+
asset_prefix,
|
|
373
|
+
error=reason,
|
|
374
|
+
)
|
|
375
|
+
except OSError:
|
|
376
|
+
pass
|
|
377
|
+
if succeeded and merge_target is not None and merge_root is not None:
|
|
378
|
+
if res.markdown is None:
|
|
379
|
+
succeeded = False
|
|
380
|
+
batch_ui.converted_count -= 1
|
|
381
|
+
batch_ui.statuses[idx] = "[!]"
|
|
382
|
+
batch_ui.details[idx] = "-"
|
|
383
|
+
batch_ui.errors.append((f_path.name, "conversion returned no Markdown for merge"))
|
|
384
|
+
else:
|
|
385
|
+
try:
|
|
386
|
+
if not merge_initialized:
|
|
387
|
+
initialize_merge_file(merge_target, merge_sources, merge_root)
|
|
388
|
+
merge_initialized = True
|
|
389
|
+
_, asset_prefix = merge_asset_paths(f_path, merge_root, merge_target)
|
|
390
|
+
append_merged_document(
|
|
391
|
+
merge_target,
|
|
392
|
+
f_path,
|
|
393
|
+
res.markdown,
|
|
394
|
+
merge_root,
|
|
395
|
+
asset_prefix,
|
|
396
|
+
)
|
|
397
|
+
merged_count += 1
|
|
398
|
+
except OSError as exc:
|
|
399
|
+
succeeded = False
|
|
400
|
+
batch_ui.converted_count -= 1
|
|
401
|
+
batch_ui.statuses[idx] = "[!]"
|
|
402
|
+
batch_ui.details[idx] = "-"
|
|
403
|
+
batch_ui.errors.append((f_path.name, f"could not append to merge output: {exc}"))
|
|
404
|
+
results.append(succeeded)
|
|
405
|
+
conversion_finished = True
|
|
406
|
+
finally:
|
|
407
|
+
if not conversion_finished:
|
|
408
|
+
batch_ui.stop()
|
|
409
|
+
if coordinator:
|
|
410
|
+
coordinator.stop()
|
|
411
|
+
|
|
412
|
+
failed_count = sum(not result for result in results)
|
|
413
|
+
merge_failed = False
|
|
414
|
+
if merge_target is not None and any(results) and not merge_skipped:
|
|
415
|
+
if merge_initialized and merged_count > 0 and merge_target.is_file():
|
|
416
|
+
batch_ui.add_result("Merged documents", merged_count)
|
|
417
|
+
else:
|
|
418
|
+
merge_failed = True
|
|
419
|
+
batch_ui.errors.append((merge_target.name, "merge did not produce an output file"))
|
|
420
|
+
|
|
421
|
+
batch_ui.finish()
|
|
422
|
+
batch_ui.add_result("Converted files", batch_ui.converted_count)
|
|
423
|
+
batch_ui.add_result("Extracted images", batch_ui.image_count)
|
|
424
|
+
batch_ui.add_result("Skipped existing outputs", batch_ui.skipped_count)
|
|
425
|
+
batch_ui.add_result("Overwritten text sources", batch_ui.overwritten_count)
|
|
426
|
+
|
|
427
|
+
if not batch_ui.can_redraw:
|
|
428
|
+
batch_ui.print_summary()
|
|
429
|
+
if batch_ui.errors:
|
|
430
|
+
batch_ui.print_error_list()
|
|
431
|
+
batch_ui.print_results()
|
|
432
|
+
|
|
433
|
+
if (
|
|
434
|
+
getattr(args, "right_click", False)
|
|
435
|
+
or getattr(args, "coordinator", False)
|
|
436
|
+
or os.environ.get("HAMMERDOWN_COORDINATOR") == "1"
|
|
437
|
+
):
|
|
438
|
+
from rich.console import Console
|
|
439
|
+
from rich.text import Text
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
follow_up = Text("\nPress ")
|
|
443
|
+
follow_up.append("Enter", style="orange1")
|
|
444
|
+
follow_up.append(" to exit... ")
|
|
445
|
+
Console().print(follow_up, end="")
|
|
446
|
+
input()
|
|
447
|
+
except (EOFError, KeyboardInterrupt):
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
return 1 if failed_count or merge_failed else 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def convert_files(
|
|
454
|
+
files: Sequence[str | os.PathLike[str]],
|
|
455
|
+
*,
|
|
456
|
+
include: Sequence[str] = (),
|
|
457
|
+
exclude: Sequence[str] = (),
|
|
458
|
+
overwrite: bool = False,
|
|
459
|
+
skip_existing: bool = False,
|
|
460
|
+
output_dir: str | os.PathLike[str] | None = None,
|
|
461
|
+
merge: str | os.PathLike[str] | None = None,
|
|
462
|
+
stdout: bool = False,
|
|
463
|
+
confirm: bool = False,
|
|
464
|
+
) -> int:
|
|
465
|
+
"""Convert selected files using the same public options as the CLI."""
|
|
466
|
+
if confirm and not stdout:
|
|
467
|
+
raise ValueError("confirm can only be used with stdout")
|
|
468
|
+
if stdout and (merge is not None or output_dir is not None or overwrite or skip_existing):
|
|
469
|
+
raise ValueError("stdout cannot be combined with merge, output_dir, overwrite, or skip_existing")
|
|
470
|
+
if merge is not None and overwrite:
|
|
471
|
+
raise ValueError("merge cannot be combined with overwrite")
|
|
472
|
+
if merge is not None and output_dir is not None:
|
|
473
|
+
raise ValueError("merge cannot be combined with output_dir")
|
|
474
|
+
if overwrite and skip_existing:
|
|
475
|
+
raise ValueError("overwrite cannot be combined with skip_existing")
|
|
476
|
+
if overwrite and output_dir is not None:
|
|
477
|
+
raise ValueError("overwrite cannot be combined with output_dir")
|
|
478
|
+
include_patterns = _normalize_patterns(include, "include")
|
|
479
|
+
exclude_patterns = _normalize_patterns(exclude, "exclude")
|
|
480
|
+
namespace = argparse.Namespace(
|
|
481
|
+
files=[os.fspath(path) for path in files],
|
|
482
|
+
include=include_patterns,
|
|
483
|
+
exclude=exclude_patterns,
|
|
484
|
+
overwrite=overwrite,
|
|
485
|
+
skip_existing=skip_existing,
|
|
486
|
+
output_dir=os.fspath(output_dir) if output_dir is not None else None,
|
|
487
|
+
merge=os.fspath(merge) if merge is not None else None,
|
|
488
|
+
stdout=stdout,
|
|
489
|
+
confirm=confirm,
|
|
490
|
+
coordinator=False,
|
|
491
|
+
context_worker=False,
|
|
492
|
+
right_click=False,
|
|
493
|
+
)
|
|
494
|
+
return run_batch(namespace)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _normalize_patterns(patterns: Sequence[str], flag: str) -> list[str]:
|
|
498
|
+
normalized_patterns: list[str] = []
|
|
499
|
+
for pattern in patterns:
|
|
500
|
+
normalized = pattern.strip()
|
|
501
|
+
if len(normalized) >= 2 and normalized[0] == normalized[-1] and normalized[0] in {"'", '"'}:
|
|
502
|
+
normalized = normalized[1:-1]
|
|
503
|
+
try:
|
|
504
|
+
re.compile(normalized)
|
|
505
|
+
except re.error as exc:
|
|
506
|
+
raise ValueError(f"{flag} invalid regular expression {pattern!r}: {exc}") from exc
|
|
507
|
+
normalized_patterns.append(normalized)
|
|
508
|
+
return normalized_patterns
|
|
@@ -89,7 +89,13 @@ def _main(argv: Sequence[str] | None = None) -> int:
|
|
|
89
89
|
action = parser.add_mutually_exclusive_group()
|
|
90
90
|
action.add_argument("--install", action="store_true", help="Install the file-manager integration")
|
|
91
91
|
action.add_argument("--uninstall", action="store_true", help="Remove the file-manager integration")
|
|
92
|
-
parser.add_argument(
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--update",
|
|
94
|
+
"--upgrade",
|
|
95
|
+
dest="update",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="Update hammerdown to the latest version",
|
|
98
|
+
)
|
|
93
99
|
parser.add_argument(
|
|
94
100
|
"-i",
|
|
95
101
|
"--include",
|