folioqueue 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.
- folioqueue/__init__.py +3 -0
- folioqueue/__main__.py +3 -0
- folioqueue/cli.py +134 -0
- folioqueue/engine.py +310 -0
- folioqueue/paths.py +106 -0
- folioqueue/report.py +69 -0
- folioqueue/storage.py +124 -0
- folioqueue/worker.py +105 -0
- folioqueue-0.1.0.dist-info/METADATA +171 -0
- folioqueue-0.1.0.dist-info/RECORD +13 -0
- folioqueue-0.1.0.dist-info/WHEEL +4 -0
- folioqueue-0.1.0.dist-info/entry_points.txt +2 -0
- folioqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
folioqueue/__init__.py
ADDED
folioqueue/__main__.py
ADDED
folioqueue/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""CLI with stable exit codes and a read-only plan command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import codecs
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import sys
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .engine import SUPPORTED, Options, plan, run
|
|
15
|
+
from .paths import QueueError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def positive(value: str) -> float:
|
|
19
|
+
number = float(value)
|
|
20
|
+
if not math.isfinite(number) or not 0 < number <= 1_000_000:
|
|
21
|
+
raise argparse.ArgumentTypeError("must be a finite positive number, at most 1000000")
|
|
22
|
+
return number
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def workers(value: str) -> int:
|
|
26
|
+
number = int(value)
|
|
27
|
+
if not 1 <= number <= 16:
|
|
28
|
+
raise argparse.ArgumentTypeError("must be between 1 and 16")
|
|
29
|
+
return number
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parser() -> argparse.ArgumentParser:
|
|
33
|
+
root = argparse.ArgumentParser(description="Restartable local document-to-Markdown batches.")
|
|
34
|
+
root.add_argument("--version", action="version", version=f"folioqueue {__version__}")
|
|
35
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
36
|
+
for command, help_text in (
|
|
37
|
+
("convert", "Convert a directory and write reports."),
|
|
38
|
+
("plan", "Inspect pending work without writing files."),
|
|
39
|
+
):
|
|
40
|
+
sub = commands.add_parser(command, help=help_text)
|
|
41
|
+
sub.add_argument("source", type=Path, help="Source directory (scanned recursively).")
|
|
42
|
+
sub.add_argument(
|
|
43
|
+
"-o",
|
|
44
|
+
"--output",
|
|
45
|
+
type=Path,
|
|
46
|
+
required=True,
|
|
47
|
+
help="Separate output directory, outside the source tree.",
|
|
48
|
+
)
|
|
49
|
+
sub.add_argument(
|
|
50
|
+
"--workers", type=workers, default=2, help="Parallel file processes (1–16)."
|
|
51
|
+
)
|
|
52
|
+
sub.add_argument(
|
|
53
|
+
"--timeout", type=positive, default=60, help="Seconds allowed per converter process."
|
|
54
|
+
)
|
|
55
|
+
sub.add_argument(
|
|
56
|
+
"--max-input-mb", type=positive, default=64, help="Per-file input limit in MiB."
|
|
57
|
+
)
|
|
58
|
+
sub.add_argument(
|
|
59
|
+
"--max-output-mb", type=positive, default=32, help="Per-file Markdown limit in MiB."
|
|
60
|
+
)
|
|
61
|
+
sub.add_argument(
|
|
62
|
+
"--encoding", default="utf-8-sig", help="TXT/MD/CSV encoding (default: utf-8-sig)."
|
|
63
|
+
)
|
|
64
|
+
sub.add_argument(
|
|
65
|
+
"--types",
|
|
66
|
+
default=",".join(sorted(t[1:] for t in SUPPORTED)),
|
|
67
|
+
help="Comma-separated subset: txt,md,csv,html,htm,docx,pdf.",
|
|
68
|
+
)
|
|
69
|
+
sub.add_argument(
|
|
70
|
+
"--force",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Reconvert unchanged files; never overwrite edited output.",
|
|
73
|
+
)
|
|
74
|
+
sub.add_argument(
|
|
75
|
+
"--json", action="store_true", help="Print the full machine-readable report."
|
|
76
|
+
)
|
|
77
|
+
return root
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: list[str] | None = None) -> int:
|
|
81
|
+
cli = parser()
|
|
82
|
+
args = cli.parse_args(argv)
|
|
83
|
+
try:
|
|
84
|
+
encoding = codecs.lookup(args.encoding).name
|
|
85
|
+
except LookupError:
|
|
86
|
+
cli.error("unknown text encoding")
|
|
87
|
+
extensions = frozenset("." + part.strip().lower().lstrip(".") for part in args.types.split(","))
|
|
88
|
+
if not extensions or not extensions <= SUPPORTED:
|
|
89
|
+
cli.error("--types must contain only supported extensions")
|
|
90
|
+
options = Options(
|
|
91
|
+
args.source,
|
|
92
|
+
args.output,
|
|
93
|
+
args.workers,
|
|
94
|
+
args.timeout,
|
|
95
|
+
max(1, int(args.max_input_mb * 1024 * 1024)),
|
|
96
|
+
max(1, int(args.max_output_mb * 1024 * 1024)),
|
|
97
|
+
encoding,
|
|
98
|
+
extensions,
|
|
99
|
+
args.force,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
report = plan(options) if args.command == "plan" else run(options)
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
print(
|
|
105
|
+
"Interrupted. Completed files are checkpointed; rerun the same command.",
|
|
106
|
+
file=sys.stderr,
|
|
107
|
+
)
|
|
108
|
+
return 130
|
|
109
|
+
except QueueError as error:
|
|
110
|
+
print(f"folioqueue: {error}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
except OSError:
|
|
113
|
+
print(
|
|
114
|
+
"folioqueue: filesystem operation failed; check permissions and disk space.",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
return 2
|
|
118
|
+
if args.json:
|
|
119
|
+
print(json.dumps(report, ensure_ascii=True, indent=2))
|
|
120
|
+
elif args.command == "plan":
|
|
121
|
+
counts = Counter(r.get("action", r["status"]) for r in report["records"])
|
|
122
|
+
print("Plan: " + ", ".join(f"{n} {s}" for s, n in sorted(counts.items())))
|
|
123
|
+
print("No files written. Add --json for per-file decisions.")
|
|
124
|
+
else:
|
|
125
|
+
print("Finished: " + ", ".join(f"{n} {s}" for s, n in report["summary"].items()))
|
|
126
|
+
print("Reports: report.html and report.json in the output directory.")
|
|
127
|
+
return (
|
|
128
|
+
1
|
|
129
|
+
if any(
|
|
130
|
+
r["status"] == "failed" or r.get("action") in {"error", "conflict"}
|
|
131
|
+
for r in report["records"]
|
|
132
|
+
)
|
|
133
|
+
else 0
|
|
134
|
+
)
|
folioqueue/engine.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Bounded conversion with per-file isolation and conservative output ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib.metadata
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .paths import QueueError, destination, no_links, roots, scan
|
|
21
|
+
from .report import finish
|
|
22
|
+
from .storage import Ledger, OutputLock, atomic_write, digest
|
|
23
|
+
|
|
24
|
+
SUPPORTED = {".txt", ".md", ".csv", ".html", ".htm", ".docx", ".pdf"}
|
|
25
|
+
TEXT_TYPES = {".txt", ".md", ".csv"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Options:
|
|
30
|
+
source: Path
|
|
31
|
+
output: Path
|
|
32
|
+
workers: int = 2
|
|
33
|
+
timeout: float = 60
|
|
34
|
+
max_input_bytes: int = 64 * 1024 * 1024
|
|
35
|
+
max_output_bytes: int = 32 * 1024 * 1024
|
|
36
|
+
encoding: str = "utf-8-sig"
|
|
37
|
+
extensions: frozenset[str] = frozenset(SUPPORTED)
|
|
38
|
+
force: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fingerprint(encoding: str, documents: bool) -> str:
|
|
42
|
+
environment = []
|
|
43
|
+
if documents:
|
|
44
|
+
# Converters are transitive dependencies. Upgrades must invalidate cached output.
|
|
45
|
+
environment = sorted(
|
|
46
|
+
{
|
|
47
|
+
(d.metadata.get("Name", "").lower(), d.version)
|
|
48
|
+
for d in importlib.metadata.distributions()
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
value = [__version__, list(sys.version_info[:2]), encoding, environment]
|
|
52
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def owned(target: Path, entry: dict | None) -> bool:
|
|
56
|
+
if not target.exists():
|
|
57
|
+
return True
|
|
58
|
+
if not target.is_file() or not entry:
|
|
59
|
+
return False
|
|
60
|
+
return digest(target) in {entry.get("output_sha256"), entry.get("previous_output_sha256")}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def prepare(options: Options) -> tuple[Path, Path, Ledger, list[dict], list[dict]]:
|
|
64
|
+
source, output = roots(options.source, options.output)
|
|
65
|
+
ledger = Ledger(output, source)
|
|
66
|
+
files, ignored = scan(source, set(options.extensions))
|
|
67
|
+
fingerprints = {False: fingerprint(options.encoding, False)}
|
|
68
|
+
if any(p.suffix.lower() not in TEXT_TYPES for p in files):
|
|
69
|
+
fingerprints[True] = fingerprint(options.encoding, True)
|
|
70
|
+
tasks = []
|
|
71
|
+
for path in files:
|
|
72
|
+
relative = path.relative_to(source).as_posix()
|
|
73
|
+
target = destination(output, relative)
|
|
74
|
+
record = {
|
|
75
|
+
"source": relative,
|
|
76
|
+
"output": target.relative_to(output).as_posix(),
|
|
77
|
+
"status": "planned",
|
|
78
|
+
"code": "ok",
|
|
79
|
+
"action": "convert",
|
|
80
|
+
"fingerprint": fingerprints[path.suffix.lower() not in TEXT_TYPES],
|
|
81
|
+
}
|
|
82
|
+
entry = ledger.entries.get(relative)
|
|
83
|
+
try:
|
|
84
|
+
if path.stat().st_size > options.max_input_bytes:
|
|
85
|
+
record.update(action="error", code="input_too_large")
|
|
86
|
+
else:
|
|
87
|
+
record["source_sha256"] = digest(path)
|
|
88
|
+
if not owned(target, entry):
|
|
89
|
+
record.update(action="conflict", code="output_conflict")
|
|
90
|
+
elif (
|
|
91
|
+
not options.force
|
|
92
|
+
and entry
|
|
93
|
+
and target.is_file()
|
|
94
|
+
and target.stat().st_size <= options.max_output_bytes
|
|
95
|
+
and entry["source_sha256"] == record["source_sha256"]
|
|
96
|
+
and entry["fingerprint"] == record["fingerprint"]
|
|
97
|
+
and digest(target) == entry["output_sha256"]
|
|
98
|
+
):
|
|
99
|
+
record.update(action="skip", code="unchanged")
|
|
100
|
+
record["output_sha256"] = entry["output_sha256"]
|
|
101
|
+
except OSError:
|
|
102
|
+
record.update(action="error", code="io_error")
|
|
103
|
+
tasks.append(record)
|
|
104
|
+
# Only absent sources are stale; selecting fewer types does not imply deletion.
|
|
105
|
+
for relative in ledger.entries:
|
|
106
|
+
destination(output, relative) # validate even stale, untrusted ledger keys
|
|
107
|
+
path = source / Path(relative)
|
|
108
|
+
if not path.exists():
|
|
109
|
+
ignored.append({"source": relative, "status": "stale", "code": "source_removed"})
|
|
110
|
+
return source, output, ledger, tasks, ignored
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def plan(options: Options) -> dict:
|
|
114
|
+
_, _, _, tasks, ignored = prepare(options)
|
|
115
|
+
return {"schema": 1, "version": __version__, "records": tasks + ignored}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Processes:
|
|
119
|
+
"""Track only our direct children so Ctrl+C never waits for all queued timeouts."""
|
|
120
|
+
|
|
121
|
+
def __init__(self):
|
|
122
|
+
self.lock = threading.Lock()
|
|
123
|
+
self.children: set[subprocess.Popen] = set()
|
|
124
|
+
self.cancelled = False
|
|
125
|
+
|
|
126
|
+
def start(self, command: list[str]) -> subprocess.Popen:
|
|
127
|
+
with self.lock:
|
|
128
|
+
if self.cancelled:
|
|
129
|
+
raise InterruptedError
|
|
130
|
+
child = subprocess.Popen(
|
|
131
|
+
command,
|
|
132
|
+
stdin=subprocess.DEVNULL,
|
|
133
|
+
stdout=subprocess.DEVNULL,
|
|
134
|
+
stderr=subprocess.DEVNULL,
|
|
135
|
+
shell=False,
|
|
136
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
137
|
+
)
|
|
138
|
+
self.children.add(child)
|
|
139
|
+
return child
|
|
140
|
+
|
|
141
|
+
def remove(self, child: subprocess.Popen) -> None:
|
|
142
|
+
with self.lock:
|
|
143
|
+
self.children.discard(child)
|
|
144
|
+
|
|
145
|
+
def cancel(self) -> None:
|
|
146
|
+
with self.lock:
|
|
147
|
+
self.cancelled = True
|
|
148
|
+
for child in self.children:
|
|
149
|
+
with contextlib.suppress(OSError):
|
|
150
|
+
child.kill()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def execute(
|
|
154
|
+
source: Path, scratch: Path, record: dict, options: Options, processes: Processes
|
|
155
|
+
) -> tuple[str, bytes | None]:
|
|
156
|
+
path = source / Path(record["source"])
|
|
157
|
+
try:
|
|
158
|
+
no_links(path)
|
|
159
|
+
# Snapshot ensures the converter sees precisely the bytes whose hash was planned.
|
|
160
|
+
with tempfile.TemporaryDirectory(prefix="job-", dir=scratch) as temp:
|
|
161
|
+
folder = Path(temp)
|
|
162
|
+
snapshot = folder / ("input" + path.suffix.lower())
|
|
163
|
+
result, status = folder / "result.md", folder / "status.json"
|
|
164
|
+
checksum = hashlib.sha256()
|
|
165
|
+
total = 0
|
|
166
|
+
with path.open("rb") as src, snapshot.open("wb") as dst:
|
|
167
|
+
for chunk in iter(lambda: src.read(1024 * 1024), b""):
|
|
168
|
+
total += len(chunk)
|
|
169
|
+
if total > options.max_input_bytes:
|
|
170
|
+
return "input_too_large", None
|
|
171
|
+
checksum.update(chunk)
|
|
172
|
+
dst.write(chunk)
|
|
173
|
+
if checksum.hexdigest() != record["source_sha256"]:
|
|
174
|
+
return "source_changed", None
|
|
175
|
+
command = [
|
|
176
|
+
sys.executable,
|
|
177
|
+
"-m",
|
|
178
|
+
"folioqueue.worker",
|
|
179
|
+
str(snapshot),
|
|
180
|
+
str(result),
|
|
181
|
+
str(status),
|
|
182
|
+
options.encoding,
|
|
183
|
+
str(options.max_output_bytes),
|
|
184
|
+
]
|
|
185
|
+
child = processes.start(command)
|
|
186
|
+
try:
|
|
187
|
+
try:
|
|
188
|
+
child.wait(timeout=options.timeout)
|
|
189
|
+
except subprocess.TimeoutExpired:
|
|
190
|
+
child.kill()
|
|
191
|
+
child.wait()
|
|
192
|
+
return "timeout", None
|
|
193
|
+
finally:
|
|
194
|
+
processes.remove(child)
|
|
195
|
+
if not status.is_file() or status.stat().st_size > 1024:
|
|
196
|
+
return "worker_failed", None
|
|
197
|
+
details = json.loads(status.read_text(encoding="utf-8"))
|
|
198
|
+
if details.get("code") != "ok":
|
|
199
|
+
code = details.get("code")
|
|
200
|
+
allowed = {
|
|
201
|
+
"empty_output",
|
|
202
|
+
"output_too_large",
|
|
203
|
+
"encoding_error",
|
|
204
|
+
"missing_backend",
|
|
205
|
+
"archive_limit",
|
|
206
|
+
"binary_text",
|
|
207
|
+
"conversion_error",
|
|
208
|
+
}
|
|
209
|
+
return code if code in allowed else "worker_failed", None
|
|
210
|
+
if child.returncode != 0 or not result.is_file():
|
|
211
|
+
return "worker_failed", None
|
|
212
|
+
if result.stat().st_size > options.max_output_bytes:
|
|
213
|
+
return "output_too_large", None
|
|
214
|
+
return "ok", result.read_bytes()
|
|
215
|
+
except (OSError, QueueError):
|
|
216
|
+
return "io_error", None
|
|
217
|
+
except (ValueError, TypeError, AttributeError):
|
|
218
|
+
return "worker_failed", None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def commit(source: Path, output: Path, ledger: Ledger, record: dict, data: bytes) -> str:
|
|
222
|
+
target = destination(output, record["source"])
|
|
223
|
+
previous = ledger.entries.get(record["source"])
|
|
224
|
+
if digest(source / Path(record["source"])) != record["source_sha256"]:
|
|
225
|
+
return "source_changed"
|
|
226
|
+
if not owned(target, previous):
|
|
227
|
+
return "output_conflict"
|
|
228
|
+
# Write-ahead ownership covers interruption between output replacement and checkpoint.
|
|
229
|
+
entry = {
|
|
230
|
+
"source_sha256": record["source_sha256"],
|
|
231
|
+
"output_sha256": hashlib.sha256(data).hexdigest(),
|
|
232
|
+
"previous_output_sha256": digest(target) if target.exists() else None,
|
|
233
|
+
"fingerprint": record["fingerprint"],
|
|
234
|
+
"status": "pending",
|
|
235
|
+
}
|
|
236
|
+
ledger.entries[record["source"]] = entry
|
|
237
|
+
ledger.save()
|
|
238
|
+
atomic_write(target, data)
|
|
239
|
+
entry["status"] = "success"
|
|
240
|
+
entry["previous_output_sha256"] = None
|
|
241
|
+
ledger.save()
|
|
242
|
+
record["output_sha256"] = entry["output_sha256"]
|
|
243
|
+
return "ok"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run(options: Options) -> dict:
|
|
247
|
+
started = time.monotonic()
|
|
248
|
+
started_at = datetime.now(UTC).isoformat()
|
|
249
|
+
source, output = roots(options.source, options.output)
|
|
250
|
+
with OutputLock(output):
|
|
251
|
+
for name in ("report.json", "report.html"):
|
|
252
|
+
no_links(output / name)
|
|
253
|
+
if (output / name).exists() and not (output / ".folioqueue/state.json").exists():
|
|
254
|
+
raise QueueError("Untracked report exists in output. Use a new output directory.")
|
|
255
|
+
source, output, ledger, tasks, records = prepare(options)
|
|
256
|
+
ledger.save()
|
|
257
|
+
scratch = output / ".folioqueue" / "work"
|
|
258
|
+
no_links(scratch)
|
|
259
|
+
scratch.mkdir(exist_ok=True)
|
|
260
|
+
queue = []
|
|
261
|
+
for record in tasks:
|
|
262
|
+
action = record.pop("action")
|
|
263
|
+
if action == "convert":
|
|
264
|
+
queue.append(record)
|
|
265
|
+
else:
|
|
266
|
+
record["status"] = {"skip": "skipped", "error": "failed", "conflict": "failed"}[
|
|
267
|
+
action
|
|
268
|
+
]
|
|
269
|
+
records.append(record)
|
|
270
|
+
processes = Processes()
|
|
271
|
+
pool = ThreadPoolExecutor(max_workers=options.workers)
|
|
272
|
+
pending = {}
|
|
273
|
+
iterator = iter(queue)
|
|
274
|
+
|
|
275
|
+
def enqueue() -> None:
|
|
276
|
+
item = next(iterator, None)
|
|
277
|
+
if item is not None:
|
|
278
|
+
pending[pool.submit(execute, source, scratch, item, options, processes)] = item
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
for _ in range(options.workers):
|
|
282
|
+
enqueue()
|
|
283
|
+
while pending:
|
|
284
|
+
completed, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
|
|
285
|
+
for future in completed:
|
|
286
|
+
record = pending.pop(future)
|
|
287
|
+
code, data = future.result()
|
|
288
|
+
if code == "ok" and data is not None:
|
|
289
|
+
try:
|
|
290
|
+
code = commit(source, output, ledger, record, data)
|
|
291
|
+
except (OSError, QueueError):
|
|
292
|
+
code = "io_error"
|
|
293
|
+
record.update(status="converted" if code == "ok" else "failed", code=code)
|
|
294
|
+
records.append(record)
|
|
295
|
+
enqueue()
|
|
296
|
+
except BaseException:
|
|
297
|
+
processes.cancel()
|
|
298
|
+
raise
|
|
299
|
+
finally:
|
|
300
|
+
pool.shutdown(wait=True, cancel_futures=True)
|
|
301
|
+
return finish(
|
|
302
|
+
output,
|
|
303
|
+
{
|
|
304
|
+
"schema": 1,
|
|
305
|
+
"version": __version__,
|
|
306
|
+
"started_at": started_at,
|
|
307
|
+
"duration_seconds": round(time.monotonic() - started, 3),
|
|
308
|
+
"records": records,
|
|
309
|
+
},
|
|
310
|
+
)
|
folioqueue/paths.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Conservative path handling. Symlinks and Windows junctions are not traversed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import unicodedata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QueueError(Exception):
|
|
12
|
+
"""A user-actionable configuration or filesystem error."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def linked(path: Path) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
info = path.lstat()
|
|
18
|
+
except FileNotFoundError:
|
|
19
|
+
return False
|
|
20
|
+
return stat.S_ISLNK(info.st_mode) or bool(
|
|
21
|
+
getattr(info, "st_file_attributes", 0) & 0x400 # FILE_ATTRIBUTE_REPARSE_POINT
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def no_links(path: Path) -> None:
|
|
26
|
+
for part in (path, *path.parents):
|
|
27
|
+
if linked(part):
|
|
28
|
+
raise QueueError("Symlinks and junctions are not supported in input/output paths.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def roots(source: Path, output: Path) -> tuple[Path, Path]:
|
|
32
|
+
source = Path(os.path.abspath(source))
|
|
33
|
+
output = Path(os.path.abspath(output))
|
|
34
|
+
no_links(source)
|
|
35
|
+
no_links(output)
|
|
36
|
+
if not source.is_dir():
|
|
37
|
+
raise QueueError("Source must be an existing directory.")
|
|
38
|
+
if source == output or source in output.parents or output in source.parents:
|
|
39
|
+
raise QueueError("Source and output must be separate, non-overlapping directories.")
|
|
40
|
+
if output.exists() and not output.is_dir():
|
|
41
|
+
raise QueueError("Output must be a directory.")
|
|
42
|
+
return source, output
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def destination(output: Path, relative: str) -> Path:
|
|
46
|
+
"""Never trust paths loaded from a ledger."""
|
|
47
|
+
parts = relative.split("/")
|
|
48
|
+
if not parts or any(p in {"", ".", ".."} or "\\" in p or ":" in p for p in parts):
|
|
49
|
+
raise QueueError("Invalid relative path in the ledger.")
|
|
50
|
+
target = output / "documents" / Path(*parts[:-1]) / (parts[-1] + ".md")
|
|
51
|
+
no_links(target)
|
|
52
|
+
return target
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scan(source: Path, extensions: set[str]) -> tuple[list[Path], list[dict]]:
|
|
56
|
+
files: list[Path] = []
|
|
57
|
+
ignored: list[dict] = []
|
|
58
|
+
|
|
59
|
+
def onerror(error: OSError) -> None:
|
|
60
|
+
raise QueueError(
|
|
61
|
+
"A source directory could not be read; no partial scan was accepted."
|
|
62
|
+
) from error
|
|
63
|
+
|
|
64
|
+
for directory, dirs, names in os.walk(source, followlinks=False, onerror=onerror):
|
|
65
|
+
base = Path(directory)
|
|
66
|
+
kept = []
|
|
67
|
+
for name in sorted(dirs):
|
|
68
|
+
path = base / name
|
|
69
|
+
if linked(path) or name.startswith("."):
|
|
70
|
+
ignored.append(
|
|
71
|
+
{
|
|
72
|
+
"source": path.relative_to(source).as_posix(),
|
|
73
|
+
"status": "ignored",
|
|
74
|
+
"code": "link_or_hidden_directory",
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
kept.append(name)
|
|
79
|
+
dirs[:] = kept
|
|
80
|
+
for name in sorted(names):
|
|
81
|
+
path = base / name
|
|
82
|
+
relative = path.relative_to(source).as_posix()
|
|
83
|
+
if linked(path) or name.startswith(".") or not path.is_file():
|
|
84
|
+
ignored.append({"source": relative, "status": "ignored", "code": "link_or_hidden"})
|
|
85
|
+
elif path.suffix.lower() not in extensions:
|
|
86
|
+
ignored.append(
|
|
87
|
+
{"source": relative, "status": "ignored", "code": "unsupported_type"}
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
files.append(path)
|
|
91
|
+
seen: set[str] = set()
|
|
92
|
+
output_names: set[str] = set()
|
|
93
|
+
for path in files:
|
|
94
|
+
relative = path.relative_to(source).as_posix()
|
|
95
|
+
key = unicodedata.normalize("NFC", relative).casefold()
|
|
96
|
+
if key in seen:
|
|
97
|
+
raise QueueError("Source paths collide under case-insensitive Unicode normalization.")
|
|
98
|
+
seen.add(key)
|
|
99
|
+
output_names.add(key + ".md")
|
|
100
|
+
# Reject names that cannot be safely represented by the ledger on all platforms.
|
|
101
|
+
destination(Path("output"), relative)
|
|
102
|
+
for name in output_names:
|
|
103
|
+
parts = name.split("/")
|
|
104
|
+
if any("/".join(parts[:index]) in output_names for index in range(1, len(parts))):
|
|
105
|
+
raise QueueError("An output file would collide with another output's parent directory.")
|
|
106
|
+
return sorted(files, key=lambda p: p.relative_to(source).as_posix()), ignored
|
folioqueue/report.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Content-free JSON and inert HTML run reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from html import escape
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from .storage import atomic_write, json_write
|
|
11
|
+
|
|
12
|
+
HINTS = {
|
|
13
|
+
"ok": "Converted and checkpointed.",
|
|
14
|
+
"unchanged": "Input, converter environment and output checksums match.",
|
|
15
|
+
"output_conflict": "Output is untracked or edited. Move it aside or choose a new output directory.",
|
|
16
|
+
"timeout": "Conversion exceeded the per-file timeout. Retry with a larger --timeout.",
|
|
17
|
+
"missing_backend": "Install folioqueue[documents] to convert PDF, DOCX and HTML.",
|
|
18
|
+
"encoding_error": "Text decoding failed. Set --encoding to the source encoding.",
|
|
19
|
+
"empty_output": "No text was extracted. Scanned PDFs may require a separate OCR workflow.",
|
|
20
|
+
"input_too_large": "Input exceeds --max-input-mb.",
|
|
21
|
+
"output_too_large": "Output exceeds --max-output-mb.",
|
|
22
|
+
"archive_limit": "DOCX exceeds the archive entry or uncompressed-size limit.",
|
|
23
|
+
"binary_text": "NUL bytes found in a text file; check its format and encoding.",
|
|
24
|
+
"conversion_error": "Converter could not process this file. Check validity and supported features.",
|
|
25
|
+
"worker_failed": "Conversion process ended without a valid result.",
|
|
26
|
+
"io_error": "A file could not be read or written; check permissions and available space.",
|
|
27
|
+
"source_changed": "Source changed during the run. Retry when the source is stable.",
|
|
28
|
+
"unsupported_type": "Extension is outside this run's selected types.",
|
|
29
|
+
"link_or_hidden": "Symlink, junction, special file or hidden-name file skipped.",
|
|
30
|
+
"link_or_hidden_directory": "Symlink, junction or hidden-name directory not traversed.",
|
|
31
|
+
"source_removed": "Source no longer exists in the selected scan. Prior output was retained.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def finish(output: Path, report: dict) -> dict:
|
|
36
|
+
report["records"].sort(key=lambda item: item["source"])
|
|
37
|
+
report["summary"] = dict(sorted(Counter(r["status"] for r in report["records"]).items()))
|
|
38
|
+
rows = []
|
|
39
|
+
for record in report["records"]:
|
|
40
|
+
record["message"] = HINTS.get(record["code"], record["code"])
|
|
41
|
+
label = escape(record["source"])
|
|
42
|
+
if record.get("output") and record["status"] in {"converted", "skipped"}:
|
|
43
|
+
label = f'<a href="{quote(record["output"], safe="/")}">{label}</a>'
|
|
44
|
+
rows.append(
|
|
45
|
+
f"<tr><td>{label}</td><td>{escape(record['status'])}</td>"
|
|
46
|
+
f"<td>{escape(record['message'])}</td></tr>"
|
|
47
|
+
)
|
|
48
|
+
summary = " · ".join(f"{count} {escape(status)}" for status, count in report["summary"].items())
|
|
49
|
+
page = f"""<!doctype html>
|
|
50
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
51
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
52
|
+
<title>FolioQueue · Conversion report</title>
|
|
53
|
+
<style>
|
|
54
|
+
:root{{color-scheme:light dark;font:16px/1.55 system-ui,sans-serif;background:#f4f5f0;color:#182827}}
|
|
55
|
+
body{{max-width:1120px;margin:0 auto;padding:48px 24px}}h1{{font-size:40px;letter-spacing:-1.5px;margin:12px 0}}
|
|
56
|
+
.eyebrow{{color:#316d60;letter-spacing:2px;font-size:12px;font-weight:700}}.summary{{font-size:22px;margin:24px 0}}
|
|
57
|
+
.meta,footer{{color:#506560;font-size:14px}}.scroll{{overflow:auto;background:#fff;border:1px solid #d6dfda;border-radius:12px}}
|
|
58
|
+
table{{border-collapse:collapse;width:100%;text-align:left}}th,td{{padding:16px;border-bottom:1px solid #e1e7e3;vertical-align:top}}
|
|
59
|
+
th{{font-size:12px;letter-spacing:1px;text-transform:uppercase;background:#eaf0ec}}td:first-child{{overflow-wrap:anywhere;min-width:180px}}
|
|
60
|
+
a{{color:#17644f}}footer{{margin-top:24px}}@media(prefers-color-scheme:dark){{:root{{background:#14221f;color:#edf3ee}}.scroll{{background:#1c302a;border-color:#385044}}th{{background:#223d32}}th,td{{border-color:#385044}}a,.eyebrow{{color:#94d6b7}}.meta,footer{{color:#afc2b7}}}}
|
|
61
|
+
</style></head><body><div class="eyebrow">FOLIOQUEUE / LOCAL DOCUMENT WORKFLOWS</div>
|
|
62
|
+
<h1>Conversion report</h1><p class="meta">{escape(report["started_at"])} · v{escape(report["version"])} · {report["duration_seconds"]:.2f}s</p>
|
|
63
|
+
<p class="summary">{summary or "No matching files"}</p><div class="scroll"><table>
|
|
64
|
+
<thead><tr><th>Source</th><th>Result</th><th>Details</th></tr></thead><tbody>{"".join(rows)}</tbody></table></div>
|
|
65
|
+
<footer>No document text is embedded in this report. Filenames can still be sensitive. Output links open local Markdown files; their content is untrusted.</footer>
|
|
66
|
+
</body></html>"""
|
|
67
|
+
json_write(output / "report.json", report)
|
|
68
|
+
atomic_write(output / "report.html", page.encode("utf-8"))
|
|
69
|
+
return report
|
folioqueue/storage.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Atomic checkpoints, output ownership and a kernel-released exclusive writer lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .paths import QueueError, no_links
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def digest(path: Path) -> str:
|
|
16
|
+
no_links(path)
|
|
17
|
+
result = hashlib.sha256()
|
|
18
|
+
with path.open("rb") as stream:
|
|
19
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
20
|
+
result.update(chunk)
|
|
21
|
+
return result.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def atomic_write(path: Path, data: bytes) -> None:
|
|
25
|
+
no_links(path)
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
no_links(path)
|
|
28
|
+
fd, name = tempfile.mkstemp(prefix=".fq-", dir=path.parent)
|
|
29
|
+
try:
|
|
30
|
+
with os.fdopen(fd, "wb") as stream:
|
|
31
|
+
stream.write(data)
|
|
32
|
+
stream.flush()
|
|
33
|
+
os.fsync(stream.fileno())
|
|
34
|
+
os.replace(name, path)
|
|
35
|
+
finally:
|
|
36
|
+
with contextlib.suppress(FileNotFoundError):
|
|
37
|
+
os.unlink(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def json_write(path: Path, value: dict) -> None:
|
|
41
|
+
atomic_write(path, (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Ledger:
|
|
45
|
+
def __init__(self, output: Path, source: Path):
|
|
46
|
+
self.path = output / ".folioqueue" / "state.json"
|
|
47
|
+
no_links(self.path)
|
|
48
|
+
if self.path.exists():
|
|
49
|
+
try:
|
|
50
|
+
self.data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
51
|
+
if (
|
|
52
|
+
self.data["schema"] != 1
|
|
53
|
+
or self.data["source_root"] != str(source)
|
|
54
|
+
or not isinstance(self.data["entries"], dict)
|
|
55
|
+
):
|
|
56
|
+
raise ValueError
|
|
57
|
+
for key, entry in self.data["entries"].items():
|
|
58
|
+
if not isinstance(key, str) or not isinstance(entry, dict):
|
|
59
|
+
raise ValueError
|
|
60
|
+
for field in ("source_sha256", "output_sha256", "fingerprint", "status"):
|
|
61
|
+
if not isinstance(entry[field], str):
|
|
62
|
+
raise ValueError
|
|
63
|
+
for field in ("source_sha256", "output_sha256"):
|
|
64
|
+
if len(entry[field]) != 64 or any(
|
|
65
|
+
c not in "0123456789abcdef" for c in entry[field]
|
|
66
|
+
):
|
|
67
|
+
raise ValueError
|
|
68
|
+
if entry["status"] not in {"pending", "success"}:
|
|
69
|
+
raise ValueError
|
|
70
|
+
previous = entry.get("previous_output_sha256")
|
|
71
|
+
if previous is not None and (
|
|
72
|
+
not isinstance(previous, str)
|
|
73
|
+
or len(previous) != 64
|
|
74
|
+
or any(c not in "0123456789abcdef" for c in previous)
|
|
75
|
+
):
|
|
76
|
+
raise ValueError
|
|
77
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
78
|
+
raise QueueError(
|
|
79
|
+
"Ledger is invalid or belongs to another source. Use a new output directory."
|
|
80
|
+
) from error
|
|
81
|
+
else:
|
|
82
|
+
self.data = {"schema": 1, "source_root": str(source), "entries": {}}
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def entries(self) -> dict:
|
|
86
|
+
return self.data["entries"]
|
|
87
|
+
|
|
88
|
+
def save(self) -> None:
|
|
89
|
+
json_write(self.path, self.data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OutputLock:
|
|
93
|
+
"""OS advisory lock; remains safe after a process crash (no stale PID guessing)."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, output: Path):
|
|
96
|
+
self.path = output / ".folioqueue" / "writer.lock"
|
|
97
|
+
|
|
98
|
+
def __enter__(self):
|
|
99
|
+
no_links(self.path)
|
|
100
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
self.stream = self.path.open("a+b")
|
|
102
|
+
self.stream.seek(0, os.SEEK_END)
|
|
103
|
+
if self.stream.tell() == 0:
|
|
104
|
+
self.stream.write(b"0")
|
|
105
|
+
self.stream.flush()
|
|
106
|
+
self.stream.seek(0)
|
|
107
|
+
try:
|
|
108
|
+
if os.name == "nt":
|
|
109
|
+
import msvcrt
|
|
110
|
+
|
|
111
|
+
msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
112
|
+
else:
|
|
113
|
+
import fcntl
|
|
114
|
+
|
|
115
|
+
fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
116
|
+
except OSError as error:
|
|
117
|
+
self.stream.close()
|
|
118
|
+
raise QueueError(
|
|
119
|
+
"Another FolioQueue process is using this output directory."
|
|
120
|
+
) from error
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, *_):
|
|
124
|
+
self.stream.close()
|
folioqueue/worker.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""One-file subprocess. No cloud clients, external plugins, or shell invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
MAX_ARCHIVE_ENTRIES = 10000
|
|
13
|
+
MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cell(value: str) -> str:
|
|
17
|
+
return (
|
|
18
|
+
value.replace("\\", "\\\\")
|
|
19
|
+
.replace("&", "&")
|
|
20
|
+
.replace("|", "\\|")
|
|
21
|
+
.replace("<", "<")
|
|
22
|
+
.replace(">", ">")
|
|
23
|
+
.replace("\r\n", "<br>")
|
|
24
|
+
.replace("\n", "<br>")
|
|
25
|
+
.replace("\r", "<br>")
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def convert(path: Path, encoding: str) -> str:
|
|
30
|
+
if path.suffix.lower() in {".txt", ".md", ".csv"}:
|
|
31
|
+
text = path.read_text(encoding=encoding)
|
|
32
|
+
if "\x00" in text:
|
|
33
|
+
raise ValueError("binary_text")
|
|
34
|
+
if path.suffix.lower() != ".csv":
|
|
35
|
+
return text
|
|
36
|
+
# Fixed comma dialect is explicit; quoted commas/newlines use the stdlib CSV parser.
|
|
37
|
+
rows = list(csv.reader(io.StringIO(text), strict=True))
|
|
38
|
+
if not rows:
|
|
39
|
+
return ""
|
|
40
|
+
width = max(map(len, rows))
|
|
41
|
+
if width == 0:
|
|
42
|
+
return ""
|
|
43
|
+
lines = [
|
|
44
|
+
"| " + " | ".join(cell(v) for v in row + [""] * (width - len(row))) + " |"
|
|
45
|
+
for row in rows
|
|
46
|
+
]
|
|
47
|
+
lines.insert(1, "| " + " | ".join(["---"] * width) + " |")
|
|
48
|
+
return "\n".join(lines) + "\n"
|
|
49
|
+
if path.suffix.lower() == ".docx":
|
|
50
|
+
with zipfile.ZipFile(path) as archive:
|
|
51
|
+
members = archive.infolist()
|
|
52
|
+
if (
|
|
53
|
+
len(members) > MAX_ARCHIVE_ENTRIES
|
|
54
|
+
or sum(m.file_size for m in members) > MAX_ARCHIVE_BYTES
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("archive_limit")
|
|
57
|
+
from markitdown import MissingDependencyException, StreamInfo
|
|
58
|
+
from markitdown.converters import DocxConverter, HtmlConverter, PdfConverter
|
|
59
|
+
|
|
60
|
+
converter = {
|
|
61
|
+
".pdf": PdfConverter,
|
|
62
|
+
".docx": DocxConverter,
|
|
63
|
+
".html": HtmlConverter,
|
|
64
|
+
".htm": HtmlConverter,
|
|
65
|
+
}[path.suffix.lower()]()
|
|
66
|
+
# Use only the intended public converter. Automatic type fallback can otherwise
|
|
67
|
+
# turn a malformed PDF into a successful plain-text result.
|
|
68
|
+
try:
|
|
69
|
+
with path.open("rb") as stream:
|
|
70
|
+
return converter.convert(stream, StreamInfo(extension=path.suffix.lower())).markdown
|
|
71
|
+
except MissingDependencyException as error:
|
|
72
|
+
raise ModuleNotFoundError("Install the documents extra") from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
source, target, status, encoding, limit = sys.argv[1:]
|
|
77
|
+
result = {}
|
|
78
|
+
try:
|
|
79
|
+
markdown = convert(Path(source), encoding)
|
|
80
|
+
if not markdown.strip():
|
|
81
|
+
result = {"code": "empty_output"}
|
|
82
|
+
elif len(markdown.encode("utf-8")) > int(limit):
|
|
83
|
+
result = {"code": "output_too_large"}
|
|
84
|
+
else:
|
|
85
|
+
Path(target).write_text(markdown, encoding="utf-8", newline="\n")
|
|
86
|
+
result = {"code": "ok"}
|
|
87
|
+
except UnicodeError:
|
|
88
|
+
result = {"code": "encoding_error"}
|
|
89
|
+
except ModuleNotFoundError:
|
|
90
|
+
result = {"code": "missing_backend"}
|
|
91
|
+
except ValueError as error:
|
|
92
|
+
result = {
|
|
93
|
+
"code": str(error)
|
|
94
|
+
if str(error) in {"archive_limit", "binary_text"}
|
|
95
|
+
else "conversion_error"
|
|
96
|
+
}
|
|
97
|
+
except Exception:
|
|
98
|
+
# Do not leak document contents, credentials, or absolute paths through library errors.
|
|
99
|
+
result = {"code": "conversion_error"}
|
|
100
|
+
Path(status).write_text(json.dumps(result), encoding="utf-8")
|
|
101
|
+
return 0 if result["code"] == "ok" else 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: folioqueue
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Restartable local document-to-Markdown batches with a checksum ledger and failure isolation
|
|
5
|
+
Project-URL: Homepage, https://github.com/GokouRuri43/folioqueue
|
|
6
|
+
Project-URL: Issues, https://github.com/GokouRuri43/folioqueue/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/GokouRuri43/folioqueue/blob/main/CHANGELOG.md
|
|
8
|
+
Author: GokouRuri43
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,document-conversion,incremental,markdown,markitdown
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Provides-Extra: documents
|
|
22
|
+
Requires-Dist: markitdown[docx,pdf]<0.2,>=0.1.7; extra == 'documents'
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: build<2,>=1; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-cov<8,>=6; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
27
|
+
Requires-Dist: ruff<1,>=0.11; extra == 'test'
|
|
28
|
+
Requires-Dist: twine<7,>=6; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# FolioQueue
|
|
32
|
+
|
|
33
|
+
**Restartable local document-to-Markdown batches, with a checksum ledger and inspectable failures.**
|
|
34
|
+
|
|
35
|
+
[](https://github.com/GokouRuri43/folioqueue/actions/workflows/ci.yml)
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
[简体中文](README.zh-CN.md) · [Design](docs/design.md) · [Validation](docs/validation.md) · [Contributing](CONTRIBUTING.md)
|
|
40
|
+
|
|
41
|
+
FolioQueue helps maintain a Markdown copy of a **local document collection**. It snapshots each input, converts files in separate processes, checkpoints completed work, and checks both source and output hashes before skipping an unchanged file. A broken document can fail while the rest of the batch finishes.
|
|
42
|
+
|
|
43
|
+
**Status: 0.1.0 alpha.** See the validation record for what has actually been tested.
|
|
44
|
+
|
|
45
|
+
## Who is this for?
|
|
46
|
+
|
|
47
|
+
- **Note / knowledge-base maintainers** folding PDFs, Word and HTML into a Markdown vault, and keeping it in sync as sources change.
|
|
48
|
+
- **Local RAG / LLM corpus builders** who need a reproducible, incremental Markdown mirror of a document folder to index.
|
|
49
|
+
- **Self-hosted archive keepers** who want failures isolated per file and edited output protected, instead of a one-shot bulk convert.
|
|
50
|
+
|
|
51
|
+
## Why use it?
|
|
52
|
+
|
|
53
|
+
- **Resume by rerunning.** Completed files are checkpointed individually; failed files are retried on the next run.
|
|
54
|
+
- **Detect real changes.** SHA-256 checksums, rather than timestamps alone, determine whether to convert. Converter dependency changes invalidate document caches.
|
|
55
|
+
- **Protect edits.** Untracked or manually changed output is a conflict, even with `--force`.
|
|
56
|
+
- **Keep names distinct.** `report.pdf` → `documents/report.pdf.md`; `report.docx` → `documents/report.docx.md`.
|
|
57
|
+
- **Contain ordinary failures.** Bounded parallel file processes, per-file timeouts, input/output size checks, and a DOCX expansion preflight.
|
|
58
|
+
- **Inspect every outcome.** Local HTML and JSON reports list converted, skipped, failed, ignored and stale files. Reports omit extracted text and raw converter exceptions.
|
|
59
|
+
- **Plan first.** `plan` shows per-file decisions without creating files.
|
|
60
|
+
|
|
61
|
+
This is an independently implemented workflow layer. PDF/DOCX/HTML extraction is delegated to [Microsoft MarkItDown](https://github.com/microsoft/markitdown); this project is not affiliated with Microsoft or OpenAI. It does not claim to improve PDF extraction fidelity. [Alternatives and research](docs/research.md).
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
Python 3.11 or later is required. Use a virtual environment. On Windows, the Python launcher may be `py` instead of `python`.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/GokouRuri43/folioqueue.git
|
|
69
|
+
cd folioqueue
|
|
70
|
+
python -m pip install ".[documents]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`python -m pip install .` installs the dependency-free TXT/Markdown/CSV core. The `documents` extra adds PDF, DOCX and HTML through MarkItDown. A wheel and source archive are also provided in GitHub Releases; the project is **not currently published to PyPI**.
|
|
74
|
+
|
|
75
|
+
## Try it on the included examples
|
|
76
|
+
|
|
77
|
+
Run these commands from the repository directory:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
folioqueue plan examples/documents -o demo-output --json
|
|
81
|
+
folioqueue convert examples/documents -o demo-output
|
|
82
|
+
folioqueue convert examples/documents -o demo-output
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The first conversion produces three Markdown files and ignores one unsupported fixture. The second skips all three unchanged files. Open `demo-output/report.html` to inspect the latest run.
|
|
86
|
+
|
|
87
|
+
For your own collection:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
folioqueue convert ./documents -o ./markdown-output --workers 2 --timeout 60
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
On Windows:
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
folioqueue convert 'C:\My Documents' -o 'C:\Markdown Output' --workers 2
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Source and output directories must not overlap. Hidden-name files/directories, symlinks and Windows junctions are skipped. No URL inputs, cloud services, LLM API calls, third-party MarkItDown plugins, or shell commands are used by the conversion workflow. Dependencies must be installed beforehand.
|
|
100
|
+
|
|
101
|
+
## Supported formats
|
|
102
|
+
|
|
103
|
+
| Input | Backend | Boundaries |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `.txt`, `.md` | Standard library | Strict UTF-8 with optional BOM by default; `--encoding` selects another encoding |
|
|
106
|
+
| `.csv` | Standard library | Comma-separated, first row as header; quoted commas/newlines supported; Markdown cell escaping |
|
|
107
|
+
| `.html`, `.htm` | MarkItDown | Text extraction; no browser, script execution or asset downloading is requested |
|
|
108
|
+
| `.docx` | MarkItDown | Extraction quality follows upstream; no visual layout preservation |
|
|
109
|
+
| `.pdf` | MarkItDown | Text-based PDFs; no OCR, no guarantee of correct reading order or tables |
|
|
110
|
+
|
|
111
|
+
Empty extracted text is a failure, including a scanned PDF with no text layer. Legacy `.doc`, spreadsheets, slides, images, media, archives and encrypted documents are outside the v0.1 support scope. Source documents remain the authoritative copy.
|
|
112
|
+
|
|
113
|
+
## Controls and exit codes
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
folioqueue convert ./documents -o ./markdown-output --types txt,csv,docx
|
|
117
|
+
folioqueue convert ./documents -o ./markdown-output --encoding gb18030
|
|
118
|
+
folioqueue convert ./documents -o ./markdown-output --max-input-mb 64 --max-output-mb 32
|
|
119
|
+
folioqueue convert ./documents -o ./markdown-output --force --json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`--workers` is 1–16 (default 2). `--timeout` covers each converter subprocess, including imports, but not scanning, hashing, copying or committing. `--force` reconverts owned, unedited output; it never authorizes overwriting a conflict. Move conflicting output aside or use a new output directory.
|
|
123
|
+
|
|
124
|
+
| Exit | Meaning |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `0` | No failed files / no blocking plan decisions; ignored and stale entries are informational |
|
|
127
|
+
| `1` | At least one failed conversion or planned conflict/error |
|
|
128
|
+
| `2` | Invalid arguments, invalid ledger, overlapping roots, lock contention or a run-level filesystem error |
|
|
129
|
+
| `130` | Interrupted; rerun to continue from completed checkpoints |
|
|
130
|
+
|
|
131
|
+
Deleted source files are reported as **stale**; their output is retained. A failed updated source can leave the previous Markdown in place. Consumers must check `report.json` and must not assume every existing Markdown file is current. The report describes the most recent completed run, not a global corpus validity certificate.
|
|
132
|
+
|
|
133
|
+
## Output
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
markdown-output/
|
|
137
|
+
documents/
|
|
138
|
+
report.pdf.md
|
|
139
|
+
subfolder/notes.txt.md
|
|
140
|
+
report.html
|
|
141
|
+
report.json
|
|
142
|
+
.folioqueue/
|
|
143
|
+
state.json
|
|
144
|
+
writer.lock
|
|
145
|
+
work/
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The private ledger binds an output directory to one absolute source path. Do not edit it or share it as a public artifact. Moving the source requires a new output directory in v0.1. A kernel-managed exclusive writer lock is automatically released on process exit; the lock file may remain and should not be deleted while a run is active.
|
|
149
|
+
|
|
150
|
+
Interrupted conversions may leave temporary source snapshots in `.folioqueue/work`. Once no run is active, you may remove that directory to reclaim space. A subprocess is **failure isolation, not a security sandbox**; process only trusted documents or use an external sandbox for untrusted inputs. See [SECURITY.md](SECURITY.md).
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
python -m pip install -e ".[test,documents]"
|
|
156
|
+
python -m pytest --cov=folioqueue --cov-report=term-missing
|
|
157
|
+
python -m ruff check .
|
|
158
|
+
python -m ruff format --check .
|
|
159
|
+
python -m build
|
|
160
|
+
python -m twine check dist/*
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The test suite includes synthetic, redistributable PDF/DOCX/HTML fixtures and actual subprocess conversions. CI tests Windows, Linux and macOS. The workflow result, rather than this sentence, is the authority for current pass/fail status.
|
|
164
|
+
|
|
165
|
+
## Roadmap
|
|
166
|
+
|
|
167
|
+
1. Collect reproducible reports from document-collection maintainers and improve format diagnostics.
|
|
168
|
+
2. Publish larger, redistributable corpus results and measure overhead, failure behavior and memory use.
|
|
169
|
+
3. Consider an explicit stale-output review command and portable ledger migration after the core behavior is exercised.
|
|
170
|
+
|
|
171
|
+
Requests for unsupported features belong in issues with a concrete workflow and a minimal non-sensitive sample. No telemetry is collected by FolioQueue.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
folioqueue/__init__.py,sha256=rAHcO_WKeV-OrjN7mjgEMHGwukpt-WgD4FQXEhv2j1A,82
|
|
2
|
+
folioqueue/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
folioqueue/cli.py,sha256=aeTv0e7bV7YMZ3m2-eD6WAS06JW2UJGpC4Eq4bhx-3w,4735
|
|
4
|
+
folioqueue/engine.py,sha256=UnTBWN1hP2VNopJ1xZq4A-vGfwgOjxE9SqyuLjWW3gA,11914
|
|
5
|
+
folioqueue/paths.py,sha256=NcDid9CyiKvhJFfOH8ECcLGP4aBmGOwvnyMQwAvR7tA,4047
|
|
6
|
+
folioqueue/report.py,sha256=GuEkn5gv4MOg-ujVyuID65Y4Q8NzuF6dHNBmG0VN454,4704
|
|
7
|
+
folioqueue/storage.py,sha256=8WABTpiaudfDvEKcNU8lXaVQO_ROvZFIhPDnyoNYkVM,4363
|
|
8
|
+
folioqueue/worker.py,sha256=jVqWTB0o7dq9O2eieUKKjvlpJ2BHrSeq_job3LE4CBI,3604
|
|
9
|
+
folioqueue-0.1.0.dist-info/METADATA,sha256=axvir8fIdh4yi-foYQhpOjUlagUzwuQCcidqQCxVNCA,9469
|
|
10
|
+
folioqueue-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
folioqueue-0.1.0.dist-info/entry_points.txt,sha256=ndLewl-ba6PVgUpywwBXQmSfYHYvAalsgXHzdwL8OvE,51
|
|
12
|
+
folioqueue-0.1.0.dist-info/licenses/LICENSE,sha256=yLlBV2MXALdOaDm4YOgd4OLnycp8eqeOtdRBWkFHsO8,1068
|
|
13
|
+
folioqueue-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GokouRuri43
|
|
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.
|