om4r932 commited on
Commit
9f149f3
·
1 Parent(s): 8b95a08

First version - Single search only -- Proto

Browse files
Files changed (6) hide show
  1. Dockerfile +16 -0
  2. app.py +436 -0
  3. requirements.txt +8 -0
  4. static/script.js +321 -0
  5. static/style.css +344 -0
  6. templates/index.html +80 -0
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9
2
+
3
+ RUN apt-get update && \
4
+ apt-get clean && rm -rf /var/lib/apt/lists/*
5
+
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+
10
+ WORKDIR /app
11
+
12
+ COPY --chown=user ./requirements.txt requirements.txt
13
+ RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ COPY --chown=user . /app
16
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from io import StringIO
2
+ import numpy as np
3
+ import pandas as pd
4
+ import requests
5
+ from bs4 import BeautifulSoup
6
+ import json
7
+ import os
8
+ import traceback
9
+ import uuid
10
+ import zipfile
11
+ import io
12
+ import subprocess
13
+ import os
14
+ import re
15
+ import time
16
+ from datetime import datetime
17
+ import warnings
18
+ from fastapi import FastAPI, HTTPException
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import FileResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+ from pydantic import BaseModel
23
+ from typing import Any, Dict, List, Literal, Optional
24
+
25
+ warnings.filterwarnings("ignore")
26
+
27
+ app = FastAPI(title="3GPP Document Finder API",
28
+ description="API to find 3GPP documents based on TSG document IDs")
29
+
30
+ app.mount("/static", StaticFiles(directory="static"), name="static")
31
+
32
+ origins = [
33
+ "*",
34
+ ]
35
+
36
+ app.add_middleware(
37
+ CORSMiddleware,
38
+ allow_origins=origins,
39
+ allow_credentials=True,
40
+ allow_methods=["*"],
41
+ allow_headers=["*"],
42
+ )
43
+
44
+ # def get_text(specification: str, version: str):
45
+ # """Récupère les bytes du PDF à partir d'une spécification et d'une version."""
46
+ # doc_id = specification
47
+ # series = doc_id.split(".")[0]
48
+
49
+ # response = requests.get(
50
+ # f"https://www.3gpp.org/ftp/Specs/archive/{series}_series/{doc_id}/{doc_id.replace('.', '')}-{version}.zip",
51
+ # verify=False,
52
+ # headers={"User-Agent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}
53
+ # )
54
+
55
+ # if response.status_code != 200:
56
+ # raise Exception(f"Téléchargement du ZIP échoué pour {specification}-{version}")
57
+
58
+ # zip_bytes = io.BytesIO(response.content)
59
+
60
+ # with zipfile.ZipFile(zip_bytes) as zf:
61
+ # for file_name in zf.namelist():
62
+ # if file_name.endswith("zip"):
63
+ # print("Another ZIP !")
64
+ # zip_bytes = io.BytesIO(zf.read(file_name))
65
+ # zf = zipfile.ZipFile(zip_bytes)
66
+ # for file_name2 in zf.namelist():
67
+ # if file_name2.endswith("doc") or file_name2.endswith("docx"):
68
+ # if "cover" in file_name2.lower():
69
+ # print("COVER !")
70
+ # continue
71
+ # ext = file_name2.split(".")[-1]
72
+ # doc_bytes = zf.read(file_name2)
73
+ # temp_id = str(uuid.uuid4())
74
+ # input_path = f"/tmp/{temp_id}.{ext}"
75
+ # output_path = f"/tmp/{temp_id}.txt"
76
+
77
+ # with open(input_path, "wb") as f:
78
+ # f.write(doc_bytes)
79
+
80
+ # subprocess.run([
81
+ # "libreoffice",
82
+ # "--headless",
83
+ # "--convert-to", "txt",
84
+ # "--outdir", "/tmp",
85
+ # input_path
86
+ # ], check=True)
87
+
88
+ # with open(output_path, "r") as f:
89
+ # txt_data = [line.strip() for line in f if line.strip()]
90
+
91
+ # os.remove(input_path)
92
+ # os.remove(output_path)
93
+ # return txt_data
94
+ # elif file_name.endswith("doc") or file_name.endswith("docx"):
95
+ # if "cover" in file_name.lower():
96
+ # print("COVER !")
97
+ # continue
98
+ # ext = file_name.split(".")[-1]
99
+ # doc_bytes = zf.read(file_name)
100
+ # temp_id = str(uuid.uuid4())
101
+ # input_path = f"/tmp/{temp_id}.{ext}"
102
+ # output_path = f"/tmp/{temp_id}.txt"
103
+
104
+ # print("Ecriture")
105
+ # with open(input_path, "wb") as f:
106
+ # f.write(doc_bytes)
107
+
108
+ # print("Convertissement")
109
+ # subprocess.run([
110
+ # "libreoffice",
111
+ # "--headless",
112
+ # "--convert-to", "txt",
113
+ # "--outdir", "/tmp",
114
+ # input_path
115
+ # ], check=True)
116
+
117
+ # print("Ecriture TXT")
118
+ # with open(output_path, "r", encoding="utf-8") as f:
119
+ # txt_data = [line.strip() for line in f if line.strip()]
120
+
121
+ # os.remove(input_path)
122
+ # os.remove(output_path)
123
+ # return txt_data
124
+
125
+ # raise Exception(f"Aucun fichier .doc/.docx trouvé dans le ZIP pour {specification}-{version}")
126
+
127
+ # def get_scope(specification: str, version: str):
128
+ # try:
129
+ # spec_text = get_text(specification, version)
130
+ # scp_i = 0
131
+ # nxt_i = 0
132
+ # for x in range(len(spec_text)):
133
+ # text = spec_text[x]
134
+ # if re.search(r"scope$", text, flags=re.IGNORECASE):
135
+ # scp_i = x
136
+ # nxt_i = scp_i + 10
137
+ # if re.search(r"references$", text, flags=re.IGNORECASE):
138
+ # nxt_i = x
139
+
140
+ # return re.sub(r"\s+", " ", " ".join(spec_text[scp_i+1:nxt_i])) if len(spec_text[scp_i+1:nxt_i]) < 2 else "Not found"
141
+ # except Exception as e:
142
+ # traceback.print_exception(e)
143
+ # return "Not found (error)"
144
+
145
+ class DocRequest(BaseModel):
146
+ doc_id: str
147
+
148
+ class DocResponse(BaseModel):
149
+ doc_id: str
150
+ url: str
151
+ scope: Optional[str] = None
152
+ search_time: float
153
+
154
+ # class BatchDocRequest(BaseModel):
155
+ # doc_ids: List[str]
156
+ # release: Optional[int] = None
157
+
158
+ # class BatchDocResponse(BaseModel):
159
+ # results: Dict[str, str]
160
+ # missing: List[str]
161
+ # search_time: float
162
+
163
+ # class KeywordRequest(BaseModel):
164
+ # keywords: str
165
+ # release: Optional[str] = None
166
+ # wg: Optional[str] = None
167
+ # spec_type: Optional[Literal["TS", "TR"]] = None
168
+ # mode: Optional[Literal["and", "or"]] = "and"
169
+
170
+ # class KeywordResponse(BaseModel):
171
+ # results: List[Dict[str, str]]
172
+ # search_time: float
173
+
174
+ class DocFinder:
175
+ def __init__(self):
176
+ self.main_ftp_url = "https://docbox.etsi.org/SET"
177
+ self.session = requests.Session()
178
+ self.indexer_file = "indexed_docs.json"
179
+ self.indexer, self.last_indexer_date = self.load_indexer()
180
+ self.session.post("https://portal.etsi.org/ETSIPages/LoginEOL.ashx", verify=False, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}, data=json.dumps({"username": os.environ.get("EOL_USER"), "password": os.environ.get("EOL_PASSWORD")}))
181
+
182
+ def load_indexer(self):
183
+ if os.path.exists(self.indexer_file):
184
+ with open(self.indexer_file, "r", encoding="utf-8") as f:
185
+ x = json.load(f)
186
+ return x["docs"], x["last_indexed_date"]
187
+ return {}, None
188
+
189
+ def save_indexer(self):
190
+ today = datetime.today()
191
+ self.last_indexer_date = today.strftime("%d/%m/%Y-%H:%M:%S")
192
+ with open(self.indexer_file, "w", encoding="utf-8") as f:
193
+ output = {"docs": self.indexer, "last_indexed_date": self.last_indexer_date}
194
+ json.dump(output, f, indent=4, ensure_ascii=False)
195
+
196
+ def get_workgroup(self, doc: str):
197
+ main_tsg = "SET-WG-R" if any(doc.startswith(kw) for kw in ["SETREQ", "SCPREQ"]) else "SET-WG-T" if any(doc.startswith(kw) for kw in ["SETTEC", "SCPTEC"]) else "SET" if any(doc.startswith(kw) for kw in ["SET", "SCP"]) else None
198
+ if main_tsg is None:
199
+ return None, None, None
200
+ regex = re.search(r'\(([^)]+)\)', doc)
201
+ workgroup = "20" + regex.group(1)
202
+ return main_tsg, workgroup, doc
203
+
204
+ def find_workgroup_url(self, main_tsg, workgroup):
205
+ response = self.session.get(f"{self.main_ftp_url}/{main_tsg}/05-CONTRIBUTIONS", verify=False)
206
+ soup = BeautifulSoup(response.text, 'html.parser')
207
+ for item in soup.find_all("tr"):
208
+ link = item.find("a")
209
+ if link and workgroup in link.get_text():
210
+ return f"{self.main_ftp_url}/{main_tsg}/05-CONTRIBUTIONS/{link.get_text()}"
211
+
212
+ return f"{self.main_ftp_url}/{main_tsg}/05-CONTRIBUTIONS/{workgroup}"
213
+
214
+ def get_docs_from_url(self, url):
215
+ try:
216
+ response = self.session.get(url, verify=False, timeout=15)
217
+ soup = BeautifulSoup(response.text, "html.parser")
218
+ return [item.get_text() for item in soup.select("tr td a")]
219
+ except Exception as e:
220
+ print(f"Error accessing {url}: {e}")
221
+ return []
222
+
223
+ def search_document(self, doc_id: str):
224
+ original = doc_id
225
+
226
+ if original in self.indexer:
227
+ return self.indexer[original]
228
+ for doc in self.indexer:
229
+ if doc.startswith(original):
230
+ return self.indexer[doc]
231
+
232
+ main_tsg, workgroup, doc = self.get_workgroup(doc_id)
233
+ if main_tsg:
234
+ wg_url = self.find_workgroup_url(main_tsg, workgroup)
235
+ if wg_url:
236
+ files = self.get_docs_from_url(wg_url)
237
+ for f in files:
238
+ if doc in f.lower() or original in f:
239
+ doc_url = f"{wg_url}/{f}"
240
+ self.indexer[original] = doc_url
241
+ self.save_indexer()
242
+ return doc_url
243
+ return f"Document {doc_id} not found"
244
+
245
+ # class DocFinder:
246
+ # def __init__(self):
247
+ # self.main_ftp_url = "https://www.3gpp.org/ftp"
248
+ # self.indexer_file = "indexed_docs.json"
249
+ # self.indexer, self.last_indexer_date = self.load_indexer()
250
+
251
+ # def load_indexer(self):
252
+ # """Load existing index if available"""
253
+ # if os.path.exists(self.indexer_file):
254
+ # with open(self.indexer_file, "r", encoding="utf-8") as f:
255
+ # x = json.load(f)
256
+ # return x["docs"], x["last_indexed_date"]
257
+ # return {}, None
258
+
259
+ # def save_indexer(self):
260
+ # """Save the updated index"""
261
+ # self.last_indexer_date = today.strftime("%d/%m/%Y-%H:%M:%S")
262
+ # with open(self.indexer_file, "w", encoding="utf-8") as f:
263
+ # today = datetime.today()
264
+ # output = {"docs": self.indexer, "last_indexed_date": self.last_indexer_date}
265
+ # json.dump(output, f, indent=4, ensure_ascii=False)
266
+
267
+ # def get_workgroup(self, doc):
268
+ # main_tsg = "tsg_ct" if doc[0] == "C" else "tsg_sa" if doc[0] == "S" else None
269
+ # if main_tsg is None:
270
+ # return None, None, None
271
+ # workgroup = f"WG{int(doc[1])}" if doc[1].isnumeric() else main_tsg.upper()
272
+ # return main_tsg, workgroup, doc
273
+
274
+ # def find_workgroup_url(self, main_tsg, workgroup):
275
+ # """Find the URL for the specific workgroup"""
276
+ # response = requests.get(f"{self.main_ftp_url}/{main_tsg}", verify=False)
277
+ # soup = BeautifulSoup(response.text, 'html.parser')
278
+
279
+ # for item in soup.find_all("tr"):
280
+ # link = item.find("a")
281
+ # if link and workgroup in link.get_text():
282
+ # return f"{self.main_ftp_url}/{main_tsg}/{link.get_text()}"
283
+
284
+ # return f"{self.main_ftp_url}/{main_tsg}/{workgroup}"
285
+
286
+ # def get_docs_from_url(self, url):
287
+ # """Get list of documents/directories from a URL"""
288
+ # try:
289
+ # response = requests.get(url, verify=False, timeout=10)
290
+ # soup = BeautifulSoup(response.text, "html.parser")
291
+ # return [item.get_text() for item in soup.select("tr td a")]
292
+ # except Exception as e:
293
+ # print(f"Error accessing {url}: {e}")
294
+ # return []
295
+
296
+ # def search_document(self, doc_id: str, release=None):
297
+ # original_id = doc_id
298
+
299
+ # if original_id in self.indexer:
300
+ # return self.indexer[original_id]
301
+ # for doc in self.indexer:
302
+ # if doc.startswith(original_id):
303
+ # return self.indexer[doc]
304
+
305
+ # # 2. Recherche live "classique" (TSG/CT)
306
+ # main_tsg, workgroup, doc = self.get_workgroup(doc_id)
307
+ # if main_tsg:
308
+ # wg_url = self.find_workgroup_url(main_tsg, workgroup)
309
+ # if wg_url:
310
+ # meeting_folders = self.get_docs_from_url(wg_url)
311
+ # for folder in meeting_folders:
312
+ # meeting_url = f"{wg_url}/{folder}"
313
+ # meeting_contents = self.get_docs_from_url(meeting_url)
314
+ # key = "docs" if "docs" in [x.lower() for x in meeting_contents] else "tdocs" if "tdocs" in [x.lower() for x in meeting_contents] else None
315
+ # if key is not None:
316
+ # docs_url = f"{meeting_url}/{key}"
317
+ # files = self.get_docs_from_url(docs_url)
318
+ # for file in files:
319
+ # if doc in file.lower() or original_id in file:
320
+ # doc_url = f"{docs_url}/{file}"
321
+ # self.indexer[original_id] = doc_url
322
+ # return doc_url
323
+ # # ZIP subfolder
324
+ # if "zip" in [x for x in files]:
325
+ # zip_url = f"{docs_url}/zip"
326
+ # zip_files = self.get_docs_from_url(zip_url)
327
+ # for file in zip_files:
328
+ # if doc in file.lower() or original_id in file:
329
+ # doc_url = f"{zip_url}/{file}"
330
+ # self.indexer[original_id] = doc_url
331
+ # self.save_indexer()
332
+ # return doc_url
333
+
334
+ # # 3. Dernier recours : tenter dans /ftp/workshop (recherche live)
335
+ # workshop_url = f"{self.main_ftp_url}/workshop"
336
+ # meetings = self.get_docs_from_url(workshop_url)
337
+ # for meeting in meetings:
338
+ # if meeting in ['./', '../']:
339
+ # continue
340
+ # meeting_url = f"{workshop_url}/{meeting}"
341
+ # contents = self.get_docs_from_url(meeting_url)
342
+ # for sub in contents:
343
+ # if sub.lower() in ['docs', 'tdocs']:
344
+ # docs_url = f"{meeting_url}/{sub}"
345
+ # files = self.get_docs_from_url(docs_url)
346
+ # for file in files:
347
+ # if doc_id.lower() in file.lower() or original_id in file:
348
+ # doc_url = f"{docs_url}/{file}"
349
+ # self.indexer[original_id] = doc_url
350
+ # self.save_indexer()
351
+ # return doc_url
352
+ # if "zip" in [x.lower() for x in files]:
353
+ # zip_url = f"{docs_url}/zip"
354
+ # zip_files = self.get_docs_from_url(zip_url)
355
+ # for file in zip_files:
356
+ # if doc_id.lower() in file.lower() or original_id in file:
357
+ # doc_url = f"{zip_url}/{file}"
358
+ # self.indexer[original_id] = doc_url
359
+ # self.save_indexer()
360
+ # return doc_url
361
+
362
+ # return f"Document {doc_id} not found"
363
+
364
+ @app.get("/")
365
+ async def main_menu():
366
+ return FileResponse(os.path.join("templates", "index.html"))
367
+
368
+ # @app.post("/search-spec", response_model=KeywordResponse)
369
+ # def search_spec(request: KeywordRequest):
370
+ # start_time = time.time()
371
+ # kws = [_.lower() for _ in request.keywords.split(" ")]
372
+ # results = []
373
+
374
+ # for string, spec in finder_spec.indexer_specs.items():
375
+ # if request.mode == "and":
376
+ # if not all(kw in string.lower() for kw in kws):
377
+ # continue
378
+ # elif request.mode == "or":
379
+ # if not any(kw in string.lower() for kw in kws):
380
+ # continue
381
+ # release = request.release
382
+ # working_group = request.wg
383
+ # spec_type = request.spec_type
384
+
385
+ # if spec.get('version', None) is None or (release is not None and spec["version"].split(".")[0] != str(release)):
386
+ # continue
387
+ # if spec.get('working_group', None) is None or (working_group is not None and spec["working_group"] != working_group):
388
+ # continue
389
+ # if spec_type is not None and spec["type"] != spec_type:
390
+ # continue
391
+
392
+ # results.append(spec)
393
+ # if len(results) > 0:
394
+ # return KeywordResponse(
395
+ # results=results,
396
+ # search_time=time.time() - start_time
397
+ # )
398
+ # else:
399
+ # raise HTTPException(status_code=404, detail="Specifications not found")
400
+
401
+ finder = DocFinder()
402
+
403
+ @app.post("/find", response_model=DocResponse)
404
+ def find_document(request: DocRequest):
405
+ start_time = time.time()
406
+ result = finder.search_document(request.doc_id)
407
+
408
+ if "not found" not in result and "Could not" not in result and "Unable" not in result:
409
+ return DocResponse(
410
+ doc_id=request.doc_id,
411
+ url=result,
412
+ search_time=time.time() - start_time
413
+ )
414
+ else:
415
+ raise HTTPException(status_code=404, detail=result)
416
+
417
+ # @app.post("/batch", response_model=BatchDocResponse)
418
+ # def find_documents_batch(request: BatchDocRequest):
419
+ # start_time = time.time()
420
+
421
+ # results = {}
422
+ # missing = []
423
+
424
+ # for doc_id in request.doc_ids:
425
+ # finder = finder_tsg if doc_id[0].isalpha() else finder_spec
426
+ # result = finder.search_document(doc_id)
427
+ # if "not found" not in result and "Could not" not in result and "Unable" not in result:
428
+ # results[doc_id] = result
429
+ # else:
430
+ # missing.append(doc_id)
431
+
432
+ # return BatchDocResponse(
433
+ # results=results,
434
+ # missing=missing,
435
+ # search_time=time.time() - start_time
436
+ # )
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ requests
4
+ beautifulsoup4
5
+ pydantic
6
+ numpy
7
+ pandas
8
+ lxml
static/script.js ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DOM elements
2
+ const dynamicDesc = document.getElementById("dynamicDesc");
3
+ const dynamicTitle = document.getElementById("dynamicTitle");
4
+
5
+ // const singleModeBtn = document.getElementById('single-mode-btn');
6
+ // const batchModeBtn = document.getElementById('batch-mode-btn');
7
+ // const keywordModeBtn = document.getElementById("keyword-mode-btn");
8
+
9
+ const singleInput = document.querySelector('.single-input');
10
+ // const batchInput = document.querySelector('.batch-input');
11
+ // const keywordSearchInput = document.querySelector(".keyword-input");
12
+
13
+ const docIdInput = document.getElementById('doc-id');
14
+ // const batchIdsInput = document.getElementById('batch-ids');
15
+ // const keywordInput = document.getElementById("keywords");
16
+
17
+ const searchBtn = document.getElementById('search-btn');
18
+ const batchSearchBtn = document.getElementById('batch-search-btn');
19
+ const keywordSearchBtn = document.getElementById("keyword-search-btn");
20
+
21
+ const loader = document.getElementById('loader');
22
+ const resultsContainer = document.getElementById('results-container');
23
+ const resultsList = document.getElementById('results-list');
24
+ const resultsStats = document.getElementById('results-stats');
25
+ const errorMessage = document.getElementById('error-message');
26
+
27
+ // Search mode toggle
28
+ // singleModeBtn.addEventListener('click', () => {
29
+ // dynamicTitle.textContent = "Find 3GPP Documents";
30
+ // dynamicDesc.textContent = "Enter a TSG document ID / specification ID (e.g., S1-123456, C2-987654 or 31.102) to locate the document in the 3GPP FTP server.";
31
+
32
+ // singleModeBtn.classList.add('active');
33
+ // keywordModeBtn.classList.remove("active");
34
+ // batchModeBtn.classList.remove('active');
35
+
36
+ // singleInput.style.display = 'block';
37
+ // batchInput.style.display = 'none';
38
+ // keywordSearchInput.style.display = "none";
39
+ // });
40
+
41
+ // batchModeBtn.addEventListener('click', () => {
42
+ // dynamicTitle.textContent = "Find multiple 3GPP Documents";
43
+ // dynamicDesc.textContent = "Enter a list of TSG document ID / specification ID (e.g., S1-123456, C2-987654 or 31.102) to locate all of the specified documents in the 3GPP FTP server.";
44
+
45
+ // batchModeBtn.classList.add('active');
46
+ // keywordModeBtn.classList.remove("active");
47
+ // singleModeBtn.classList.remove('active');
48
+
49
+ // batchInput.style.display = 'block';
50
+ // keywordSearchInput.style.display = "none";
51
+ // singleInput.style.display = 'none';
52
+ // });
53
+
54
+ // keywordModeBtn.addEventListener('click', () => {
55
+ // dynamicTitle.textContent = "Search 3GPP specifications";
56
+ // dynamicDesc.textContent = "With keywords and filters, find all of 3GPP's specifications that matches your needs (with keywords, specification number, release or even working group (C1, S5, SP, CP: always the first letter of the group followed by the workgroup number)";
57
+
58
+ // keywordModeBtn.classList.add("active");
59
+ // singleModeBtn.classList.remove('active');
60
+ // batchModeBtn.classList.remove("active");
61
+
62
+ // singleInput.style.display = "none";
63
+ // batchInput.style.display = "none";
64
+ // keywordSearchInput.style.display = "block";
65
+ // })
66
+
67
+ // keywordSearchBtn.addEventListener("click", async ()=>{
68
+ // const keywords = keywordInput.value.trim();
69
+ // if (!keywords) {
70
+ // showError("Please enter at least one keyword");
71
+ // return;
72
+ // }
73
+
74
+ // showLoader();
75
+ // hideError();
76
+
77
+ // try{
78
+ // const response = await fetch("/search-spec", {
79
+ // method: "POST",
80
+ // headers: {
81
+ // "Content-Type": "application/json"
82
+ // },
83
+ // body: JSON.stringify({ keywords })
84
+ // });
85
+
86
+ // const data = await response.json();
87
+ // if (response.ok){
88
+ // displayKeywordResults(data);
89
+ // } else {
90
+ // showError('Error processing batch request');
91
+ // }
92
+ // } catch (error) {
93
+ // showError('Error connecting to the server. Please check if the API is running.');
94
+ // console.error('Error:', error);
95
+ // } finally {
96
+ // hideLoader();
97
+ // }
98
+ // })
99
+
100
+
101
+ // Single document search
102
+ searchBtn.addEventListener('click', async () => {
103
+ const docId = docIdInput.value.trim();
104
+ if (!docId) {
105
+ showError('Please enter a document ID');
106
+ return;
107
+ }
108
+
109
+ showLoader();
110
+ hideError();
111
+
112
+ try {
113
+ const response = await fetch(`/find`, {
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/json'
117
+ },
118
+ body: JSON.stringify({ doc_id: docId })
119
+ });
120
+
121
+ const data = await response.json();
122
+
123
+ if (response.ok) {
124
+ displaySingleResult(data);
125
+ } else {
126
+ displaySingleNotFound(docId, data.detail);
127
+ }
128
+ } catch (error) {
129
+ showError('Error connecting to the server. Please check if the API is running.');
130
+ console.error('Error:', error);
131
+ } finally {
132
+ hideLoader();
133
+ }
134
+ });
135
+
136
+ // Batch document search
137
+ // batchSearchBtn.addEventListener('click', async () => {
138
+ // const batchText = batchIdsInput.value.trim();
139
+ // if (!batchText) {
140
+ // showError('Please enter at least one document ID');
141
+ // return;
142
+ // }
143
+
144
+ // const docIds = batchText.split('\n')
145
+ // .map(id => id.trim())
146
+ // .filter(id => id !== '');
147
+
148
+ // if (docIds.length === 0) {
149
+ // showError('Please enter at least one valid document ID');
150
+ // return;
151
+ // }
152
+
153
+ // showLoader();
154
+ // hideError();
155
+
156
+ // try {
157
+ // const response = await fetch(`/batch`, {
158
+ // method: 'POST',
159
+ // headers: {
160
+ // 'Content-Type': 'application/json'
161
+ // },
162
+ // body: JSON.stringify({ doc_ids: docIds })
163
+ // });
164
+
165
+ // const data = await response.json();
166
+
167
+ // if (response.ok) {
168
+ // displayBatchResults(data);
169
+ // } else {
170
+ // showError('Error processing batch request');
171
+ // }
172
+ // } catch (error) {
173
+ // showError('Error connecting to the server. Please check if the API is running.');
174
+ // console.error('Error:', error);
175
+ // } finally {
176
+ // hideLoader();
177
+ // }
178
+ // });
179
+
180
+ // Display single result
181
+ function displaySingleResult(data) {
182
+ resultsList.innerHTML = '';
183
+
184
+ const resultItem = document.createElement('div');
185
+ resultItem.className = 'result-item';
186
+ let scopeItem = data.scope ? `<p>Scope : ${data.scope}</p>` : ""
187
+ resultItem.innerHTML = `
188
+ <div class="result-header">
189
+ <div class="result-id">${data.doc_id}</div>
190
+ <div class="result-status status-found">Found</div>
191
+ </div>
192
+ <div class="result-url">
193
+ <a href="${data.url}" target="_blank">${data.url}</a>
194
+ ${scopeItem}
195
+ </div>
196
+ `;
197
+
198
+ resultsList.appendChild(resultItem);
199
+ resultsStats.textContent = `Found in ${data.search_time.toFixed(2)} seconds`;
200
+ resultsContainer.style.display = 'block';
201
+ }
202
+
203
+ // Display single not found result
204
+ function displaySingleNotFound(docId, message) {
205
+ resultsList.innerHTML = '';
206
+
207
+ const resultItem = document.createElement('div');
208
+ resultItem.className = 'result-item';
209
+ resultItem.innerHTML = `
210
+ <div class="result-header">
211
+ <div class="result-id">${docId}</div>
212
+ <div class="result-status status-not-found">Not Found</div>
213
+ </div>
214
+ <div>${message}</div>
215
+ `;
216
+
217
+ resultsList.appendChild(resultItem);
218
+ resultsStats.textContent = 'Document not found';
219
+ resultsContainer.style.display = 'block';
220
+ }
221
+
222
+ // function displayKeywordResults(data) {
223
+ // resultsList.innerHTML = '';
224
+
225
+ // data.results.forEach(spec => {
226
+ // const resultItem = document.createElement("div");
227
+ // resultItem.className = "result-item"
228
+ // resultItem.innerHTML = `
229
+ // <div class="result-header">
230
+ // <div class="result-id">${spec.id}</div>
231
+ // <div class="result-status status-found">Found</div>
232
+ // </div>
233
+ // <div class="result-url">
234
+ // <p>Title: ${spec.title}</p>
235
+ // <p>Type: ${spec.type}</p>
236
+ // <p>Release: ${spec.release}</p>
237
+ // <p>Version: ${spec.version}</p>
238
+ // <p>WG: ${spec.working_group}</p>
239
+ // <p>URL: <a target="_blank" href="${spec.url}">${spec.url}</a></p>
240
+ // <p>Scope: ${spec.scope}</p>
241
+ // </div>
242
+ // `;
243
+ // resultsList.appendChild(resultItem);
244
+ // });
245
+ // resultsStats.textContent = `Found in ${data.search_time.toFixed(2)} seconds`
246
+ // resultsContainer.style.display = 'block';
247
+ // }
248
+
249
+ // // Display batch results
250
+ // function displayBatchResults(data) {
251
+ // resultsList.innerHTML = '';
252
+
253
+ // // Found documents
254
+ // Object.entries(data.results).forEach(([docId, url]) => {
255
+ // const resultItem = document.createElement('div');
256
+ // resultItem.className = 'result-item';
257
+ // resultItem.innerHTML = `
258
+ // <div class="result-header">
259
+ // <div class="result-id">${docId}</div>
260
+ // <div class="result-status status-found">Found</div>
261
+ // </div>
262
+ // <div class="result-url">
263
+ // <a href="${url}" target="_blank">${url}</a>
264
+ // </div>
265
+ // `;
266
+ // resultsList.appendChild(resultItem);
267
+ // });
268
+
269
+ // // Not found documents
270
+ // data.missing.forEach(docId => {
271
+ // const resultItem = document.createElement('div');
272
+ // resultItem.className = 'result-item';
273
+ // resultItem.innerHTML = `
274
+ // <div class="result-header">
275
+ // <div class="result-id">${docId}</div>
276
+ // <div class="result-status status-not-found">Not Found</div>
277
+ // </div>
278
+ // `;
279
+ // resultsList.appendChild(resultItem);
280
+ // });
281
+
282
+ // const foundCount = Object.keys(data.results).length;
283
+ // const totalCount = foundCount + data.missing.length;
284
+
285
+ // resultsStats.textContent = `Found ${foundCount} of ${totalCount} documents in ${data.search_time.toFixed(2)} seconds`;
286
+ // resultsContainer.style.display = 'block';
287
+ // }
288
+
289
+ // Show loader
290
+ function showLoader() {
291
+ loader.style.display = 'block';
292
+ }
293
+
294
+ // Hide loader
295
+ function hideLoader() {
296
+ loader.style.display = 'none';
297
+ }
298
+
299
+ // Show error message
300
+ function showError(message) {
301
+ errorMessage.textContent = message;
302
+ errorMessage.style.display = 'block';
303
+ }
304
+
305
+ // Hide error message
306
+ function hideError() {
307
+ errorMessage.style.display = 'none';
308
+ }
309
+
310
+ // Enter key event for single search
311
+ docIdInput.addEventListener('keypress', (e) => {
312
+ if (e.key === 'Enter') {
313
+ searchBtn.click();
314
+ }
315
+ });
316
+
317
+ // keywordInput.addEventListener('keypress', (event)=>{
318
+ // if (event.key === "Enter"){
319
+ // keywordSearchBtn.click();
320
+ // }
321
+ // })
static/style.css ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary-color: #8ab4f8;
3
+ --secondary-color: #181a20;
4
+ --accent-color: #4285f4;
5
+ --text-color: #e8eaed;
6
+ --light-text: #b0b5bd;
7
+ --error-color: #ff6d6d;
8
+ --success-color: #34e89e;
9
+ --border-color: #282b33;
10
+ --shadow-color: rgba(0, 0, 0, 0.7);
11
+ }
12
+
13
+ * {
14
+ margin: 0;
15
+ padding: 0;
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ body {
20
+ font-family: 'Roboto', sans-serif;
21
+ background-color: var(--secondary-color);
22
+ color: var(--text-color);
23
+ line-height: 1.6;
24
+ padding: 0;
25
+ margin: 0;
26
+ }
27
+
28
+ .container {
29
+ max-width: 1200px;
30
+ margin: 0 auto;
31
+ padding: 20px;
32
+ }
33
+
34
+ header {
35
+ background-color: #23262f;
36
+ box-shadow: 0 2px 10px var(--shadow-color);
37
+ padding: 20px 0;
38
+ position: sticky;
39
+ top: 0;
40
+ z-index: 100;
41
+ }
42
+
43
+ .header-content {
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ }
48
+
49
+ .logo {
50
+ display: flex;
51
+ align-items: center;
52
+ }
53
+
54
+ .logo img {
55
+ height: 40px;
56
+ margin-right: 10px;
57
+ }
58
+
59
+ .logo h1 {
60
+ font-size: 24px;
61
+ font-weight: 500;
62
+ color: var(--primary-color);
63
+ }
64
+
65
+ .search-container {
66
+ background-color: #23262f;
67
+ border-radius: 8px;
68
+ box-shadow: 0 4px 15px var(--shadow-color);
69
+ padding: 30px;
70
+ margin-top: 30px;
71
+ }
72
+
73
+ .search-header {
74
+ margin-bottom: 20px;
75
+ }
76
+
77
+ .search-header h2 {
78
+ font-size: 22px;
79
+ font-weight: 500;
80
+ color: var(--text-color);
81
+ margin-bottom: 10px;
82
+ }
83
+
84
+ .search-header p {
85
+ color: var(--light-text);
86
+ font-size: 16px;
87
+ }
88
+
89
+ .search-form {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 20px;
93
+ }
94
+
95
+ .input-group {
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: 8px;
99
+ }
100
+
101
+ .input-group label {
102
+ font-size: 14px;
103
+ font-weight: 500;
104
+ color: var(--light-text);
105
+ }
106
+
107
+ .input-field {
108
+ display: flex;
109
+ gap: 10px;
110
+ }
111
+
112
+ .input-field input {
113
+ flex: 1;
114
+ padding: 12px 16px;
115
+ border: 1px solid var(--border-color);
116
+ border-radius: 4px;
117
+ font-size: 16px;
118
+ background: #181a20;
119
+ color: var(--text-color);
120
+ outline: none;
121
+ transition: border-color 0.3s;
122
+ }
123
+
124
+ .input-field input:focus {
125
+ border-color: var(--primary-color);
126
+ box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.15);
127
+ }
128
+
129
+ .btn {
130
+ background-color: var(--primary-color);
131
+ color: #181a20;
132
+ border: none;
133
+ border-radius: 4px;
134
+ padding: 12px 24px;
135
+ font-size: 16px;
136
+ font-weight: 500;
137
+ cursor: pointer;
138
+ transition: background-color 0.3s;
139
+ }
140
+
141
+ .btn:hover {
142
+ background-color: var(--accent-color);
143
+ color: #fff;
144
+ }
145
+
146
+ .search-mode {
147
+ display: flex;
148
+ gap: 20px;
149
+ margin-bottom: 20px;
150
+ }
151
+
152
+ .search-mode button {
153
+ background: none;
154
+ border: none;
155
+ font-size: 16px;
156
+ font-weight: 500;
157
+ color: var(--light-text);
158
+ padding: 8px 16px;
159
+ cursor: pointer;
160
+ border-bottom: 2px solid transparent;
161
+ transition: all 0.3s;
162
+ }
163
+
164
+ .search-mode button.active {
165
+ color: var(--primary-color);
166
+ border-bottom: 2px solid var(--primary-color);
167
+ }
168
+
169
+ .batch-input {
170
+ display: none;
171
+ }
172
+
173
+ .keyword-input {
174
+ display: none;
175
+ }
176
+
177
+ .batch-input textarea {
178
+ width: 100%;
179
+ height: 120px;
180
+ padding: 12px 16px;
181
+ border: 1px solid var(--border-color);
182
+ border-radius: 4px;
183
+ font-size: 16px;
184
+ font-family: 'Roboto', sans-serif;
185
+ resize: vertical;
186
+ outline: none;
187
+ background: #181a20;
188
+ color: var(--text-color);
189
+ }
190
+
191
+ .batch-input textarea:focus {
192
+ border-color: var(--primary-color);
193
+ box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.15);
194
+ }
195
+
196
+ .batch-input .hint {
197
+ font-size: 14px;
198
+ color: var(--light-text);
199
+ margin-top: 8px;
200
+ }
201
+
202
+ .results-container {
203
+ margin-top: 30px;
204
+ background-color: #23262f;
205
+ border-radius: 8px;
206
+ box-shadow: 0 4px 15px var(--shadow-color);
207
+ padding: 30px;
208
+ display: none;
209
+ }
210
+
211
+ .results-header {
212
+ display: flex;
213
+ justify-content: space-between;
214
+ align-items: center;
215
+ margin-bottom: 20px;
216
+ padding-bottom: 15px;
217
+ border-bottom: 1px solid var(--border-color);
218
+ }
219
+
220
+ .results-header h2 {
221
+ font-size: 22px;
222
+ font-weight: 500;
223
+ }
224
+
225
+ .results-stats {
226
+ color: var(--light-text);
227
+ font-size: 14px;
228
+ }
229
+
230
+ .results-list {
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 15px;
234
+ }
235
+
236
+ .result-item {
237
+ padding: 15px;
238
+ border: 1px solid var(--border-color);
239
+ border-radius: 8px;
240
+ background: #181a20;
241
+ transition: box-shadow 0.3s;
242
+ }
243
+
244
+ .result-item:hover {
245
+ box-shadow: 0 4px 8px var(--shadow-color);
246
+ }
247
+
248
+ .result-header {
249
+ display: flex;
250
+ justify-content: space-between;
251
+ align-items: center;
252
+ margin-bottom: 10px;
253
+ }
254
+
255
+ .result-id {
256
+ font-weight: 500;
257
+ font-size: 18px;
258
+ color: var(--primary-color);
259
+ }
260
+
261
+ .result-status {
262
+ font-size: 14px;
263
+ padding: 4px 12px;
264
+ border-radius: 12px;
265
+ }
266
+
267
+ .status-found {
268
+ background-color: rgba(52, 232, 158, 0.1);
269
+ color: var(--success-color);
270
+ }
271
+
272
+ .status-not-found {
273
+ background-color: rgba(255, 109, 109, 0.1);
274
+ color: var(--error-color);
275
+ }
276
+
277
+ .result-url {
278
+ word-break: break-all;
279
+ margin-top: 10px;
280
+ }
281
+
282
+ .result-url a {
283
+ color: var(--primary-color);
284
+ text-decoration: none;
285
+ transition: color 0.3s;
286
+ }
287
+
288
+ .result-url a:hover {
289
+ text-decoration: underline;
290
+ }
291
+
292
+ .loader {
293
+ display: none;
294
+ text-align: center;
295
+ padding: 20px;
296
+ }
297
+
298
+ .spinner {
299
+ border: 4px solid rgba(255, 255, 255, 0.1);
300
+ border-radius: 50%;
301
+ border-top: 4px solid var(--primary-color);
302
+ width: 40px;
303
+ height: 40px;
304
+ animation: spin 1s linear infinite;
305
+ margin: 0 auto;
306
+ }
307
+
308
+ @keyframes spin {
309
+ 0% { transform: rotate(0deg); }
310
+ 100% { transform: rotate(360deg); }
311
+ }
312
+
313
+ .error-message {
314
+ background-color: rgba(255, 109, 109, 0.1);
315
+ color: var(--error-color);
316
+ padding: 15px;
317
+ border-radius: 4px;
318
+ margin-top: 20px;
319
+ display: none;
320
+ }
321
+
322
+ footer {
323
+ text-align: center;
324
+ padding: 30px 0;
325
+ margin-top: 50px;
326
+ color: var(--light-text);
327
+ font-size: 14px;
328
+ }
329
+
330
+ @media (max-width: 768px) {
331
+ .header-content {
332
+ flex-direction: column;
333
+ gap: 15px;
334
+ }
335
+
336
+ .input-field {
337
+ flex-direction: column;
338
+ }
339
+
340
+ .search-mode {
341
+ overflow-x: auto;
342
+ padding-bottom: 5px;
343
+ }
344
+ }
templates/index.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ETSI Document Finder</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <div class="container header-content">
13
+ <div class="logo">
14
+ <h1>ETSI Document Finder</h1>
15
+ </div>
16
+ </div>
17
+ </header>
18
+
19
+ <div class="container">
20
+ <div class="search-container">
21
+ <div class="search-header">
22
+ <h2 id="dynamicTitle">Find ETSI Documents</h2>
23
+ <p id="dynamicDesc">Enter a SET/SCP document IDto locate the document in the ETSI DocBox server.</p>
24
+ </div>
25
+
26
+ <div class="search-mode">
27
+ <button id="single-mode-btn" class="active">Single Document</button>
28
+ <!-- <button id="batch-mode-btn">Batch Search</button>
29
+ <button id="keyword-mode-btn">Keyword Search</button> -->
30
+ </div>
31
+
32
+ <div class="search-form">
33
+ <div class="input-group single-input">
34
+ <label for="doc-id">Document ID</label>
35
+ <div class="input-field">
36
+ <input type="text" id="doc-id" placeholder="Enter SET/SCP document ID (e.g. SETREQ(15)0015451r1, SCP(12)15151, SCPTEC(11)44754r3)">
37
+ <button id="search-btn" class="btn">Search</button>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- <div class="input-group batch-input">
42
+ <label for="batch-ids">Document IDs or Specification IDs (one per line)</label>
43
+ <textarea id="batch-ids" placeholder="Enter document IDs or specification IDs, one per line (e.g., S1-123456, C2-987654, 31.102)"></textarea>
44
+ <div class="hint">Enter one document ID per line</div>
45
+ <button id="batch-search-btn" class="btn" style="margin-top: 10px;">Search All</button>
46
+ </div>
47
+
48
+ <div class="input-group keyword-input">
49
+ <label for="keywords">Keywords</label>
50
+ <div class="input-field">
51
+ <input type="text" id="keywords" placeholder="Enter your keywords separated by space">
52
+ <button id="keyword-search-btn" class="btn">Search</button>
53
+ </div>
54
+ </div> -->
55
+ </div>
56
+
57
+ <div class="error-message" id="error-message"></div>
58
+
59
+ <div class="loader" id="loader">
60
+ <div class="spinner"></div>
61
+ <p>Searching for documents...</p>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="results-container" id="results-container">
66
+ <div class="results-header">
67
+ <h2>Search Results</h2>
68
+ <div class="results-stats" id="results-stats"></div>
69
+ </div>
70
+ <div class="results-list" id="results-list"></div>
71
+ </div>
72
+ </div>
73
+
74
+ <footer>
75
+ <p>© 2025 ETSI Document Finder | Powered by FastAPI</p>
76
+ </footer>
77
+
78
+ <script src="/static/script.js"></script>
79
+ </body>
80
+ </html>