Compare commits

...

3 Commits

@ -130,3 +130,7 @@ docker builder prune -af
docker volume prune -f
docker compose build --no-cache
docker compose up
docker compose down
docker compose build
docker compose up

@ -17,26 +17,28 @@ from scraper.abort import set_abort
from scraper.progress import get_progress
# UI LOGS (GLOBAL — no book_id)
from scraper.ui_log import get_ui_logs, reset_ui_logs # <-- ADDED
from scraper.ui_log import get_ui_logs, reset_ui_logs
from celery.result import AsyncResult
from scraper.state import state as r
# ⬇⬇⬇ TOEGEVOEGD voor cover-serving
# Cover serving
from flask import send_from_directory
import os
app = Flask(__name__)
import redis
# Flask
app = Flask(__name__)
# =====================================================
# STATIC FILE SERVING FOR OUTPUT ← TOEGEVOEGD
# STATIC FILE SERVING FOR OUTPUT
# =====================================================
OUTPUT_ROOT = os.getenv("BOOKSCRAPER_OUTPUT_DIR", "output")
@app.route("/output/<path:filename>")
def serve_output(filename):
"""Serve output files such as cover.jpg and volumes."""
return send_from_directory(OUTPUT_ROOT, filename, as_attachment=False)
@ -56,13 +58,15 @@ def start_scraping():
url = request.form.get("url", "").strip()
if not url:
return render_template("result.html", error="Geen URL opgegeven.")
# ★ FIX: dashboard moet altijd books + logs meekrijgen
return render_template(
"dashboard/dashboard.html",
error="Geen URL opgegeven.",
books=list_active_books(),
logs=get_ui_logs(),
)
# ---------------------------------------------------------
# NEW: Clear UI log buffer when starting a new scrape
# ---------------------------------------------------------
reset_ui_logs()
log_debug(f"[WEB] Scraping via Celery: {url}")
async_result = celery_app.send_task(
@ -71,16 +75,17 @@ def start_scraping():
queue="scraping",
)
# ★ FIX: direct dashboard tonen met actuele data
return render_template(
"result.html",
message="Scraping gestart.",
"dashboard/dashboard.html",
scraping_task_id=async_result.id,
book_title=None,
books=list_active_books(),
logs=get_ui_logs(),
)
# =====================================================
# CLEAR UI LOGS MANUALLY (NEW)
# CLEAR UI LOGS
# =====================================================
@app.route("/clear-logs", methods=["POST"])
def clear_logs():
@ -107,15 +112,7 @@ def progress(book_id):
# =====================================================
# LOGS — GLOBAL UI LOGS
# =====================================================
@app.route("/logs", methods=["GET"])
def logs():
return jsonify({"logs": get_ui_logs()})
# =====================================================
# CELERY RESULT → return book_id when scraping finishes
# CELERY RESULT → return book_id
# =====================================================
@app.route("/celery-result/<task_id>", methods=["GET"])
def celery_result(task_id):
@ -123,13 +120,162 @@ def celery_result(task_id):
if result.successful():
return jsonify({"ready": True, "result": result.get()})
if result.failed():
return jsonify({"ready": True, "error": "failed"})
return jsonify({"ready": False})
# =====================================================
# REDIS BACKEND — BOOK STATE MODEL
# =====================================================
REDIS_URL = os.getenv("REDIS_BROKER", "redis://redis:6379/0")
r = redis.Redis.from_url(REDIS_URL, decode_responses=True)
def list_active_books():
"""Return list of active books from Redis Book State Model."""
keys = r.keys("book:*:status")
books = []
for key in keys:
book_id = key.split(":")[1]
status = r.get(f"book:{book_id}:status") or "unknown"
title = r.get(f"book:{book_id}:title") or book_id
dl_done = int(r.get(f"book:{book_id}:download:done") or 0)
dl_total = int(r.get(f"book:{book_id}:download:total") or 0)
au_done = int(r.get(f"book:{book_id}:audio:done") or 0)
au_total = dl_total
books.append(
{
"book_id": book_id,
"title": title,
"status": status,
"download_done": dl_done,
"download_total": dl_total,
"audio_done": au_done,
"audio_total": au_total,
}
)
return books
# =====================================================
# API: list all active books
# =====================================================
@app.route("/api/books")
def api_books():
return jsonify(list_active_books())
# =====================================================
# API: book status
# =====================================================
@app.route("/api/book/<book_id>/status")
def api_book_status(book_id):
status = r.get(f"book:{book_id}:status") or "unknown"
dl_done = int(r.get(f"book:{book_id}:download:done") or 0)
dl_total = int(r.get(f"book:{book_id}:download:total") or 0)
au_done = int(r.get(f"book:{book_id}:audio:done") or 0)
au_total = dl_total
return jsonify(
{
"book_id": book_id,
"status": status,
"download_done": dl_done,
"download_total": dl_total,
"audio_done": au_done,
"audio_total": au_total,
}
)
# =====================================================
# API: book logs
# =====================================================
@app.route("/api/book/<book_id>/logs")
def api_book_logs(book_id):
logs = r.lrange(f"logs:{book_id}", 0, -1) or []
return jsonify(logs)
# =====================================================
# VIEW: DASHBOARD
# =====================================================
@app.route("/dashboard")
def dashboard():
logs_list = get_ui_logs() or []
# ★ FIX: dashboard moet altijd books + logs krijgen
return render_template(
"dashboard/dashboard.html",
books=list_active_books(),
logs=logs_list, # dashboard krijgt LIST, geen dict
)
# =====================================================
# VIEW: BOOK DETAIL PAGE
# =====================================================
@app.route("/book/<book_id>")
def book_detail(book_id):
title = r.get(f"book:{book_id}:title") or book_id
return render_template(
"dashboard/book_detail.html",
book_id=book_id,
title=title,
logs=get_ui_logs(),
)
@app.route("/debug/redis-keys")
def debug_redis_keys():
cursor = 0
results = {}
while True:
cursor, keys = r.scan(cursor, match="*", count=200)
for k in keys:
try:
results[k] = r.get(k)
except:
results[k] = "<non-string value>"
if cursor == 0:
break
return jsonify(results)
# ============================================================
# Rolling log endpoint (no new file)
# ============================================================
from flask import jsonify, request
# =====================================================
# ROLLING LOG ENDPOINT — DELTA POLLING VIA ui_log
# =====================================================
from scraper.ui_log import get_ui_logs_delta
@app.route("/logs", methods=["GET"])
def logs():
"""
Delta log delivery for WebGUI.
Browser sends ?last_index=N, we return only new lines.
"""
try:
last_index = int(request.args.get("last_index", -1))
except:
last_index = -1
new_lines, total = get_ui_logs_delta(last_index)
return jsonify({"lines": new_lines, "total": total})
# =====================================================
# RUN FLASK
# =====================================================

@ -27,3 +27,32 @@ def log(message: str):
push_ui(message)
except Exception:
pass
# ============================================================
# Delta-based log retrieval using Redis indexes
# ============================================================
def get_ui_logs_delta(last_index: int):
"""
Returns (new_lines, total_count)
Only returns log lines AFTER last_index.
Example:
last_index = 10 returns logs with Redis indexes 11..end
"""
# Determine total lines in buffer
total = r.llen(UI_LOG_KEY)
if total == 0:
return [], 0
# First load OR index invalid → send entire buffer
if last_index < 0 or last_index >= total:
logs = r.lrange(UI_LOG_KEY, 0, -1)
return logs, total
# Only new lines:
new_lines = r.lrange(UI_LOG_KEY, last_index + 1, -1)
return new_lines, total

@ -0,0 +1,7 @@
# scraper/state.py
import os
import redis
REDIS_STATE_URL = os.getenv("REDIS_STATE", "redis://redis:6379/2")
state = redis.Redis.from_url(REDIS_STATE_URL, decode_responses=True)

@ -13,6 +13,9 @@ from scraper.download_controller import DownloadController
from scraper.progress import (
set_total,
)
from urllib.parse import urlparse
import redis
import os
from scraper.abort import abort_requested
print(">>> [IMPORT] controller_tasks.py loaded")
@ -31,8 +34,30 @@ def launch_downloads(self, book_id: str, scrape_result: dict):
title = scrape_result.get("title", "UnknownBook")
chapters = scrape_result.get("chapters", []) or []
total = len(chapters)
# ------------------------------------------------------------
# INIT BOOK STATE MODEL (required for Active Books dashboard)
# ------------------------------------------------------------
broker_url = os.getenv("REDIS_BROKER", "redis://redis:6379/0")
parsed = urlparse(broker_url)
state = redis.Redis(
host=parsed.hostname,
port=parsed.port,
db=int(parsed.path.strip("/")),
decode_responses=True,
)
# Book metadata
state.set(f"book:{book_id}:title", title)
state.set(f"book:{book_id}:status", "starting")
# Download counters
state.set(f"book:{book_id}:download:total", total)
state.set(f"book:{book_id}:download:done", 0)
log(f"[CTRL] Book '{title}'{total} chapters (book_id={book_id})")
# Audio counters (start at zero)
state.set(f"book:{book_id}:audio:done", 0)
# ------------------------------------------------------------
# INIT PROGRESS

@ -26,11 +26,10 @@ def parse_chapter(self, download_result: dict):
book_id = download_result.get("book_id", "NOBOOK")
chapter_dict = download_result.get("chapter") or {}
book_meta = download_result.get("book_meta") or {}
chapter_title = chapter_dict.get("title")
chapter_num = chapter_dict.get("num")
chapter_url = chapter_dict.get("url")
html = download_result.get("html")
# ------------------------------------------------------------
# SKIPPED DOWNLOAD → SKIP PARSE
# ------------------------------------------------------------
@ -127,9 +126,8 @@ def parse_chapter(self, download_result: dict):
else:
prev_blank = False
cleaned.append(stripped)
text = "\n".join(cleaned)
text = chapter_title + "\n" + text
# ------------------------------------------------------------
# Add header to chapter 1
# ------------------------------------------------------------

@ -44,3 +44,31 @@ def reset_ui_logs():
- Auto-clear when new book scraping starts
"""
r.delete(UI_LOG_KEY)
# ============================================================
# Delta-based log retrieval using Redis indexes
# ============================================================
def get_ui_logs_delta(last_index: int):
"""
Returns (new_lines, total_count).
Only returns log lines AFTER last_index.
Example:
last_index = 10 returns logs with Redis indexes 11..end
"""
total = r.llen(UI_LOG_KEY)
if total == 0:
return [], 0
# First load OR index invalid → send entire buffer
if last_index < 0 or last_index >= total:
logs = r.lrange(UI_LOG_KEY, 0, -1)
return logs, total
# Only new logs
new_lines = r.lrange(UI_LOG_KEY, last_index + 1, -1)
return new_lines, total

@ -0,0 +1,262 @@
/* =======================================================================
File: static/css/dashboard.css
Purpose:
Clean full-width vertical dashboard layout with large log viewer.
======================================================================= */
/* ------------------------------
GENERAL PAGE LAYOUT
------------------------------ */
/* Dashboard content should use full width */
.dashboard-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 1200px; /* voorkomt overflow rechts */
margin: 20px auto;
padding: 0 20px;
gap: 18px; /* kleiner dan 30px */
}
/* ------------------------------
SECTIONS (input, progress, logs)
------------------------------ */
.dashboard-section {
background: #ffffff;
padding: 16px; /* kleiner */
border-radius: 6px;
border: 1px solid #ddd;
margin: 0; /* weg extra witruimte */
}
.page-title {
font-size: 22px;
margin-bottom: 15px;
}
/* ------------------------------
BOOK LIST (optional)
------------------------------ */
.book-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.book-list-empty {
padding: 18px;
text-align: center;
color: #777;
}
/* List item */
.book-list-item {
padding: 12px 16px;
background: #f7f7f7;
border-radius: 6px;
border: 1px solid #ccc;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
transition: background 0.2s, border-color 0.2s;
}
.book-list-item:hover,
.book-list-item.active {
background: #eaf3ff;
border-color: #1e88e5;
}
/* Title + metadata */
.book-title {
font-size: 16px;
font-weight: 600;
}
.book-meta {
font-size: 12px;
color: #555;
}
.meta-label {
font-weight: 600;
}
/* ------------------------------
PROGRESS BOX
------------------------------ */
.progress-box {
background: #fafafa;
border: 1px solid #ddd;
padding: 18px;
border-radius: 6px;
width: 100%;
}
.progress-header h2 {
margin-bottom: 5px;
}
.progress-subtitle {
font-size: 14px;
color: #333;
font-weight: 600;
}
.progress-bookid {
font-size: 12px;
color: #777;
margin-bottom: 15px;
}
.progress-bar {
height: 14px;
background: #ddd;
border-radius: 6px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-bar-fill {
height: 100%;
background: #1e88e5;
}
.progress-bar-fill.audio-fill {
background: #e65100;
}
.progress-stats {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #444;
margin-top: 4px;
}
/* ------------------------------
LOG VIEWER LARGE FULL-WIDTH
------------------------------ */
.log-viewer {
width: 100%;
max-width: 100%;
overflow: hidden; /* voorkom horizontaal uitsteken */
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-filters {
display: flex;
align-items: center;
gap: 8px;
}
.log-output {
flex: 1;
width: 100%;
max-width: 100%;
min-height: 60vh;
max-height: 75vh;
overflow-y: auto;
overflow-x: hidden; /* voorkom dat de log naar rechts uitsteekt */
background: #000000; /* Pure terminal black */
color: #00ff66; /* Matrix / retro green */
border: 1px solid #0f0; /* neon green frame */
border-radius: 6px;
padding: 12px;
font-family: "SF Mono", "Consolas", "Courier New", monospace;
font-size: 13px;
line-height: 1.35;
white-space: pre-wrap; /* wraps text */
word-break: break-word; /* lange links breken */
}
/* Basestijl voor alle logregels */
.log-line {
white-space: pre-wrap;
padding: 2px 0;
font-family: "SF Mono", "Consolas", "Courier New", monospace;
}
/* Subklassen per logtype */
.log-line.default {
color: #00ff66; /* groen */
}
.log-line.dl {
color: #00ccff; /* cyan */
}
.log-line.parse {
color: #ffaa00; /* oranje */
}
.log-line.save {
color: #ffdd33; /* geel */
}
.log-line.audio {
color: #ff66ff; /* paars */
}
.log-line.ctrl {
color: #66aaff; /* lichtblauw */
}
.log-line.error {
color: #ff3333; /* rood */
}
/* ------------------------------
PLACEHOLDER
------------------------------ */
.dashboard-placeholder {
font-size: 15px;
padding: 20px;
text-align: center;
color: #777;
}
.footer {
text-align: center;
padding: 12px;
color: #666;
margin-top: 25px;
font-size: 12px;
border-top: 1px solid #ddd;
}
.book-abort-area {
margin-top: 10px;
text-align: right;
}
.abort-btn {
padding: 6px 12px;
border-radius: 4px;
border: 1px solid #cc0000;
background: #ff4444;
color: white;
font-size: 12px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.abort-btn:hover {
background: #ff2222;
border-color: #aa0000;
}

@ -0,0 +1,160 @@
/* =======================================================================
File: static/css/style.css
Purpose:
Global base styling for all pages.
Includes typography, buttons, forms, layout primitives.
======================================================================= */
/* ------------------------------
RESET / BASE
------------------------------ */
html,
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
background: #f5f6fa;
color: #222;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
h1,
h2,
h3 {
margin: 0 0 15px 0;
font-weight: 600;
}
a {
color: #1e88e5;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ------------------------------
BUTTONS
------------------------------ */
.btn-primary {
background: #1e88e5;
color: #fff;
padding: 10px 18px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 15px;
transition: background 0.2s ease;
}
.btn-primary:hover {
background: #1669b9;
}
.btn-small {
padding: 5px 10px;
background: #ccc;
border-radius: 4px;
border: none;
font-size: 13px;
}
.btn-small:hover {
background: #bbb;
}
/* ------------------------------
FORM ELEMENTS
------------------------------ */
.url-form {
display: flex;
gap: 10px;
flex-direction: column;
max-width: 550px;
}
.url-label {
font-weight: 600;
}
.url-input {
padding: 10px;
font-size: 15px;
border: 1px solid #bbb;
border-radius: 4px;
}
.url-submit {
align-self: flex-start;
}
/* ------------------------------
NAVBAR
------------------------------ */
.navbar {
background: #ffffff;
border-bottom: 1px solid #ddd;
padding: 12px 20px;
}
.nav-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand a {
font-size: 20px;
font-weight: bold;
color: #1e88e5;
}
.nav-links {
list-style: none;
display: flex;
gap: 25px;
margin: 0;
padding: 0;
}
.nav-item {
font-size: 15px;
color: #333;
}
.nav-item:hover {
color: #1e88e5;
}
/* ------------------------------
LANDING PAGE
------------------------------ */
.landing-container {
max-width: 600px;
margin: 40px auto;
background: #fff;
padding: 25px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.landing-title {
margin-bottom: 20px;
}
.landing-links {
margin-top: 20px;
}

@ -0,0 +1,33 @@
/* =======================================================================
File: static/js/app.js
Purpose:
Global utility functions shared across all scripts.
No page-specific logic here.
======================================================================= */
// Shortcuts
const $ = (sel, parent = document) => parent.querySelector(sel);
const $$ = (sel, parent = document) => parent.querySelectorAll(sel);
// Safe log
function dbg(...args) {
console.log("[APP]", ...args);
}
// AJAX helper
async function apiGet(url) {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error("API GET Error:", url, err);
return null;
}
}
// Auto-scroll utility
function autoScroll(el) {
if (!el) return;
el.scrollTop = el.scrollHeight;
}

@ -0,0 +1,200 @@
/* =======================================================================
File: static/js/dashboard.js
Purpose:
Dashboard interactions:
- select book
- refresh logs
- refresh progress
NOTE:
$ / $$ / autoScroll komen uit helpers.js
======================================================================= */
/* ---------------------------------------------------------
Simple fetch wrapper
--------------------------------------------------------- */
async function apiGet(url) {
try {
const r = await fetch(url);
if (!r.ok) return null;
return await r.json();
} catch (e) {
console.error("API GET failed:", url, e);
return null;
}
}
/* ---------------------------------------------------------
Dashboard state
--------------------------------------------------------- */
let ACTIVE_BOOK = null;
let REFRESH_INTERVAL = null;
console.log(">>> dashboard.js LOADED");
/* ---------------------------------------------------------
DOM Ready setup
--------------------------------------------------------- */
document.addEventListener("DOMContentLoaded", () => {
console.log(">>> dashboard.js DOMContentLoaded");
// =====================================================
// GLOBAL FALLBACK POLLING — ALWAYS FETCH LOGS
// Runs when no books exist or no selection has been made
// =====================================================
console.log(">>> dashboard.js: enabling global fallback polling");
setInterval(() => {
// if no active book → fetch global logs
if (!ACTIVE_BOOK) {
refreshBook(null); // triggers /logs
}
}, 2000);
const items = $$(".book-list-item");
console.log(">>> dashboard.js found book-list items:", items.length);
// Geen boeken → geen polling starten
// if (!items || items.length === 0) {
// console.log(">>> dashboard.js: geen boeken aanwezig, polling uit.");
// return;
// }
// Book selection listener
items.forEach((item) => {
item.addEventListener("click", () => {
console.log(">>> dashboard.js: user clicked book:", item.dataset.bookId);
selectBook(item.dataset.bookId);
});
});
// Auto-select first book
if (!ACTIVE_BOOK && items[0]) {
console.log(
">>> dashboard.js: auto-select first book:",
items[0].dataset.bookId
);
selectBook(items[0].dataset.bookId);
}
});
/* ---------------------------------------------------------
Select a book (updates UI + starts polling)
--------------------------------------------------------- */
function selectBook(bookId) {
console.log(">>> selectBook(", bookId, ")");
ACTIVE_BOOK = bookId;
// Highlight
$$(".book-list-item").forEach((el) => {
el.classList.toggle("active", el.dataset.bookId === bookId);
});
// Reset previous polling
if (REFRESH_INTERVAL) {
console.log(">>> dashboard.js: clearing previous polling interval");
clearInterval(REFRESH_INTERVAL);
}
// Start new polling
console.log(">>> dashboard.js: starting polling for bookId =", bookId);
REFRESH_INTERVAL = setInterval(() => {
refreshBook(ACTIVE_BOOK);
}, 2000);
// Immediate refresh
refreshBook(ACTIVE_BOOK);
}
setInterval(refreshActiveBooks, 2000);
async function refreshActiveBooks() {
const books = await apiGet("/api/books");
if (!books) return;
const container = $("#book-list");
if (!container) return;
// Herbouw de lijst
container.innerHTML = "";
books.forEach((b) => {
const div = document.createElement("div");
div.className = "book-list-item";
div.dataset.bookId = b.book_id;
div.innerHTML = `
<div class="book-title">${b.title}</div>
<div class="book-status">${b.status}</div>
<div class="book-progress">
${b.download_done}/${b.download_total} downloaded,
${b.audio_done}/${b.audio_total} audio
</div>
<button class="abort-btn" onclick="abortBook('${b.book_id}')">Abort</button>
`;
// Event listener opnieuw koppelen
div.addEventListener("click", () => selectBook(b.book_id));
container.appendChild(div);
});
// Als ACTIVE_BOOK nog niet bekend → auto-selecteer eerste boek
if (!ACTIVE_BOOK && books.length > 0) {
selectBook(books[0].book_id);
}
}
/* ---------------------------------------------------------
Fetch logs + progress from API
--------------------------------------------------------- */
async function refreshBook(bookId) {
console.log(">>> refreshBook(", bookId, ")");
// 1) Als er GEEN bookId is → haal alleen globale logs op
if (!bookId) {
console.log(">>> refreshBook: no active book → fetch /logs");
const data = await apiGet("/logs");
if (data && data.logs) updateLogs(data.logs);
return; // klaar
}
// 2) Als er WEL een boek is → haal book status + logs op
const state = await apiGet(`/api/book/${bookId}/status`);
const logs = await apiGet(`/api/book/${bookId}/logs`);
console.log(">>> refreshBook state =", state);
console.log(">>> refreshBook logs =", logs);
if (state) updateProgressBars(state);
if (logs) updateLogs(logs);
}
/* ---------------------------------------------------------
Update LOG VIEW panel
--------------------------------------------------------- */
function updateLogs(logList) {
const output = $("#log-output");
if (!output) {
console.warn(">>> updateLogs: no #log-output element found");
return;
}
output.innerHTML = "";
logList.forEach((line) => logAppend(line));
autoScroll(output);
}
function abortBook(book_id) {
if (!confirm(`Abort tasks for book ${book_id}?`)) return;
fetch(`/abort/${book_id}`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
console.log("Abort:", data);
})
.catch((err) => {
console.error("Abort failed:", err);
});
}

@ -0,0 +1,13 @@
/* =======================================================================
File: static/js/helpers.js
Purpose:
Shared DOM helpers for all JS files.
======================================================================= */
window.$ = (sel) => document.querySelector(sel);
window.$$ = (sel) => document.querySelectorAll(sel);
window.autoScroll = function (el) {
if (!el) return;
el.scrollTop = el.scrollHeight;
};

@ -0,0 +1,132 @@
/* =======================================================================
File: static/js/log_view.js
Purpose:
Log viewer functionality:
- filtering
- clearing
- auto-scroll
- delta polling (efficient)
- rolling limit (prevent GUI freeze)
======================================================================= */
console.log(">>> log_view.js LOADING…");
/* ---------------------------------------------------------
Log filtering
--------------------------------------------------------- */
let LOG_FILTER = "ALL";
let LAST_LOG_INDEX = -1; // For delta polling
const MAX_LOG_LINES = 2000; // Rolling cap to prevent freezing
function applyLogFilter() {
console.log(">>> log_view.js applyLogFilter(), filter =", LOG_FILTER);
const lines = $$(".log-line");
console.log(">>> log_view.js number of log-line elements:", lines.length);
lines.forEach((line) => {
const text = line.innerText;
line.style.display =
LOG_FILTER === "ALL" || text.includes(LOG_FILTER) ? "block" : "none";
});
}
/* ---------------------------------------------------------
UI bindings
--------------------------------------------------------- */
document.addEventListener("DOMContentLoaded", () => {
console.log(">>> log_view.js DOMContentLoaded");
const filterSel = $("#log-filter");
const clearBtn = $("#log-clear");
const output = $("#log-output");
if (!filterSel) {
console.log(">>> log_view.js: No log viewer found on this page.");
return;
}
console.log(">>> log_view.js: log viewer detected.");
// Filter dropdown
filterSel.addEventListener("change", () => {
LOG_FILTER = filterSel.value;
console.log(">>> log_view.js filter changed to:", LOG_FILTER);
applyLogFilter();
});
// Clear log window
if (clearBtn) {
clearBtn.addEventListener("click", () => {
console.log(">>> log_view.js log-clear clicked → clearing output");
if (output) {
output.innerHTML = "";
LAST_LOG_INDEX = -1; // reset delta polling
}
});
}
});
/* ---------------------------------------------------------
Append + Rolling buffer
--------------------------------------------------------- */
function logAppend(lineText) {
const output = $("#log-output");
if (!output) return;
const div = document.createElement("div");
div.classList.add("log-line");
// -----------------------------------------------------
// Assign subtype classes
// -----------------------------------------------------
if (lineText.includes("[DL]") || lineText.includes("[DOWNLOAD]")) {
div.classList.add("dl");
} else if (lineText.includes("[PARSE]")) {
div.classList.add("parse");
} else if (lineText.includes("[SAVE]")) {
div.classList.add("save");
} else if (lineText.includes("[AUDIO]")) {
div.classList.add("audio");
} else if (lineText.includes("[CTRL]")) {
div.classList.add("ctrl");
} else if (lineText.includes("[ERROR]")) {
div.classList.add("error");
} else {
div.classList.add("default");
}
div.innerText = lineText;
output.appendChild(div);
// Rolling buffer
while (output.children.length > MAX_LOG_LINES) {
output.removeChild(output.firstChild);
}
applyLogFilter();
autoScroll(output);
}
/* ---------------------------------------------------------
Delta-based log polling
--------------------------------------------------------- */
function pollLogs() {
fetch(`/logs?last_index=${LAST_LOG_INDEX}`)
.then((r) => r.json())
.then((data) => {
const lines = data.lines || [];
if (lines.length > 0) {
lines.forEach((line) => logAppend(line));
LAST_LOG_INDEX = data.total - 1;
}
})
.catch((err) => {
console.warn(">>> log_view.js pollLogs() error:", err);
});
}
// Poll every 800 ms
setInterval(pollLogs, 800);
console.log(">>> log_view.js LOADED");

@ -0,0 +1,72 @@
/* =======================================================================
File: static/js/progress.js
Purpose:
Update progress bars dynamically for the current book.
Expects data from API endpoints via dashboard.js or start.js.
======================================================================= */
console.log(">>> progress.js LOADED");
function updateProgressBars(data) {
console.log(">>> progress.js updateProgressBars() CALLED with:", data);
if (!data) {
console.warn(">>> progress.js: NO DATA RECEIVED");
return;
}
// Data format expected:
// {
// download_done,
// download_total,
// audio_done,
// audio_total
// }
const barDL = $(".progress-bar-fill");
const barAU = $(".progress-bar-fill.audio-fill");
console.log(">>> progress.js barDL =", barDL);
console.log(">>> progress.js barAU =", barAU);
const pctDL =
data.download_total > 0
? (100 * data.download_done) / data.download_total
: 0;
const pctAU =
data.audio_total > 0 ? (100 * data.audio_done) / data.audio_total : 0;
if (barDL) {
barDL.style.width = pctDL.toFixed(1) + "%";
console.log(">>> progress.js updated DL bar to", pctDL.toFixed(1) + "%");
} else {
console.warn(">>> progress.js: barDL NOT FOUND");
}
if (barAU) {
barAU.style.width = pctAU.toFixed(1) + "%";
console.log(">>> progress.js updated AU bar to", pctAU.toFixed(1) + "%");
} else {
console.warn(">>> progress.js: barAU NOT FOUND");
}
// Update textual stats
const stats = $$(".progress-stats span");
console.log(">>> progress.js stats elements found:", stats.length);
// Expected structure: [DL "x/y", DL "pct", AU "x/y", AU "pct"]
if (stats.length >= 4) {
stats[0].innerText = `${data.download_done} / ${data.download_total}`;
stats[1].innerText = pctDL.toFixed(1) + "%";
stats[2].innerText = `${data.audio_done} / ${data.audio_total}`;
stats[3].innerText = pctAU.toFixed(1) + "%";
console.log(">>> progress.js stats updated");
} else {
console.warn(
">>> progress.js: not enough stats spans, found",
stats.length
);
}
}

@ -0,0 +1,26 @@
<!-- File: templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>BookScraper</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/dashboard.css" />
</head>
<body>
<!-- Global Navigation -->
{% include "components/nav.html" %}
<!-- Main Content Area -->
<main class="container">{% block content %}{% endblock %}</main>
<!-- JS -->
<script src="/static/js/app.js"></script>
<script src="/static/js/log_view.js"></script>
<script src="/static/js/progress.js"></script>
<script src="/static/js/dashboard.js"></script>
</body>
</html>

@ -0,0 +1,64 @@
<!-- =======================================================================
File: templates/components/book_list_item.html
Purpose:
Dashboard weergave van één boek in de lijst.
Variabelen komen binnen via:
→ Dus alle velden moeten via "book.<veld>" aangesproken worden.
======================================================================= -->
<div class="book-list-item" data-book-id="{{ book.book_id }}">
<!-- Left area: title + metadata -->
<div class="book-info">
<div class="book-title">{{ book.title }}</div>
<div class="book-meta">
<span class="meta-label">ID:</span> {{ book.book_id }} {% if
book.last_update %}
<span class="meta-separator"></span>
<span class="meta-label">Updated:</span> {{ book.last_update }} {% endif
%}
</div>
</div>
<!-- Center area: Status -->
<div class="book-status">
<span class="status-badge status-{{ book.status|lower }}">
{{ book.status|capitalize }}
</span>
</div>
<!-- Right area: progress mini-bars -->
<div class="book-progress-mini">
<!-- Download progress -->
<div class="progress-mini-row">
<span class="mini-label">DL:</span>
{% set pct_dl = 0 %} {% if book.download_total > 0 %} {% set pct_dl = (100
* book.download_done / book.download_total) | round(0) %} {% endif %}
<div class="progress-mini-bar">
<div class="fill" style="width: {{ pct_dl }}%;"></div>
</div>
<span class="mini-value">{{ pct_dl }}%</span>
</div>
<!-- Audio progress -->
<div class="progress-mini-row">
<span class="mini-label">AU:</span>
{% set pct_au = 0 %} {% if book.audio_total > 0 %} {% set pct_au = (100 *
book.audio_done / book.audio_total) | round(0) %} {% endif %}
<div class="progress-mini-bar audio">
<div class="fill audio-fill" style="width: {{ pct_au }}%;"></div>
</div>
<span class="mini-value">{{ pct_au }}%</span>
</div>
</div>
<div class="book-abort-area">
<button class="abort-btn" onclick="abortBook('{{ book.book_id }}')">
Abort
</button>
</div>
</div>

@ -0,0 +1,44 @@
<!-- =======================================================================
File: templates/components/log_view.html
Purpose: Reusable log viewer component for any page (dashboard/start/book)
Notes:
- Requires JS: /static/js/log_view.js
- Supports filtering by tag (e.g. [DL], [PARSE], [AUDIO], [CTRL], ...)
- Template expects optional variable `logs` (list[str])
======================================================================= -->
<div id="log-viewer" class="log-viewer">
<!-- ========================== HEADER ========================== -->
<div class="log-header">
<h2>Live Log</h2>
<div class="log-filters">
<label for="log-filter">Filter:</label>
<select id="log-filter">
<option value="ALL">All</option>
<option value="[DL]">Download</option>
<option value="[PARSE]">Parse</option>
<option value="[SAVE]">Save</option>
<option value="[AUDIO]">Audio</option>
<option value="[CTRL]">Controller</option>
<option value="[SCRAPING]">Scraping</option>
<option value="[BOOK]">Book</option>
<option value="[ERROR]">Errors</option>
</select>
<button id="log-clear" class="btn-small">Clear</button>
</div>
</div>
<!-- ========================== OUTPUT ========================== -->
<div id="log-output" class="log-output">
{% if logs and logs|length > 0 %} {% for line in logs %}
<div class="log-line">{{ line }}</div>
{% endfor %} {% else %}
<div class="log-empty">No logs yet…</div>
{% endif %}
</div>
</div>
<script src="/static/js/log_view.js"></script>

@ -0,0 +1,28 @@
<!-- =======================================================================
File: templates/components/nav.html
Purpose: Global navigation bar for BookScraper UI
======================================================================= -->
<nav class="navbar">
<div class="nav-inner">
<!-- Left side: Branding -->
<div class="nav-brand">
<a href="/">BookScraper</a>
</div>
<!-- Right side: Navigation Links -->
<ul class="nav-links">
<li>
<a href="/dashboard" class="nav-item"> Dashboard </a>
</li>
<li>
<a href="/api/books" class="nav-item"> Active Books </a>
</li>
<li>
<a href="/logs" class="nav-item"> Logs </a>
</li>
</ul>
</div>
</nav>

@ -0,0 +1,61 @@
<!-- =======================================================================
File: templates/components/progress_box.html
Purpose: Reusable progress overview (download + audio) for any book.
Notes:
- Expects the following variables from Flask:
book_id: str
title: str
download_total: int
download_done: int
audio_total: int
audio_done: int
- Pure HTML; JS for live updates will be added later.
======================================================================= -->
<div class="progress-box">
<!-- Header -->
<div class="progress-header">
<h2>Progress</h2>
{% if title %}
<div class="progress-subtitle">{{ title }}</div>
{% endif %} {% if book_id %}
<div class="progress-bookid">Book ID: <span>{{ book_id }}</span></div>
{% endif %}
</div>
<!-- DOWNLOAD SECTION -->
<div class="progress-section">
<h3>Download Progress</h3>
<div class="progress-bar">
{% set pct = 0 %} {% if download_total > 0 %} {% set pct = (100 *
download_done / download_total) | round(1) %} {% endif %}
<div class="progress-bar-fill" style="width: {{ pct }}%;"></div>
</div>
<div class="progress-stats">
<span>{{ download_done }} / {{ download_total }}</span>
<span>{{ pct }}%</span>
</div>
</div>
<!-- AUDIO SECTION -->
<div class="progress-section">
<h3>Audio Progress</h3>
<div class="progress-bar audio">
{% set pct2 = 0 %} {% if audio_total > 0 %} {% set pct2 = (100 *
audio_done / audio_total) | round(1) %} {% endif %}
<div
class="progress-bar-fill audio-fill"
style="width: {{ pct2 }}%;"
></div>
</div>
<div class="progress-stats">
<span>{{ audio_done }} / {{ audio_total }}</span>
<span>{{ pct2 }}%</span>
</div>
</div>
<script src="/static/js/progress.js"></script>
</div>

@ -0,0 +1,21 @@
<!-- =======================================================================
File: templates/components/url_input.html
Purpose:
Reusable component for entering a book URL.
Used on landing pages or detail pages.
======================================================================= -->
<form method="POST" action="/start" class="url-form">
<label for="url" class="url-label">Book URL:</label>
<input
type="text"
id="url"
name="url"
class="url-input"
placeholder="https://www.piaotia.com/bookinfo/6/6072.html"
required
/>
<button type="submit" class="btn-primary url-submit">Start Scraping</button>
</form>

@ -0,0 +1,44 @@
<!-- =======================================================================
File: templates/dashboard/book_detail.html
Purpose:
Detailpagina voor één book_id.
Toont progress (download/audio) + filters + live logs.
======================================================================= -->
{% extends "layout.html" %} {% block content %}
<div class="dashboard-detail">
<h1 class="page-title">{{ title }}</h1>
<p class="breadcrumb">
<a href="/dashboard">← Terug naar dashboard</a>
</p>
<!-- Progress box -->
<section id="progressSection">
{% include "components/progress_box.html" %}
</section>
<!-- Log view -->
<section class="log-section">
<h2>Live Log</h2>
{% include "components/log_view.html" %}
</section>
</div>
<!-- PAGE-SPECIFIC JS -->
<script>
const BOOK_ID = "{{ book_id }}";
</script>
<script src="/static/js/helpers.js"></script>
<!-- Shared log viewer -->
<script src="/static/js/log_view.js"></script>
<!-- Dashboard behaviour (only does something if dashboard HTML is present) -->
<script src="/static/js/dashboard.js"></script>
<!-- Existing global app logic -->
<script src="/static/js/progress.js"></script>
<script src="/static/js/app.js"></script>
{% endblock %}

@ -0,0 +1,57 @@
{% extends "layout.html" %} {% block content %}
<div class="dashboard-container">
<!-- =======================================================================
File: templates/dashboard/dashboard.html
Purpose:
Functioneel dashboard:
• Start nieuwe scrape via URL input component
• Toont lijst van actieve boeken (actieve state model)
• Toont globale live logs
Vereist:
- books: lijst van actieve boeken
- logs: lijst van globale logs (optioneel)
======================================================================= -->
<!-- ===========================================================
URL INPUT — Start nieuwe scrape
=========================================================== -->
<section class="dashboard-section">
<h2>Start nieuwe scrape</h2>
{% include "components/url_input.html" %}
</section>
<hr />
<!-- ===========================================================
BOOK LIST
=========================================================== -->
<section class="dashboard-section">
<h2>Actieve boeken</h2>
{% if books and books|length > 0 %}
<div id="book-list" class="book-list">
{% for book in books %} {% include "components/book_list_item.html" %} {%
endfor %}
</div>
{% else %}
<div id="book-list" class="book-list-empty">Geen actieve boeken.</div>
{% endif %}
</section>
<hr />
<!-- ===========================================================
GLOBAL LIVE LOG VIEW
=========================================================== -->
<section class="dashboard-section">
<h2>Live log (globaal)</h2>
{# log_view verwacht altijd 'logs' — garandeer list #} {% set logs = logs or
[] %} {% include "components/log_view.html" %}
</section>
</div>
<!-- JS -->
<script src="/static/js/dashboard.js"></script>
{% endblock %}

@ -0,0 +1,23 @@
<!-- =======================================================================
File: templates/home.html
Purpose:
New landing page for starting a scrape.
Does NOT replace existing index.html.
Uses reusable components (url_input).
Redirects to /start?url=...
======================================================================= -->
{% extends "layout.html" %} {% block content %}
<div class="landing-container">
<h1 class="landing-title">Start a New Book Scrape</h1>
<!-- Reusable URL input component -->
{% include "components/url_input.html" %}
<div class="landing-links">
<a href="/dashboard">→ Go to Dashboard</a>
</div>
</div>
{% endblock %}

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<!-- =======================================================================
File: templates/layout.html
Purpose:
Globale layout voor alle paginas
======================================================================= -->
<meta charset="UTF-8" />
<title>BookScraper</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/dashboard.css" />
<!-- GLOBAL HELPERS (moet ALTIJD boven alles geladen worden) -->
<script src="/static/js/helpers.js"></script>
</head>
<body>
{% include "components/nav.html" %}
<main class="container">{% block content %}{% endblock %}</main>
<footer class="footer">
BookScraper © 2025 — Powered by Celery + Redis
</footer>
<!-- GLOBAL APP LOGIC (altijd als laatste) -->
<script src="/static/js/app.js"></script>
</body>
</html>
Loading…
Cancel
Save