Initial commit
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,119 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
import httpx, asyncio, re, html as html_lib
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
KIWIX_URL = "http://kiwix:8080"
|
||||
PDFS_URL = "http://pdfs:8000"
|
||||
FREQS_URL = "http://freqs:8000"
|
||||
INV_URL = "http://inventory:8000"
|
||||
|
||||
def text_match(item, q, fields):
|
||||
ql = q.lower()
|
||||
for f in fields:
|
||||
v = item.get(f) or ""
|
||||
if ql in str(v).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
async def search_kiwix(client, q, limit=30):
|
||||
"""Use OPDS catalog search to find matching ZIM books."""
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{KIWIX_URL}/library/catalog/v2/entries",
|
||||
params={"q": q, "count": limit},
|
||||
timeout=8.0
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
text = r.text
|
||||
results = []
|
||||
for entry in re.findall(r"<entry>(.*?)</entry>", text, re.DOTALL)[:limit]:
|
||||
t = re.search(r"<title[^>]*>(.*?)</title>", entry, re.DOTALL)
|
||||
name = re.search(r"<name>(.*?)</name>", entry, re.DOTALL)
|
||||
s = re.search(r"<summary[^>]*>(.*?)</summary>", entry, re.DOTALL)
|
||||
# Find the content link
|
||||
content = re.search(r"<link[^>]*href=\"(/library/content/[^\"]+)\"", entry)
|
||||
if t and content:
|
||||
results.append({
|
||||
"source": "Library",
|
||||
"title": html_lib.unescape(t.group(1).strip()),
|
||||
"url": content.group(1),
|
||||
"snippet": re.sub(r"<[^>]+>", "", html_lib.unescape(s.group(1)))[:300] if s else "",
|
||||
"context": name.group(1).strip() if name else "",
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def search_pdfs(client, q, limit=20):
|
||||
try:
|
||||
r = await client.get(f"{PDFS_URL}/api/list", timeout=5.0)
|
||||
if r.status_code != 200: return []
|
||||
items = r.json()
|
||||
matches = [i for i in items if text_match(i, q, ["title", "tag", "filename"])][:limit]
|
||||
return [{
|
||||
"source": "PDFs",
|
||||
"title": i["title"],
|
||||
"url": f"/pdfs/view/{i['id']}",
|
||||
"snippet": f"Tag: {i.get('tag') or 'none'}",
|
||||
"context": i.get("tag", ""),
|
||||
} for i in matches]
|
||||
except: return []
|
||||
|
||||
async def search_freqs(client, q, limit=20):
|
||||
try:
|
||||
r = await client.get(f"{FREQS_URL}/api/list", timeout=5.0)
|
||||
if r.status_code != 200: return []
|
||||
items = r.json()
|
||||
matches = [i for i in items if text_match(i, q, ["name","freq","band","mode","tone","tag","description"])][:limit]
|
||||
return [{
|
||||
"source": "Frequencies",
|
||||
"title": f"{i.get('freq','')} - {i.get('name','')}",
|
||||
"url": "/freqs/",
|
||||
"snippet": i.get("description") or f"{i.get('band','')} {i.get('mode','')} {i.get('tone','')}".strip(),
|
||||
"context": i.get("band", ""),
|
||||
} for i in matches]
|
||||
except: return []
|
||||
|
||||
async def search_inventory(client, q, limit=20):
|
||||
try:
|
||||
r = await client.get(f"{INV_URL}/api/list", timeout=5.0)
|
||||
if r.status_code != 200: return []
|
||||
items = r.json()
|
||||
matches = [i for i in items if text_match(i, q, ["name","category","location","notes"])][:limit]
|
||||
return [{
|
||||
"source": "Inventory",
|
||||
"title": i["name"],
|
||||
"url": "/inventory/",
|
||||
"snippet": f"Qty: {i.get('quantity',0)} {i.get('unit','')} @ {i.get('location','?')} | {i.get('notes','')}".strip(),
|
||||
"context": i.get("category", ""),
|
||||
} for i in matches]
|
||||
except: return []
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(q: str = ""):
|
||||
q = q.strip()
|
||||
if not q:
|
||||
return JSONResponse({"results": {}, "total": 0})
|
||||
async with httpx.AsyncClient() as client:
|
||||
zims, pdfs, freqs, inv = await asyncio.gather(
|
||||
search_kiwix(client, q),
|
||||
search_pdfs(client, q),
|
||||
search_freqs(client, q),
|
||||
search_inventory(client, q),
|
||||
)
|
||||
grouped = {}
|
||||
if zims: grouped["Library"] = zims
|
||||
if pdfs: grouped["PDFs"] = pdfs
|
||||
if freqs: grouped["Frequencies"] = freqs
|
||||
if inv: grouped["Inventory"] = inv
|
||||
total = sum(len(v) for v in grouped.values())
|
||||
return {"results": grouped, "total": total}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/index.html") as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Search - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1100px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.search-box{display:flex;gap:0.5rem;margin-bottom:2rem}
|
||||
.search-box input{flex:1;background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.85rem 1rem;border-radius:6px;font-size:1rem;font-family:ui-monospace,monospace}
|
||||
.search-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 18px rgba(34,211,238,0.15)}
|
||||
.search-box button{background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0 1.5rem;border-radius:6px;font-size:0.85rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.search-box button:hover{background:rgba(34,211,238,0.25)}
|
||||
.summary{font-size:0.8rem;color:var(--muted);font-family:ui-monospace,monospace;margin-bottom:1rem}
|
||||
.section{margin-bottom:2rem}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.section-title .count{color:var(--muted);font-size:0.7rem}
|
||||
.result{display:block;background:linear-gradient(135deg,rgba(18,20,24,0.85),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:6px;padding:0.85rem 1rem;margin-bottom:0.5rem;text-decoration:none;color:inherit;transition:all 0.15s}
|
||||
.result:hover{border-color:rgba(34,211,238,0.5);transform:translateX(3px)}
|
||||
.result .title{font-size:0.95rem;color:var(--text);font-weight:500}
|
||||
.result .snippet{font-size:0.78rem;color:var(--muted);margin-top:0.25rem;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.result .context{display:inline-block;font-size:0.65rem;color:rgb(165,235,247);background:rgba(34,211,238,0.1);border:1px solid rgba(34,211,238,0.25);padding:0.1rem 0.4rem;border-radius:3px;margin-top:0.4rem;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.loading{color:var(--accent);text-align:center;padding:2rem;font-family:ui-monospace,monospace;letter-spacing:0.2em;text-transform:uppercase;font-size:0.85rem}
|
||||
.hint{color:var(--muted);font-size:0.85rem;text-align:center;padding:2rem;line-height:1.7}
|
||||
.hint code{background:rgba(34,211,238,0.08);border:1px solid rgba(34,211,238,0.2);color:rgb(165,235,247);padding:0.1rem 0.4rem;border-radius:3px;font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Search</h1>
|
||||
<a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a></header>
|
||||
|
||||
<form class="search-box" id="searchForm">
|
||||
<input type="text" id="q" placeholder="Search across Library, PDFs, Frequencies, Inventory..." autofocus>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<div id="summary" class="summary"></div>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const initial = params.get("q") || "";
|
||||
if (initial) document.getElementById("q").value = initial;
|
||||
|
||||
async function doSearch(q){
|
||||
if(!q){
|
||||
document.getElementById("results").innerHTML = '<div class="hint">Type a query above. Searches across<br>Kiwix ZIMs, PDFs, Frequencies, and Inventory.<br><br>Example: <code>carburetor</code>, <code>FM 21-76</code>, <code>NOAA</code></div>';
|
||||
document.getElementById("summary").textContent = "";
|
||||
return;
|
||||
}
|
||||
document.getElementById("results").innerHTML = '<div class="loading">Searching...</div>';
|
||||
document.getElementById("summary").textContent = "";
|
||||
history.replaceState({}, "", "?q=" + encodeURIComponent(q));
|
||||
|
||||
try {
|
||||
const r = await fetch("/search/api/search?q=" + encodeURIComponent(q));
|
||||
const data = await r.json();
|
||||
render(q, data);
|
||||
} catch(e){
|
||||
document.getElementById("results").innerHTML = '<div class="empty">Search failed: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function render(q, data){
|
||||
const total = data.total || 0;
|
||||
document.getElementById("summary").textContent = total + " result" + (total===1?"":"s") + " for \"" + q + "\"";
|
||||
if(!total){
|
||||
document.getElementById("results").innerHTML = '<div class="empty">No matches.</div>';
|
||||
return;
|
||||
}
|
||||
const out = [];
|
||||
for (const [source, items] of Object.entries(data.results)){
|
||||
out.push('<div class="section"><div class="section-title">' + source + '<span class="count">' + items.length + '</span></div>');
|
||||
out.push(items.map(it =>
|
||||
'<a class="result" href="' + it.url + '">' +
|
||||
'<div class="title">' + escapeHtml(it.title) + '</div>' +
|
||||
(it.snippet ? '<div class="snippet">' + escapeHtml(it.snippet) + '</div>' : '') +
|
||||
(it.context ? '<div class="context">' + escapeHtml(it.context) + '</div>' : '') +
|
||||
'</a>'
|
||||
).join(""));
|
||||
out.push('</div>');
|
||||
}
|
||||
document.getElementById("results").innerHTML = out.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
document.getElementById("searchForm").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
doSearch(document.getElementById("q").value.trim());
|
||||
});
|
||||
|
||||
doSearch(initial);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user