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.
Files changed (59) hide show
  1. {hammerdown-3.1.4 → hammerdown-3.2.2}/PKG-INFO +7 -24
  2. {hammerdown-3.1.4 → hammerdown-3.2.2}/README.md +6 -23
  3. {hammerdown-3.1.4 → hammerdown-3.2.2}/pyproject.toml +3 -8
  4. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/__init__.py +1 -1
  5. hammerdown-3.2.2/src/hammerdown/assets/icon_merge_darkred.ico +0 -0
  6. hammerdown-3.2.2/src/hammerdown/assets/icon_merge_orange.ico +0 -0
  7. hammerdown-3.2.2/src/hammerdown/batch/__init__.py +508 -0
  8. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/cli.py +7 -1
  9. hammerdown-3.2.2/src/hammerdown/core/__init__.py +325 -0
  10. hammerdown-3.2.2/src/hammerdown/core/description.py +6 -0
  11. hammerdown-3.2.2/src/hammerdown/core/discovery.py +143 -0
  12. hammerdown-3.2.2/src/hammerdown/core/estimates.py +126 -0
  13. hammerdown-3.2.2/src/hammerdown/core/file_selection.py +51 -0
  14. hammerdown-3.2.2/src/hammerdown/core/formatting.py +22 -0
  15. hammerdown-3.2.2/src/hammerdown/core/google_drive.py +161 -0
  16. hammerdown-3.2.2/src/hammerdown/core/ipc.py +170 -0
  17. hammerdown-3.2.2/src/hammerdown/core/merge.py +146 -0
  18. hammerdown-3.2.2/src/hammerdown/core/path_utils.py +47 -0
  19. hammerdown-3.2.2/src/hammerdown/core/platform_utils.py +16 -0
  20. hammerdown-3.2.2/src/hammerdown/core/resources.py +46 -0
  21. hammerdown-3.2.2/src/hammerdown/core/stdout_mode.py +117 -0
  22. hammerdown-3.2.2/src/hammerdown/core/updater.py +238 -0
  23. hammerdown-3.2.2/src/hammerdown/core/windows_console.py +67 -0
  24. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/windows.py +2 -2
  25. hammerdown-3.2.2/src/hammerdown/ui/batch_ui.py +285 -0
  26. hammerdown-3.2.2/src/hammerdown/ui/batch_ui_render.py +208 -0
  27. hammerdown-3.2.2/src/hammerdown/ui/ui_constants.py +27 -0
  28. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/PKG-INFO +7 -24
  29. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/SOURCES.txt +22 -1
  30. {hammerdown-3.1.4 → hammerdown-3.2.2}/setup.cfg +0 -0
  31. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/__main__.py +0 -0
  32. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/assets/icon.ico +0 -0
  33. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/assets/icon.png +0 -0
  34. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/__init__.py +0 -0
  35. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/_common.py +0 -0
  36. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/linux.py +0 -0
  37. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/macos.py +0 -0
  38. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/integrations/windows_terminal.py +0 -0
  39. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/__init__.py +0 -0
  40. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/archive.py +0 -0
  41. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/charts.py +0 -0
  42. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/html.py +0 -0
  43. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/markdown_render.py +0 -0
  44. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/math/__init__.py +0 -0
  45. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/math/symbols.json +0 -0
  46. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/odt.py +0 -0
  47. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/__init__.py +0 -0
  48. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/legacy.py +0 -0
  49. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/presentation.py +0 -0
  50. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/spreadsheet.py +0 -0
  51. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/office/word.py +0 -0
  52. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/pdf.py +0 -0
  53. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/tables.py +0 -0
  54. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/parsers/text.py +0 -0
  55. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown/utils.py +0 -0
  56. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/dependency_links.txt +0 -0
  57. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/entry_points.txt +0 -0
  58. {hammerdown-3.1.4 → hammerdown-3.2.2}/src/hammerdown.egg-info/requires.txt +0 -0
  59. {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.1.4
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 --update
198
+ hammerdown --upgrade
199
199
  ```
200
200
 
201
- > [!warning]
202
- > **Legacy version ambiguity (versions below 2.0.0):** due to a release synchronization conflict on version 1.5.2, run the clean recovery sequence below to avoid environment inconsistencies:
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.0`) is a **stable** release: it is marked as the latest and published to PyPI. A tag with a prerelease suffix (for example `v3.1.0-pre1`) is a **preview**: it is published as a GitHub release but is never marked latest and is not pushed to PyPI.
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
- Release tags are protected by tag rulesets (`Protect release tags`) on GitHub; the release bot needs `contents: write` permission and must be allowed to create tags for the automatic bump to work.
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 --update
181
+ hammerdown --upgrade
182
182
  ```
183
183
 
184
- > [!warning]
185
- > **Legacy version ambiguity (versions below 2.0.0):** due to a release synchronization conflict on version 1.5.2, run the clean recovery sequence below to avoid environment inconsistencies:
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.0`) is a **stable** release: it is marked as the latest and published to PyPI. A tag with a prerelease suffix (for example `v3.1.0-pre1`) is a **preview**: it is published as a GitHub release but is never marked latest and is not pushed to PyPI.
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
- Release tags are protected by tag rulesets (`Protect release tags`) on GitHub; the release bot needs `contents: write` permission and must be allowed to create tags for the automatic bump to work.
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
- "hammerdown",
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/icon.ico", "assets/icon.png", "parsers/math/symbols.json"]
33
+ hammerdown = ["assets/*", "parsers/math/symbols.json"]
39
34
 
40
35
  [tool.setuptools.dynamic]
41
36
  version = {attr = "hammerdown.__version__"}
@@ -36,7 +36,7 @@ from .core import (
36
36
  )
37
37
  from .batch import convert_files
38
38
 
39
- __version__ = "3.1.4"
39
+ __version__ = "3.2.2"
40
40
 
41
41
  __all__ = [
42
42
  "APP_NAME",
@@ -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("--update", action="store_true", help="Update hammerdown to the latest version")
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",