Omar ID EL MOUMEN commited on
Commit
8840360
·
1 Parent(s): b435ee2

Deploy FastAPI server + html page

Browse files
Files changed (7) hide show
  1. Dockerfile +13 -0
  2. app.py +196 -0
  3. indexed_docs.json +8 -0
  4. requirements.txt +5 -0
  5. static/script.js +262 -0
  6. static/style.css +381 -0
  7. templates/index.html +80 -0
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./requirements.txt requirements.txt
10
+ RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir --upgrade -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+ import json
4
+ import os
5
+ import time
6
+ import warnings
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import FileResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from pydantic import BaseModel
12
+ from typing import Dict, List, Optional
13
+ import uvicorn
14
+
15
+ warnings.filterwarnings("ignore")
16
+
17
+ app = FastAPI(title="3GPP Document Finder API",
18
+ description="API to find 3GPP documents based on TSG document IDs")
19
+
20
+ app.mount("/static", StaticFiles(directory="static"), name="static")
21
+
22
+ origins = [
23
+ "*",
24
+ ]
25
+
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=origins,
29
+ allow_credentials=True,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ class DocRequest(BaseModel):
35
+ tsg_doc_id: str
36
+
37
+ class DocResponse(BaseModel):
38
+ tsg_doc_id: str
39
+ url: str
40
+ search_time: float
41
+
42
+ class BatchDocRequest(BaseModel):
43
+ tsg_doc_ids: List[str]
44
+
45
+ class BatchDocResponse(BaseModel):
46
+ results: Dict[str, str]
47
+ missing: List[str]
48
+ search_time: float
49
+
50
+ class TsgDocFinder:
51
+ def __init__(self):
52
+ self.main_ftp_url = "https://www.3gpp.org/ftp"
53
+ self.indexer_file = "indexed_docs.json"
54
+ self.indexer = self.load_indexer()
55
+
56
+ def load_indexer(self):
57
+ """Load existing index if available"""
58
+ if os.path.exists(self.indexer_file):
59
+ with open(self.indexer_file, "r", encoding="utf-8") as f:
60
+ return json.load(f)
61
+ return {}
62
+
63
+ def save_indexer(self):
64
+ """Save the updated index"""
65
+ with open(self.indexer_file, "w", encoding="utf-8") as f:
66
+ json.dump(self.indexer, f, indent=4, ensure_ascii=False)
67
+
68
+ def get_workgroup(self, tsg_doc):
69
+ main_tsg = "tsg_ct" if tsg_doc[0] == "C" else "tsg_sa" if tsg_doc[0] == "S" else None
70
+ if main_tsg is None:
71
+ return None, None, None
72
+ workgroup = f"WG{int(tsg_doc[1])}" if tsg_doc[1].isnumeric() else main_tsg.upper()
73
+ return main_tsg, workgroup, tsg_doc
74
+
75
+ def find_workgroup_url(self, main_tsg, workgroup):
76
+ """Find the URL for the specific workgroup"""
77
+ response = requests.get(f"{self.main_ftp_url}/{main_tsg}", verify=False)
78
+ soup = BeautifulSoup(response.text, 'html.parser')
79
+
80
+ for item in soup.find_all("tr"):
81
+ link = item.find("a")
82
+ if link and workgroup in link.get_text():
83
+ return f"{self.main_ftp_url}/{main_tsg}/{link.get_text()}"
84
+
85
+ return f"{self.main_ftp_url}/{main_tsg}/{workgroup}"
86
+
87
+ def get_docs_from_url(self, url):
88
+ """Get list of documents/directories from a URL"""
89
+ try:
90
+ response = requests.get(url, verify=False, timeout=10)
91
+ soup = BeautifulSoup(response.text, "html.parser")
92
+ return [item.get_text() for item in soup.select("tr td a")]
93
+ except Exception as e:
94
+ print(f"Error accessing {url}: {e}")
95
+ return []
96
+
97
+ def search_document(self, tsg_doc_id):
98
+ """Search for a specific document by its ID"""
99
+ original_id = tsg_doc_id
100
+
101
+ # Check if already indexed
102
+ if original_id in self.indexer:
103
+ return self.indexer[original_id]
104
+
105
+ # Parse the document ID
106
+ main_tsg, workgroup, tsg_doc = self.get_workgroup(tsg_doc_id)
107
+ if not main_tsg:
108
+ return f"Could not parse document ID: {tsg_doc_id}"
109
+
110
+ print(f"Searching for {original_id} (parsed as {tsg_doc}) in {main_tsg}/{workgroup}...")
111
+
112
+ # Find the workgroup URL
113
+ wg_url = self.find_workgroup_url(main_tsg, workgroup)
114
+ if not wg_url:
115
+ return f"Could not find workgroup for {tsg_doc_id}"
116
+
117
+ # Search in the workgroup directories
118
+ meeting_folders = self.get_docs_from_url(wg_url)
119
+
120
+ for folder in meeting_folders:
121
+ meeting_url = f"{wg_url}/{folder}"
122
+ meeting_contents = self.get_docs_from_url(meeting_url)
123
+
124
+ if "Docs" in meeting_contents:
125
+ docs_url = f"{meeting_url}/Docs"
126
+ print(f"Checking {docs_url}...")
127
+ files = self.get_docs_from_url(docs_url)
128
+
129
+ # Check for the document in the main Docs folder
130
+ for file in files:
131
+ if tsg_doc in file.lower() or original_id in file:
132
+ doc_url = f"{docs_url}/{file}"
133
+ self.indexer[original_id] = doc_url
134
+ self.save_indexer()
135
+ return doc_url
136
+
137
+ # Check in ZIP subfolder if it exists
138
+ if "ZIP" in files:
139
+ zip_url = f"{docs_url}/ZIP"
140
+ print(f"Checking {zip_url}...")
141
+ zip_files = self.get_docs_from_url(zip_url)
142
+
143
+ for file in zip_files:
144
+ if tsg_doc in file.lower() or original_id in file:
145
+ doc_url = f"{zip_url}/{file}"
146
+ self.indexer[original_id] = doc_url
147
+ self.save_indexer()
148
+ return doc_url
149
+
150
+ return f"Document {tsg_doc_id} not found"
151
+
152
+ # Create a global instance of the finder
153
+ finder = TsgDocFinder()
154
+
155
+ @app.get("/")
156
+ async def main_menu():
157
+ return FileResponse(os.path.join("templates", "index.html"))
158
+
159
+ @app.post("/find", response_model=DocResponse)
160
+ def find_document(request: DocRequest):
161
+ start_time = time.time()
162
+
163
+ result = finder.search_document(request.tsg_doc_id)
164
+
165
+ if "not found" not in result and "Could not" not in result:
166
+ return DocResponse(
167
+ tsg_doc_id=request.tsg_doc_id,
168
+ url=result,
169
+ search_time=time.time() - start_time
170
+ )
171
+ else:
172
+ raise HTTPException(status_code=404, detail=result)
173
+
174
+ @app.post("/batch", response_model=BatchDocResponse)
175
+ def find_documents_batch(request: BatchDocRequest):
176
+ start_time = time.time()
177
+
178
+ results = {}
179
+ missing = []
180
+
181
+ for doc_id in request.tsg_doc_ids:
182
+ result = finder.search_document(doc_id)
183
+ if "not found" not in result and "Could not" not in result:
184
+ results[doc_id] = result
185
+ else:
186
+ missing.append(doc_id)
187
+
188
+ return BatchDocResponse(
189
+ results=results,
190
+ missing=missing,
191
+ search_time=time.time() - start_time
192
+ )
193
+
194
+ @app.get("/indexed", response_model=List[str])
195
+ def get_indexed_documents():
196
+ return list(finder.indexer.keys())
indexed_docs.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "S4-110084": "https://www.3gpp.org/ftp/tsg_sa/WG4_CODEC/TSGS4_62/Docs/S4-110084.zip",
3
+ "SP-000182": "https://www.3gpp.org/ftp/tsg_sa/TSG_SA/TSGS_08/Docs/ZIP/SP-000182.zip",
4
+ "SP-000183": "https://www.3gpp.org/ftp/tsg_sa/TSG_SA/TSGS_08/Docs/ZIP/SP-000183.zip",
5
+ "SP-000184": "https://www.3gpp.org/ftp/tsg_sa/TSG_SA/TSGS_08/Docs/ZIP/SP-000184.zip",
6
+ "SP-000185": "https://www.3gpp.org/ftp/tsg_sa/TSG_SA/TSGS_08/Docs/ZIP/SP-000185.zip",
7
+ "SP-090017": "https://www.3gpp.org/ftp/tsg_sa/TSG_SA/TSGS_43/Docs/SP-090017.zip"
8
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ requests
4
+ beautifulsoup4
5
+ pydantic
static/script.js ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DOM elements
2
+ const singleModeBtn = document.getElementById('single-mode-btn');
3
+ const batchModeBtn = document.getElementById('batch-mode-btn');
4
+ const singleInput = document.querySelector('.single-input');
5
+ const batchInput = document.querySelector('.batch-input');
6
+ const docIdInput = document.getElementById('doc-id');
7
+ const batchIdsInput = document.getElementById('batch-ids');
8
+ const searchBtn = document.getElementById('search-btn');
9
+ const batchSearchBtn = document.getElementById('batch-search-btn');
10
+ const loader = document.getElementById('loader');
11
+ const resultsContainer = document.getElementById('results-container');
12
+ const resultsList = document.getElementById('results-list');
13
+ const resultsStats = document.getElementById('results-stats');
14
+ const errorMessage = document.getElementById('error-message');
15
+ const indexedDocs = document.getElementById('indexed-docs');
16
+ const indexedCount = document.getElementById('indexed-count');
17
+ const indexedList = document.getElementById('indexed-list');
18
+
19
+ // Search mode toggle
20
+ singleModeBtn.addEventListener('click', () => {
21
+ singleModeBtn.classList.add('active');
22
+ batchModeBtn.classList.remove('active');
23
+ singleInput.style.display = 'block';
24
+ batchInput.style.display = 'none';
25
+ });
26
+
27
+ batchModeBtn.addEventListener('click', () => {
28
+ batchModeBtn.classList.add('active');
29
+ singleModeBtn.classList.remove('active');
30
+ batchInput.style.display = 'block';
31
+ singleInput.style.display = 'none';
32
+ });
33
+
34
+ // Single document search
35
+ searchBtn.addEventListener('click', async () => {
36
+ const docId = docIdInput.value.trim();
37
+ if (!docId) {
38
+ showError('Please enter a document ID');
39
+ return;
40
+ }
41
+
42
+ showLoader();
43
+ hideError();
44
+
45
+ try {
46
+ const response = await fetch(`/find`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json'
50
+ },
51
+ body: JSON.stringify({ tsg_doc_id: docId })
52
+ });
53
+
54
+ const data = await response.json();
55
+
56
+ if (response.ok) {
57
+ displaySingleResult(data);
58
+ loadIndexedDocuments(); // Refresh indexed docs
59
+ } else {
60
+ displaySingleNotFound(docId, data.detail);
61
+ }
62
+ } catch (error) {
63
+ showError('Error connecting to the server. Please check if the API is running.');
64
+ console.error('Error:', error);
65
+ } finally {
66
+ hideLoader();
67
+ }
68
+ });
69
+
70
+ // Batch document search
71
+ batchSearchBtn.addEventListener('click', async () => {
72
+ const batchText = batchIdsInput.value.trim();
73
+ if (!batchText) {
74
+ showError('Please enter at least one document ID');
75
+ return;
76
+ }
77
+
78
+ const docIds = batchText.split('\n')
79
+ .map(id => id.trim())
80
+ .filter(id => id !== '');
81
+
82
+ if (docIds.length === 0) {
83
+ showError('Please enter at least one valid document ID');
84
+ return;
85
+ }
86
+
87
+ showLoader();
88
+ hideError();
89
+
90
+ try {
91
+ const response = await fetch(`/batch`, {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Content-Type': 'application/json'
95
+ },
96
+ body: JSON.stringify({ tsg_doc_ids: docIds })
97
+ });
98
+
99
+ const data = await response.json();
100
+
101
+ if (response.ok) {
102
+ displayBatchResults(data);
103
+ loadIndexedDocuments(); // Refresh indexed docs
104
+ } else {
105
+ showError('Error processing batch request');
106
+ }
107
+ } catch (error) {
108
+ showError('Error connecting to the server. Please check if the API is running.');
109
+ console.error('Error:', error);
110
+ } finally {
111
+ hideLoader();
112
+ }
113
+ });
114
+
115
+ // Display single result
116
+ function displaySingleResult(data) {
117
+ resultsList.innerHTML = '';
118
+
119
+ const resultItem = document.createElement('div');
120
+ resultItem.className = 'result-item';
121
+ resultItem.innerHTML = `
122
+ <div class="result-header">
123
+ <div class="result-id">${data.tsg_doc_id}</div>
124
+ <div class="result-status status-found">Found</div>
125
+ </div>
126
+ <div class="result-url">
127
+ <a href="${data.url}" target="_blank">${data.url}</a>
128
+ </div>
129
+ `;
130
+
131
+ resultsList.appendChild(resultItem);
132
+ resultsStats.textContent = `Found in ${data.search_time.toFixed(2)} seconds`;
133
+ resultsContainer.style.display = 'block';
134
+ }
135
+
136
+ // Display single not found result
137
+ function displaySingleNotFound(docId, message) {
138
+ resultsList.innerHTML = '';
139
+
140
+ const resultItem = document.createElement('div');
141
+ resultItem.className = 'result-item';
142
+ resultItem.innerHTML = `
143
+ <div class="result-header">
144
+ <div class="result-id">${docId}</div>
145
+ <div class="result-status status-not-found">Not Found</div>
146
+ </div>
147
+ <div>${message}</div>
148
+ `;
149
+
150
+ resultsList.appendChild(resultItem);
151
+ resultsStats.textContent = 'Document not found';
152
+ resultsContainer.style.display = 'block';
153
+ }
154
+
155
+ // Display batch results
156
+ function displayBatchResults(data) {
157
+ resultsList.innerHTML = '';
158
+
159
+ // Found documents
160
+ Object.entries(data.results).forEach(([docId, url]) => {
161
+ const resultItem = document.createElement('div');
162
+ resultItem.className = 'result-item';
163
+ resultItem.innerHTML = `
164
+ <div class="result-header">
165
+ <div class="result-id">${docId}</div>
166
+ <div class="result-status status-found">Found</div>
167
+ </div>
168
+ <div class="result-url">
169
+ <a href="${url}" target="_blank">${url}</a>
170
+ </div>
171
+ `;
172
+ resultsList.appendChild(resultItem);
173
+ });
174
+
175
+ // Not found documents
176
+ data.missing.forEach(docId => {
177
+ const resultItem = document.createElement('div');
178
+ resultItem.className = 'result-item';
179
+ resultItem.innerHTML = `
180
+ <div class="result-header">
181
+ <div class="result-id">${docId}</div>
182
+ <div class="result-status status-not-found">Not Found</div>
183
+ </div>
184
+ `;
185
+ resultsList.appendChild(resultItem);
186
+ });
187
+
188
+ const foundCount = Object.keys(data.results).length;
189
+ const totalCount = foundCount + data.missing.length;
190
+
191
+ resultsStats.textContent = `Found ${foundCount} of ${totalCount} documents in ${data.search_time.toFixed(2)} seconds`;
192
+ resultsContainer.style.display = 'block';
193
+ }
194
+
195
+ // Load indexed documents
196
+ async function loadIndexedDocuments() {
197
+ try {
198
+ const response = await fetch(`/indexed`);
199
+ if (response.ok) {
200
+ const data = await response.json();
201
+ displayIndexedDocuments(data);
202
+ }
203
+ } catch (error) {
204
+ console.error('Error loading indexed documents:', error);
205
+ }
206
+ }
207
+
208
+ // Display indexed documents
209
+ function displayIndexedDocuments(docIds) {
210
+ indexedList.innerHTML = '';
211
+ indexedCount.textContent = `${docIds.length} documents`;
212
+
213
+ if (docIds.length === 0) {
214
+ const emptyMessage = document.createElement('p');
215
+ emptyMessage.textContent = 'No documents have been indexed yet.';
216
+ indexedList.appendChild(emptyMessage);
217
+ return;
218
+ }
219
+
220
+ docIds.forEach(docId => {
221
+ const docItem = document.createElement('div');
222
+ docItem.className = 'indexed-item';
223
+ docItem.textContent = docId;
224
+ docItem.addEventListener('click', () => {
225
+ docIdInput.value = docId;
226
+ singleModeBtn.click();
227
+ searchBtn.click();
228
+ });
229
+ indexedList.appendChild(docItem);
230
+ });
231
+ }
232
+
233
+ // Show loader
234
+ function showLoader() {
235
+ loader.style.display = 'block';
236
+ }
237
+
238
+ // Hide loader
239
+ function hideLoader() {
240
+ loader.style.display = 'none';
241
+ }
242
+
243
+ // Show error message
244
+ function showError(message) {
245
+ errorMessage.textContent = message;
246
+ errorMessage.style.display = 'block';
247
+ }
248
+
249
+ // Hide error message
250
+ function hideError() {
251
+ errorMessage.style.display = 'none';
252
+ }
253
+
254
+ // Enter key event for single search
255
+ docIdInput.addEventListener('keypress', (e) => {
256
+ if (e.key === 'Enter') {
257
+ searchBtn.click();
258
+ }
259
+ });
260
+
261
+ // Load indexed documents on page load
262
+ document.addEventListener('DOMContentLoaded', loadIndexedDocuments);
static/style.css ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary-color: #1a73e8;
3
+ --secondary-color: #f8f9fa;
4
+ --accent-color: #4285f4;
5
+ --text-color: #202124;
6
+ --light-text: #5f6368;
7
+ --error-color: #ea4335;
8
+ --success-color: #34a853;
9
+ --border-color: #dadce0;
10
+ --shadow-color: rgba(0, 0, 0, 0.1);
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: white;
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: white;
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
+ outline: none;
119
+ transition: border-color 0.3s;
120
+ }
121
+
122
+ .input-field input:focus {
123
+ border-color: var(--primary-color);
124
+ box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
125
+ }
126
+
127
+ .btn {
128
+ background-color: var(--primary-color);
129
+ color: white;
130
+ border: none;
131
+ border-radius: 4px;
132
+ padding: 12px 24px;
133
+ font-size: 16px;
134
+ font-weight: 500;
135
+ cursor: pointer;
136
+ transition: background-color 0.3s;
137
+ }
138
+
139
+ .btn:hover {
140
+ background-color: var(--accent-color);
141
+ }
142
+
143
+ .search-mode {
144
+ display: flex;
145
+ gap: 20px;
146
+ margin-bottom: 20px;
147
+ }
148
+
149
+ .search-mode button {
150
+ background: none;
151
+ border: none;
152
+ font-size: 16px;
153
+ font-weight: 500;
154
+ color: var(--light-text);
155
+ padding: 8px 16px;
156
+ cursor: pointer;
157
+ border-bottom: 2px solid transparent;
158
+ transition: all 0.3s;
159
+ }
160
+
161
+ .search-mode button.active {
162
+ color: var(--primary-color);
163
+ border-bottom: 2px solid var(--primary-color);
164
+ }
165
+
166
+ .batch-input {
167
+ display: none;
168
+ }
169
+
170
+ .batch-input textarea {
171
+ width: 100%;
172
+ height: 120px;
173
+ padding: 12px 16px;
174
+ border: 1px solid var(--border-color);
175
+ border-radius: 4px;
176
+ font-size: 16px;
177
+ font-family: 'Roboto', sans-serif;
178
+ resize: vertical;
179
+ outline: none;
180
+ }
181
+
182
+ .batch-input textarea:focus {
183
+ border-color: var(--primary-color);
184
+ box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
185
+ }
186
+
187
+ .batch-input .hint {
188
+ font-size: 14px;
189
+ color: var(--light-text);
190
+ margin-top: 8px;
191
+ }
192
+
193
+ .results-container {
194
+ margin-top: 30px;
195
+ background-color: white;
196
+ border-radius: 8px;
197
+ box-shadow: 0 4px 15px var(--shadow-color);
198
+ padding: 30px;
199
+ display: none;
200
+ }
201
+
202
+ .results-header {
203
+ display: flex;
204
+ justify-content: space-between;
205
+ align-items: center;
206
+ margin-bottom: 20px;
207
+ padding-bottom: 15px;
208
+ border-bottom: 1px solid var(--border-color);
209
+ }
210
+
211
+ .results-header h2 {
212
+ font-size: 22px;
213
+ font-weight: 500;
214
+ }
215
+
216
+ .results-stats {
217
+ color: var(--light-text);
218
+ font-size: 14px;
219
+ }
220
+
221
+ .results-list {
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: 15px;
225
+ }
226
+
227
+ .result-item {
228
+ padding: 15px;
229
+ border: 1px solid var(--border-color);
230
+ border-radius: 8px;
231
+ transition: box-shadow 0.3s;
232
+ }
233
+
234
+ .result-item:hover {
235
+ box-shadow: 0 4px 8px var(--shadow-color);
236
+ }
237
+
238
+ .result-header {
239
+ display: flex;
240
+ justify-content: space-between;
241
+ align-items: center;
242
+ margin-bottom: 10px;
243
+ }
244
+
245
+ .result-id {
246
+ font-weight: 500;
247
+ font-size: 18px;
248
+ color: var(--primary-color);
249
+ }
250
+
251
+ .result-status {
252
+ font-size: 14px;
253
+ padding: 4px 12px;
254
+ border-radius: 12px;
255
+ }
256
+
257
+ .status-found {
258
+ background-color: rgba(52, 168, 83, 0.1);
259
+ color: var(--success-color);
260
+ }
261
+
262
+ .status-not-found {
263
+ background-color: rgba(234, 67, 53, 0.1);
264
+ color: var(--error-color);
265
+ }
266
+
267
+ .result-url {
268
+ word-break: break-all;
269
+ margin-top: 10px;
270
+ }
271
+
272
+ .result-url a {
273
+ color: var(--primary-color);
274
+ text-decoration: none;
275
+ transition: color 0.3s;
276
+ }
277
+
278
+ .result-url a:hover {
279
+ text-decoration: underline;
280
+ }
281
+
282
+ .loader {
283
+ display: none;
284
+ text-align: center;
285
+ padding: 20px;
286
+ }
287
+
288
+ .spinner {
289
+ border: 4px solid rgba(0, 0, 0, 0.1);
290
+ border-radius: 50%;
291
+ border-top: 4px solid var(--primary-color);
292
+ width: 40px;
293
+ height: 40px;
294
+ animation: spin 1s linear infinite;
295
+ margin: 0 auto;
296
+ }
297
+
298
+ @keyframes spin {
299
+ 0% { transform: rotate(0deg); }
300
+ 100% { transform: rotate(360deg); }
301
+ }
302
+
303
+ .error-message {
304
+ background-color: rgba(234, 67, 53, 0.1);
305
+ color: var(--error-color);
306
+ padding: 15px;
307
+ border-radius: 4px;
308
+ margin-top: 20px;
309
+ display: none;
310
+ }
311
+
312
+ .indexed-docs {
313
+ margin-top: 30px;
314
+ background-color: white;
315
+ border-radius: 8px;
316
+ box-shadow: 0 4px 15px var(--shadow-color);
317
+ padding: 30px;
318
+ }
319
+
320
+ .indexed-header {
321
+ display: flex;
322
+ justify-content: space-between;
323
+ align-items: center;
324
+ margin-bottom: 20px;
325
+ }
326
+
327
+ .indexed-header h2 {
328
+ font-size: 22px;
329
+ font-weight: 500;
330
+ }
331
+
332
+ .indexed-count {
333
+ background-color: var(--primary-color);
334
+ color: white;
335
+ font-size: 14px;
336
+ padding: 4px 12px;
337
+ border-radius: 12px;
338
+ }
339
+
340
+ .indexed-list {
341
+ display: flex;
342
+ flex-wrap: wrap;
343
+ gap: 10px;
344
+ }
345
+
346
+ .indexed-item {
347
+ background-color: var(--secondary-color);
348
+ padding: 8px 16px;
349
+ border-radius: 4px;
350
+ font-size: 14px;
351
+ cursor: pointer;
352
+ transition: background-color 0.3s;
353
+ }
354
+
355
+ .indexed-item:hover {
356
+ background-color: rgba(26, 115, 232, 0.1);
357
+ }
358
+
359
+ footer {
360
+ text-align: center;
361
+ padding: 30px 0;
362
+ margin-top: 50px;
363
+ color: var(--light-text);
364
+ font-size: 14px;
365
+ }
366
+
367
+ @media (max-width: 768px) {
368
+ .header-content {
369
+ flex-direction: column;
370
+ gap: 15px;
371
+ }
372
+
373
+ .input-field {
374
+ flex-direction: column;
375
+ }
376
+
377
+ .search-mode {
378
+ overflow-x: auto;
379
+ padding-bottom: 5px;
380
+ }
381
+ }
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>3GPP 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
+ <img src="https://www.3gpp.org/images/3gpp_logo.svg" alt="3GPP Logo">
15
+ <h1>3GPP Document Finder</h1>
16
+ </div>
17
+ </div>
18
+ </header>
19
+
20
+ <div class="container">
21
+ <div class="search-container">
22
+ <div class="search-header">
23
+ <h2>Find 3GPP Documents</h2>
24
+ <p>Enter a TSG document ID (e.g., S1-123456, C2-987654) to locate the document in the 3GPP FTP server.</p>
25
+ </div>
26
+
27
+ <div class="search-mode">
28
+ <button id="single-mode-btn" class="active">Single Document</button>
29
+ <button id="batch-mode-btn">Batch 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 document ID (e.g., S1-123456)">
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 (one per line)</label>
43
+ <textarea id="batch-ids" placeholder="Enter document IDs, one per line (e.g., S1-123456, C2-987654)"></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
+ </div>
48
+
49
+ <div class="error-message" id="error-message"></div>
50
+
51
+ <div class="loader" id="loader">
52
+ <div class="spinner"></div>
53
+ <p>Searching for documents...</p>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="results-container" id="results-container">
58
+ <div class="results-header">
59
+ <h2>Search Results</h2>
60
+ <div class="results-stats" id="results-stats"></div>
61
+ </div>
62
+ <div class="results-list" id="results-list"></div>
63
+ </div>
64
+
65
+ <div class="indexed-docs" id="indexed-docs">
66
+ <div class="indexed-header">
67
+ <h2>Indexed Documents</h2>
68
+ <div class="indexed-count" id="indexed-count">0 documents</div>
69
+ </div>
70
+ <div class="indexed-list" id="indexed-list"></div>
71
+ </div>
72
+ </div>
73
+
74
+ <footer>
75
+ <p>© 2025 3GPP Document Finder | Powered by FastAPI</p>
76
+ </footer>
77
+
78
+ <script src="/static/script.js"></script>
79
+ </body>
80
+ </html>