Spaces:
Running
Running
add versioning selection in the back and front end
Browse files- app.py +40 -8
- classes.py +39 -12
- schemas.py +16 -4
- static/script.js +22 -2
- static/style.css +15 -0
- templates/index.html +10 -2
app.py
CHANGED
|
@@ -115,11 +115,37 @@ def get_tdoc_url(doc_id):
|
|
| 115 |
return tdoc["url"]
|
| 116 |
return "Document not indexed (re-indexing documents ?)"
|
| 117 |
|
| 118 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
series = document.split(".")[0].zfill(2)
|
| 120 |
url = f"https://www.3gpp.org/ftp/Specs/archive/{series}_series/{document}"
|
| 121 |
versions = get_docs_from_url(url)
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
def get_document(spec_id: str, spec_title: str, source: str):
|
| 125 |
text = [f"{spec_id} - {spec_title}"]
|
|
@@ -230,14 +256,15 @@ def reconnect():
|
|
| 230 |
def find_document(request: DocRequest):
|
| 231 |
start_time = time.time()
|
| 232 |
document = request.doc_id
|
|
|
|
| 233 |
if valid_3gpp_doc_format.match(document):
|
| 234 |
url = get_tdoc_url(document)
|
| 235 |
elif valid_3gpp_spec_format.match(document):
|
| 236 |
-
url = get_spec_url(document)
|
| 237 |
elif valid_etsi_doc_format.match(document):
|
| 238 |
url = etsi_doc_finder.search_document(document)
|
| 239 |
elif valid_etsi_spec_format.match(document):
|
| 240 |
-
url = etsi_spec_finder.search_document(document)
|
| 241 |
elif document.startswith("GP"):
|
| 242 |
for sp in gp_spec_locations:
|
| 243 |
if document.lower() in sp.lower():
|
|
@@ -251,6 +278,10 @@ def find_document(request: DocRequest):
|
|
| 251 |
version = None
|
| 252 |
if valid_3gpp_spec_format.match(document):
|
| 253 |
version = url.split("/")[-1].replace(".zip", "").split("-")[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
scope = None
|
| 255 |
spec_metadatas = spec_metadatas_3gpp if valid_3gpp_spec_format.match(document) else spec_metadatas_etsi
|
| 256 |
for spec in spec_metadatas:
|
|
@@ -286,6 +317,7 @@ def find_document(request: DocRequest):
|
|
| 286 |
def find_document_batch(request: BatchDocRequest):
|
| 287 |
start_time = time.time()
|
| 288 |
documents = request.doc_ids
|
|
|
|
| 289 |
results = {}
|
| 290 |
missing = []
|
| 291 |
|
|
@@ -293,11 +325,11 @@ def find_document_batch(request: BatchDocRequest):
|
|
| 293 |
if valid_3gpp_doc_format.match(document):
|
| 294 |
url = get_tdoc_url(document)
|
| 295 |
elif valid_3gpp_spec_format.match(document):
|
| 296 |
-
url = get_spec_url(document)
|
| 297 |
elif valid_etsi_doc_format.match(document):
|
| 298 |
-
etsi_doc_finder.search_document(document)
|
| 299 |
elif valid_etsi_spec_format.match(document):
|
| 300 |
-
etsi_spec_finder.search_document(document)
|
| 301 |
elif document.startswith("GP"):
|
| 302 |
for sp in gp_spec_locations:
|
| 303 |
if document.lower() in sp.lower():
|
|
@@ -308,7 +340,7 @@ def find_document_batch(request: BatchDocRequest):
|
|
| 308 |
missing.append(document)
|
| 309 |
else:
|
| 310 |
results[document] = url
|
| 311 |
-
|
| 312 |
return BatchDocResponse(
|
| 313 |
results=results,
|
| 314 |
missing=missing,
|
|
|
|
| 115 |
return tdoc["url"]
|
| 116 |
return "Document not indexed (re-indexing documents ?)"
|
| 117 |
|
| 118 |
+
def version_to_3gpp(version: str) -> str:
|
| 119 |
+
"""Convert semantic version string (e.g. '17.6.0') to 3GPP filename suffix (e.g. 'h60').
|
| 120 |
+
Returns the input unchanged if it does not look like a semantic version."""
|
| 121 |
+
parts = version.split(".")
|
| 122 |
+
if len(parts) != 3:
|
| 123 |
+
return version
|
| 124 |
+
try:
|
| 125 |
+
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
|
| 126 |
+
except ValueError:
|
| 127 |
+
return version
|
| 128 |
+
if major < 10:
|
| 129 |
+
return version
|
| 130 |
+
letter = chr(ord('a') + major - 10)
|
| 131 |
+
return f"{letter}{minor}{patch}"
|
| 132 |
+
|
| 133 |
+
def get_spec_url(document, version: str = None):
|
| 134 |
series = document.split(".")[0].zfill(2)
|
| 135 |
url = f"https://www.3gpp.org/ftp/Specs/archive/{series}_series/{document}"
|
| 136 |
versions = get_docs_from_url(url)
|
| 137 |
+
if not versions:
|
| 138 |
+
return f"Specification {document} not found"
|
| 139 |
+
if version:
|
| 140 |
+
suffix = version_to_3gpp(version)
|
| 141 |
+
# filename pattern: <specnumber>-<suffix>.zip
|
| 142 |
+
spec_number = document.replace(".", "")
|
| 143 |
+
target = f"{spec_number}-{suffix}.zip"
|
| 144 |
+
for v in versions:
|
| 145 |
+
if v.lower() == target.lower():
|
| 146 |
+
return url + "/" + v
|
| 147 |
+
# fallback to latest if requested version not found
|
| 148 |
+
return url + "/" + versions[-1]
|
| 149 |
|
| 150 |
def get_document(spec_id: str, spec_title: str, source: str):
|
| 151 |
text = [f"{spec_id} - {spec_title}"]
|
|
|
|
| 256 |
def find_document(request: DocRequest):
|
| 257 |
start_time = time.time()
|
| 258 |
document = request.doc_id
|
| 259 |
+
requested_version = request.version
|
| 260 |
if valid_3gpp_doc_format.match(document):
|
| 261 |
url = get_tdoc_url(document)
|
| 262 |
elif valid_3gpp_spec_format.match(document):
|
| 263 |
+
url = get_spec_url(document, requested_version)
|
| 264 |
elif valid_etsi_doc_format.match(document):
|
| 265 |
url = etsi_doc_finder.search_document(document)
|
| 266 |
elif valid_etsi_spec_format.match(document):
|
| 267 |
+
url = etsi_spec_finder.search_document(document, requested_version)
|
| 268 |
elif document.startswith("GP"):
|
| 269 |
for sp in gp_spec_locations:
|
| 270 |
if document.lower() in sp.lower():
|
|
|
|
| 278 |
version = None
|
| 279 |
if valid_3gpp_spec_format.match(document):
|
| 280 |
version = url.split("/")[-1].replace(".zip", "").split("-")[-1]
|
| 281 |
+
elif valid_etsi_spec_format.match(document):
|
| 282 |
+
# extract version from the resolved URL path segment before the filename
|
| 283 |
+
parts = url.rstrip("/").split("/")
|
| 284 |
+
version = parts[-2] if len(parts) >= 2 else None
|
| 285 |
scope = None
|
| 286 |
spec_metadatas = spec_metadatas_3gpp if valid_3gpp_spec_format.match(document) else spec_metadatas_etsi
|
| 287 |
for spec in spec_metadatas:
|
|
|
|
| 317 |
def find_document_batch(request: BatchDocRequest):
|
| 318 |
start_time = time.time()
|
| 319 |
documents = request.doc_ids
|
| 320 |
+
requested_version = request.version
|
| 321 |
results = {}
|
| 322 |
missing = []
|
| 323 |
|
|
|
|
| 325 |
if valid_3gpp_doc_format.match(document):
|
| 326 |
url = get_tdoc_url(document)
|
| 327 |
elif valid_3gpp_spec_format.match(document):
|
| 328 |
+
url = get_spec_url(document, requested_version)
|
| 329 |
elif valid_etsi_doc_format.match(document):
|
| 330 |
+
url = etsi_doc_finder.search_document(document)
|
| 331 |
elif valid_etsi_spec_format.match(document):
|
| 332 |
+
url = etsi_spec_finder.search_document(document, requested_version)
|
| 333 |
elif document.startswith("GP"):
|
| 334 |
for sp in gp_spec_locations:
|
| 335 |
if document.lower() in sp.lower():
|
|
|
|
| 340 |
missing.append(document)
|
| 341 |
else:
|
| 342 |
results[document] = url
|
| 343 |
+
|
| 344 |
return BatchDocResponse(
|
| 345 |
results=results,
|
| 346 |
missing=missing,
|
classes.py
CHANGED
|
@@ -100,7 +100,30 @@ class ETSISpecFinder:
|
|
| 100 |
print(f"Error accessing {url}: {e}")
|
| 101 |
return []
|
| 102 |
|
| 103 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
# Example : 103 666[-2 opt]
|
| 105 |
original = doc_id
|
| 106 |
|
|
@@ -108,17 +131,21 @@ class ETSISpecFinder:
|
|
| 108 |
url2 = f"{self.second_url}/{self.get_spec_path(original)}/"
|
| 109 |
print(url)
|
| 110 |
print(url2)
|
| 111 |
-
|
| 112 |
releases = self.get_docs_from_url(url)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
releases = self.get_docs_from_url(url2)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
return f"Specification {doc_id} not found"
|
|
|
|
| 100 |
print(f"Error accessing {url}: {e}")
|
| 101 |
return []
|
| 102 |
|
| 103 |
+
def _normalise_version(self, version: str) -> str:
|
| 104 |
+
"""Normalise a user-supplied version string to ETSI zero-padded format.
|
| 105 |
+
'17.6.0' -> '17.06.00' (the '_60' release suffix is ignored during matching)
|
| 106 |
+
Already-normalised strings like '17.06.00' are returned unchanged."""
|
| 107 |
+
parts = version.strip("/").split(".")
|
| 108 |
+
if len(parts) == 3:
|
| 109 |
+
try:
|
| 110 |
+
return f"{int(parts[0]):02d}.{int(parts[1]):02d}.{int(parts[2]):02d}"
|
| 111 |
+
except ValueError:
|
| 112 |
+
pass
|
| 113 |
+
return version.strip("/")
|
| 114 |
+
|
| 115 |
+
def _pick_release(self, releases: list, version: str = None) -> str:
|
| 116 |
+
"""Return the release folder matching version, or the latest if not found/specified."""
|
| 117 |
+
if version:
|
| 118 |
+
target = self._normalise_version(version)
|
| 119 |
+
for r in releases:
|
| 120 |
+
# folder names are like '17.06.00_60'; match on the part before '_'
|
| 121 |
+
folder = r.strip("/").split("_")[0]
|
| 122 |
+
if folder == target:
|
| 123 |
+
return r
|
| 124 |
+
return releases[-1]
|
| 125 |
+
|
| 126 |
+
def search_document(self, doc_id: str, version: str = None):
|
| 127 |
# Example : 103 666[-2 opt]
|
| 128 |
original = doc_id
|
| 129 |
|
|
|
|
| 131 |
url2 = f"{self.second_url}/{self.get_spec_path(original)}/"
|
| 132 |
print(url)
|
| 133 |
print(url2)
|
| 134 |
+
|
| 135 |
releases = self.get_docs_from_url(url)
|
| 136 |
+
if releases:
|
| 137 |
+
release = self._pick_release(releases, version)
|
| 138 |
+
files = self.get_docs_from_url(url + release)
|
| 139 |
+
for f in files:
|
| 140 |
+
if f.endswith(".pdf"):
|
| 141 |
+
return url + release + "/" + f
|
| 142 |
+
|
| 143 |
releases = self.get_docs_from_url(url2)
|
| 144 |
+
if releases:
|
| 145 |
+
release = self._pick_release(releases, version)
|
| 146 |
+
files = self.get_docs_from_url(url2 + release)
|
| 147 |
+
for f in files:
|
| 148 |
+
if f.endswith(".pdf"):
|
| 149 |
+
return url2 + release + "/" + f
|
| 150 |
+
|
| 151 |
return f"Specification {doc_id} not found"
|
schemas.py
CHANGED
|
@@ -4,14 +4,20 @@ from typing import *
|
|
| 4 |
class DocRequest(BaseModel):
|
| 5 |
"""
|
| 6 |
Request model for single document retrieval.
|
| 7 |
-
|
| 8 |
Used to specify which document or specification to retrieve by its unique identifier.
|
| 9 |
"""
|
| 10 |
doc_id: str = Field(
|
| 11 |
-
...,
|
| 12 |
title="Document Identifier",
|
| 13 |
description="Unique identifier for the document or specification.",
|
| 14 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
class DocResponse(BaseModel):
|
| 17 |
"""
|
|
@@ -48,14 +54,20 @@ class DocResponse(BaseModel):
|
|
| 48 |
class BatchDocRequest(BaseModel):
|
| 49 |
"""
|
| 50 |
Request model for batch document retrieval.
|
| 51 |
-
|
| 52 |
Allows retrieval of multiple documents in a single API call for efficiency.
|
| 53 |
"""
|
| 54 |
doc_ids: List[str] = Field(
|
| 55 |
-
...,
|
| 56 |
title="Document Identifier List",
|
| 57 |
description="List of document identifiers to retrieve."
|
| 58 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
class BatchDocResponse(BaseModel):
|
| 61 |
"""
|
|
|
|
| 4 |
class DocRequest(BaseModel):
|
| 5 |
"""
|
| 6 |
Request model for single document retrieval.
|
| 7 |
+
|
| 8 |
Used to specify which document or specification to retrieve by its unique identifier.
|
| 9 |
"""
|
| 10 |
doc_id: str = Field(
|
| 11 |
+
...,
|
| 12 |
title="Document Identifier",
|
| 13 |
description="Unique identifier for the document or specification.",
|
| 14 |
)
|
| 15 |
+
version: Optional[str] = Field(
|
| 16 |
+
None,
|
| 17 |
+
title="Specification Version",
|
| 18 |
+
description="Desired version of the specification (e.g. '17.6.0'). Defaults to the latest version when omitted.",
|
| 19 |
+
examples=["17.6.0", "15.3.1", "18.0.0"]
|
| 20 |
+
)
|
| 21 |
|
| 22 |
class DocResponse(BaseModel):
|
| 23 |
"""
|
|
|
|
| 54 |
class BatchDocRequest(BaseModel):
|
| 55 |
"""
|
| 56 |
Request model for batch document retrieval.
|
| 57 |
+
|
| 58 |
Allows retrieval of multiple documents in a single API call for efficiency.
|
| 59 |
"""
|
| 60 |
doc_ids: List[str] = Field(
|
| 61 |
+
...,
|
| 62 |
title="Document Identifier List",
|
| 63 |
description="List of document identifiers to retrieve."
|
| 64 |
)
|
| 65 |
+
version: Optional[str] = Field(
|
| 66 |
+
None,
|
| 67 |
+
title="Specification Version",
|
| 68 |
+
description="Desired version applied to all specification lookups in the batch (e.g. '17.6.0'). Defaults to latest when omitted.",
|
| 69 |
+
examples=["17.6.0", "15.3.1"]
|
| 70 |
+
)
|
| 71 |
|
| 72 |
class BatchDocResponse(BaseModel):
|
| 73 |
"""
|
static/script.js
CHANGED
|
@@ -43,6 +43,18 @@ function switchMode(mode) {
|
|
| 43 |
hideResults();
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
// Keyboard shortcuts management
|
| 47 |
function setupKeyboardHandlers() {
|
| 48 |
document.getElementById('doc-id').addEventListener('keypress', function(e) {
|
|
@@ -71,12 +83,16 @@ async function searchSingle() {
|
|
| 71 |
updateHeaderStats('Searching...');
|
| 72 |
|
| 73 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
const response = await fetch(`/find/single`, {
|
| 75 |
method: 'POST',
|
| 76 |
headers: {
|
| 77 |
'Content-Type': 'application/json',
|
| 78 |
},
|
| 79 |
-
body: JSON.stringify(
|
| 80 |
});
|
| 81 |
|
| 82 |
const data = await response.json();
|
|
@@ -118,12 +134,16 @@ async function searchBatch() {
|
|
| 118 |
updateHeaderStats('Searching...');
|
| 119 |
|
| 120 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
const response = await fetch(`/find/batch`, {
|
| 122 |
method: 'POST',
|
| 123 |
headers: {
|
| 124 |
'Content-Type': 'application/json',
|
| 125 |
},
|
| 126 |
-
body: JSON.stringify(
|
| 127 |
});
|
| 128 |
|
| 129 |
const data = await response.json();
|
|
|
|
| 43 |
hideResults();
|
| 44 |
}
|
| 45 |
|
| 46 |
+
// Show version field only when the input looks like a spec (ETSI or 3GPP)
|
| 47 |
+
const ETSI_SPEC_RE = /^\d{3} \d{3}/;
|
| 48 |
+
const GPP_SPEC_RE = /^\d{2}\.\d{3}/;
|
| 49 |
+
|
| 50 |
+
function toggleVersionField() {
|
| 51 |
+
const docId = document.getElementById('doc-id').value.trim();
|
| 52 |
+
const group = document.getElementById('single-version-group');
|
| 53 |
+
const isSpec = ETSI_SPEC_RE.test(docId) || GPP_SPEC_RE.test(docId);
|
| 54 |
+
group.style.display = isSpec ? 'block' : 'none';
|
| 55 |
+
if (!isSpec) document.getElementById('doc-version').value = '';
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
// Keyboard shortcuts management
|
| 59 |
function setupKeyboardHandlers() {
|
| 60 |
document.getElementById('doc-id').addEventListener('keypress', function(e) {
|
|
|
|
| 83 |
updateHeaderStats('Searching...');
|
| 84 |
|
| 85 |
try {
|
| 86 |
+
const version = document.getElementById('doc-version').value.trim() || null;
|
| 87 |
+
const body = { doc_id: docId };
|
| 88 |
+
if (version) body.version = version;
|
| 89 |
+
|
| 90 |
const response = await fetch(`/find/single`, {
|
| 91 |
method: 'POST',
|
| 92 |
headers: {
|
| 93 |
'Content-Type': 'application/json',
|
| 94 |
},
|
| 95 |
+
body: JSON.stringify(body)
|
| 96 |
});
|
| 97 |
|
| 98 |
const data = await response.json();
|
|
|
|
| 134 |
updateHeaderStats('Searching...');
|
| 135 |
|
| 136 |
try {
|
| 137 |
+
const version = document.getElementById('batch-version').value.trim() || null;
|
| 138 |
+
const body = { doc_ids: docIds };
|
| 139 |
+
if (version) body.version = version;
|
| 140 |
+
|
| 141 |
const response = await fetch(`/find/batch`, {
|
| 142 |
method: 'POST',
|
| 143 |
headers: {
|
| 144 |
'Content-Type': 'application/json',
|
| 145 |
},
|
| 146 |
+
body: JSON.stringify(body)
|
| 147 |
});
|
| 148 |
|
| 149 |
const data = await response.json();
|
static/style.css
CHANGED
|
@@ -136,6 +136,21 @@ body {
|
|
| 136 |
color: var(--text-primary);
|
| 137 |
}
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
.input-group {
|
| 140 |
display: flex;
|
| 141 |
gap: 0.75rem;
|
|
|
|
| 136 |
color: var(--text-primary);
|
| 137 |
}
|
| 138 |
|
| 139 |
+
.label-hint {
|
| 140 |
+
font-weight: 400;
|
| 141 |
+
font-size: 0.8rem;
|
| 142 |
+
color: var(--text-secondary);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.version-group {
|
| 146 |
+
animation: fadeIn 0.2s ease;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
@keyframes fadeIn {
|
| 150 |
+
from { opacity: 0; transform: translateY(-4px); }
|
| 151 |
+
to { opacity: 1; transform: translateY(0); }
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
.input-group {
|
| 155 |
display: flex;
|
| 156 |
gap: 0.75rem;
|
templates/index.html
CHANGED
|
@@ -35,10 +35,14 @@
|
|
| 35 |
<div class="form-group">
|
| 36 |
<label for="doc-id">Document ID</label>
|
| 37 |
<div class="input-group">
|
| 38 |
-
<input type="text" id="doc-id" placeholder="e.g., 23.401, S1-123456, SET(24)123">
|
| 39 |
<button class="btn btn-primary" onclick="searchSingle()">Search</button>
|
| 40 |
</div>
|
| 41 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
<!-- Batch Search -->
|
|
@@ -46,8 +50,12 @@
|
|
| 46 |
<div class="form-group">
|
| 47 |
<label for="batch-ids">List of IDs (one per line)</label>
|
| 48 |
<textarea id="batch-ids" placeholder="23.401 S1-123456 103 666" rows="6"></textarea>
|
| 49 |
-
<button class="btn btn-primary" onclick="searchBatch()">Search Batch</button>
|
| 50 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</div>
|
| 52 |
|
| 53 |
<!-- Keyword Search -->
|
|
|
|
| 35 |
<div class="form-group">
|
| 36 |
<label for="doc-id">Document ID</label>
|
| 37 |
<div class="input-group">
|
| 38 |
+
<input type="text" id="doc-id" placeholder="e.g., 23.401, S1-123456, SET(24)123, 102 223" oninput="toggleVersionField()">
|
| 39 |
<button class="btn btn-primary" onclick="searchSingle()">Search</button>
|
| 40 |
</div>
|
| 41 |
</div>
|
| 42 |
+
<div class="form-group version-group" id="single-version-group" style="display:none;">
|
| 43 |
+
<label for="doc-version">Version <span class="label-hint">(leave empty for latest)</span></label>
|
| 44 |
+
<input type="text" id="doc-version" placeholder="e.g., 17.6.0">
|
| 45 |
+
</div>
|
| 46 |
</div>
|
| 47 |
|
| 48 |
<!-- Batch Search -->
|
|
|
|
| 50 |
<div class="form-group">
|
| 51 |
<label for="batch-ids">List of IDs (one per line)</label>
|
| 52 |
<textarea id="batch-ids" placeholder="23.401 S1-123456 103 666" rows="6"></textarea>
|
|
|
|
| 53 |
</div>
|
| 54 |
+
<div class="form-group version-group">
|
| 55 |
+
<label for="batch-version">Version <span class="label-hint">(applies to all specs — leave empty for latest)</span></label>
|
| 56 |
+
<input type="text" id="batch-version" placeholder="e.g., 17.6.0">
|
| 57 |
+
</div>
|
| 58 |
+
<button class="btn btn-primary" onclick="searchBatch()">Search Batch</button>
|
| 59 |
</div>
|
| 60 |
|
| 61 |
<!-- Keyword Search -->
|