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,635 @@
1
+ """The file list: what goes in the deposit, and in what order.
2
+
3
+ The table *is* the order list. Rows can be dragged, nudged with the
4
+ buttons, or replaced wholesale by an imported list or the suggested
5
+ dependency order. Files nobody named are appended and tinted, so an
6
+ automatic decision never passes for a deliberate one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from PySide6.QtCore import Qt, Signal
14
+ from PySide6.QtGui import QBrush, QColor, QFont
15
+ from PySide6.QtWidgets import (
16
+ QAbstractItemView,
17
+ QFileDialog,
18
+ QHBoxLayout,
19
+ QHeaderView,
20
+ QInputDialog,
21
+ QLabel,
22
+ QMessageBox,
23
+ QPushButton,
24
+ QTableWidget,
25
+ QTableWidgetItem,
26
+ QVBoxLayout,
27
+ QWidget,
28
+ )
29
+
30
+ from ...core import lineranges, ordering
31
+ from ...core.discovery import DiscoveredFile, DiscoveryResult
32
+ from ...core.ordering import SOURCE_UNLISTED, OrderPlan
33
+
34
+ COL_INCLUDE = 0
35
+ COL_PATH = 1
36
+ COL_LANGUAGE = 2
37
+ COL_LINES = 3
38
+ COL_RANGES = 4
39
+ COL_ORIGIN = 5
40
+ COL_PAGES = 6
41
+
42
+ # Row tints are alpha-blended over whatever the theme's base colour is, so
43
+ # they must stay faint: on a dark theme an alpha in the 40s stops reading as
44
+ # a highlight and turns the row into a solid block of colour.
45
+ UNLISTED_TINT = QColor(255, 193, 7, 26)
46
+ OMITTED_TINT = QColor(128, 128, 128, 34)
47
+ PARTIAL_TINT = QColor(64, 140, 255, 30)
48
+
49
+ # Readable on both light and dark themes, unlike a blended background.
50
+ AMBER_TEXT = QColor(198, 132, 0)
51
+
52
+ RANGE_HINT = (
53
+ "Blank = the whole file. Otherwise list the lines to deposit, using the "
54
+ "line numbers you see in your editor:\n"
55
+ " 1-50, 120-200 two blocks\n"
56
+ " 1-80 one block\n"
57
+ " 305- from line 305 to the end\n"
58
+ " -40 the first 40 lines\n"
59
+ "Omitted stretches are marked in the PDF with a dashed rule naming the "
60
+ "missing lines, and the file gets a PARTIAL FILE banner."
61
+ )
62
+
63
+
64
+ class ReorderTable(QTableWidget):
65
+ """A table whose rows can be dragged into a new order."""
66
+
67
+ orderChanged = Signal()
68
+
69
+ def __init__(self, parent=None) -> None:
70
+ super().__init__(parent)
71
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
72
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
73
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
74
+ self.setDragDropOverwriteMode(False)
75
+ self.setDropIndicatorShown(True)
76
+ self.verticalHeader().setSectionsMovable(False)
77
+
78
+ def dropEvent(self, event) -> None: # noqa: N802 - Qt naming
79
+ if event.source() is not self:
80
+ super().dropEvent(event)
81
+ return
82
+
83
+ target = self.indexAt(event.position().toPoint()).row()
84
+ if target < 0:
85
+ target = self.rowCount()
86
+
87
+ rows = sorted({index.row() for index in self.selectedIndexes()})
88
+ if not rows:
89
+ event.ignore()
90
+ return
91
+
92
+ taken: list[list[QTableWidgetItem | None]] = []
93
+ for row in reversed(rows):
94
+ taken.insert(0, [self.takeItem(row, col) for col in range(self.columnCount())])
95
+ self.removeRow(row)
96
+
97
+ target -= sum(1 for row in rows if row < target)
98
+ target = max(0, min(target, self.rowCount()))
99
+
100
+ for offset, cells in enumerate(taken):
101
+ self.insertRow(target + offset)
102
+ for col, item in enumerate(cells):
103
+ if item is not None:
104
+ self.setItem(target + offset, col, item)
105
+
106
+ self.clearSelection()
107
+ for offset in range(len(taken)):
108
+ self.selectRow(target + offset)
109
+ event.accept()
110
+ self.orderChanged.emit()
111
+
112
+
113
+ class FilesPanel(QWidget):
114
+ changed = Signal()
115
+ rescanRequested = Signal()
116
+ suggestRequested = Signal()
117
+ rangeError = Signal(str)
118
+
119
+ def __init__(self, parent=None) -> None:
120
+ super().__init__(parent)
121
+ layout = QVBoxLayout(self)
122
+
123
+ toolbar = QHBoxLayout()
124
+ self.rescan_button = QPushButton("Rescan folder")
125
+ self.rescan_button.clicked.connect(self.rescanRequested.emit)
126
+ self.suggest_button = QPushButton("Suggest order")
127
+ self.suggest_button.setToolTip(
128
+ "Order by entry point, then the import / #include graph, so the deposit "
129
+ "has an identifiable beginning"
130
+ )
131
+ self.suggest_button.clicked.connect(self.suggestRequested.emit)
132
+ self.import_button = QPushButton("Import list...")
133
+ self.import_button.clicked.connect(self._import_order)
134
+ self.export_button = QPushButton("Export list...")
135
+ self.export_button.clicked.connect(self._export_order)
136
+ for button in (
137
+ self.rescan_button, self.suggest_button, self.import_button, self.export_button
138
+ ):
139
+ toolbar.addWidget(button)
140
+ toolbar.addStretch(1)
141
+
142
+ self.lines_button = QPushButton("Deposit lines...")
143
+ self.lines_button.setToolTip(RANGE_HINT)
144
+ self.lines_button.clicked.connect(self._prompt_ranges)
145
+ self.include_button = QPushButton("Include")
146
+ self.include_button.clicked.connect(lambda: self._set_included(True))
147
+ self.exclude_button = QPushButton("Exclude")
148
+ self.exclude_button.clicked.connect(lambda: self._set_included(False))
149
+ toolbar.addWidget(self.lines_button)
150
+ toolbar.addWidget(self.include_button)
151
+ toolbar.addWidget(self.exclude_button)
152
+
153
+ move_bar = QHBoxLayout()
154
+ for label, handler in (
155
+ ("Top", self._move_top),
156
+ ("Up", lambda: self._move(-1)),
157
+ ("Down", lambda: self._move(1)),
158
+ ("Bottom", self._move_bottom),
159
+ ):
160
+ button = QPushButton(label)
161
+ button.clicked.connect(handler)
162
+ move_bar.addWidget(button)
163
+ move_bar.addStretch(1)
164
+ self.summary = QLabel("No folder scanned yet.")
165
+ move_bar.addWidget(self.summary)
166
+
167
+ self.table = ReorderTable()
168
+ self.table.setColumnCount(7)
169
+ self.table.setHorizontalHeaderLabels(
170
+ ["In", "Path", "Language", "Lines", "Deposit lines", "Origin", "Pages"]
171
+ )
172
+ self.table.horizontalHeaderItem(COL_RANGES).setToolTip(RANGE_HINT)
173
+ self.table.orderChanged.connect(self.changed.emit)
174
+ self.table.itemChanged.connect(self._on_item_changed)
175
+ header = self.table.horizontalHeader()
176
+ header.setSectionResizeMode(COL_PATH, QHeaderView.ResizeMode.Stretch)
177
+ for column in (COL_INCLUDE, COL_LANGUAGE, COL_LINES, COL_ORIGIN, COL_PAGES):
178
+ header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents)
179
+ header.setSectionResizeMode(COL_RANGES, QHeaderView.ResizeMode.Interactive)
180
+ self.table.setColumnWidth(COL_RANGES, 150)
181
+
182
+ layout.addLayout(toolbar)
183
+ layout.addLayout(move_bar)
184
+ layout.addWidget(self.table, 1)
185
+
186
+ self._files: dict[str, DiscoveredFile] = {}
187
+ # Range specs are held here, not read back from cells, so they
188
+ # survive a rescan and a reorder.
189
+ self._ranges: dict[str, str] = {}
190
+ self._loading = False
191
+
192
+ # -- population --------------------------------------------------------
193
+
194
+ def populate(self, discovery: DiscoveryResult, plan: OrderPlan) -> None:
195
+ self._files = discovery.by_path()
196
+ excluded = set(plan.excluded)
197
+ rows = [(item.rel_path, item.source) for item in plan.items]
198
+ rows += [(path, SOURCE_UNLISTED) for path in sorted(excluded)]
199
+
200
+ self._loading = True
201
+ try:
202
+ self.table.setRowCount(len(rows))
203
+ for row, (path, origin) in enumerate(rows):
204
+ file = self._files.get(path)
205
+ include = QTableWidgetItem()
206
+ include.setFlags(
207
+ Qt.ItemFlag.ItemIsUserCheckable
208
+ | Qt.ItemFlag.ItemIsEnabled
209
+ | Qt.ItemFlag.ItemIsSelectable
210
+ | Qt.ItemFlag.ItemIsDragEnabled
211
+ )
212
+ include.setCheckState(
213
+ Qt.CheckState.Unchecked if path in excluded else Qt.CheckState.Checked
214
+ )
215
+ self.table.setItem(row, COL_INCLUDE, include)
216
+
217
+ path_item = QTableWidgetItem(path)
218
+ path_item.setData(Qt.ItemDataRole.UserRole, path)
219
+ path_item.setFlags(
220
+ Qt.ItemFlag.ItemIsEnabled
221
+ | Qt.ItemFlag.ItemIsSelectable
222
+ | Qt.ItemFlag.ItemIsDragEnabled
223
+ )
224
+ self.table.setItem(row, COL_PATH, path_item)
225
+
226
+ self._set_cell(row, COL_LANGUAGE, file.language if file else "")
227
+ self._set_cell(row, COL_LINES, str(file.raw_line_count) if file else "")
228
+ self._set_range_cell(row, self._ranges.get(path, ""))
229
+ self._set_cell(
230
+ row,
231
+ COL_ORIGIN,
232
+ "auto" if origin == SOURCE_UNLISTED else "listed",
233
+ )
234
+ self._set_cell(row, COL_PAGES, "")
235
+
236
+ if origin == SOURCE_UNLISTED and path not in excluded:
237
+ self._tint_row(row, UNLISTED_TINT)
238
+ # The row tint alone cannot be relied on: it is blended
239
+ # over a theme colour we do not control. Colouring the
240
+ # Origin text carries the flag on light and dark alike.
241
+ origin_item = self.table.item(row, COL_ORIGIN)
242
+ if origin_item is not None:
243
+ origin_item.setForeground(QBrush(AMBER_TEXT))
244
+ path_item.setToolTip(
245
+ "Not named in the order list; appended automatically."
246
+ )
247
+ if path in excluded:
248
+ self._grey_row(row)
249
+ finally:
250
+ self._loading = False
251
+
252
+ self._update_summary()
253
+ self.changed.emit()
254
+
255
+ def apply_estimate(self, estimate) -> None:
256
+ """Fill in the Pages column and flag files the Office will not see."""
257
+ ranges = {r.rel_path: r for r in estimate.layout.file_ranges}
258
+ omitted = set(estimate.omitted_files())
259
+ partial = set(estimate.split_files())
260
+
261
+ self._loading = True
262
+ try:
263
+ for row in range(self.table.rowCount()):
264
+ path = self._path_at(row)
265
+ page_range = ranges.get(path)
266
+ if page_range is None:
267
+ self._set_cell(row, COL_PAGES, "-")
268
+ continue
269
+ if page_range.first_page == page_range.last_page:
270
+ label = str(page_range.first_page)
271
+ else:
272
+ label = f"{page_range.first_page}-{page_range.last_page}"
273
+ if path in omitted:
274
+ label += " (not deposited)"
275
+ elif path in partial:
276
+ label += " (part deposited)"
277
+ self._set_cell(row, COL_PAGES, label)
278
+ if path in omitted:
279
+ self._tint_row(row, OMITTED_TINT)
280
+ finally:
281
+ self._loading = False
282
+
283
+ def _set_cell(self, row: int, column: int, text: str) -> None:
284
+ item = QTableWidgetItem(text)
285
+ item.setFlags(
286
+ Qt.ItemFlag.ItemIsEnabled
287
+ | Qt.ItemFlag.ItemIsSelectable
288
+ | Qt.ItemFlag.ItemIsDragEnabled
289
+ )
290
+ self.table.setItem(row, column, item)
291
+
292
+ def _set_range_cell(self, row: int, spec: str) -> None:
293
+ """The one editable column: which lines of this file to deposit."""
294
+ item = QTableWidgetItem(spec)
295
+ item.setFlags(
296
+ Qt.ItemFlag.ItemIsEnabled
297
+ | Qt.ItemFlag.ItemIsSelectable
298
+ | Qt.ItemFlag.ItemIsDragEnabled
299
+ | Qt.ItemFlag.ItemIsEditable
300
+ )
301
+ # Remember the last accepted value so a bad edit can be reverted.
302
+ item.setData(Qt.ItemDataRole.UserRole, spec)
303
+ if spec:
304
+ item.setBackground(QBrush(PARTIAL_TINT))
305
+ item.setToolTip(f"Only lines {spec} of this file are deposited.")
306
+ else:
307
+ item.setToolTip(RANGE_HINT)
308
+ self.table.setItem(row, COL_RANGES, item)
309
+
310
+ def _tint_row(self, row: int, colour: QColor) -> None:
311
+ brush = QBrush(colour)
312
+ path = self._path_at(row)
313
+ for column in range(self.table.columnCount()):
314
+ # A partial-file marker outranks the row tint: it is the more
315
+ # important fact about that cell.
316
+ if column == COL_RANGES and self._ranges.get(path):
317
+ continue
318
+ item = self.table.item(row, column)
319
+ if item is not None:
320
+ item.setBackground(brush)
321
+
322
+ def _grey_row(self, row: int) -> None:
323
+ font = QFont()
324
+ font.setStrikeOut(True)
325
+ for column in range(self.table.columnCount()):
326
+ item = self.table.item(row, column)
327
+ if item is not None:
328
+ item.setForeground(QBrush(QColor(128, 128, 128)))
329
+ if column == COL_PATH:
330
+ item.setFont(font)
331
+
332
+ # -- interaction -------------------------------------------------------
333
+
334
+ def _path_at(self, row: int) -> str:
335
+ item = self.table.item(row, COL_PATH)
336
+ return item.text() if item is not None else ""
337
+
338
+ def _selected_rows(self) -> list[int]:
339
+ return sorted({index.row() for index in self.table.selectedIndexes()})
340
+
341
+ def _move(self, delta: int) -> None:
342
+ rows = self._selected_rows()
343
+ if not rows:
344
+ return
345
+ ordered = rows if delta < 0 else list(reversed(rows))
346
+ for row in ordered:
347
+ target = row + delta
348
+ if target < 0 or target >= self.table.rowCount():
349
+ return
350
+ self._swap(row, target)
351
+ self.table.clearSelection()
352
+ for row in rows:
353
+ self.table.selectRow(row + delta)
354
+ self.changed.emit()
355
+
356
+ def _swap(self, a: int, b: int) -> None:
357
+ self._loading = True
358
+ try:
359
+ for column in range(self.table.columnCount()):
360
+ item_a = self.table.takeItem(a, column)
361
+ item_b = self.table.takeItem(b, column)
362
+ if item_b is not None:
363
+ self.table.setItem(a, column, item_b)
364
+ if item_a is not None:
365
+ self.table.setItem(b, column, item_a)
366
+ finally:
367
+ self._loading = False
368
+
369
+ def _move_top(self) -> None:
370
+ self._move_to_edge(top=True)
371
+
372
+ def _move_bottom(self) -> None:
373
+ self._move_to_edge(top=False)
374
+
375
+ def _move_to_edge(self, top: bool) -> None:
376
+ rows = self._selected_rows()
377
+ if not rows:
378
+ return
379
+ order = self.current_order()
380
+ selected = [order[row] for row in rows]
381
+ remaining = [path for index, path in enumerate(order) if index not in set(rows)]
382
+ new_order = selected + remaining if top else remaining + selected
383
+ self.reorder_to(new_order)
384
+
385
+ def _set_included(self, included: bool) -> None:
386
+ self._loading = True
387
+ try:
388
+ for row in self._selected_rows():
389
+ item = self.table.item(row, COL_INCLUDE)
390
+ if item is not None:
391
+ item.setCheckState(
392
+ Qt.CheckState.Checked if included else Qt.CheckState.Unchecked
393
+ )
394
+ if included:
395
+ self._tint_row(row, QColor(0, 0, 0, 0))
396
+ for column in range(self.table.columnCount()):
397
+ cell = self.table.item(row, column)
398
+ if cell is not None:
399
+ cell.setForeground(QBrush())
400
+ cell.setFont(QFont())
401
+ else:
402
+ self._grey_row(row)
403
+ finally:
404
+ self._loading = False
405
+ self._update_summary()
406
+ self.changed.emit()
407
+
408
+ def _on_item_changed(self, item: QTableWidgetItem) -> None:
409
+ if self._loading:
410
+ return
411
+ if item.column() == COL_RANGES:
412
+ self._on_range_edited(item)
413
+ return
414
+ if item.column() != COL_INCLUDE:
415
+ return
416
+ row = item.row()
417
+ if item.checkState() == Qt.CheckState.Unchecked:
418
+ self._grey_row(row)
419
+ else:
420
+ self._loading = True
421
+ try:
422
+ for column in range(self.table.columnCount()):
423
+ cell = self.table.item(row, column)
424
+ if cell is not None:
425
+ cell.setForeground(QBrush())
426
+ cell.setFont(QFont())
427
+ finally:
428
+ self._loading = False
429
+ self._update_summary()
430
+ self.changed.emit()
431
+
432
+ def _on_range_edited(self, item: QTableWidgetItem) -> None:
433
+ """Validate an edited range spec; revert and explain if it is bad."""
434
+ row = item.row()
435
+ path = self._path_at(row)
436
+ spec = item.text().strip()
437
+ previous = item.data(Qt.ItemDataRole.UserRole) or ""
438
+
439
+ file = self._files.get(path)
440
+ total = file.raw_line_count if file else None
441
+ error = lineranges.validate(spec, total)
442
+ if error:
443
+ self.rangeError.emit(f"{path}: {error}")
444
+ self._loading = True
445
+ try:
446
+ self._set_range_cell(row, previous)
447
+ finally:
448
+ self._loading = False
449
+ return
450
+
451
+ if spec:
452
+ # Store the tidied form so the table shows what will be used.
453
+ ranges, _ = lineranges.parse_ranges(spec, total)
454
+ spec = lineranges.format_ranges(ranges, total)
455
+ self._ranges[path] = spec
456
+ else:
457
+ self._ranges.pop(path, None)
458
+
459
+ self._loading = True
460
+ try:
461
+ self._set_range_cell(row, spec)
462
+ finally:
463
+ self._loading = False
464
+ self._update_summary()
465
+ self.changed.emit()
466
+
467
+ def _prompt_ranges(self) -> None:
468
+ rows = self._selected_rows()
469
+ if not rows:
470
+ return
471
+ current = self._ranges.get(self._path_at(rows[0]), "")
472
+ spec, ok = QInputDialog.getText(
473
+ self,
474
+ "Deposit only part of these files",
475
+ RANGE_HINT + "\n\nLines to deposit:",
476
+ text=current,
477
+ )
478
+ if not ok:
479
+ return
480
+ spec = spec.strip()
481
+ error = lineranges.validate(spec)
482
+ if error:
483
+ QMessageBox.warning(self, "That range is not valid", error)
484
+ return
485
+ self._loading = True
486
+ try:
487
+ for row in rows:
488
+ path = self._path_at(row)
489
+ file = self._files.get(path)
490
+ total = file.raw_line_count if file else None
491
+ if spec:
492
+ ranges, _ = lineranges.parse_ranges(spec, total)
493
+ tidied = lineranges.format_ranges(ranges, total)
494
+ if not ranges:
495
+ continue
496
+ self._ranges[path] = tidied
497
+ else:
498
+ tidied = ""
499
+ self._ranges.pop(path, None)
500
+ self._set_range_cell(row, tidied)
501
+ finally:
502
+ self._loading = False
503
+ self._update_summary()
504
+ self.changed.emit()
505
+
506
+ def _update_summary(self) -> None:
507
+ total = self.table.rowCount()
508
+ included = len(self.included_paths())
509
+ text = f"{included} of {total} file(s) included"
510
+ ranged = sum(1 for path in self.included_paths() if self._ranges.get(path))
511
+ if ranged:
512
+ text += f", {ranged} with a line range"
513
+ self.summary.setText(text)
514
+
515
+ # -- order -------------------------------------------------------------
516
+
517
+ def current_order(self) -> list[str]:
518
+ return [self._path_at(row) for row in range(self.table.rowCount())]
519
+
520
+ def included_paths(self) -> list[str]:
521
+ result = []
522
+ for row in range(self.table.rowCount()):
523
+ item = self.table.item(row, COL_INCLUDE)
524
+ if item is not None and item.checkState() == Qt.CheckState.Checked:
525
+ result.append(self._path_at(row))
526
+ return result
527
+
528
+ def excluded_paths(self) -> list[str]:
529
+ included = set(self.included_paths())
530
+ return [path for path in self.current_order() if path not in included]
531
+
532
+ def line_ranges(self) -> dict[str, str]:
533
+ """Per-file range specs, for files that are actually included."""
534
+ included = set(self.included_paths())
535
+ return {
536
+ path: spec
537
+ for path, spec in sorted(self._ranges.items())
538
+ if spec and path in included
539
+ }
540
+
541
+ def set_line_ranges(self, ranges: dict[str, str]) -> None:
542
+ self._ranges = {
543
+ path.replace("\\", "/"): spec for path, spec in ranges.items() if spec
544
+ }
545
+ self._loading = True
546
+ try:
547
+ for row in range(self.table.rowCount()):
548
+ self._set_range_cell(row, self._ranges.get(self._path_at(row), ""))
549
+ finally:
550
+ self._loading = False
551
+ self._update_summary()
552
+
553
+ def set_included(self, paths: list[str], included: bool) -> None:
554
+ """Include or exclude specific files by path."""
555
+ wanted = set(paths)
556
+ rows = [
557
+ row for row in range(self.table.rowCount()) if self._path_at(row) in wanted
558
+ ]
559
+ if not rows:
560
+ return
561
+ self.table.clearSelection()
562
+ for row in rows:
563
+ self.table.selectRow(row)
564
+ self._set_included(included)
565
+
566
+ def reorder_to(self, order: list[str]) -> None:
567
+ """Rearrange existing rows to match `order`; unknown paths keep place."""
568
+ current = self.current_order()
569
+ position = {path: index for index, path in enumerate(current)}
570
+ wanted = [path for path in order if path in position]
571
+ wanted += [path for path in current if path not in set(wanted)]
572
+ if wanted == current:
573
+ return
574
+
575
+ self._loading = True
576
+ try:
577
+ snapshot = {
578
+ path: [self.table.takeItem(position[path], col) for col in range(self.table.columnCount())]
579
+ for path in current
580
+ }
581
+ for row, path in enumerate(wanted):
582
+ for column, item in enumerate(snapshot[path]):
583
+ if item is not None:
584
+ self.table.setItem(row, column, item)
585
+ finally:
586
+ self._loading = False
587
+ self.changed.emit()
588
+
589
+ def _import_order(self) -> None:
590
+ path, _ = QFileDialog.getOpenFileName(
591
+ self, "Import order list", "", "Text files (*.txt *.lst);;All files (*)"
592
+ )
593
+ if not path:
594
+ return
595
+ entries = ordering.parse_order_text(Path(path).read_text(encoding="utf-8"))
596
+ resolved: list[str] = []
597
+ known = set(self.current_order())
598
+ missing: list[str] = []
599
+ for entry in entries:
600
+ normalised = entry.replace("\\", "/").lstrip("./")
601
+ if normalised in known:
602
+ resolved.append(normalised)
603
+ continue
604
+ suffix = "/" + normalised
605
+ hits = [p for p in known if p.endswith(suffix)]
606
+ if len(hits) == 1:
607
+ resolved.append(hits[0])
608
+ elif not hits:
609
+ missing.append(entry)
610
+ else:
611
+ QMessageBox.warning(
612
+ self,
613
+ "Ambiguous entry",
614
+ f"'{entry}' matches {len(hits)} files:\n\n"
615
+ + "\n".join(hits)
616
+ + "\n\nUse a fuller path to say which one you mean.",
617
+ )
618
+ missing.append(entry)
619
+ self.reorder_to(resolved)
620
+ if missing:
621
+ QMessageBox.information(
622
+ self,
623
+ "Some entries were not used",
624
+ "These entries did not match a discovered file:\n\n" + "\n".join(missing[:20]),
625
+ )
626
+
627
+ def _export_order(self) -> None:
628
+ path, _ = QFileDialog.getSaveFileName(
629
+ self, "Export order list", "deposit_order.txt", "Text files (*.txt)"
630
+ )
631
+ if not path:
632
+ return
633
+ Path(path).write_text(
634
+ ordering.format_order_text(self.included_paths()), encoding="utf-8"
635
+ )