aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/packages.py
diff options
context:
space:
mode:
Diffstat (limited to 'sb_builder/packages.py')
-rw-r--r--sb_builder/packages.py332
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>&#9888; 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>{' &nbsp; '.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 "")