codendium 1.0.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.
- codendium-1.0.0.dist-info/METADATA +332 -0
- codendium-1.0.0.dist-info/RECORD +45 -0
- codendium-1.0.0.dist-info/WHEEL +5 -0
- codendium-1.0.0.dist-info/entry_points.txt +6 -0
- codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
- codendium-1.0.0.dist-info/top_level.txt +1 -0
- copyright_deposit/__init__.py +15 -0
- copyright_deposit/__main__.py +34 -0
- copyright_deposit/assets/__init__.py +5 -0
- copyright_deposit/assets/fonts/README.md +31 -0
- copyright_deposit/assets/logo.svg +26 -0
- copyright_deposit/cli.py +314 -0
- copyright_deposit/config.py +269 -0
- copyright_deposit/core/__init__.py +1 -0
- copyright_deposit/core/deposit.py +117 -0
- copyright_deposit/core/discovery.py +260 -0
- copyright_deposit/core/encoding.py +136 -0
- copyright_deposit/core/languages.py +190 -0
- copyright_deposit/core/layout.py +349 -0
- copyright_deposit/core/lineranges.py +219 -0
- copyright_deposit/core/manifest.py +282 -0
- copyright_deposit/core/metrics.py +279 -0
- copyright_deposit/core/ordering.py +349 -0
- copyright_deposit/core/pipeline.py +386 -0
- copyright_deposit/core/redaction.py +162 -0
- copyright_deposit/core/render.py +242 -0
- copyright_deposit/core/scanning/__init__.py +61 -0
- copyright_deposit/core/scanning/secrets.py +181 -0
- copyright_deposit/core/scanning/thirdparty.py +190 -0
- copyright_deposit/core/strip/__init__.py +337 -0
- copyright_deposit/core/strip/cfamily_strip.py +235 -0
- copyright_deposit/core/strip/pygments_strip.py +85 -0
- copyright_deposit/core/strip/python_strip.py +131 -0
- copyright_deposit/gui/__init__.py +1 -0
- copyright_deposit/gui/app.py +34 -0
- copyright_deposit/gui/branding.py +83 -0
- copyright_deposit/gui/history.py +192 -0
- copyright_deposit/gui/main_window.py +617 -0
- copyright_deposit/gui/panels/__init__.py +1 -0
- copyright_deposit/gui/panels/estimate.py +166 -0
- copyright_deposit/gui/panels/files.py +635 -0
- copyright_deposit/gui/panels/identification.py +193 -0
- copyright_deposit/gui/panels/options.py +445 -0
- copyright_deposit/gui/panels/preflight.py +260 -0
- copyright_deposit/gui/workers.py +96 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Main window: wires the panels to the pipeline.
|
|
2
|
+
|
|
3
|
+
Only one worker runs at a time and the actions are disabled while it does,
|
|
4
|
+
which keeps the shared pipeline cache single-writer without any locking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import Qt
|
|
15
|
+
from PySide6.QtGui import QAction, QKeySequence
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QDialog,
|
|
18
|
+
QDialogButtonBox,
|
|
19
|
+
QFileDialog,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QInputDialog,
|
|
22
|
+
QLabel,
|
|
23
|
+
QListWidget,
|
|
24
|
+
QListWidgetItem,
|
|
25
|
+
QMainWindow,
|
|
26
|
+
QMessageBox,
|
|
27
|
+
QProgressBar,
|
|
28
|
+
QPushButton,
|
|
29
|
+
QTabWidget,
|
|
30
|
+
QVBoxLayout,
|
|
31
|
+
QWidget,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from .. import DISPLAY_NAME, __version__
|
|
35
|
+
from ..config import BuildSettings
|
|
36
|
+
from ..core.pipeline import Pipeline
|
|
37
|
+
from .branding import app_icon
|
|
38
|
+
from .history import HistoryStore, RunRecord
|
|
39
|
+
from .panels.estimate import EstimatePanel
|
|
40
|
+
from .panels.files import FilesPanel
|
|
41
|
+
from .panels.identification import IdentificationPanel
|
|
42
|
+
from .panels.options import OptionsPanel
|
|
43
|
+
from .panels.preflight import PreflightPanel
|
|
44
|
+
from .workers import (
|
|
45
|
+
BuildWorker,
|
|
46
|
+
DiscoverWorker,
|
|
47
|
+
EstimateWorker,
|
|
48
|
+
SuggestOrderWorker,
|
|
49
|
+
WhatIfWorker,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HistoryDialog(QDialog):
|
|
54
|
+
def __init__(self, runs: list[RunRecord], parent=None) -> None:
|
|
55
|
+
super().__init__(parent)
|
|
56
|
+
self.setWindowTitle("Previous builds")
|
|
57
|
+
self.resize(640, 420)
|
|
58
|
+
self.selected: RunRecord | None = None
|
|
59
|
+
|
|
60
|
+
layout = QVBoxLayout(self)
|
|
61
|
+
layout.addWidget(
|
|
62
|
+
QLabel("Open a previous build to restore the exact settings it used.")
|
|
63
|
+
)
|
|
64
|
+
self.list = QListWidget()
|
|
65
|
+
for run in runs:
|
|
66
|
+
item = QListWidgetItem(run.label())
|
|
67
|
+
item.setData(Qt.ItemDataRole.UserRole, run)
|
|
68
|
+
item.setToolTip(
|
|
69
|
+
f"{run.source_root}\n"
|
|
70
|
+
f"{run.deposit_pages} page(s) deposited ({run.deposit_mode})\n"
|
|
71
|
+
+ "\n".join(run.outputs)
|
|
72
|
+
)
|
|
73
|
+
self.list.addItem(item)
|
|
74
|
+
self.list.itemDoubleClicked.connect(lambda _: self.accept())
|
|
75
|
+
layout.addWidget(self.list, 1)
|
|
76
|
+
|
|
77
|
+
buttons = QDialogButtonBox(
|
|
78
|
+
QDialogButtonBox.StandardButton.Open | QDialogButtonBox.StandardButton.Cancel
|
|
79
|
+
)
|
|
80
|
+
buttons.accepted.connect(self.accept)
|
|
81
|
+
buttons.rejected.connect(self.reject)
|
|
82
|
+
layout.addWidget(buttons)
|
|
83
|
+
|
|
84
|
+
def accept(self) -> None:
|
|
85
|
+
item = self.list.currentItem()
|
|
86
|
+
if item is not None:
|
|
87
|
+
self.selected = item.data(Qt.ItemDataRole.UserRole)
|
|
88
|
+
super().accept()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class MainWindow(QMainWindow):
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.setWindowTitle(f"{DISPLAY_NAME} {__version__}")
|
|
95
|
+
# Set here as well as on the QApplication, so the icon is correct
|
|
96
|
+
# when MainWindow is constructed directly (tests, screenshots,
|
|
97
|
+
# embedding) rather than through gui.app.run().
|
|
98
|
+
self.setWindowIcon(app_icon())
|
|
99
|
+
self.resize(1120, 820)
|
|
100
|
+
|
|
101
|
+
self.pipeline = Pipeline()
|
|
102
|
+
self.history = HistoryStore()
|
|
103
|
+
self._worker = None
|
|
104
|
+
self._estimate = None
|
|
105
|
+
self._discovery = None
|
|
106
|
+
|
|
107
|
+
self.identification = IdentificationPanel()
|
|
108
|
+
self.files = FilesPanel()
|
|
109
|
+
self.options = OptionsPanel()
|
|
110
|
+
self.preflight = PreflightPanel()
|
|
111
|
+
self.estimate_panel = EstimatePanel()
|
|
112
|
+
|
|
113
|
+
self.tabs = QTabWidget()
|
|
114
|
+
self.tabs.addTab(self.identification, "1. Program")
|
|
115
|
+
self.tabs.addTab(self.files, "2. Files and order")
|
|
116
|
+
self.tabs.addTab(self.options, "3. Options")
|
|
117
|
+
self.tabs.addTab(self.preflight, "4. Pre-flight")
|
|
118
|
+
self.tabs.addTab(self.estimate_panel, "5. Estimate and build")
|
|
119
|
+
|
|
120
|
+
container = QWidget()
|
|
121
|
+
layout = QVBoxLayout(container)
|
|
122
|
+
layout.addWidget(self.tabs, 1)
|
|
123
|
+
layout.addLayout(self._action_bar())
|
|
124
|
+
self.setCentralWidget(container)
|
|
125
|
+
|
|
126
|
+
self._build_menu()
|
|
127
|
+
self._connect()
|
|
128
|
+
self._set_status("Choose a source folder to begin.")
|
|
129
|
+
|
|
130
|
+
# -- construction ------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _action_bar(self) -> QHBoxLayout:
|
|
133
|
+
bar = QHBoxLayout()
|
|
134
|
+
self.status = QLabel("")
|
|
135
|
+
self.status.setWordWrap(True)
|
|
136
|
+
self.progress = QProgressBar()
|
|
137
|
+
self.progress.setMaximumWidth(220)
|
|
138
|
+
self.progress.setVisible(False)
|
|
139
|
+
|
|
140
|
+
self.estimate_button = QPushButton("Estimate pages")
|
|
141
|
+
self.estimate_button.setToolTip("Exact page count without writing a PDF (Ctrl+E)")
|
|
142
|
+
self.estimate_button.clicked.connect(self.run_estimate)
|
|
143
|
+
|
|
144
|
+
self.build_button = QPushButton("Build deposit")
|
|
145
|
+
self.build_button.setToolTip("Render the PDFs, manifest and summary (Ctrl+B)")
|
|
146
|
+
self.build_button.clicked.connect(self.run_build)
|
|
147
|
+
self.build_button.setDefault(True)
|
|
148
|
+
|
|
149
|
+
bar.addWidget(self.status, 1)
|
|
150
|
+
bar.addWidget(self.progress)
|
|
151
|
+
bar.addWidget(self.estimate_button)
|
|
152
|
+
bar.addWidget(self.build_button)
|
|
153
|
+
return bar
|
|
154
|
+
|
|
155
|
+
def _build_menu(self) -> None:
|
|
156
|
+
file_menu = self.menuBar().addMenu("&File")
|
|
157
|
+
|
|
158
|
+
new_action = QAction("&New settings", self)
|
|
159
|
+
new_action.setShortcut(QKeySequence.StandardKey.New)
|
|
160
|
+
new_action.triggered.connect(self.reset_settings)
|
|
161
|
+
file_menu.addAction(new_action)
|
|
162
|
+
|
|
163
|
+
file_menu.addSeparator()
|
|
164
|
+
save_profile = QAction("&Save profile...", self)
|
|
165
|
+
save_profile.setShortcut(QKeySequence.StandardKey.Save)
|
|
166
|
+
save_profile.triggered.connect(self.save_profile)
|
|
167
|
+
file_menu.addAction(save_profile)
|
|
168
|
+
|
|
169
|
+
load_profile = QAction("&Load profile...", self)
|
|
170
|
+
load_profile.setShortcut(QKeySequence.StandardKey.Open)
|
|
171
|
+
load_profile.triggered.connect(self.load_profile)
|
|
172
|
+
file_menu.addAction(load_profile)
|
|
173
|
+
|
|
174
|
+
delete_profile = QAction("&Delete profile...", self)
|
|
175
|
+
delete_profile.triggered.connect(self.delete_profile)
|
|
176
|
+
file_menu.addAction(delete_profile)
|
|
177
|
+
|
|
178
|
+
file_menu.addSeparator()
|
|
179
|
+
history_action = QAction("Previous &builds...", self)
|
|
180
|
+
history_action.setShortcut("Ctrl+H")
|
|
181
|
+
history_action.triggered.connect(self.show_history)
|
|
182
|
+
file_menu.addAction(history_action)
|
|
183
|
+
|
|
184
|
+
file_menu.addSeparator()
|
|
185
|
+
export_settings = QAction("&Export settings to JSON...", self)
|
|
186
|
+
export_settings.triggered.connect(self.export_settings)
|
|
187
|
+
file_menu.addAction(export_settings)
|
|
188
|
+
import_settings = QAction("&Import settings from JSON...", self)
|
|
189
|
+
import_settings.triggered.connect(self.import_settings)
|
|
190
|
+
file_menu.addAction(import_settings)
|
|
191
|
+
|
|
192
|
+
file_menu.addSeparator()
|
|
193
|
+
quit_action = QAction("E&xit", self)
|
|
194
|
+
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
|
|
195
|
+
quit_action.triggered.connect(self.close)
|
|
196
|
+
file_menu.addAction(quit_action)
|
|
197
|
+
|
|
198
|
+
build_menu = self.menuBar().addMenu("&Build")
|
|
199
|
+
estimate_action = QAction("&Estimate pages", self)
|
|
200
|
+
estimate_action.setShortcut("Ctrl+E")
|
|
201
|
+
estimate_action.triggered.connect(self.run_estimate)
|
|
202
|
+
build_menu.addAction(estimate_action)
|
|
203
|
+
|
|
204
|
+
build_action = QAction("&Build deposit", self)
|
|
205
|
+
build_action.setShortcut("Ctrl+B")
|
|
206
|
+
build_action.triggered.connect(self.run_build)
|
|
207
|
+
build_menu.addAction(build_action)
|
|
208
|
+
|
|
209
|
+
compare_action = QAction("&Compare comment policies", self)
|
|
210
|
+
compare_action.triggered.connect(self.run_what_if)
|
|
211
|
+
build_menu.addAction(compare_action)
|
|
212
|
+
|
|
213
|
+
help_menu = self.menuBar().addMenu("&Help")
|
|
214
|
+
about_action = QAction("&About", self)
|
|
215
|
+
about_action.triggered.connect(self.show_about)
|
|
216
|
+
help_menu.addAction(about_action)
|
|
217
|
+
|
|
218
|
+
def _connect(self) -> None:
|
|
219
|
+
self.identification.sourceChanged.connect(self._on_source_changed)
|
|
220
|
+
self.identification.changed.connect(self._mark_stale)
|
|
221
|
+
self.options.changed.connect(self._mark_stale)
|
|
222
|
+
self.files.changed.connect(self._mark_stale)
|
|
223
|
+
self.files.rescanRequested.connect(self.rescan)
|
|
224
|
+
self.files.suggestRequested.connect(self.suggest_order)
|
|
225
|
+
self.files.rangeError.connect(self._set_status)
|
|
226
|
+
self.preflight.redactRequested.connect(self._queue_redactions)
|
|
227
|
+
self.preflight.excludeRequested.connect(self._exclude_files)
|
|
228
|
+
self.preflight.ignoreChanged.connect(self._mark_stale)
|
|
229
|
+
self.estimate_panel.whatIfRequested.connect(self.run_what_if)
|
|
230
|
+
|
|
231
|
+
# -- settings ----------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def collect_settings(self) -> BuildSettings:
|
|
234
|
+
settings = BuildSettings()
|
|
235
|
+
self.identification.collect(settings)
|
|
236
|
+
self.options.collect(settings)
|
|
237
|
+
# The table is the order list: included rows, in their shown order.
|
|
238
|
+
settings.order_entries = self.files.included_paths()
|
|
239
|
+
settings.excluded = self.files.excluded_paths()
|
|
240
|
+
settings.line_ranges = self.files.line_ranges()
|
|
241
|
+
settings.include_unlisted = True
|
|
242
|
+
settings.scan.ignored_findings = self.preflight.ignored_ids()
|
|
243
|
+
return settings
|
|
244
|
+
|
|
245
|
+
def apply_settings(self, settings: BuildSettings) -> None:
|
|
246
|
+
self.identification.apply(settings)
|
|
247
|
+
self.options.apply(settings)
|
|
248
|
+
self.preflight.set_ignored_ids(settings.scan.ignored_findings)
|
|
249
|
+
# Ranges must be in place before the rescan repopulates the table,
|
|
250
|
+
# so restored rows show their selection immediately.
|
|
251
|
+
self.files.set_line_ranges(settings.line_ranges)
|
|
252
|
+
if settings.source_root and Path(settings.source_root).is_dir():
|
|
253
|
+
self.rescan(then_order=settings.order_entries)
|
|
254
|
+
|
|
255
|
+
def reset_settings(self) -> None:
|
|
256
|
+
self.files.set_line_ranges({})
|
|
257
|
+
self.apply_settings(BuildSettings())
|
|
258
|
+
self.files.table.setRowCount(0)
|
|
259
|
+
self._set_status("Settings reset.")
|
|
260
|
+
|
|
261
|
+
# -- status / worker plumbing -----------------------------------------
|
|
262
|
+
|
|
263
|
+
def _set_status(self, message: str) -> None:
|
|
264
|
+
self.status.setText(message)
|
|
265
|
+
|
|
266
|
+
def _mark_stale(self) -> None:
|
|
267
|
+
if self._estimate is not None:
|
|
268
|
+
self._set_status("Settings changed - the estimate is out of date.")
|
|
269
|
+
self._estimate = None
|
|
270
|
+
|
|
271
|
+
def _busy(self, busy: bool) -> None:
|
|
272
|
+
for widget in (self.estimate_button, self.build_button, self.tabs):
|
|
273
|
+
widget.setEnabled(not busy)
|
|
274
|
+
self.progress.setVisible(busy)
|
|
275
|
+
if not busy:
|
|
276
|
+
self.progress.setRange(0, 100)
|
|
277
|
+
self.progress.setValue(0)
|
|
278
|
+
|
|
279
|
+
def _on_progress(self, stage: str, current: int, total: int) -> None:
|
|
280
|
+
self._set_status(f"{stage}...")
|
|
281
|
+
if total > 1:
|
|
282
|
+
self.progress.setRange(0, total)
|
|
283
|
+
self.progress.setValue(current)
|
|
284
|
+
else:
|
|
285
|
+
self.progress.setRange(0, 0)
|
|
286
|
+
|
|
287
|
+
def _on_failure(self, message: str) -> None:
|
|
288
|
+
self._busy(False)
|
|
289
|
+
self._worker = None
|
|
290
|
+
self._set_status("Failed.")
|
|
291
|
+
QMessageBox.critical(self, "Something went wrong", message)
|
|
292
|
+
|
|
293
|
+
def _start(self, worker) -> bool:
|
|
294
|
+
if self._worker is not None and self._worker.isRunning():
|
|
295
|
+
QMessageBox.information(self, "Busy", "Another task is still running.")
|
|
296
|
+
return False
|
|
297
|
+
self._worker = worker
|
|
298
|
+
worker.progressed.connect(self._on_progress)
|
|
299
|
+
worker.failed.connect(self._on_failure)
|
|
300
|
+
self._busy(True)
|
|
301
|
+
worker.start()
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
def _require_source(self) -> BuildSettings | None:
|
|
305
|
+
settings = self.collect_settings()
|
|
306
|
+
if not settings.source_root or not Path(settings.source_root).is_dir():
|
|
307
|
+
QMessageBox.warning(
|
|
308
|
+
self, "No source folder", "Choose a folder containing the source code first."
|
|
309
|
+
)
|
|
310
|
+
self.tabs.setCurrentIndex(0)
|
|
311
|
+
return None
|
|
312
|
+
return settings
|
|
313
|
+
|
|
314
|
+
# -- actions -----------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def _on_source_changed(self, path: str) -> None:
|
|
317
|
+
self._mark_stale()
|
|
318
|
+
if path and Path(path).is_dir():
|
|
319
|
+
self.rescan()
|
|
320
|
+
|
|
321
|
+
def rescan(self, then_order: list[str] | None = None) -> None:
|
|
322
|
+
settings = self.collect_settings()
|
|
323
|
+
if not settings.source_root or not Path(settings.source_root).is_dir():
|
|
324
|
+
return
|
|
325
|
+
settings.order_entries = list(then_order) if then_order else settings.order_entries
|
|
326
|
+
|
|
327
|
+
worker = DiscoverWorker(self.pipeline, settings, self)
|
|
328
|
+
|
|
329
|
+
def done(discovery) -> None:
|
|
330
|
+
self._discovery = discovery
|
|
331
|
+
from ..core import ordering
|
|
332
|
+
|
|
333
|
+
plan = ordering.resolve_order(
|
|
334
|
+
settings.order_entries,
|
|
335
|
+
discovery.files,
|
|
336
|
+
include_unlisted=True,
|
|
337
|
+
excluded=settings.excluded,
|
|
338
|
+
)
|
|
339
|
+
self.files.populate(discovery, plan)
|
|
340
|
+
self._busy(False)
|
|
341
|
+
self._worker = None
|
|
342
|
+
skipped = len(discovery.skipped)
|
|
343
|
+
self._set_status(
|
|
344
|
+
f"Found {len(discovery.files)} source file(s)"
|
|
345
|
+
+ (f"; {skipped} skipped." if skipped else ".")
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
worker.completed.connect(done)
|
|
349
|
+
self._start(worker)
|
|
350
|
+
|
|
351
|
+
def suggest_order(self) -> None:
|
|
352
|
+
settings = self._require_source()
|
|
353
|
+
if settings is None:
|
|
354
|
+
return
|
|
355
|
+
worker = SuggestOrderWorker(self.pipeline, settings, self)
|
|
356
|
+
|
|
357
|
+
def done(order: list[str]) -> None:
|
|
358
|
+
self.files.reorder_to(order)
|
|
359
|
+
self._busy(False)
|
|
360
|
+
self._worker = None
|
|
361
|
+
self._set_status(
|
|
362
|
+
"Order suggested from the entry points and the dependency graph."
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
worker.completed.connect(done)
|
|
366
|
+
self._start(worker)
|
|
367
|
+
|
|
368
|
+
def run_estimate(self) -> None:
|
|
369
|
+
settings = self._require_source()
|
|
370
|
+
if settings is None:
|
|
371
|
+
return
|
|
372
|
+
worker = EstimateWorker(self.pipeline, settings, self)
|
|
373
|
+
|
|
374
|
+
def done(estimate) -> None:
|
|
375
|
+
self._estimate = estimate
|
|
376
|
+
self.estimate_panel.apply_estimate(estimate)
|
|
377
|
+
self.preflight.apply_estimate(estimate)
|
|
378
|
+
self.files.apply_estimate(estimate)
|
|
379
|
+
self._busy(False)
|
|
380
|
+
self._worker = None
|
|
381
|
+
self.tabs.setCurrentWidget(self.estimate_panel)
|
|
382
|
+
self._set_status(
|
|
383
|
+
f"{estimate.page_count} page(s); "
|
|
384
|
+
f"{estimate.selection.deposited_pages} would be deposited."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
worker.completed.connect(done)
|
|
388
|
+
self._start(worker)
|
|
389
|
+
|
|
390
|
+
def run_what_if(self) -> None:
|
|
391
|
+
settings = self._require_source()
|
|
392
|
+
if settings is None:
|
|
393
|
+
return
|
|
394
|
+
worker = WhatIfWorker(self.pipeline, settings, self)
|
|
395
|
+
|
|
396
|
+
def done(rows) -> None:
|
|
397
|
+
self.estimate_panel.apply_what_if(rows)
|
|
398
|
+
self._busy(False)
|
|
399
|
+
self._worker = None
|
|
400
|
+
self.tabs.setCurrentWidget(self.estimate_panel)
|
|
401
|
+
self._set_status("Comparison complete.")
|
|
402
|
+
|
|
403
|
+
worker.completed.connect(done)
|
|
404
|
+
self._start(worker)
|
|
405
|
+
|
|
406
|
+
def run_build(self, force: bool = False) -> None:
|
|
407
|
+
settings = self._require_source()
|
|
408
|
+
if settings is None:
|
|
409
|
+
return
|
|
410
|
+
if not settings.output_dir:
|
|
411
|
+
QMessageBox.warning(self, "No output folder", "Choose where to write the PDFs.")
|
|
412
|
+
self.tabs.setCurrentIndex(0)
|
|
413
|
+
return
|
|
414
|
+
if not settings.header.program_name.strip():
|
|
415
|
+
answer = QMessageBox.question(
|
|
416
|
+
self,
|
|
417
|
+
"No program name",
|
|
418
|
+
"The Copyright Office requires the program title on the first page.\n\n"
|
|
419
|
+
"Build anyway with a placeholder?",
|
|
420
|
+
)
|
|
421
|
+
if answer != QMessageBox.StandardButton.Yes:
|
|
422
|
+
self.tabs.setCurrentIndex(0)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
worker = BuildWorker(self.pipeline, settings, force=force, parent=self)
|
|
426
|
+
|
|
427
|
+
def done(result) -> None:
|
|
428
|
+
self._busy(False)
|
|
429
|
+
self._worker = None
|
|
430
|
+
self._estimate = result.estimate
|
|
431
|
+
self.estimate_panel.apply_estimate(result.estimate)
|
|
432
|
+
self.preflight.apply_estimate(result.estimate)
|
|
433
|
+
self.files.apply_estimate(result.estimate)
|
|
434
|
+
|
|
435
|
+
if result.blocked_by:
|
|
436
|
+
self._handle_blocked(result)
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
self.history.record_run(
|
|
440
|
+
settings,
|
|
441
|
+
result.estimate.page_count,
|
|
442
|
+
result.estimate.selection.deposited_pages,
|
|
443
|
+
result.estimate.selection.mode,
|
|
444
|
+
result.outputs,
|
|
445
|
+
len(result.estimate.warnings),
|
|
446
|
+
)
|
|
447
|
+
self._set_status(f"Built {len(result.outputs)} file(s).")
|
|
448
|
+
self._show_success(result)
|
|
449
|
+
|
|
450
|
+
worker.completed.connect(done)
|
|
451
|
+
self._start(worker)
|
|
452
|
+
|
|
453
|
+
def _handle_blocked(self, result) -> None:
|
|
454
|
+
self.tabs.setCurrentWidget(self.preflight)
|
|
455
|
+
listing = "\n".join(
|
|
456
|
+
f" {f.location()} - {f.detail} ({f.excerpt})" for f in result.blocked_by[:12]
|
|
457
|
+
)
|
|
458
|
+
box = QMessageBox(self)
|
|
459
|
+
box.setIcon(QMessageBox.Icon.Warning)
|
|
460
|
+
box.setWindowTitle("Possible credentials in the deposit")
|
|
461
|
+
box.setText(
|
|
462
|
+
f"{len(result.blocked_by)} finding(s) look like real credentials.\n\n"
|
|
463
|
+
"A deposit is a public record, so anything left in the code can be read "
|
|
464
|
+
"by anyone who inspects the filing."
|
|
465
|
+
)
|
|
466
|
+
box.setDetailedText(listing)
|
|
467
|
+
review = box.addButton("Review findings", QMessageBox.ButtonRole.AcceptRole)
|
|
468
|
+
anyway = box.addButton("Build anyway", QMessageBox.ButtonRole.DestructiveRole)
|
|
469
|
+
box.addButton(QMessageBox.StandardButton.Cancel)
|
|
470
|
+
box.exec()
|
|
471
|
+
if box.clickedButton() is anyway:
|
|
472
|
+
self.run_build(force=True)
|
|
473
|
+
elif box.clickedButton() is review:
|
|
474
|
+
self.tabs.setCurrentWidget(self.preflight)
|
|
475
|
+
|
|
476
|
+
def _show_success(self, result) -> None:
|
|
477
|
+
outputs = list(result.outputs)
|
|
478
|
+
if result.manifest_path:
|
|
479
|
+
outputs += [result.manifest_path, result.summary_path]
|
|
480
|
+
box = QMessageBox(self)
|
|
481
|
+
box.setIcon(QMessageBox.Icon.Information)
|
|
482
|
+
box.setWindowTitle("Deposit built")
|
|
483
|
+
box.setText(
|
|
484
|
+
f"{result.estimate.page_count} page(s) laid out; "
|
|
485
|
+
f"{result.estimate.selection.deposited_pages} deposited."
|
|
486
|
+
)
|
|
487
|
+
box.setInformativeText(
|
|
488
|
+
result.estimate.selection.filing_statement()
|
|
489
|
+
+ "\n\nWritten:\n"
|
|
490
|
+
+ "\n".join(outputs)
|
|
491
|
+
)
|
|
492
|
+
open_button = box.addButton("Open folder", QMessageBox.ButtonRole.ActionRole)
|
|
493
|
+
box.addButton(QMessageBox.StandardButton.Ok)
|
|
494
|
+
box.exec()
|
|
495
|
+
if box.clickedButton() is open_button and outputs:
|
|
496
|
+
self._reveal(Path(outputs[0]).parent)
|
|
497
|
+
|
|
498
|
+
@staticmethod
|
|
499
|
+
def _reveal(folder: Path) -> None:
|
|
500
|
+
try:
|
|
501
|
+
if sys.platform.startswith("win"):
|
|
502
|
+
os.startfile(str(folder)) # noqa: S606
|
|
503
|
+
elif sys.platform == "darwin":
|
|
504
|
+
subprocess.run(["open", str(folder)], check=False)
|
|
505
|
+
else:
|
|
506
|
+
subprocess.run(["xdg-open", str(folder)], check=False)
|
|
507
|
+
except OSError:
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
# -- pre-flight follow-ups --------------------------------------------
|
|
511
|
+
|
|
512
|
+
def _queue_redactions(self, locations: list[str]) -> None:
|
|
513
|
+
self.options.queue_redactions(locations)
|
|
514
|
+
self.tabs.setCurrentWidget(self.options)
|
|
515
|
+
self._set_status(
|
|
516
|
+
f"{len(locations)} line(s) queued for redaction. Re-run the estimate to confirm."
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def _exclude_files(self, paths: list[str]) -> None:
|
|
520
|
+
self.files.set_included(paths, False)
|
|
521
|
+
self.tabs.setCurrentWidget(self.files)
|
|
522
|
+
self._set_status(f"{len(paths)} file(s) excluded from the deposit.")
|
|
523
|
+
|
|
524
|
+
# -- profiles and history ---------------------------------------------
|
|
525
|
+
|
|
526
|
+
def save_profile(self) -> None:
|
|
527
|
+
settings = self.collect_settings()
|
|
528
|
+
default = settings.header.program_name or Path(settings.source_root).name
|
|
529
|
+
name, ok = QInputDialog.getText(self, "Save profile", "Profile name:", text=default)
|
|
530
|
+
if not ok or not name.strip():
|
|
531
|
+
return
|
|
532
|
+
self.history.save_profile(name.strip(), settings)
|
|
533
|
+
self._set_status(f"Profile '{name.strip()}' saved.")
|
|
534
|
+
|
|
535
|
+
def load_profile(self) -> None:
|
|
536
|
+
names = self.history.profile_names()
|
|
537
|
+
if not names:
|
|
538
|
+
QMessageBox.information(self, "No profiles", "No profiles have been saved yet.")
|
|
539
|
+
return
|
|
540
|
+
name, ok = QInputDialog.getItem(self, "Load profile", "Profile:", names, 0, False)
|
|
541
|
+
if not ok:
|
|
542
|
+
return
|
|
543
|
+
settings = self.history.load_profile(name)
|
|
544
|
+
if settings is None:
|
|
545
|
+
QMessageBox.warning(self, "Could not load", f"Profile '{name}' could not be read.")
|
|
546
|
+
return
|
|
547
|
+
self.apply_settings(settings)
|
|
548
|
+
self._set_status(f"Profile '{name}' loaded.")
|
|
549
|
+
|
|
550
|
+
def delete_profile(self) -> None:
|
|
551
|
+
names = self.history.profile_names()
|
|
552
|
+
if not names:
|
|
553
|
+
return
|
|
554
|
+
name, ok = QInputDialog.getItem(self, "Delete profile", "Profile:", names, 0, False)
|
|
555
|
+
if ok and name:
|
|
556
|
+
self.history.delete_profile(name)
|
|
557
|
+
self._set_status(f"Profile '{name}' deleted.")
|
|
558
|
+
|
|
559
|
+
def show_history(self) -> None:
|
|
560
|
+
runs = self.history.recent_runs()
|
|
561
|
+
if not runs:
|
|
562
|
+
QMessageBox.information(self, "No history", "No builds have been recorded yet.")
|
|
563
|
+
return
|
|
564
|
+
dialog = HistoryDialog(runs, self)
|
|
565
|
+
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected is not None:
|
|
566
|
+
try:
|
|
567
|
+
settings = dialog.selected.settings()
|
|
568
|
+
except (ValueError, TypeError):
|
|
569
|
+
QMessageBox.warning(self, "Could not load", "That run's settings could not be read.")
|
|
570
|
+
return
|
|
571
|
+
self.apply_settings(settings)
|
|
572
|
+
self._set_status(f"Restored settings from {dialog.selected.label()}.")
|
|
573
|
+
|
|
574
|
+
def export_settings(self) -> None:
|
|
575
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
576
|
+
self, "Export settings", "deposit_settings.json", "JSON (*.json)"
|
|
577
|
+
)
|
|
578
|
+
if path:
|
|
579
|
+
Path(path).write_text(self.collect_settings().to_json(), encoding="utf-8")
|
|
580
|
+
self._set_status(f"Settings written to {path}")
|
|
581
|
+
|
|
582
|
+
def import_settings(self) -> None:
|
|
583
|
+
path, _ = QFileDialog.getOpenFileName(self, "Import settings", "", "JSON (*.json)")
|
|
584
|
+
if not path:
|
|
585
|
+
return
|
|
586
|
+
try:
|
|
587
|
+
settings = BuildSettings.from_json(Path(path).read_text(encoding="utf-8"))
|
|
588
|
+
except (ValueError, TypeError) as exc:
|
|
589
|
+
QMessageBox.warning(self, "Could not read", str(exc))
|
|
590
|
+
return
|
|
591
|
+
self.apply_settings(settings)
|
|
592
|
+
self._set_status(f"Settings loaded from {path}")
|
|
593
|
+
|
|
594
|
+
def show_about(self) -> None:
|
|
595
|
+
QMessageBox.about(
|
|
596
|
+
self,
|
|
597
|
+
f"About {DISPLAY_NAME}",
|
|
598
|
+
f"<b>{DISPLAY_NAME} {__version__}</b>"
|
|
599
|
+
"<p>Builds source-code deposit PDFs for US copyright registration.</p>"
|
|
600
|
+
"<ul>"
|
|
601
|
+
"<li>Monospaced type at 9.5 pt, about 40 lines per page, continuous "
|
|
602
|
+
"page numbers and clear file paths.</li>"
|
|
603
|
+
"<li>Applies Compendium sec. 721.6: the whole program at or under 50 "
|
|
604
|
+
"pages, otherwise the first 25 and last 25.</li>"
|
|
605
|
+
"<li>Supports blocked-out material under sec. 721.7, with the 49% "
|
|
606
|
+
"limit checked.</li>"
|
|
607
|
+
"<li>Page estimates are exact - they come from the same layout the "
|
|
608
|
+
"renderer uses.</li>"
|
|
609
|
+
"</ul>"
|
|
610
|
+
"<p>This tool prepares a deposit; it is not legal advice.</p>",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def closeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
|
614
|
+
if self._worker is not None and self._worker.isRunning():
|
|
615
|
+
self._worker.wait(3000)
|
|
616
|
+
self.history.close()
|
|
617
|
+
super().closeEvent(event)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Main-window panels."""
|