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.
Files changed (45) hide show
  1. codendium-1.0.0.dist-info/METADATA +332 -0
  2. codendium-1.0.0.dist-info/RECORD +45 -0
  3. codendium-1.0.0.dist-info/WHEEL +5 -0
  4. codendium-1.0.0.dist-info/entry_points.txt +6 -0
  5. codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. codendium-1.0.0.dist-info/top_level.txt +1 -0
  7. copyright_deposit/__init__.py +15 -0
  8. copyright_deposit/__main__.py +34 -0
  9. copyright_deposit/assets/__init__.py +5 -0
  10. copyright_deposit/assets/fonts/README.md +31 -0
  11. copyright_deposit/assets/logo.svg +26 -0
  12. copyright_deposit/cli.py +314 -0
  13. copyright_deposit/config.py +269 -0
  14. copyright_deposit/core/__init__.py +1 -0
  15. copyright_deposit/core/deposit.py +117 -0
  16. copyright_deposit/core/discovery.py +260 -0
  17. copyright_deposit/core/encoding.py +136 -0
  18. copyright_deposit/core/languages.py +190 -0
  19. copyright_deposit/core/layout.py +349 -0
  20. copyright_deposit/core/lineranges.py +219 -0
  21. copyright_deposit/core/manifest.py +282 -0
  22. copyright_deposit/core/metrics.py +279 -0
  23. copyright_deposit/core/ordering.py +349 -0
  24. copyright_deposit/core/pipeline.py +386 -0
  25. copyright_deposit/core/redaction.py +162 -0
  26. copyright_deposit/core/render.py +242 -0
  27. copyright_deposit/core/scanning/__init__.py +61 -0
  28. copyright_deposit/core/scanning/secrets.py +181 -0
  29. copyright_deposit/core/scanning/thirdparty.py +190 -0
  30. copyright_deposit/core/strip/__init__.py +337 -0
  31. copyright_deposit/core/strip/cfamily_strip.py +235 -0
  32. copyright_deposit/core/strip/pygments_strip.py +85 -0
  33. copyright_deposit/core/strip/python_strip.py +131 -0
  34. copyright_deposit/gui/__init__.py +1 -0
  35. copyright_deposit/gui/app.py +34 -0
  36. copyright_deposit/gui/branding.py +83 -0
  37. copyright_deposit/gui/history.py +192 -0
  38. copyright_deposit/gui/main_window.py +617 -0
  39. copyright_deposit/gui/panels/__init__.py +1 -0
  40. copyright_deposit/gui/panels/estimate.py +166 -0
  41. copyright_deposit/gui/panels/files.py +635 -0
  42. copyright_deposit/gui/panels/identification.py +193 -0
  43. copyright_deposit/gui/panels/options.py +445 -0
  44. copyright_deposit/gui/panels/preflight.py +260 -0
  45. 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."""