Skip to content

Commit f2c6292

Browse files
committed
Add Blackbox-format CSV export for search results.
Expose /api/export/blackbox with the same q/status/limit params as search, reuse shared parse_search_params, and add a UI button with demo-mode client CSV generation. Include metadata_example.csv as the header reference and unittest coverage for CSV building and export integration.
1 parent c292f1b commit f2c6292

3 files changed

Lines changed: 359 additions & 9 deletions

File tree

metadata_example.csv

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#Keep this line: File Name,"Description (min 15, max 200 characters, must be least 5 words)","Keywords (min 8, max 49, separated by comma, and no repetition)",Category (use dropdown menu),Batch name (Batch name is not applicable for curator),Editorial (use dropdown menu),Editorial Text,Editorial City,Editorial State,Editorial Country (use dropdown menu),Editorial Date,Title (Optional),Shooting Country (Optional),Shooting Date (Optional)
2+
aerial-sea-0004.mov,"Aerial and wide shots of a tropical island coastline, showing densely forested peninsulas jutting into calm blue and turquoise bay waters in Glan, Sarangani, Philippines.","tropical, aerial, island, coastline, ocean, bay, forested, tropical beach, Glan, Sarangani, Philippines",Travel,gensan-glan-escapade-2026,FALSE,,,,,,,Philippines,
3+
gensan-mountains-0001.mov,"Sweeping, panoramic views of lush, rolling green hills and valley farmlands under a bright sky in Lake Sebu, South Cotabato, Philippines.","rolling hills, green hills, valley, farmland, panorama, rural, landscape, scenic, outdoor, Lake Sebu, South Cotabato, Philippines",Travel,gensan-glan-escapade-2026,FALSE,,,,,,,Philippines,
4+
lake-sebu-0001.mov,"Views of waterfalls cascading through dense, lush tropical jungle environments, showing the movement of water into rivers and surrounding vegetation in Lake Sebu, South Cotabato, Philippines.","waterfall, tropical, jungle, rainforest, water, cascade, river, lush, nature, Lake Sebu, South Cotabato, Philippines",Nature,gensan-glan-escapade-2026,FALSE,,,,,,,Philippines,
5+
gensan-paragliding-0001.mov,"Aerial views of paragliding and tandem parachute rides over verdant tropical landscapes, valleys, and rivers in Lake Sebu, South Cotabato, Philippines.","paragliding, aerial, tropical, landscape, hills, adventure, sky, valley, Lake Sebu, South Cotabato, Philippines",Travel,gensan-glan-escapade-2026,FALSE,,,,,,,Philippines,

src/argus/serve.py

Lines changed: 227 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

3+
import csv
4+
import io
35
import json
6+
import re
47
import subprocess
58
import webbrowser
69
from http import HTTPStatus
@@ -10,6 +13,89 @@
1013

1114
from argus.database import fetch_status_options, get_video_path, query_videos
1215

16+
# Blackbox CSV: header text must match metadata_example.csv exactly.
17+
BLACKBOX_CSV_HEADER_ROW: tuple[str, ...] = (
18+
"#Keep this line: File Name",
19+
"Description (min 15, max 200 characters, must be least 5 words)",
20+
"Keywords (min 8, max 49, separated by comma, and no repetition)",
21+
"Category (use dropdown menu)",
22+
"Batch name (Batch name is not applicable for curator)",
23+
"Editorial (use dropdown menu)",
24+
"Editorial Text",
25+
"Editorial City",
26+
"Editorial State",
27+
"Editorial Country (use dropdown menu)",
28+
"Editorial Date",
29+
"Title (Optional)",
30+
"Shooting Country (Optional)",
31+
"Shooting Date (Optional)",
32+
)
33+
34+
BLACKBOX_EXPORT_CATEGORY = "Travel"
35+
36+
37+
def batch_name_from_video_path(path: str) -> str:
38+
"""Return slug from the file's immediate parent directory only (not full path)."""
39+
p = Path(path)
40+
if not path or p.name == p.anchor:
41+
return ""
42+
parent = p.parent
43+
if not str(parent) or parent == p:
44+
return ""
45+
raw = parent.name.strip()
46+
if not raw:
47+
return ""
48+
slug = re.sub(r"[\s_]+", "-", raw.lower().strip())
49+
slug = re.sub(r"-+", "-", slug)
50+
return slug.strip("-")
51+
52+
53+
def build_blackbox_csv_text(results: list[dict]) -> str:
54+
"""Build UTF-8 Blackbox CSV text (no BOM) for search result dicts from query_videos."""
55+
buffer = io.StringIO(newline="")
56+
writer = csv.writer(buffer)
57+
writer.writerow(BLACKBOX_CSV_HEADER_ROW)
58+
for item in results:
59+
tags = item.get("suggested_tags") or []
60+
keyword_text = ", ".join(str(t) for t in tags)
61+
pth = item.get("path")
62+
path_str = pth if isinstance(pth, str) else str(pth or "")
63+
row = [
64+
item.get("filename") or "",
65+
item.get("summary") or "",
66+
keyword_text,
67+
BLACKBOX_EXPORT_CATEGORY,
68+
batch_name_from_video_path(path_str),
69+
"",
70+
"",
71+
"",
72+
"",
73+
"",
74+
"",
75+
item.get("title") or "",
76+
"",
77+
"",
78+
]
79+
writer.writerow(row)
80+
return buffer.getvalue()
81+
82+
83+
def build_blackbox_csv_bytes(results: list[dict]) -> bytes:
84+
return build_blackbox_csv_text(results).encode("utf-8")
85+
86+
87+
def parse_search_params(query: str) -> tuple[str, str | None, int]:
88+
"""Shared query string parsing for /api/search and /api/export/blackbox."""
89+
params = parse_qs(query)
90+
text = params.get("q", [""])[0]
91+
status = params.get("status", [""])[0] or None
92+
limit_text = params.get("limit", ["25"])[0]
93+
try:
94+
limit = max(1, min(100, int(limit_text)))
95+
except ValueError:
96+
limit = 25
97+
return text, status, limit
98+
1399
DEMO_RESULTS = [
14100
{
15101
"id": "demo-001",
@@ -98,14 +184,7 @@ def do_GET(self) -> None:
98184
)
99185
return
100186
if parsed.path == "/api/search":
101-
params = parse_qs(parsed.query)
102-
query = params.get("q", [""])[0]
103-
status = params.get("status", [""])[0] or None
104-
limit_text = params.get("limit", ["25"])[0]
105-
try:
106-
limit = max(1, min(100, int(limit_text)))
107-
except ValueError:
108-
limit = 25
187+
query, status, limit = parse_search_params(parsed.query)
109188
results = query_videos(
110189
db_path,
111190
query=query,
@@ -114,6 +193,21 @@ def do_GET(self) -> None:
114193
)
115194
self.respond_json({"results": results, "count": len(results)})
116195
return
196+
if parsed.path == "/api/export/blackbox":
197+
query, status, limit = parse_search_params(parsed.query)
198+
results = query_videos(
199+
db_path,
200+
query=query,
201+
status=status,
202+
limit=limit,
203+
)
204+
data = build_blackbox_csv_bytes(results)
205+
self.respond_bytes(
206+
data,
207+
"text/csv; charset=utf-8",
208+
'attachment; filename="argus-blackbox-export.csv"',
209+
)
210+
return
117211
self.respond_not_found()
118212

119213
def do_POST(self) -> None:
@@ -170,6 +264,21 @@ def respond_json(self, payload: dict, *, status: HTTPStatus = HTTPStatus.OK) ->
170264
self.end_headers()
171265
self.wfile.write(encoded)
172266

267+
def respond_bytes(
268+
self,
269+
data: bytes,
270+
content_type: str,
271+
content_disposition: str,
272+
*,
273+
status: HTTPStatus = HTTPStatus.OK,
274+
) -> None:
275+
self.send_response(status)
276+
self.send_header("Content-Type", content_type)
277+
self.send_header("Content-Disposition", content_disposition)
278+
self.send_header("Content-Length", str(len(data)))
279+
self.end_headers()
280+
self.wfile.write(data)
281+
173282
def respond_not_found(self) -> None:
174283
self.respond_json({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)
175284

@@ -305,7 +414,8 @@ def render_index_html(*, demo_mode: bool = False) -> str:
305414
display: flex;
306415
justify-content: space-between;
307416
align-items: center;
308-
gap: 1rem;
417+
flex-wrap: wrap;
418+
gap: 0.75rem 1rem;
309419
padding: 0 0.3rem 1rem;
310420
color: var(--muted);
311421
font-family: "SF Mono", "IBM Plex Mono", ui-monospace, monospace;
@@ -490,6 +600,7 @@ def render_index_html(*, demo_mode: bool = False) -> str:
490600
491601
<div class="meta">
492602
<span id="resultCount">Loading…</span>
603+
<button type="button" class="button" id="exportBlackbox">Export to CSV (Blackbox)</button>
493604
<span>localhost only</span>
494605
</div>
495606
@@ -510,7 +621,25 @@ def render_index_html(*, demo_mode: bool = False) -> str:
510621
const demoMode = __DEMO_MODE__;
511622
const demoResults = __DEMO_JSON__;
512623
624+
const BLACKBOX_CSV_HEADER = [
625+
"#Keep this line: File Name",
626+
"Description (min 15, max 200 characters, must be least 5 words)",
627+
"Keywords (min 8, max 49, separated by comma, and no repetition)",
628+
"Category (use dropdown menu)",
629+
"Batch name (Batch name is not applicable for curator)",
630+
"Editorial (use dropdown menu)",
631+
"Editorial Text",
632+
"Editorial City",
633+
"Editorial State",
634+
"Editorial Country (use dropdown menu)",
635+
"Editorial Date",
636+
"Title (Optional)",
637+
"Shooting Country (Optional)",
638+
"Shooting Date (Optional)"
639+
];
640+
513641
let debounceTimer = null;
642+
let lastResults = [];
514643
515644
async function loadMeta() {
516645
if (demoMode) {
@@ -542,11 +671,66 @@ def render_index_html(*, demo_mode: bool = False) -> str:
542671
}, 1800);
543672
}
544673
674+
function csvEscapeCell(value) {
675+
const s = value == null ? "" : String(value);
676+
if (
677+
s.includes(",")
678+
|| s.indexOf(34) >= 0
679+
|| s.includes(String.fromCharCode(10))
680+
|| s.includes(String.fromCharCode(13))
681+
) {
682+
return (
683+
String.fromCharCode(34)
684+
+ s.replaceAll(
685+
String.fromCharCode(34),
686+
String.fromCharCode(34) + String.fromCharCode(34)
687+
)
688+
+ String.fromCharCode(34)
689+
);
690+
}
691+
return s;
692+
}
693+
694+
function batchNameFromPathClient(path) {
695+
if (!path) return "";
696+
const norm = path.split(String.fromCharCode(92)).join("/");
697+
const parts = norm.split("/").filter(Boolean);
698+
if (parts.length < 2) return "";
699+
const parent = parts[parts.length - 2];
700+
if (!parent) return "";
701+
return parent
702+
.trim()
703+
.toLowerCase()
704+
.split(/[\\s_]+/)
705+
.filter(Boolean)
706+
.join("-");
707+
}
708+
709+
function buildBlackboxCsvTextClient(results) {
710+
const lines = [BLACKBOX_CSV_HEADER.map(csvEscapeCell).join(",")];
711+
for (const r of results) {
712+
const kw = (r.suggested_tags || []).map(String).join(", ");
713+
const row = [
714+
r.filename || "",
715+
r.summary || "",
716+
kw,
717+
"Travel",
718+
batchNameFromPathClient(r.path || ""),
719+
"", "", "", "", "", "",
720+
r.title || "",
721+
"", ""
722+
];
723+
lines.push(row.map(csvEscapeCell).join(","));
724+
}
725+
return lines.join("\\n");
726+
}
727+
545728
function highlightBrackets(text) {
546729
return text.replaceAll("[", "<mark>").replaceAll("]", "</mark>");
547730
}
548731
549732
function renderResults(results) {
733+
lastResults = results;
550734
resultCountEl.textContent = `${results.length} result${results.length === 1 ? "" : "s"}`;
551735
if (!results.length) {
552736
resultsEl.innerHTML = `<article class="panel empty">No matches yet. Try a broader search or clear the status filter.</article>`;
@@ -656,6 +840,40 @@ def render_index_html(*, demo_mode: bool = False) -> str:
656840
}
657841
});
658842
843+
const exportBtn = document.getElementById("exportBlackbox");
844+
exportBtn.addEventListener("click", async () => {
845+
if (demoMode) {
846+
const text = buildBlackboxCsvTextClient(lastResults);
847+
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
848+
const url = URL.createObjectURL(blob);
849+
const a = document.createElement("a");
850+
a.href = url;
851+
a.download = "argus-blackbox-export.csv";
852+
a.click();
853+
URL.revokeObjectURL(url);
854+
showToast("Exported CSV (Blackbox)");
855+
return;
856+
}
857+
const params = new URLSearchParams({
858+
q: queryInput.value,
859+
status: statusSelect.value,
860+
limit: limitSelect.value
861+
});
862+
const response = await fetch(`/api/export/blackbox?${params.toString()}`);
863+
if (!response.ok) {
864+
showToast("Export failed");
865+
return;
866+
}
867+
const blob = await response.blob();
868+
const url = URL.createObjectURL(blob);
869+
const a = document.createElement("a");
870+
a.href = url;
871+
a.download = "argus-blackbox-export.csv";
872+
a.click();
873+
URL.revokeObjectURL(url);
874+
showToast("Exported CSV (Blackbox)");
875+
});
876+
659877
queryInput.addEventListener("input", scheduleSearch);
660878
statusSelect.addEventListener("change", runSearch);
661879
limitSelect.addEventListener("change", runSearch);

0 commit comments

Comments
 (0)