scitex 2.17.0__py3-none-any.whl → 2.17.4__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 (46) hide show
  1. scitex/_dev/__init__.py +122 -0
  2. scitex/_dev/_config.py +391 -0
  3. scitex/_dev/_dashboard/__init__.py +11 -0
  4. scitex/_dev/_dashboard/_app.py +89 -0
  5. scitex/_dev/_dashboard/_routes.py +182 -0
  6. scitex/_dev/_dashboard/_scripts.py +422 -0
  7. scitex/_dev/_dashboard/_styles.py +295 -0
  8. scitex/_dev/_dashboard/_templates.py +130 -0
  9. scitex/_dev/_dashboard/static/version-dashboard-favicon.svg +12 -0
  10. scitex/_dev/_ecosystem.py +109 -0
  11. scitex/_dev/_github.py +360 -0
  12. scitex/_dev/_mcp/__init__.py +11 -0
  13. scitex/_dev/_mcp/handlers.py +182 -0
  14. scitex/_dev/_rtd.py +122 -0
  15. scitex/_dev/_ssh.py +362 -0
  16. scitex/_dev/_versions.py +272 -0
  17. scitex/_mcp_tools/__init__.py +2 -0
  18. scitex/_mcp_tools/dev.py +186 -0
  19. scitex/audio/_audio_check.py +84 -41
  20. scitex/cli/capture.py +45 -22
  21. scitex/cli/dev.py +494 -0
  22. scitex/cli/main.py +2 -0
  23. scitex/cli/stats.py +48 -20
  24. scitex/cli/verify.py +33 -36
  25. scitex/plt/__init__.py +16 -6
  26. scitex/scholar/_mcp/crossref_handlers.py +45 -7
  27. scitex/scholar/_mcp/openalex_handlers.py +45 -7
  28. scitex/scholar/config/default.yaml +2 -0
  29. scitex/scholar/local_dbs/__init__.py +5 -1
  30. scitex/scholar/local_dbs/export.py +93 -0
  31. scitex/scholar/local_dbs/unified.py +505 -0
  32. scitex/scholar/metadata_engines/ScholarEngine.py +11 -0
  33. scitex/scholar/metadata_engines/individual/OpenAlexLocalEngine.py +346 -0
  34. scitex/scholar/metadata_engines/individual/__init__.py +1 -0
  35. scitex/template/__init__.py +18 -1
  36. scitex/template/clone_research_minimal.py +111 -0
  37. scitex/verify/README.md +0 -12
  38. scitex/verify/__init__.py +0 -4
  39. scitex/verify/_visualize.py +0 -4
  40. scitex/verify/_viz/__init__.py +0 -18
  41. {scitex-2.17.0.dist-info → scitex-2.17.4.dist-info}/METADATA +2 -1
  42. {scitex-2.17.0.dist-info → scitex-2.17.4.dist-info}/RECORD +45 -24
  43. scitex/verify/_viz/_plotly.py +0 -193
  44. {scitex-2.17.0.dist-info → scitex-2.17.4.dist-info}/WHEEL +0 -0
  45. {scitex-2.17.0.dist-info → scitex-2.17.4.dist-info}/entry_points.txt +0 -0
  46. {scitex-2.17.0.dist-info → scitex-2.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ # Timestamp: 2026-02-02
3
+ # File: scitex/_dev/_dashboard/_routes.py
4
+
5
+ """Flask routes for the dashboard."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from flask import Flask
13
+
14
+
15
+ def register_routes(app: Flask) -> None:
16
+ """Register dashboard routes with Flask app."""
17
+ from flask import jsonify, request
18
+
19
+ from ._templates import get_dashboard_html, get_error_html
20
+
21
+ @app.route("/")
22
+ def index():
23
+ """Serve the main dashboard page."""
24
+ try:
25
+ return get_dashboard_html()
26
+ except Exception as e:
27
+ return get_error_html(str(e)), 500
28
+
29
+ @app.route("/api/versions")
30
+ def api_versions():
31
+ """Get version data as JSON."""
32
+ try:
33
+ data = _get_all_version_data()
34
+ return jsonify(data)
35
+ except Exception as e:
36
+ return jsonify({"error": str(e)}), 500
37
+
38
+ @app.route("/api/packages")
39
+ def api_packages():
40
+ """Get local package versions only (fast)."""
41
+ try:
42
+ from .._versions import list_versions
43
+
44
+ return jsonify(list_versions())
45
+ except Exception as e:
46
+ return jsonify({"error": str(e)}), 500
47
+
48
+ @app.route("/api/config")
49
+ def api_config():
50
+ """Get current configuration."""
51
+ try:
52
+ from .._config import get_config_path, load_config
53
+
54
+ config = load_config()
55
+ return jsonify(
56
+ {
57
+ "config_path": str(get_config_path()),
58
+ "packages": [
59
+ {
60
+ "name": p.name,
61
+ "local_path": p.local_path,
62
+ "pypi_name": p.pypi_name,
63
+ }
64
+ for p in config.packages
65
+ ],
66
+ "hosts": [
67
+ {
68
+ "name": h.name,
69
+ "hostname": h.hostname,
70
+ "role": h.role,
71
+ "enabled": h.enabled,
72
+ }
73
+ for h in config.hosts
74
+ ],
75
+ "github_remotes": [
76
+ {"name": r.name, "org": r.org, "enabled": r.enabled}
77
+ for r in config.github_remotes
78
+ ],
79
+ "branches": config.branches,
80
+ }
81
+ )
82
+ except Exception as e:
83
+ return jsonify({"error": str(e)}), 500
84
+
85
+ @app.route("/api/refresh", methods=["POST"])
86
+ def api_refresh():
87
+ """Trigger a data refresh."""
88
+ try:
89
+ data = _get_all_version_data(force_refresh=True)
90
+ return jsonify({"status": "ok", "data": data})
91
+ except Exception as e:
92
+ return jsonify({"error": str(e)}), 500
93
+
94
+ @app.route("/api/hosts")
95
+ def api_hosts():
96
+ """Get host version data."""
97
+ try:
98
+ packages = request.args.getlist("package") or None
99
+ hosts = request.args.getlist("host") or None
100
+ from .._ssh import check_all_hosts
101
+
102
+ data = check_all_hosts(packages=packages, hosts=hosts)
103
+ return jsonify(data)
104
+ except Exception as e:
105
+ return jsonify({"error": str(e)}), 500
106
+
107
+ @app.route("/api/remotes")
108
+ def api_remotes():
109
+ """Get GitHub remote version data."""
110
+ try:
111
+ packages = request.args.getlist("package") or None
112
+ remotes = request.args.getlist("remote") or None
113
+ from .._github import check_all_remotes
114
+
115
+ data = check_all_remotes(packages=packages, remotes=remotes)
116
+ return jsonify(data)
117
+ except Exception as e:
118
+ return jsonify({"error": str(e)}), 500
119
+
120
+ @app.route("/api/rtd")
121
+ def api_rtd():
122
+ """Get Read the Docs build status."""
123
+ try:
124
+ packages = request.args.getlist("package") or None
125
+ versions = request.args.getlist("version") or None
126
+ from .._rtd import check_all_rtd
127
+
128
+ data = check_all_rtd(packages=packages, versions=versions)
129
+ return jsonify(data)
130
+ except Exception as e:
131
+ return jsonify({"error": str(e)}), 500
132
+
133
+
134
+ def _get_all_version_data(force_refresh: bool = False) -> dict[str, Any]:
135
+ """Get all version data from all sources.
136
+
137
+ Parameters
138
+ ----------
139
+ force_refresh : bool
140
+ If True, bypass any caching.
141
+
142
+ Returns
143
+ -------
144
+ dict
145
+ Combined version data.
146
+ """
147
+ from .._config import get_enabled_hosts, get_enabled_remotes, load_config
148
+ from .._github import check_all_remotes
149
+ from .._ssh import check_all_hosts
150
+ from .._versions import list_versions
151
+
152
+ config = load_config()
153
+
154
+ # Get local versions
155
+ packages_data = list_versions()
156
+
157
+ # Get host versions (if any hosts configured)
158
+ hosts_data = {}
159
+ enabled_hosts = get_enabled_hosts(config)
160
+ if enabled_hosts:
161
+ try:
162
+ hosts_data = check_all_hosts(config=config)
163
+ except Exception:
164
+ pass
165
+
166
+ # Get remote versions (if any remotes configured)
167
+ remotes_data = {}
168
+ enabled_remotes = get_enabled_remotes(config)
169
+ if enabled_remotes:
170
+ try:
171
+ remotes_data = check_all_remotes(config=config)
172
+ except Exception:
173
+ pass
174
+
175
+ return {
176
+ "packages": packages_data,
177
+ "hosts": hosts_data,
178
+ "remotes": remotes_data,
179
+ }
180
+
181
+
182
+ # EOF
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env python3
2
+ # Timestamp: 2026-02-02
3
+ # File: scitex/_dev/_dashboard/_scripts.py
4
+
5
+ """JavaScript for the dashboard."""
6
+
7
+
8
+ def get_javascript() -> str:
9
+ """Return dashboard JavaScript."""
10
+ return """
11
+ let cachedData = { packages: {}, hosts: {}, remotes: {}, rtd: {} };
12
+
13
+ async function fetchVersions() {
14
+ showLoading(true);
15
+ cachedData = { packages: {}, hosts: {}, remotes: {}, rtd: {} };
16
+ renderFilters();
17
+ renderData();
18
+
19
+ // Load packages first (fast)
20
+ fetchPackages();
21
+ // Load hosts, remotes, and RTD in parallel (slower)
22
+ fetchHosts();
23
+ fetchRemotes();
24
+ fetchRtd();
25
+ }
26
+
27
+ async function fetchPackages() {
28
+ setSectionLoading('package', true);
29
+ try {
30
+ const response = await fetch('/api/packages');
31
+ cachedData.packages = await response.json();
32
+ renderFilters();
33
+ renderData();
34
+ updateTimestamp();
35
+ setSectionUpdated('package');
36
+ } catch (err) {
37
+ console.error('Failed to fetch packages:', err);
38
+ } finally {
39
+ showLoading(false);
40
+ setSectionLoading('package', false);
41
+ }
42
+ }
43
+
44
+ async function fetchHosts() {
45
+ setSectionLoading('host', true);
46
+ try {
47
+ const response = await fetch('/api/hosts');
48
+ cachedData.hosts = await response.json();
49
+ renderFilters();
50
+ renderData();
51
+ setSectionUpdated('host');
52
+ } catch (err) {
53
+ console.error('Failed to fetch hosts:', err);
54
+ cachedData.hosts = { error: err.message };
55
+ } finally {
56
+ setSectionLoading('host', false);
57
+ }
58
+ }
59
+
60
+ async function fetchRemotes() {
61
+ setSectionLoading('remote', true);
62
+ try {
63
+ const response = await fetch('/api/remotes');
64
+ cachedData.remotes = await response.json();
65
+ renderFilters();
66
+ renderData();
67
+ setSectionUpdated('remote');
68
+ } catch (err) {
69
+ console.error('Failed to fetch remotes:', err);
70
+ cachedData.remotes = { error: err.message };
71
+ } finally {
72
+ setSectionLoading('remote', false);
73
+ }
74
+ }
75
+
76
+ async function fetchRtd() {
77
+ setSectionLoading('rtd', true);
78
+ try {
79
+ const response = await fetch('/api/rtd');
80
+ cachedData.rtd = await response.json();
81
+ renderFilters();
82
+ renderData();
83
+ setSectionUpdated('rtd');
84
+ } catch (err) {
85
+ console.error('Failed to fetch RTD status:', err);
86
+ cachedData.rtd = { error: err.message };
87
+ } finally {
88
+ setSectionLoading('rtd', false);
89
+ }
90
+ }
91
+
92
+ function setSectionLoading(section, loading) {
93
+ const el = document.getElementById(section + 'Filters');
94
+ if (el) {
95
+ if (loading) {
96
+ el.classList.add('loading-section');
97
+ } else {
98
+ el.classList.remove('loading-section');
99
+ }
100
+ }
101
+ }
102
+
103
+ function setSectionUpdated(section) {
104
+ const el = document.getElementById(section + 'Filters');
105
+ if (el) {
106
+ el.classList.add('just-updated');
107
+ setTimeout(() => el.classList.remove('just-updated'), 1000);
108
+ }
109
+ }
110
+
111
+ function updateTimestamp() {
112
+ document.getElementById('lastUpdated').textContent =
113
+ 'Last updated: ' + new Date().toLocaleTimeString();
114
+ }
115
+
116
+ // Auto-refresh settings
117
+ let autoRefreshInterval = null;
118
+ let autoRefreshSeconds = 0;
119
+
120
+ function toggleAutoRefresh(seconds) {
121
+ if (autoRefreshInterval) {
122
+ clearInterval(autoRefreshInterval);
123
+ autoRefreshInterval = null;
124
+ autoRefreshSeconds = 0;
125
+ document.getElementById('autoRefreshBtn').textContent = 'Auto: Off';
126
+ return;
127
+ }
128
+ autoRefreshSeconds = seconds;
129
+ document.getElementById('autoRefreshBtn').textContent = `Auto: ${seconds}s`;
130
+ autoRefreshInterval = setInterval(() => {
131
+ fetchPackages();
132
+ fetchHosts();
133
+ fetchRemotes();
134
+ }, seconds * 1000);
135
+ }
136
+
137
+ function cycleAutoRefresh() {
138
+ const options = [0, 30, 60, 120];
139
+ const current = options.indexOf(autoRefreshSeconds);
140
+ const next = options[(current + 1) % options.length];
141
+ if (autoRefreshInterval) {
142
+ clearInterval(autoRefreshInterval);
143
+ autoRefreshInterval = null;
144
+ }
145
+ if (next > 0) {
146
+ toggleAutoRefresh(next);
147
+ } else {
148
+ autoRefreshSeconds = 0;
149
+ document.getElementById('autoRefreshBtn').textContent = 'Auto: Off';
150
+ }
151
+ }
152
+
153
+ function showLoading(show) {
154
+ document.getElementById('loading').classList.toggle('active', show);
155
+ document.getElementById('overlay').classList.toggle('active', show);
156
+ }
157
+
158
+ function renderFilters() {
159
+ if (!cachedData) return;
160
+
161
+ const packageFilters = document.getElementById('packageFilters');
162
+ const packages = Object.keys(cachedData.packages || {});
163
+ packageFilters.innerHTML = packages.map(pkg =>
164
+ `<label><input type="checkbox" value="${pkg}" checked onchange="renderData()"> ${pkg}</label>`
165
+ ).join('');
166
+
167
+ const hostFilters = document.getElementById('hostFilters');
168
+ const hosts = Object.keys(cachedData.hosts || {});
169
+ if (hosts.length > 0) {
170
+ hostFilters.innerHTML = hosts.map(host =>
171
+ `<label><input type="checkbox" value="${host}" checked onchange="renderData()"> ${host}</label>`
172
+ ).join('');
173
+ } else {
174
+ hostFilters.innerHTML = '<span style="color: var(--text-secondary)">No hosts configured</span>';
175
+ }
176
+
177
+ const remoteFilters = document.getElementById('remoteFilters');
178
+ const remotes = Object.keys(cachedData.remotes || {});
179
+ if (remotes.length > 0) {
180
+ remoteFilters.innerHTML = remotes.map(remote =>
181
+ `<label><input type="checkbox" value="${remote}" checked onchange="renderData()"> ${remote}</label>`
182
+ ).join('');
183
+ } else {
184
+ remoteFilters.innerHTML = '<span style="color: var(--text-secondary)">No remotes configured</span>';
185
+ }
186
+
187
+ const rtdFilters = document.getElementById('rtdFilters');
188
+ const rtdVersions = Object.keys(cachedData.rtd || {});
189
+ if (rtdVersions.length > 0) {
190
+ rtdFilters.innerHTML = rtdVersions.map(v =>
191
+ `<label><input type="checkbox" value="${v}" checked onchange="renderData()"> ${v}</label>`
192
+ ).join('');
193
+ } else {
194
+ rtdFilters.innerHTML = '<span style="color: var(--text-secondary)">Loading...</span>';
195
+ }
196
+
197
+ document.querySelectorAll('#statusFilters input').forEach(input => {
198
+ input.onchange = renderData;
199
+ });
200
+ }
201
+
202
+ function getSelectedFilters() {
203
+ const getChecked = (containerId) =>
204
+ [...document.querySelectorAll(`#${containerId} input:checked`)].map(el => el.value);
205
+ return {
206
+ packages: getChecked('packageFilters'),
207
+ statuses: getChecked('statusFilters'),
208
+ hosts: getChecked('hostFilters'),
209
+ remotes: getChecked('remoteFilters')
210
+ };
211
+ }
212
+
213
+ function getEffectiveStatus(name, info) {
214
+ // Check RTD status and downgrade if needed
215
+ const rtdData = cachedData.rtd || {};
216
+ let status = info.status;
217
+ if (status === 'ok') {
218
+ const rtdLatest = rtdData['latest'] && rtdData['latest'][name];
219
+ const rtdStable = rtdData['stable'] && rtdData['stable'][name];
220
+ if ((rtdLatest && rtdLatest.status === 'failing') ||
221
+ (rtdStable && rtdStable.status === 'failing') ||
222
+ (rtdLatest && rtdLatest.status === 'not_found')) {
223
+ status = 'mismatch';
224
+ }
225
+ }
226
+ return status;
227
+ }
228
+
229
+ function renderData() {
230
+ if (!cachedData) return;
231
+ const filters = getSelectedFilters();
232
+ const packages = cachedData.packages || {};
233
+
234
+ const filteredPackages = Object.entries(packages)
235
+ .filter(([name, info]) => {
236
+ if (!filters.packages.includes(name)) return false;
237
+ const effectiveStatus = getEffectiveStatus(name, info);
238
+ if (!filters.statuses.includes(effectiveStatus)) return false;
239
+ return true;
240
+ });
241
+
242
+ const summary = {
243
+ total: filteredPackages.length,
244
+ ok: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'ok').length,
245
+ unreleased: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'unreleased').length,
246
+ mismatch: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'mismatch').length,
247
+ outdated: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'outdated').length
248
+ };
249
+
250
+ document.getElementById('summary').innerHTML = `
251
+ <div class="summary-card total"><div class="number">${summary.total}</div><div class="label">Total</div></div>
252
+ <div class="summary-card ok"><div class="number">${summary.ok}</div><div class="label">OK</div></div>
253
+ <div class="summary-card unreleased"><div class="number">${summary.unreleased}</div><div class="label">Unreleased</div></div>
254
+ <div class="summary-card mismatch"><div class="number">${summary.mismatch}</div><div class="label">Mismatch</div></div>
255
+ `;
256
+
257
+ document.getElementById('packages').innerHTML = filteredPackages.map(([name, info]) => {
258
+ const local = info.local || {};
259
+ const git = info.git || {};
260
+ const remote = info.remote || {};
261
+ const hostData = cachedData.hosts || {};
262
+ const remoteData = cachedData.remotes || {};
263
+ const rtdData = cachedData.rtd || {};
264
+
265
+ const hostVersions = Object.entries(hostData)
266
+ .filter(([h]) => !h.startsWith('_') && filters.hosts.includes(h))
267
+ .map(([hostName, hostInfo]) => ({ name: hostName, ...(hostInfo[name] || {}) }));
268
+
269
+ const remoteVersions = Object.entries(remoteData)
270
+ .filter(([r]) => !r.startsWith('_') && filters.remotes.includes(r))
271
+ .map(([remoteName, remoteInfo]) => ({ name: remoteName, ...(remoteInfo[name] || {}) }));
272
+
273
+ // Get RTD status for this package (latest and stable)
274
+ const rtdStatus = {};
275
+ Object.entries(rtdData).forEach(([version, pkgData]) => {
276
+ if (pkgData[name]) {
277
+ rtdStatus[version] = pkgData[name];
278
+ }
279
+ });
280
+
281
+ return renderPackageCard(name, info, local, git, remote, hostVersions, remoteVersions, rtdStatus);
282
+ }).join('');
283
+ }
284
+
285
+ function renderPackageCard(name, info, local, git, remote, hostVersions, remoteVersions, rtdStatus) {
286
+ const pypiUrl = `https://pypi.org/project/${name}/`;
287
+ const githubUrl = `https://github.com/ywatanabe1989/${name}`;
288
+ const rtdUrl = `https://${name === 'scitex' ? 'scitex-python' : name}.readthedocs.io/`;
289
+
290
+ // Collect all issues for tooltip
291
+ let allIssues = [...(info.issues || [])];
292
+
293
+ // Re-evaluate status based on RTD
294
+ let effectiveStatus = info.status;
295
+ if (rtdStatus && Object.keys(rtdStatus).length > 0) {
296
+ const rtdLatest = rtdStatus['latest'];
297
+ const rtdStable = rtdStatus['stable'];
298
+ if (rtdLatest && rtdLatest.status === 'failing') {
299
+ allIssues.push('RTD latest build failing');
300
+ if (effectiveStatus === 'ok') effectiveStatus = 'mismatch';
301
+ }
302
+ if (rtdStable && rtdStable.status === 'failing') {
303
+ allIssues.push('RTD stable build failing');
304
+ if (effectiveStatus === 'ok') effectiveStatus = 'mismatch';
305
+ }
306
+ if (rtdLatest && rtdLatest.status === 'not_found') {
307
+ allIssues.push('RTD project not found');
308
+ if (effectiveStatus === 'ok') effectiveStatus = 'mismatch';
309
+ }
310
+ }
311
+
312
+ // Create tooltip text from issues (using &#10; for newlines in title attribute)
313
+ const tooltipText = allIssues.length > 0 ? allIssues.join('&#10;') : '';
314
+ const tooltipAttr = tooltipText ? `title="${tooltipText}"` : '';
315
+
316
+ let html = `
317
+ <div class="package-card collapsed">
318
+ <div class="package-header" onclick="toggleCard(this)">
319
+ <span class="fold-icon">▶</span>
320
+ <a href="${githubUrl}" target="_blank" class="package-name" onclick="event.stopPropagation()">${name}</a>
321
+ <span class="status-badge status-${effectiveStatus}" ${tooltipAttr}>${effectiveStatus}</span>
322
+ <span class="quick-links">
323
+ <a href="${pypiUrl}" target="_blank" title="PyPI" onclick="event.stopPropagation()">📦</a>
324
+ <a href="${githubUrl}" target="_blank" title="GitHub" onclick="event.stopPropagation()">🐙</a>
325
+ <a href="${rtdUrl}" target="_blank" title="Docs" onclick="event.stopPropagation()">📖</a>
326
+ </span>
327
+ </div>
328
+ <div class="package-body">
329
+ <div class="version-grid">
330
+ <div class="version-section">
331
+ <h4>LOCAL</h4>
332
+ <div class="version-item"><span class="key">toml</span><span class="value">${local.pyproject_toml || '-'}</span></div>
333
+ <div class="version-item"><span class="key">installed</span><span class="value">${local.installed || '-'}</span></div>
334
+ </div>
335
+ <div class="version-section">
336
+ <h4>GIT</h4>
337
+ <div class="version-item"><span class="key">tag</span><span class="value">${git.latest_tag || '-'}</span></div>
338
+ <div class="version-item"><span class="key">branch</span><span class="value">${git.branch || '-'}</span></div>
339
+ </div>
340
+ <div class="version-section">
341
+ <h4><a href="https://pypi.org/project/${name}/" target="_blank">PYPI</a></h4>
342
+ <div class="version-item"><span class="key">published</span><span class="value">${remote.pypi || '-'}</span></div>
343
+ </div>`;
344
+
345
+ if (hostVersions.length > 0) {
346
+ hostVersions.forEach(h => {
347
+ html += `<div class="version-section"><h4>${h.name.toUpperCase()}</h4>`;
348
+ html += `<div class="version-item"><span class="key">toml</span><span class="value">${h.toml || '-'}</span></div>`;
349
+ html += `<div class="version-item"><span class="key">installed</span><span class="value">${h.installed || h.error || '-'}</span></div>`;
350
+ html += `</div>`;
351
+ });
352
+ }
353
+
354
+ if (remoteVersions.length > 0) {
355
+ html += `<div class="version-section"><h4><a href="${githubUrl}" target="_blank">GITHUB</a></h4>`;
356
+ remoteVersions.forEach(r => {
357
+ html += `<div class="version-item"><span class="key">${r.name}</span><span class="value">${r.latest_tag || r.error || '-'}</span></div>`;
358
+ });
359
+ html += `</div>`;
360
+ }
361
+
362
+ if (rtdStatus && Object.keys(rtdStatus).length > 0) {
363
+ html += `<div class="version-section"><h4><a href="${rtdUrl}" target="_blank">RTD</a></h4>`;
364
+ Object.entries(rtdStatus).forEach(([version, data]) => {
365
+ const statusClass = data.status === 'passing' ? 'rtd-passing' : (data.status === 'failing' ? 'rtd-failing' : 'rtd-unknown');
366
+ const statusIcon = data.status === 'passing' ? '✓' : (data.status === 'failing' ? '✗' : '?');
367
+ const link = data.url ? `<a href="${data.url}" target="_blank">${statusIcon}</a>` : statusIcon;
368
+ html += `<div class="version-item"><span class="key">${version}</span><span class="value ${statusClass}">${link} ${data.status || '-'}</span></div>`;
369
+ });
370
+ html += `</div>`;
371
+ }
372
+
373
+ html += `</div>`;
374
+
375
+ if (allIssues.length > 0) {
376
+ html += `<div class="issues"><h4>Issues</h4><ul>`;
377
+ allIssues.forEach(i => { html += `<li>${i}</li>`; });
378
+ html += `</ul></div>`;
379
+ }
380
+
381
+ html += `</div></div>`;
382
+ return html;
383
+ }
384
+
385
+ async function refreshData() { await fetchVersions(); }
386
+
387
+ function toggleCard(header) {
388
+ const card = header.parentElement;
389
+ card.classList.toggle('collapsed');
390
+ }
391
+
392
+ function toggleAllCards(expand) {
393
+ document.querySelectorAll('.package-card').forEach(card => {
394
+ if (expand) {
395
+ card.classList.remove('collapsed');
396
+ } else {
397
+ card.classList.add('collapsed');
398
+ }
399
+ });
400
+ }
401
+
402
+ function toggleFilters() {
403
+ const filters = document.querySelector('.filters');
404
+ filters.classList.toggle('collapsed');
405
+ }
406
+
407
+ function exportJSON() {
408
+ if (!cachedData) return;
409
+ const blob = new Blob([JSON.stringify(cachedData, null, 2)], { type: 'application/json' });
410
+ const url = URL.createObjectURL(blob);
411
+ const a = document.createElement('a');
412
+ a.href = url;
413
+ a.download = 'scitex-versions.json';
414
+ a.click();
415
+ URL.revokeObjectURL(url);
416
+ }
417
+
418
+ fetchVersions();
419
+ """
420
+
421
+
422
+ # EOF