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,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
|
+
)
|