linux-kernel-manager 0.1.2__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. linux_kernel_manager-0.1.2.dist-info/METADATA +271 -0
  2. linux_kernel_manager-0.1.2.dist-info/RECORD +45 -0
  3. linux_kernel_manager-0.1.2.dist-info/WHEEL +4 -0
  4. linux_kernel_manager-0.1.2.dist-info/entry_points.txt +3 -0
  5. linux_kernel_manager-0.1.2.dist-info/licenses/LICENSE +675 -0
  6. lkm/__init__.py +2 -0
  7. lkm/cli/__init__.py +0 -0
  8. lkm/cli/main.py +332 -0
  9. lkm/cli/output.py +57 -0
  10. lkm/core/__init__.py +0 -0
  11. lkm/core/backends/__init__.py +54 -0
  12. lkm/core/backends/apk.py +46 -0
  13. lkm/core/backends/apt.py +77 -0
  14. lkm/core/backends/base.py +81 -0
  15. lkm/core/backends/dnf.py +44 -0
  16. lkm/core/backends/nix.py +195 -0
  17. lkm/core/backends/pacman.py +78 -0
  18. lkm/core/backends/portage.py +107 -0
  19. lkm/core/backends/xbps.py +139 -0
  20. lkm/core/backends/zypper.py +44 -0
  21. lkm/core/kernel.py +95 -0
  22. lkm/core/manager.py +243 -0
  23. lkm/core/providers/__init__.py +62 -0
  24. lkm/core/providers/base.py +88 -0
  25. lkm/core/providers/distro.py +139 -0
  26. lkm/core/providers/gentoo.py +68 -0
  27. lkm/core/providers/liquorix.py +80 -0
  28. lkm/core/providers/lkf_build.py +355 -0
  29. lkm/core/providers/local_file.py +104 -0
  30. lkm/core/providers/mainline.py +106 -0
  31. lkm/core/providers/nixos.py +76 -0
  32. lkm/core/providers/void.py +69 -0
  33. lkm/core/providers/xanmod.py +81 -0
  34. lkm/core/system.py +235 -0
  35. lkm/gui/__init__.py +0 -0
  36. lkm/gui/app.py +94 -0
  37. lkm/gui/kernel_model.py +98 -0
  38. lkm/gui/main_window.py +385 -0
  39. lkm/gui/widgets/__init__.py +0 -0
  40. lkm/gui/widgets/gentoo_compile_dialog.py +132 -0
  41. lkm/gui/widgets/kernel_view.py +121 -0
  42. lkm/gui/widgets/lkf_build_dialog.py +370 -0
  43. lkm/gui/widgets/log_panel.py +78 -0
  44. lkm/gui/widgets/note_dialog.py +38 -0
  45. lkm/qt.py +122 -0
@@ -0,0 +1,69 @@
1
+ """Void Linux provider — xbps package manager."""
2
+ from __future__ import annotations
3
+
4
+ import platform
5
+ import re
6
+ from collections.abc import Iterator
7
+
8
+ from lkm.core.kernel import KernelEntry, KernelFamily, KernelStatus, KernelVersion
9
+ from lkm.core.providers.base import KernelProvider
10
+
11
+
12
+ class VoidProvider(KernelProvider):
13
+
14
+ @property
15
+ def id(self) -> str:
16
+ return "void"
17
+
18
+ @property
19
+ def display_name(self) -> str:
20
+ return "Void Linux"
21
+
22
+ @property
23
+ def family(self) -> KernelFamily:
24
+ return KernelFamily.DISTRO
25
+
26
+ @property
27
+ def supported_arches(self) -> list[str]:
28
+ return ["*"]
29
+
30
+ def is_available(self) -> bool:
31
+ import shutil
32
+ return bool(shutil.which("xbps-install"))
33
+
34
+ def list(self, arch: str, refresh: bool = False) -> list[KernelEntry]:
35
+ running = platform.release()
36
+ if refresh:
37
+ # Consume and discard sync output
38
+ list(self._backend.sync())
39
+
40
+ names = self._backend.list_available_kernels()
41
+ entries = []
42
+ for name in names:
43
+ m = re.search(r"(\d+\.\d+(?:\.\d+)?)", name)
44
+ if not m:
45
+ continue
46
+ try:
47
+ ver = KernelVersion.parse(m.group(1))
48
+ except ValueError:
49
+ continue
50
+ installed = self._backend.is_installed(name)
51
+ if installed:
52
+ status = KernelStatus.RUNNING if m.group(1) in running else KernelStatus.INSTALLED
53
+ else:
54
+ status = KernelStatus.AVAILABLE
55
+ entries.append(KernelEntry(
56
+ version=ver,
57
+ family=self.family,
58
+ flavor=name,
59
+ arch=arch,
60
+ provider_id=self.id,
61
+ status=status,
62
+ ))
63
+ return entries
64
+
65
+ def install(self, entry: KernelEntry) -> Iterator[str]:
66
+ yield from self._backend.install_packages([entry.flavor])
67
+
68
+ def remove(self, entry: KernelEntry, purge: bool = False) -> Iterator[str]:
69
+ yield from self._backend.remove_packages([entry.flavor], purge=purge)
@@ -0,0 +1,81 @@
1
+ """XanMod provider — x86_64 only, apt repository."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import urllib.request
6
+ from collections.abc import Iterator
7
+
8
+ from lkm.core.kernel import KernelEntry, KernelFamily, KernelStatus, KernelVersion
9
+ from lkm.core.providers.base import KernelProvider
10
+ from lkm.core.system import system_info
11
+
12
+ _REPO_LINE = "deb [signed-by=/usr/share/keyrings/xanmod-archive-keyring.gpg] http://deb.xanmod.org releases main"
13
+ _KEY_URL = "https://dl.xanmod.org/archive.key"
14
+ _FLAVORS = ["xanmod1", "xanmod2", "xanmod3", "xanmod4", "edge", "lts", "rt"]
15
+
16
+
17
+ class XanmodProvider(KernelProvider):
18
+
19
+ @property
20
+ def id(self) -> str:
21
+ return "xanmod"
22
+
23
+ @property
24
+ def display_name(self) -> str:
25
+ return "XanMod"
26
+
27
+ @property
28
+ def family(self) -> KernelFamily:
29
+ return KernelFamily.XANMOD
30
+
31
+ @property
32
+ def supported_arches(self) -> list[str]:
33
+ return ["amd64"]
34
+
35
+ def is_available(self) -> bool:
36
+ return bool(shutil.which("apt-get"))
37
+
38
+ def availability_reason(self) -> str:
39
+ return "XanMod requires apt (x86_64 only)."
40
+
41
+ def list(self, arch: str, refresh: bool = False) -> list[KernelEntry]:
42
+ running = system_info().running_kernel
43
+ try:
44
+ with urllib.request.urlopen(
45
+ "https://dl.xanmod.org/versions.json", timeout=10
46
+ ) as resp:
47
+ import json
48
+ data = json.loads(resp.read())
49
+ except Exception:
50
+ return []
51
+
52
+ entries = []
53
+ for item in data.get("kernels", []):
54
+ ver_str = item.get("version", "")
55
+ flavor = item.get("flavor", "xanmod1")
56
+ try:
57
+ ver = KernelVersion.parse(ver_str)
58
+ except ValueError:
59
+ continue
60
+ status = KernelStatus.RUNNING if ver_str in running else KernelStatus.AVAILABLE
61
+ entries.append(KernelEntry(
62
+ version=ver,
63
+ family=self.family,
64
+ flavor=flavor,
65
+ arch=arch,
66
+ provider_id=self.id,
67
+ status=status,
68
+ ))
69
+ return entries
70
+
71
+ def install(self, entry: KernelEntry) -> Iterator[str]:
72
+ # Ensure the XanMod repo is present
73
+ yield "Adding XanMod repository...\n"
74
+ yield from self._backend.add_apt_repo(_REPO_LINE, _KEY_URL)
75
+ pkg = f"linux-xanmod-{entry.flavor}"
76
+ yield f"Installing {pkg}\n"
77
+ yield from self._backend.install_packages([pkg])
78
+
79
+ def remove(self, entry: KernelEntry, purge: bool = False) -> Iterator[str]:
80
+ pkg = f"linux-xanmod-{entry.flavor}"
81
+ yield from self._backend.remove_packages([pkg], purge=purge)
lkm/core/system.py ADDED
@@ -0,0 +1,235 @@
1
+ """
2
+ System detection: distro, architecture, package manager, running kernel.
3
+
4
+ All detection is lazy and cached. Call system_info() to get a SystemInfo
5
+ snapshot; it is safe to call multiple times (returns the same object).
6
+
7
+ Extended from ukm to add NixOS and Void Linux (xbps) support.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+ from functools import lru_cache
18
+ from pathlib import Path
19
+
20
+
21
+ class PackageManagerKind(Enum):
22
+ APT = "apt" # Debian / Ubuntu / Mint / etc.
23
+ PACMAN = "pacman" # Arch / Manjaro / EndeavourOS / CachyOS / etc.
24
+ DNF = "dnf" # Fedora / RHEL / AlmaLinux / Rocky / etc.
25
+ ZYPPER = "zypper" # openSUSE
26
+ APK = "apk" # Alpine
27
+ PORTAGE = "portage" # Gentoo
28
+ XBPS = "xbps" # Void Linux
29
+ NIX = "nix" # NixOS
30
+ UNKNOWN = "unknown"
31
+
32
+
33
+ class DistroFamily(Enum):
34
+ DEBIAN = "debian"
35
+ ARCH = "arch"
36
+ FEDORA = "fedora"
37
+ SUSE = "suse"
38
+ ALPINE = "alpine"
39
+ GENTOO = "gentoo"
40
+ VOID = "void"
41
+ NIXOS = "nixos"
42
+ UNKNOWN = "unknown"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class DistroInfo:
47
+ id: str
48
+ id_like: list[str] = field(default_factory=list)
49
+ name: str = ""
50
+ version: str = ""
51
+ codename: str = ""
52
+ family: DistroFamily = DistroFamily.UNKNOWN
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SystemInfo:
57
+ distro: DistroInfo
58
+ arch: str # normalised: amd64, arm64, armhf, riscv64, ppc64el, s390x, i386
59
+ arch_raw: str # as reported by uname -m
60
+ package_manager: PackageManagerKind
61
+ running_kernel: str # uname -r output
62
+ has_secure_boot: bool
63
+ has_pkexec: bool
64
+ has_sudo: bool
65
+ in_nix_shell: bool # True when inside nix-shell / nix develop
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Internal helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def _read_os_release() -> dict[str, str]:
73
+ result: dict[str, str] = {}
74
+ for path in ("/etc/os-release", "/usr/lib/os-release"):
75
+ try:
76
+ with open(path) as f:
77
+ for line in f:
78
+ line = line.strip()
79
+ if "=" not in line or line.startswith("#"):
80
+ continue
81
+ k, _, v = line.partition("=")
82
+ result[k.strip()] = v.strip().strip('"')
83
+ break
84
+ except FileNotFoundError:
85
+ continue
86
+ return result
87
+
88
+
89
+ def _detect_distro() -> DistroInfo:
90
+ data = _read_os_release()
91
+ distro_id = data.get("ID", "").lower()
92
+ id_like = [x.lower() for x in data.get("ID_LIKE", "").split()]
93
+ all_ids = {distro_id} | set(id_like)
94
+
95
+ if distro_id == "nixos" or "nixos" in all_ids:
96
+ family = DistroFamily.NIXOS
97
+ elif distro_id == "void" or "void" in all_ids:
98
+ family = DistroFamily.VOID
99
+ elif any(x in all_ids for x in (
100
+ "debian", "ubuntu", "linuxmint", "pop", "elementary",
101
+ "kali", "parrot", "devuan", "raspbian", "mx",
102
+ "antix", "zorin", "sparky", "bunsenlabs",
103
+ )):
104
+ family = DistroFamily.DEBIAN
105
+ elif any(x in all_ids for x in (
106
+ "arch", "manjaro", "endeavouros", "cachyos",
107
+ "artix", "garuda", "rebornos", "archcraft",
108
+ )):
109
+ family = DistroFamily.ARCH
110
+ elif any(x in all_ids for x in (
111
+ "fedora", "rhel", "centos", "almalinux",
112
+ "rocky", "nobara", "ultramarine", "oracle",
113
+ )):
114
+ family = DistroFamily.FEDORA
115
+ elif any(x in all_ids for x in ("opensuse", "suse", "sles")):
116
+ family = DistroFamily.SUSE
117
+ elif "alpine" in all_ids:
118
+ family = DistroFamily.ALPINE
119
+ elif "gentoo" in all_ids:
120
+ family = DistroFamily.GENTOO
121
+ else:
122
+ family = DistroFamily.UNKNOWN
123
+
124
+ return DistroInfo(
125
+ id=distro_id,
126
+ id_like=id_like,
127
+ name=data.get("NAME", distro_id),
128
+ version=data.get("VERSION_ID", ""),
129
+ codename=data.get("VERSION_CODENAME", ""),
130
+ family=family,
131
+ )
132
+
133
+
134
+ def _normalise_arch(raw: str) -> str:
135
+ """Map uname -m values to Debian-style arch names used throughout lkm."""
136
+ mapping = {
137
+ "x86_64": "amd64",
138
+ "aarch64": "arm64",
139
+ "armv7l": "armhf",
140
+ "armv6l": "armel",
141
+ "i686": "i386",
142
+ "i386": "i386",
143
+ "riscv64": "riscv64",
144
+ "ppc64le": "ppc64el",
145
+ "s390x": "s390x",
146
+ }
147
+ return mapping.get(raw, raw)
148
+
149
+
150
+ def _detect_package_manager(family: DistroFamily) -> PackageManagerKind:
151
+ # Explicit binary detection takes priority over family inference so that
152
+ # distros with unusual setups (e.g. Arch with apt installed) still work.
153
+ checks = [
154
+ ("apt-get", PackageManagerKind.APT),
155
+ ("pacman", PackageManagerKind.PACMAN),
156
+ ("dnf", PackageManagerKind.DNF),
157
+ ("zypper", PackageManagerKind.ZYPPER),
158
+ ("apk", PackageManagerKind.APK),
159
+ ("emerge", PackageManagerKind.PORTAGE),
160
+ ("xbps-install", PackageManagerKind.XBPS),
161
+ ("nix-env", PackageManagerKind.NIX),
162
+ ]
163
+ for cmd, kind in checks:
164
+ if shutil.which(cmd):
165
+ return kind
166
+
167
+ _family_map = {
168
+ DistroFamily.DEBIAN: PackageManagerKind.APT,
169
+ DistroFamily.ARCH: PackageManagerKind.PACMAN,
170
+ DistroFamily.FEDORA: PackageManagerKind.DNF,
171
+ DistroFamily.SUSE: PackageManagerKind.ZYPPER,
172
+ DistroFamily.ALPINE: PackageManagerKind.APK,
173
+ DistroFamily.GENTOO: PackageManagerKind.PORTAGE,
174
+ DistroFamily.VOID: PackageManagerKind.XBPS,
175
+ DistroFamily.NIXOS: PackageManagerKind.NIX,
176
+ }
177
+ return _family_map.get(family, PackageManagerKind.UNKNOWN)
178
+
179
+
180
+ def _detect_secure_boot() -> bool:
181
+ if shutil.which("mokutil"):
182
+ try:
183
+ result = subprocess.run(
184
+ ["mokutil", "--sb-state"],
185
+ capture_output=True, text=True, timeout=3,
186
+ )
187
+ return "enabled" in result.stdout.lower()
188
+ except Exception:
189
+ pass
190
+ sb_var = Path(
191
+ "/sys/firmware/efi/efivars/"
192
+ "SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"
193
+ )
194
+ if sb_var.exists():
195
+ try:
196
+ data = sb_var.read_bytes()
197
+ return len(data) >= 5 and data[4] == 1
198
+ except Exception:
199
+ pass
200
+ return False
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Public API
205
+ # ---------------------------------------------------------------------------
206
+
207
+ @lru_cache(maxsize=1)
208
+ def system_info() -> SystemInfo:
209
+ """Return a cached SystemInfo for the current machine."""
210
+ distro = _detect_distro()
211
+ arch_raw = platform.machine()
212
+ arch = _normalise_arch(arch_raw)
213
+ pm = _detect_package_manager(distro.family)
214
+
215
+ return SystemInfo(
216
+ distro=distro,
217
+ arch=arch,
218
+ arch_raw=arch_raw,
219
+ package_manager=pm,
220
+ running_kernel=platform.release(),
221
+ has_secure_boot=_detect_secure_boot(),
222
+ has_pkexec=bool(shutil.which("pkexec")),
223
+ has_sudo=bool(shutil.which("sudo")),
224
+ in_nix_shell=bool(os.environ.get("IN_NIX_SHELL") or os.environ.get("LKM_NIX_SHELL")),
225
+ )
226
+
227
+
228
+ def privilege_escalation_cmd() -> list[str]:
229
+ """Return the best available privilege escalation prefix."""
230
+ info = system_info()
231
+ if info.has_pkexec:
232
+ return ["pkexec"]
233
+ if info.has_sudo:
234
+ return ["sudo"]
235
+ return []
lkm/gui/__init__.py ADDED
File without changes
lkm/gui/app.py ADDED
@@ -0,0 +1,94 @@
1
+ """QApplication entry point for lkm-gui."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from lkm.gui.main_window import MainWindow
7
+ from lkm.qt import QApplication
8
+
9
+ _STYLESHEET = """
10
+ QMainWindow, QDialog {
11
+ background-color: #1e1e2e;
12
+ color: #cdd6f4;
13
+ }
14
+ QTabWidget::pane {
15
+ border: 1px solid #45475a;
16
+ }
17
+ QTabBar::tab {
18
+ background: #313244;
19
+ color: #cdd6f4;
20
+ padding: 6px 14px;
21
+ border-radius: 4px 4px 0 0;
22
+ }
23
+ QTabBar::tab:selected {
24
+ background: #89b4fa;
25
+ color: #1e1e2e;
26
+ }
27
+ QTableView {
28
+ background-color: #181825;
29
+ alternate-background-color: #1e1e2e;
30
+ gridline-color: #313244;
31
+ color: #cdd6f4;
32
+ selection-background-color: #45475a;
33
+ }
34
+ QHeaderView::section {
35
+ background-color: #313244;
36
+ color: #cdd6f4;
37
+ padding: 4px;
38
+ border: none;
39
+ }
40
+ QToolBar {
41
+ background-color: #181825;
42
+ border-bottom: 1px solid #313244;
43
+ spacing: 4px;
44
+ }
45
+ QStatusBar {
46
+ background-color: #181825;
47
+ color: #a6adc8;
48
+ }
49
+ QPushButton {
50
+ background-color: #313244;
51
+ color: #cdd6f4;
52
+ border: 1px solid #45475a;
53
+ border-radius: 4px;
54
+ padding: 4px 12px;
55
+ }
56
+ QPushButton:hover { background-color: #45475a; }
57
+ QPushButton:pressed { background-color: #585b70; }
58
+ QPushButton:default { border-color: #89b4fa; }
59
+ QLineEdit, QComboBox, QTextEdit, QSpinBox {
60
+ background-color: #313244;
61
+ color: #cdd6f4;
62
+ border: 1px solid #45475a;
63
+ border-radius: 4px;
64
+ padding: 2px 6px;
65
+ }
66
+ QGroupBox {
67
+ border: 1px solid #45475a;
68
+ border-radius: 4px;
69
+ margin-top: 8px;
70
+ color: #a6adc8;
71
+ }
72
+ QGroupBox::title {
73
+ subcontrol-origin: margin;
74
+ left: 8px;
75
+ padding: 0 4px;
76
+ }
77
+ QCheckBox { color: #cdd6f4; }
78
+ QLabel { color: #cdd6f4; }
79
+ """
80
+
81
+
82
+ def main() -> None:
83
+ app = QApplication(sys.argv)
84
+ app.setApplicationName("lkm")
85
+ app.setApplicationVersion("0.1.0")
86
+ app.setStyleSheet(_STYLESHEET)
87
+
88
+ win = MainWindow()
89
+ win.show()
90
+ sys.exit(app.exec())
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
@@ -0,0 +1,98 @@
1
+ """QAbstractTableModel wrapping a list of KernelEntry objects."""
2
+ from __future__ import annotations
3
+
4
+ from lkm.core.kernel import KernelEntry, KernelStatus
5
+ from lkm.qt import (
6
+ QAbstractTableModel,
7
+ QColor,
8
+ QFont,
9
+ QModelIndex,
10
+ Qt,
11
+ )
12
+
13
+ _COLUMNS = ["Version", "Family", "Flavor", "Arch", "Status", "Notes"]
14
+ _COL_VERSION = 0
15
+ _COL_FAMILY = 1
16
+ _COL_FLAVOR = 2
17
+ _COL_ARCH = 3
18
+ _COL_STATUS = 4
19
+ _COL_NOTES = 5
20
+
21
+ _STATUS_COLORS = {
22
+ KernelStatus.RUNNING: "#2ecc71",
23
+ KernelStatus.INSTALLED: "#3498db",
24
+ KernelStatus.HELD: "#e67e22",
25
+ KernelStatus.AVAILABLE: None,
26
+ }
27
+
28
+
29
+ class KernelTableModel(QAbstractTableModel):
30
+
31
+ def __init__(self, entries: list[KernelEntry] | None = None, parent=None) -> None:
32
+ super().__init__(parent)
33
+ self._entries: list[KernelEntry] = entries or []
34
+
35
+ def set_entries(self, entries: list[KernelEntry]) -> None:
36
+ self.beginResetModel()
37
+ self._entries = entries
38
+ self.endResetModel()
39
+
40
+ def entry_at(self, row: int) -> KernelEntry | None:
41
+ if 0 <= row < len(self._entries):
42
+ return self._entries[row]
43
+ return None
44
+
45
+ # ------------------------------------------------------------------
46
+ # QAbstractTableModel interface
47
+ # ------------------------------------------------------------------
48
+
49
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
50
+ return len(self._entries)
51
+
52
+ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
53
+ return len(_COLUMNS)
54
+
55
+ def headerData(self, section: int, orientation, role=Qt.ItemDataRole.DisplayRole):
56
+ if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
57
+ return _COLUMNS[section]
58
+ return None
59
+
60
+ def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
61
+ if not index.isValid():
62
+ return None
63
+ entry = self._entries[index.row()]
64
+ col = index.column()
65
+
66
+ if role == Qt.ItemDataRole.DisplayRole:
67
+ return self._display(entry, col)
68
+
69
+ if role == Qt.ItemDataRole.ForegroundRole:
70
+ color = _STATUS_COLORS.get(entry.status)
71
+ if color:
72
+ return QColor(color)
73
+
74
+ if role == Qt.ItemDataRole.FontRole and entry.is_running:
75
+ f = QFont()
76
+ f.setBold(True)
77
+ return f
78
+
79
+ if role == Qt.ItemDataRole.UserRole:
80
+ return entry
81
+
82
+ return None
83
+
84
+ def _display(self, entry: KernelEntry, col: int) -> str:
85
+ if col == _COL_VERSION:
86
+ return str(entry.version)
87
+ if col == _COL_FAMILY:
88
+ return entry.family.value
89
+ if col == _COL_FLAVOR:
90
+ return entry.flavor
91
+ if col == _COL_ARCH:
92
+ return entry.arch
93
+ if col == _COL_STATUS:
94
+ s = entry.status.value
95
+ return s + " [held]" if entry.held else s
96
+ if col == _COL_NOTES:
97
+ return entry.notes[:60] if entry.notes else ""
98
+ return ""