diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 20:53:43 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 20:53:43 +0200 |
| commit | b8400f38411818aa54a773dffb2c3a0c56b06dba (patch) | |
| tree | e6390c860efb005e424b91594c6c619c120d9b75 /sb_builder/packages.py | |
| parent | d29fc1cd128b7d6a18e3e50d8ff4d6262f7e88ae (diff) | |
| download | sb-builder-b8400f38411818aa54a773dffb2c3a0c56b06dba.tar.gz sb-builder-b8400f38411818aa54a773dffb2c3a0c56b06dba.zip | |
Add Packages tab, hint view, Run it and signing key unlock
Packages tab over the helper inventory: query bar with cat:/maint:/
req:/status: prefixes, overlay and status chips, check column, and a
detail pane with versions, dependencies after hints and a structured
read-only view of the hint (hints.py mirrors slackrepo's parser).
Actions start slackrepo previews; the Jobs tab offers Run it with a
publish box (default from [builds] publish), asking for the signing
key passphrase first when the key is locked. Status bar shows the key
state; Dashboard tiles open their tabs; inventory reloads after
slackrepo and publish jobs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'sb_builder/packages.py')
| -rw-r--r-- | sb_builder/packages.py | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/sb_builder/packages.py b/sb_builder/packages.py new file mode 100644 index 0000000..34ce7f5 --- /dev/null +++ b/sb_builder/packages.py @@ -0,0 +1,332 @@ +# sb-builder - Packages tab +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Packages tab: search the inventory, read a package's hints, start slackrepo previews.""" + +import html + +from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget) + +from . import hints as hintlib +from .dashboard import STATUS +from .fmt import NA, fmt_time + +OVERLAYS = ("personal", "pentesting") +STATUSES = ("ok", "failed", "never", "outdated") +COLUMNS = ("", "name", "category", "version", "built", "status", "maintainer") +STATUS_COLOR = {"ok": STATUS["ok"], "failed": STATUS["failed"], "outdated": STATUS["aborted"]} +FIELDS = {"cat": "category", "maint": "maintainer", "req": "requires", "status": "status"} + + +def status(row): + # ponytail: outdated compares versions only; slackrepo also rebuilds on + # SlackBuild or dependency changes, which its preview shows + if not row["built_version"] and not row["result"]: + return "never" + if row["result"] in ("failed", "bad", "aborted"): + return "failed" + if row["built_version"] != row["version"]: + return "outdated" + return "ok" + + +def overlay(row): + cat = row["path"].split("/", 1)[0] + return cat if cat in OVERLAYS else "upstream" + + +def parse_query(text): + """[(field or None, term)], lower-cased; unknown prefixes count as plain text.""" + terms = [] + for word in text.lower().split(): + field, sep, term = word.partition(":") + if sep and field in FIELDS and term: + terms.append((FIELDS[field], term)) + else: + terms.append((None, word)) + return terms + + +def matches(row, terms, overlays=(), statuses=()): + """All terms match, and the row is in a toggled chip of each non-empty group.""" + if overlays and overlay(row) not in overlays: + return False + if statuses and row["status"] not in statuses: + return False + for field, term in terms: + if field is None: + hay = f"{row['name']} {row['summary'] or ''}" + elif field == "requires": + hay = " ".join(row["requires"]) + else: + hay = row[field] or "" + if term not in hay.lower(): + return False + return True + + +def load_rows(inventory): + rows = [] + for values in inventory["rows"]: + row = dict(zip(inventory["columns"], values)) + row["category"], row["name"] = row["path"].split("/", 1) + row["status"] = status(row) + rows.append(row) + return rows + + +class Model(QAbstractTableModel): + def __init__(self): + super().__init__() + self.rows = [] + self.checked = set() # package names + + def set_rows(self, rows): + self.beginResetModel() + self.rows = rows + names = {r["name"] for r in rows} + self.checked &= names + self.endResetModel() + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.rows) + + def columnCount(self, parent=QModelIndex()): + return len(COLUMNS) + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return COLUMNS[section] + return None + + def flags(self, index): + f = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + return f | Qt.ItemFlag.ItemIsUserCheckable if index.column() == 0 else f + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + row, col = self.rows[index.row()], index.column() + if role == Qt.ItemDataRole.CheckStateRole and col == 0: + return Qt.CheckState.Checked if row["name"] in self.checked else Qt.CheckState.Unchecked + if role == Qt.ItemDataRole.DisplayRole: + return (None, row["name"], row["category"], row["version"], + row["built_version"] or "", row["status"], row["maintainer"] or "")[col] + if role == Qt.ItemDataRole.ForegroundRole and col == 5 and row["status"] in STATUS_COLOR: + return QColor(STATUS_COLOR[row["status"]]) + if role == Qt.ItemDataRole.UserRole: + return row + return None + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + if role != Qt.ItemDataRole.CheckStateRole or index.column() != 0: + return False + name = self.rows[index.row()]["name"] + if Qt.CheckState(value) == Qt.CheckState.Checked: + self.checked.add(name) + else: + self.checked.discard(name) + self.dataChanged.emit(index, index) + return True + + +class Filter(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.terms, self.overlays, self.statuses = [], set(), set() + + def update(self, text, overlays, statuses): + self.terms, self.overlays, self.statuses = parse_query(text), overlays, statuses + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, _parent): + row = self.sourceModel().rows[source_row] + return matches(row, self.terms, self.overlays, self.statuses) + + +def chip(text): + b = QPushButton(text, checkable=True) + b.setStyleSheet("QPushButton { border: 1px solid palette(mid); border-radius: 10px;" + " padding: 2px 10px; }" + "QPushButton:checked { background: palette(highlight);" + " color: palette(highlighted-text); }") + return b + + +def detail_html(row): + """The detail pane for one package: facts, dependencies, then its hints.""" + view = hintlib.interpret(row["hints"], row["name"], row["info_version"], row["requires"]) + e = html.escape + out = [f"<h3>{e(row['name'])}</h3><p>{e(row['path'])}" + f"{' · ' + e(row['summary']) if row['summary'] else ''}</p>"] + if view["skip"]: + out.append(f'<p style="color:{STATUS["aborted"]}"><b>⚠ Skipped by slackrepo:</b> ' + f"{e(view['skip'])}</p>") + facts = [(".info version", row["info_version"]), ("hint version", row["hint_version"]), + ("built version", row["built_version"]), + ("built", fmt_time(row["built_time"]) if row["built_time"] else None), + ("last result", row["result"]), ("maintainer", row["maintainer"])] + out.append(table([(k, v or NA) for k, v in facts])) + marks = {"": "", "del": "<s>{}</s> <small>(DELREQUIRES)</small>", + "add": "+{} <small>(ADDREQUIRES)</small>", "build": "+{} <small>(build time only)</small>", + "pragma": "+{} <small>(pragma)</small>"} + deps = [(marks[m] or "{}").format(e(d)) for d, m in view["deps"]] + out.append(f"<h4>Dependencies</h4><p>{' '.join(deps) or 'none'}</p>") + if view["conflicts"]: + out.append(f"<p>Conflicts: {e(' '.join(view['conflicts']))}</p>") + for title, rows in view["sections"]: + out.append(f"<h4>{e(title)}</h4>" + table(rows)) + if view["shell"]: + out.append(f"<p><small>Expanded by the shell at build time, shown as written: " + f"{e(', '.join(view['shell']))}</small></p>") + if view["ignored"]: + out.append(f'<p style="color:{STATUS["aborted"]}">Not read by slackrepo: ' + f"{e(', '.join(view['ignored']))}</p>") + if row["hint_path"]: + out.append(f"<p><small>hint: {e(row['hint_path'])}</small></p>") + return "".join(out) + + +def table(rows): + cells = "".join(f"<tr><td style='padding-right:12px'><i>{html.escape(str(k))}</i></td>" + f"<td>{html.escape(str(v))}</td></tr>" for k, v in rows) + return f"<table>{cells}</table>" + + +class PackagesTab(QWidget): + start_requested = pyqtSignal(list) # job-start arguments + + def __init__(self, remote, parent=None): + super().__init__(parent) + self.remote = remote + self.model = Model() + self.proxy = Filter() + self.proxy.setSourceModel(self.model) + + self.query = QLineEdit(placeholderText="search (cat: maint: req: status:)") + self.query.textChanged.connect(self.refilter) + self.chips = {} + chips = QHBoxLayout() + for group in (("personal", "pentesting", "upstream"), STATUSES): + for name in group: + self.chips[name] = chip(name) + self.chips[name].toggled.connect(self.refilter) + chips.addWidget(self.chips[name]) + chips.addSpacing(16) + chips.addStretch() + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.clicked.connect(self.reload) + chips.addWidget(self.refresh_btn) + + self.table = QTableView(sortingEnabled=True) + self.table.setModel(self.proxy) + self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self.table.verticalHeader().hide() + self.table.horizontalHeader().setStretchLastSection(True) + for col, width in enumerate((28, 220, 110, 120, 120, 80)): + self.table.setColumnWidth(col, width) + self.table.selectionModel().currentRowChanged.connect(self.show_detail) + self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True, + alignment=Qt.AlignmentFlag.AlignTop) + self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.detail.setContentsMargins(12, 8, 12, 8) + scroll = QScrollArea(widgetResizable=True) + scroll.setWidget(self.detail) + split = QSplitter() + split.addWidget(self.table) + split.addWidget(scroll) + split.setSizes([700, 400]) + + self.count = QLabel() + bar = QHBoxLayout() + bar.addWidget(self.count) + for label, slot in (("Select visible", self.select_visible), ("Clear", self.clear)): + b = QPushButton(label) + b.clicked.connect(slot) + bar.addWidget(b) + bar.addStretch() + for action in ("build", "update", "rebuild", "remove"): + b = QPushButton(action.capitalize()) + b.clicked.connect(lambda _c, a=action: self.act(a)) + bar.addWidget(b) + for label, slot in (("Update all", self.update_all), ("Publish", self.publish)): + b = QPushButton(label) + b.clicked.connect(slot) + bar.addWidget(b) + + layout = QVBoxLayout(self) + layout.addWidget(self.query) + layout.addLayout(chips) + layout.addWidget(split) + layout.addLayout(bar) + self.model.dataChanged.connect(lambda *_: self.update_count()) + self.update_count() + + def reload(self): + self.count.setText("loading…") + self.remote.call(self._loaded, "inventory") + + def _loaded(self, data, err): + if err: + self.count.setText(f"inventory failed: {err}") + return + self.model.set_rows(load_rows(data)) + self.update_count() + + def refilter(self): + on = {n for n, b in self.chips.items() if b.isChecked()} + self.proxy.update(self.query.text(), on - set(STATUSES), on & set(STATUSES)) + self.update_count() + + def update_count(self): + self.count.setText(f"{self.proxy.rowCount():,} shown · {len(self.model.checked)} selected") + + def select_visible(self): + for i in range(self.proxy.rowCount()): + self.model.checked.add(self.proxy.index(i, 0).data(Qt.ItemDataRole.UserRole)["name"]) + self.model.layoutChanged.emit() + self.update_count() + + def clear(self): + self.model.checked.clear() + self.model.layoutChanged.emit() + self.update_count() + + def targets(self): + """Checked packages, else the current row; sorted names.""" + if self.model.checked: + return sorted(self.model.checked) + current = self.table.currentIndex() + return [current.data(Qt.ItemDataRole.UserRole)["name"]] if current.isValid() else [] + + def act(self, action): + names = self.targets() + if names: + self.start_requested.emit(["slackrepo", action, "--preview", *names]) + + def update_all(self): + self.start_requested.emit(["slackrepo", "update", "--preview"]) + + def publish(self): + if QMessageBox.question(self, "Publish", "Push the package repository to the mirror now?") \ + == QMessageBox.StandardButton.Yes: + self.start_requested.emit(["publish"]) + + def show_detail(self, current, _previous): + row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None + self.detail.setText(detail_html(row) if row else "") |
