# sb-builder - Packages tab # Copyright (C) 2026 Danilo M. # # 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, QKeySequence, QShortcut from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, 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"

{e(row['name'])}

{e(row['path'])}" f"{' · ' + e(row['summary']) if row['summary'] else ''}

"] if view["skip"]: out.append(f'

⚠ Skipped by slackrepo: ' f"{e(view['skip'])}

") 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": "{} (DELREQUIRES)", "add": "+{} (ADDREQUIRES)", "build": "+{} (build time only)", "pragma": "+{} (pragma)"} deps = [(marks[m] or "{}").format(e(d)) for d, m in view["deps"]] out.append(f"

Dependencies

{'   '.join(deps) or 'none'}

") if view["conflicts"]: out.append(f"

Conflicts: {e(' '.join(view['conflicts']))}

") for title, rows in view["sections"]: out.append(f"

{e(title)}

" + table(rows)) if view["shell"]: out.append(f"

Expanded by the shell at build time, shown as written: " f"{e(', '.join(view['shell']))}

") if view["ignored"]: out.append(f'

Not read by slackrepo: ' f"{e(', '.join(view['ignored']))}

") if row["hint_path"]: out.append(f"

hint: {e(row['hint_path'])}

") return "".join(out) def table(rows): cells = "".join(f"{html.escape(str(k))}" f"{html.escape(str(v))}" for k, v in rows) return f"{cells}
" class PackagesTab(QWidget): start_requested = pyqtSignal(list) # job-start arguments hints_requested = pyqtSignal(str) # package to show in the Hints tab 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) self.new_hint_btn = QPushButton("New hint") self.new_hint_btn.clicked.connect(self.new_hint) self.open_hint_btn = QPushButton("Open in Hints") self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"])) detail_bar = QHBoxLayout() detail_bar.addWidget(self.new_hint_btn) detail_bar.addWidget(self.open_hint_btn) detail_bar.addStretch() pane = QWidget() pane_layout = QVBoxLayout(pane) pane_layout.addWidget(self.detail) pane_layout.addLayout(detail_bar) pane_layout.addStretch() scroll = QScrollArea(widgetResizable=True) scroll.setWidget(pane) self.show_detail(QModelIndex(), None) split = QSplitter() split.addWidget(self.table) split.addWidget(scroll) split.setSizes([700, 400]) self.count = QLabel() bar = QHBoxLayout() bar.addWidget(self.count) self.keys = [] for label, slot, key in (("Select visible", self.select_visible, "Ctrl+A"), ("Clear", self.clear, "Ctrl+Shift+A")): b = QPushButton(label, toolTip=key) b.clicked.connect(slot) bar.addWidget(b) # in the search field Ctrl+A still selects its text: QLineEdit overrides it self.keys.append(QShortcut(QKeySequence(key), self, slot, context=Qt.ShortcutContext.WidgetWithChildrenShortcut)) 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 current(self): index = self.table.currentIndex() return index.data(Qt.ItemDataRole.UserRole) if index.isValid() else None 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 "") self.new_hint_btn.setVisible(bool(row) and not row["hint_path"]) self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"])) def ask_new_hint(self, name): """(version or "", no download) for a new hint, None on cancel.""" d = QDialog(self, windowTitle=f"New hint: {name}") version = QLineEdit(placeholderText="the .info version") no_dl = QCheckBox('no download (DOWNLOAD="no")') buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(d.accept) buttons.rejected.connect(d.reject) form = QFormLayout(d) form.addRow("version", version) form.addRow(no_dl) form.addRow(buttons) if d.exec() != QDialog.DialogCode.Accepted: return None return version.text().strip(), no_dl.isChecked() def new_hint(self): row = self.current() answer = row and self.ask_new_hint(row["name"]) if answer: version, no_dl = answer self.start_requested.emit(["hint-new", row["name"], *([version] if version else []), *(["--no-dl"] if no_dl else [])])