Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -20,6 +20,7 @@ with open("items.json", "r", encoding="utf-8") as f:
|
|
| 20 |
|
| 21 |
ITEM_TO_TYPE = {v["name"]: v["type"].lower() for v in items_data.values() if "name" in v and "type" in v}
|
| 22 |
ALL_ITEMS = list(ITEM_TO_TYPE.keys())
|
|
|
|
| 23 |
ALL_CATEGORIES = sorted(set(ITEM_TO_TYPE.values()))
|
| 24 |
ITEM_FILE_MTIME = os.path.getmtime("items.json")
|
| 25 |
|
|
@@ -115,7 +116,7 @@ def parse_freeform_query(text: str):
|
|
| 115 |
if not text:
|
| 116 |
return "", ""
|
| 117 |
text = text.strip().lower()
|
| 118 |
-
m = re.match(r"(.+?)
|
| 119 |
if m:
|
| 120 |
return m.group(1).strip(), m.group(2).strip()
|
| 121 |
parts = text.split()
|
|
@@ -155,7 +156,7 @@ def fetch_yata(force_refresh=False):
|
|
| 155 |
_cache.update({
|
| 156 |
"data": data,
|
| 157 |
"timestamp": time.time(),
|
| 158 |
-
"last_update": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 159 |
})
|
| 160 |
return data, _cache["last_update"]
|
| 161 |
except Exception as e:
|
|
@@ -172,38 +173,62 @@ def get_live_categories(data):
|
|
| 172 |
live_cats.add(cat.lower())
|
| 173 |
return sorted(live_cats)
|
| 174 |
|
| 175 |
-
# ---------------- Core logic ----------------
|
| 176 |
def query_inventory(query_text="", category="", country_name="", capacity=10, refresh=False):
|
| 177 |
data, last_update = fetch_yata(force_refresh=refresh)
|
| 178 |
rows = []
|
|
|
|
|
|
|
| 179 |
parsed_item, parsed_country = parse_freeform_query(query_text)
|
| 180 |
if not country_name and parsed_country:
|
| 181 |
country_name = parsed_country
|
| 182 |
item_term = parsed_item
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
user_code = normalize_country_query(country_name)
|
|
|
|
| 187 |
for code_raw, cdata in data.get("stocks", {}).items():
|
| 188 |
code = code_raw.upper()
|
| 189 |
cname = COUNTRY_NAMES.get(code, code)
|
|
|
|
| 190 |
if country_name:
|
| 191 |
-
if user_code
|
| 192 |
-
|
| 193 |
-
|
|
|
|
| 194 |
continue
|
|
|
|
| 195 |
update_ts = cdata.get("update")
|
| 196 |
update_str = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(update_ts)) if update_ts else "Unknown"
|
|
|
|
| 197 |
for item in cdata.get("stocks", []):
|
| 198 |
iname = item.get("name", "")
|
| 199 |
itype = ITEM_TO_TYPE.get(iname, "").lower()
|
| 200 |
qty = item.get("quantity", 0)
|
| 201 |
cost = item.get("cost", 0)
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
if item_ok:
|
| 208 |
rows.append({
|
| 209 |
"Country": cname,
|
|
@@ -214,13 +239,97 @@ def query_inventory(query_text="", category="", country_name="", capacity=10, re
|
|
| 214 |
"Max Capacity Cost": cost * capacity,
|
| 215 |
"Updated": update_str
|
| 216 |
})
|
|
|
|
| 217 |
if not rows:
|
| 218 |
return pd.DataFrame([{"Result": "No inventory found for that query."}]), f"Last update: {last_update}"
|
|
|
|
| 219 |
df = pd.DataFrame(rows).sort_values(by=["Country", "Item"])
|
| 220 |
for col in ["Quantity", "Cost", "Max Capacity Cost"]:
|
| 221 |
df[col] = df[col].apply(lambda x: f"{x:,.0f}" if isinstance(x, (int, float)) else x)
|
| 222 |
return df, f"Last update: {last_update}"
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
# ---------------- Wrappers ----------------
|
| 225 |
def run_query(query_text, category, country, capacity, refresh):
|
| 226 |
data, _ = fetch_yata(force_refresh=refresh)
|
|
@@ -228,67 +337,59 @@ def run_query(query_text, category, country, capacity, refresh):
|
|
| 228 |
live_categories = get_live_categories(data)
|
| 229 |
return df, ts, gr.update(choices=[""] + live_categories)
|
| 230 |
|
| 231 |
-
def run_multi(searches, capacity):
|
| 232 |
-
"""Executes multiple searches (like convenience buttons) correctly filtering by country."""
|
| 233 |
-
dfs = []
|
| 234 |
-
print(f"π§© Running multi-search: {len(searches)} queries")
|
| 235 |
-
for q in searches:
|
| 236 |
-
item_term, country_term = parse_freeform_query(q)
|
| 237 |
-
print(f" π Sub-query β item='{item_term}', country='{country_term}'")
|
| 238 |
-
df, _ = query_inventory(item_term, "", country_term, capacity, False)
|
| 239 |
-
dfs.append(df)
|
| 240 |
-
merged = pd.concat(dfs, ignore_index=True)
|
| 241 |
-
total = pd.DataFrame([{
|
| 242 |
-
"Country": "β", "Item": "TOTAL", "Category": "",
|
| 243 |
-
"Quantity": merged["Quantity"].replace(",", "", regex=True).astype(float).sum(),
|
| 244 |
-
"Cost": "", "Max Capacity Cost": "", "Updated": ""
|
| 245 |
-
}])
|
| 246 |
-
merged = pd.concat([merged, total])
|
| 247 |
-
return merged, f"Last update: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}"
|
| 248 |
-
|
| 249 |
# ---------------- Gradio UI ----------------
|
| 250 |
-
with gr.Blocks(title="π§³ Torn
|
| 251 |
-
gr.Markdown("## π§³ Torn
|
| 252 |
-
gr.Markdown("_Search YATA
|
| 253 |
-
|
| 254 |
-
query_box = gr.Textbox(label="Search (semantic, e.g. 'flowers in England')")
|
| 255 |
-
category_drop = gr.Dropdown(label="Category (optional exact match)", choices=[""] + ALL_CATEGORIES)
|
| 256 |
-
country_box = gr.Textbox(label="Country (optional, e.g. UK, Cayman, Japan)")
|
| 257 |
-
capacity_slider = gr.Number(label="Travel Capacity", value=10, minimum=5, maximum=88, precision=0)
|
| 258 |
-
refresh_check = gr.Checkbox(label="Force refresh (ignore cache)", value=False)
|
| 259 |
|
|
|
|
| 260 |
with gr.Row():
|
| 261 |
btn_short = gr.Button("πΈ Flushies (short haul)")
|
| 262 |
btn_medium = gr.Button("π§Έ Flushies (medium haul)")
|
| 263 |
btn_long = gr.Button("π Flushies (long haul)")
|
| 264 |
-
btn_xanax = gr.Button("π Xanax
|
| 265 |
btn_temps = gr.Button("𧨠Temps")
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
result_df = gr.Dataframe(label="Results")
|
| 268 |
meta_box = gr.Textbox(label="Metadata / Last Update")
|
| 269 |
run_btn = gr.Button("π Search / Refresh")
|
| 270 |
|
| 271 |
-
run_btn.click(run_query,
|
|
|
|
| 272 |
outputs=[result_df, meta_box, category_drop])
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
btn_xanax.click(lambda c: run_multi(["xanax in south africa"], c),
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
| 290 |
|
| 291 |
-
# --- JS
|
| 292 |
gr.HTML("""
|
| 293 |
<script>
|
| 294 |
(function(){
|
|
|
|
| 20 |
|
| 21 |
ITEM_TO_TYPE = {v["name"]: v["type"].lower() for v in items_data.values() if "name" in v and "type" in v}
|
| 22 |
ALL_ITEMS = list(ITEM_TO_TYPE.keys())
|
| 23 |
+
ALL_ITEMS_LOWER = {name.lower(): name for name in ALL_ITEMS}
|
| 24 |
ALL_CATEGORIES = sorted(set(ITEM_TO_TYPE.values()))
|
| 25 |
ITEM_FILE_MTIME = os.path.getmtime("items.json")
|
| 26 |
|
|
|
|
| 116 |
if not text:
|
| 117 |
return "", ""
|
| 118 |
text = text.strip().lower()
|
| 119 |
+
m = re.match(r"(.+?)\s+in\s+(.+)", text, flags=re.IGNORECASE)
|
| 120 |
if m:
|
| 121 |
return m.group(1).strip(), m.group(2).strip()
|
| 122 |
parts = text.split()
|
|
|
|
| 156 |
_cache.update({
|
| 157 |
"data": data,
|
| 158 |
"timestamp": time.time(),
|
| 159 |
+
"last_update": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) # UTC ISO
|
| 160 |
})
|
| 161 |
return data, _cache["last_update"]
|
| 162 |
except Exception as e:
|
|
|
|
| 173 |
live_cats.add(cat.lower())
|
| 174 |
return sorted(live_cats)
|
| 175 |
|
| 176 |
+
# ---------------- Core logic: single query ----------------
|
| 177 |
def query_inventory(query_text="", category="", country_name="", capacity=10, refresh=False):
|
| 178 |
data, last_update = fetch_yata(force_refresh=refresh)
|
| 179 |
rows = []
|
| 180 |
+
|
| 181 |
+
# Parse freeform if present
|
| 182 |
parsed_item, parsed_country = parse_freeform_query(query_text)
|
| 183 |
if not country_name and parsed_country:
|
| 184 |
country_name = parsed_country
|
| 185 |
item_term = parsed_item
|
| 186 |
+
|
| 187 |
+
# Detect if user meant an exact item (e.g., "xanax")
|
| 188 |
+
item_lower = (item_term or "").lower()
|
| 189 |
+
exact_item_name = ALL_ITEMS_LOWER.get(item_lower)
|
| 190 |
+
sem = semantic_match(item_term) if item_term and not exact_item_name else {"category": None, "items": []}
|
| 191 |
+
semantic_items = sem["items"]
|
| 192 |
+
semantic_category = sem["category"]
|
| 193 |
+
|
| 194 |
+
# Country gating (strict)
|
| 195 |
user_code = normalize_country_query(country_name)
|
| 196 |
+
|
| 197 |
for code_raw, cdata in data.get("stocks", {}).items():
|
| 198 |
code = code_raw.upper()
|
| 199 |
cname = COUNTRY_NAMES.get(code, code)
|
| 200 |
+
|
| 201 |
if country_name:
|
| 202 |
+
if user_code:
|
| 203 |
+
if code != user_code:
|
| 204 |
+
continue
|
| 205 |
+
elif country_name.lower() not in cname.lower():
|
| 206 |
continue
|
| 207 |
+
|
| 208 |
update_ts = cdata.get("update")
|
| 209 |
update_str = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(update_ts)) if update_ts else "Unknown"
|
| 210 |
+
|
| 211 |
for item in cdata.get("stocks", []):
|
| 212 |
iname = item.get("name", "")
|
| 213 |
itype = ITEM_TO_TYPE.get(iname, "").lower()
|
| 214 |
qty = item.get("quantity", 0)
|
| 215 |
cost = item.get("cost", 0)
|
| 216 |
+
|
| 217 |
+
# Strict item filtering
|
| 218 |
+
if item_term:
|
| 219 |
+
if exact_item_name:
|
| 220 |
+
item_ok = (iname.lower() == item_lower) # exact item only
|
| 221 |
+
else:
|
| 222 |
+
item_ok = (
|
| 223 |
+
(item_lower and item_lower in iname.lower()) or
|
| 224 |
+
(semantic_category and itype == semantic_category.lower()) or
|
| 225 |
+
(iname in semantic_items)
|
| 226 |
+
)
|
| 227 |
+
elif category:
|
| 228 |
+
item_ok = (category.lower() == itype)
|
| 229 |
+
else:
|
| 230 |
+
item_ok = True
|
| 231 |
+
|
| 232 |
if item_ok:
|
| 233 |
rows.append({
|
| 234 |
"Country": cname,
|
|
|
|
| 239 |
"Max Capacity Cost": cost * capacity,
|
| 240 |
"Updated": update_str
|
| 241 |
})
|
| 242 |
+
|
| 243 |
if not rows:
|
| 244 |
return pd.DataFrame([{"Result": "No inventory found for that query."}]), f"Last update: {last_update}"
|
| 245 |
+
|
| 246 |
df = pd.DataFrame(rows).sort_values(by=["Country", "Item"])
|
| 247 |
for col in ["Quantity", "Cost", "Max Capacity Cost"]:
|
| 248 |
df[col] = df[col].apply(lambda x: f"{x:,.0f}" if isinstance(x, (int, float)) else x)
|
| 249 |
return df, f"Last update: {last_update}"
|
| 250 |
|
| 251 |
+
# ---------------- Multi-query (convenience buttons) ----------------
|
| 252 |
+
def run_multi(phrases, capacity):
|
| 253 |
+
"""
|
| 254 |
+
Execute multiple 'item in country' phrases with strict per-country filtering and no duplicates.
|
| 255 |
+
"""
|
| 256 |
+
data, last_update = fetch_yata(False)
|
| 257 |
+
|
| 258 |
+
# Group requested item_terms by normalized country code
|
| 259 |
+
tasks_by_code = {} # code -> [item_term, ...]
|
| 260 |
+
for phrase in phrases:
|
| 261 |
+
item_term, country_term = parse_freeform_query(phrase)
|
| 262 |
+
code = normalize_country_query(country_term) or ""
|
| 263 |
+
if not code:
|
| 264 |
+
# If country not recognized (shouldn't happen with our lists), fall back to pass-through single query
|
| 265 |
+
code = "__ALL__"
|
| 266 |
+
tasks_by_code.setdefault(code, []).append(item_term)
|
| 267 |
+
|
| 268 |
+
rows = []
|
| 269 |
+
for code_raw, cdata in data.get("stocks", {}).items():
|
| 270 |
+
code = code_raw.upper()
|
| 271 |
+
if code not in tasks_by_code:
|
| 272 |
+
continue
|
| 273 |
+
|
| 274 |
+
cname = COUNTRY_NAMES.get(code, code)
|
| 275 |
+
update_ts = cdata.get("update")
|
| 276 |
+
update_str = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(update_ts)) if update_ts else "Unknown"
|
| 277 |
+
|
| 278 |
+
# Precompute semantic intents for each term in this country
|
| 279 |
+
compiled_terms = []
|
| 280 |
+
for term in tasks_by_code[code]:
|
| 281 |
+
t = (term or "").strip().lower()
|
| 282 |
+
exact_item_name = ALL_ITEMS_LOWER.get(t)
|
| 283 |
+
if exact_item_name:
|
| 284 |
+
compiled_terms.append({"mode": "exact", "value": t})
|
| 285 |
+
else:
|
| 286 |
+
sem = semantic_match(t) if t else {"category": None, "items": []}
|
| 287 |
+
compiled_terms.append({
|
| 288 |
+
"mode": "fuzzy",
|
| 289 |
+
"value": t,
|
| 290 |
+
"category": (sem["category"] or "").lower() if sem["category"] else "",
|
| 291 |
+
"items": set(sem["items"])
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
# Scan this country's items once
|
| 295 |
+
for item in cdata.get("stocks", []):
|
| 296 |
+
iname = item.get("name", "")
|
| 297 |
+
itype = ITEM_TO_TYPE.get(iname, "").lower()
|
| 298 |
+
qty = item.get("quantity", 0)
|
| 299 |
+
cost = item.get("cost", 0)
|
| 300 |
+
|
| 301 |
+
matched = False
|
| 302 |
+
for ct in compiled_terms:
|
| 303 |
+
if ct["mode"] == "exact":
|
| 304 |
+
if iname.lower() == ct["value"]:
|
| 305 |
+
matched = True
|
| 306 |
+
break
|
| 307 |
+
else:
|
| 308 |
+
q = ct["value"]
|
| 309 |
+
if (q and q in iname.lower()) or (ct["category"] and itype == ct["category"]) or (iname in ct["items"]):
|
| 310 |
+
matched = True
|
| 311 |
+
break
|
| 312 |
+
|
| 313 |
+
if matched:
|
| 314 |
+
rows.append({
|
| 315 |
+
"Country": cname,
|
| 316 |
+
"Item": iname,
|
| 317 |
+
"Category": itype.title(),
|
| 318 |
+
"Quantity": qty,
|
| 319 |
+
"Cost": cost,
|
| 320 |
+
"Max Capacity Cost": cost * capacity,
|
| 321 |
+
"Updated": update_str
|
| 322 |
+
})
|
| 323 |
+
|
| 324 |
+
if not rows:
|
| 325 |
+
return pd.DataFrame([{"Result": "No results for that set."}]), f"Last update: {last_update}"
|
| 326 |
+
|
| 327 |
+
# Deduplicate rows by (Country, Item, Updated)
|
| 328 |
+
df = pd.DataFrame(rows).drop_duplicates(subset=["Country", "Item", "Updated"]).sort_values(by=["Country", "Item"])
|
| 329 |
+
for col in ["Quantity", "Cost", "Max Capacity Cost"]:
|
| 330 |
+
df[col] = df[col].apply(lambda x: f"{x:,.0f}" if isinstance(x, (int, float)) else x)
|
| 331 |
+
return df, f"Last update: {last_update}"
|
| 332 |
+
|
| 333 |
# ---------------- Wrappers ----------------
|
| 334 |
def run_query(query_text, category, country, capacity, refresh):
|
| 335 |
data, _ = fetch_yata(force_refresh=refresh)
|
|
|
|
| 337 |
live_categories = get_live_categories(data)
|
| 338 |
return df, ts, gr.update(choices=[""] + live_categories)
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
# ---------------- Gradio UI ----------------
|
| 341 |
+
with gr.Blocks(title="π§³ Torn Inventory Viewer") as iface:
|
| 342 |
+
gr.Markdown("## π§³ Torn Inventory Viewer")
|
| 343 |
+
gr.Markdown("_Search Torn YATA travel stocks β timestamps shown in your local timezone._")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
+
# Convenience buttons
|
| 346 |
with gr.Row():
|
| 347 |
btn_short = gr.Button("πΈ Flushies (short haul)")
|
| 348 |
btn_medium = gr.Button("π§Έ Flushies (medium haul)")
|
| 349 |
btn_long = gr.Button("π Flushies (long haul)")
|
| 350 |
+
btn_xanax = gr.Button("π Xanax")
|
| 351 |
btn_temps = gr.Button("𧨠Temps")
|
| 352 |
|
| 353 |
+
query_box = gr.Textbox(label="Search (semantic, e.g. 'flowers in England')")
|
| 354 |
+
category_drop = gr.Dropdown(label="Category (optional exact match)", choices=[""] + ALL_CATEGORIES)
|
| 355 |
+
country_box = gr.Textbox(label="Country (optional, e.g. UK, Cayman, Japan)")
|
| 356 |
+
capacity_slider = gr.Number(label="Travel Capacity", value=10, minimum=5, maximum=88, precision=0)
|
| 357 |
+
refresh_check = gr.Checkbox(label="Force refresh (ignore cache)", value=False)
|
| 358 |
+
|
| 359 |
result_df = gr.Dataframe(label="Results")
|
| 360 |
meta_box = gr.Textbox(label="Metadata / Last Update")
|
| 361 |
run_btn = gr.Button("π Search / Refresh")
|
| 362 |
|
| 363 |
+
run_btn.click(run_query,
|
| 364 |
+
inputs=[query_box, category_drop, country_box, capacity_slider, refresh_check],
|
| 365 |
outputs=[result_df, meta_box, category_drop])
|
| 366 |
|
| 367 |
+
# Convenience button bindings (use run_multi with per-country grouping)
|
| 368 |
+
btn_short.click(lambda c: run_multi(
|
| 369 |
+
["flowers in mexico", "flowers in cayman islands", "flowers in canada",
|
| 370 |
+
"plushies in mexico", "plushies in cayman islands", "plushies in canada"], c),
|
| 371 |
+
inputs=[capacity_slider], outputs=[result_df, meta_box])
|
| 372 |
+
|
| 373 |
+
btn_medium.click(lambda c: run_multi(
|
| 374 |
+
["flowers in hawaii", "flowers in united kingdom", "flowers in argentina",
|
| 375 |
+
"flowers in switzerland", "flowers in japan",
|
| 376 |
+
"plushies in hawaii", "plushies in united kingdom", "plushies in argentina",
|
| 377 |
+
"plushies in switzerland", "plushies in japan"], c),
|
| 378 |
+
inputs=[capacity_slider], outputs=[result_df, meta_box])
|
| 379 |
+
|
| 380 |
+
btn_long.click(lambda c: run_multi(
|
| 381 |
+
["flowers in uae", "flowers in china", "flowers in south africa",
|
| 382 |
+
"plushies in uae", "plushies in china", "plushies in south africa"], c),
|
| 383 |
+
inputs=[capacity_slider], outputs=[result_df, meta_box])
|
| 384 |
+
|
| 385 |
btn_xanax.click(lambda c: run_multi(["xanax in south africa"], c),
|
| 386 |
+
inputs=[capacity_slider], outputs=[result_df, meta_box])
|
| 387 |
+
|
| 388 |
+
btn_temps.click(lambda c: run_multi(
|
| 389 |
+
["tear gas in argentina", "smoke grenade in south africa", "flash grenade in switzerland"], c),
|
| 390 |
+
inputs=[capacity_slider], outputs=[result_df, meta_box])
|
| 391 |
|
| 392 |
+
# --- JS: convert all UTC ISO timestamps to browser's local time ---
|
| 393 |
gr.HTML("""
|
| 394 |
<script>
|
| 395 |
(function(){
|