fix: URL persistence in export + cache-busting v4.8.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- getDocument() now explicitly lists all block fields including url - loadDocument() initializes url/series_id for backward compat with older JSONs - Cache-busting query params (?v=4.8) on all static assets - PDF generator uses block.url directly (proper model field) - Added 7 new URL tests (model, API, PDF link annotations) - Version bumped to 4.8.0
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
VERSION = "4.7.0"
|
VERSION = "4.8.0"
|
||||||
MAX_FILE_SIZE_MB = 10
|
MAX_FILE_SIZE_MB = 10
|
||||||
DEFAULT_COLOR = "#ffffff"
|
DEFAULT_COLOR = "#ffffff"
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ def generate_pdf(doc) -> bytes:
|
|||||||
c.restoreState()
|
c.restoreState()
|
||||||
|
|
||||||
# If block has a URL, make the entire block a clickable link
|
# If block has a URL, make the entire block a clickable link
|
||||||
if getattr(block, 'url', None):
|
if block.url:
|
||||||
c.linkURL(block.url,
|
c.linkURL(block.url,
|
||||||
(bx + inset, row_y + inset,
|
(bx + inset, row_y + inset,
|
||||||
bx + bw - inset, row_y + row_h - inset),
|
bx + bw - inset, row_y + row_h - inset),
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css?v=4.8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="header-title">Scénář Creator</h1>
|
<h1 class="header-title">Scénář Creator</h1>
|
||||||
<span class="header-version">v4.7</span>
|
<span class="header-version">v4.8</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
|
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
|
||||||
@@ -249,10 +249,10 @@
|
|||||||
<div class="toast hidden" id="toast"></div>
|
<div class="toast hidden" id="toast"></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
|
||||||
<script src="/static/js/api.js"></script>
|
<script src="/static/js/api.js?v=4.8"></script>
|
||||||
<script src="/static/js/canvas.js"></script>
|
<script src="/static/js/canvas.js?v=4.8"></script>
|
||||||
<script src="/static/js/export.js"></script>
|
<script src="/static/js/export.js?v=4.8"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js?v=4.8"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -84,7 +84,18 @@ const App = {
|
|||||||
date: this.state.event.date_from, // backward compat
|
date: this.state.event.date_from, // backward compat
|
||||||
},
|
},
|
||||||
program_types: this.state.program_types.map(pt => ({ ...pt })),
|
program_types: this.state.program_types.map(pt => ({ ...pt })),
|
||||||
blocks: this.state.blocks.map(b => ({ ...b }))
|
blocks: this.state.blocks.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
date: b.date,
|
||||||
|
start: b.start,
|
||||||
|
end: b.end,
|
||||||
|
title: b.title,
|
||||||
|
type_id: b.type_id,
|
||||||
|
responsible: b.responsible || null,
|
||||||
|
notes: b.notes || null,
|
||||||
|
url: b.url || null,
|
||||||
|
series_id: b.series_id || null,
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -104,7 +115,9 @@ const App = {
|
|||||||
this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
|
this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
|
||||||
this.state.blocks = (doc.blocks || []).map(b => ({
|
this.state.blocks = (doc.blocks || []).map(b => ({
|
||||||
...b,
|
...b,
|
||||||
id: b.id || this.uid()
|
id: b.id || this.uid(),
|
||||||
|
url: b.url || null, // backward compat: older JSONs lack this
|
||||||
|
series_id: b.series_id || null,
|
||||||
}));
|
}));
|
||||||
this.syncEventToUI();
|
this.syncEventToUI();
|
||||||
this.renderTypes();
|
this.renderTypes();
|
||||||
|
|||||||
@@ -185,3 +185,23 @@ def test_backward_compat_date_field(client):
|
|||||||
r = client.post("/api/validate", json=doc)
|
r = client.post("/api/validate", json=doc)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["valid"] is True
|
assert r.json()["valid"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdf_with_url_block(client):
|
||||||
|
"""PDF generation should succeed when blocks contain url field."""
|
||||||
|
doc = make_valid_doc()
|
||||||
|
doc["blocks"][0]["url"] = "https://example.com/linked"
|
||||||
|
r = client.post("/api/generate-pdf", json=doc)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.content[:5] == b'%PDF-'
|
||||||
|
# Verify link annotation is present in the PDF
|
||||||
|
assert b'example.com/linked' in r.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_url_field(client):
|
||||||
|
"""Blocks with or without url should both validate successfully."""
|
||||||
|
doc = make_valid_doc()
|
||||||
|
doc["blocks"][0]["url"] = "https://example.com"
|
||||||
|
r = client.post("/api/validate", json=doc)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["valid"] is True
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def test_block_optional_fields():
|
|||||||
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
|
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
|
||||||
assert b.responsible is None
|
assert b.responsible is None
|
||||||
assert b.notes is None
|
assert b.notes is None
|
||||||
|
assert b.url is None
|
||||||
assert b.series_id is None
|
assert b.series_id is None
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,26 @@ def test_block_series_id():
|
|||||||
assert b.series_id == "s_abc123"
|
assert b.series_id == "s_abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_with_url():
|
||||||
|
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws",
|
||||||
|
url="https://example.com/test")
|
||||||
|
assert b.url == "https://example.com/test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_url_in_serialization():
|
||||||
|
"""url field must appear in serialized JSON even when None."""
|
||||||
|
b = Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
|
||||||
|
title="Test", type_id="ws")
|
||||||
|
data = b.model_dump(mode="json")
|
||||||
|
assert "url" in data
|
||||||
|
assert data["url"] is None
|
||||||
|
|
||||||
|
b2 = Block(id="b2", date="2026-03-01", start="09:00", end="10:00",
|
||||||
|
title="Test", type_id="ws", url="https://example.com")
|
||||||
|
data2 = b2.model_dump(mode="json")
|
||||||
|
assert data2["url"] == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_block_with_all_fields():
|
def test_block_with_all_fields():
|
||||||
b = Block(
|
b = Block(
|
||||||
id="custom-id", date="2026-03-01", start="09:00", end="10:00",
|
id="custom-id", date="2026-03-01", start="09:00", end="10:00",
|
||||||
|
|||||||
@@ -173,3 +173,60 @@ def test_generate_pdf_no_notes_single_page():
|
|||||||
pdf_bytes = generate_pdf(doc)
|
pdf_bytes = generate_pdf(doc)
|
||||||
pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes))
|
pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes))
|
||||||
assert pages == 1, f"Expected 1 page, got {pages}"
|
assert pages == 1, f"Expected 1 page, got {pages}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdf_block_with_url_creates_link():
|
||||||
|
"""Block with url should produce a clickable link annotation in PDF."""
|
||||||
|
import re
|
||||||
|
doc = make_doc(
|
||||||
|
blocks=[
|
||||||
|
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
|
||||||
|
title="Linked Block", type_id="ws",
|
||||||
|
url="https://example.com/test"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pdf_bytes = generate_pdf(doc)
|
||||||
|
assert pdf_bytes[:5] == b'%PDF-'
|
||||||
|
# Must contain a /URI annotation with the URL
|
||||||
|
uris = re.findall(rb'/URI\s*\(([^)]+)\)', pdf_bytes)
|
||||||
|
assert len(uris) == 1, f"Expected 1 URI annotation, got {len(uris)}"
|
||||||
|
assert uris[0] == b'https://example.com/test'
|
||||||
|
# Must have a Link subtype annotation
|
||||||
|
assert re.search(rb'/Subtype\s*/Link', pdf_bytes), "Missing /Subtype /Link annotation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdf_block_without_url_no_link():
|
||||||
|
"""Blocks without url should NOT produce link annotations."""
|
||||||
|
import re
|
||||||
|
doc = make_doc(
|
||||||
|
blocks=[
|
||||||
|
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
|
||||||
|
title="No Link", type_id="ws"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pdf_bytes = generate_pdf(doc)
|
||||||
|
assert pdf_bytes[:5] == b'%PDF-'
|
||||||
|
uris = re.findall(rb'/URI\s*\(([^)]+)\)', pdf_bytes)
|
||||||
|
assert len(uris) == 0, f"Expected 0 URI annotations, got {len(uris)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdf_mixed_url_blocks():
|
||||||
|
"""Only blocks with url should produce link annotations."""
|
||||||
|
import re
|
||||||
|
doc = make_doc(
|
||||||
|
blocks=[
|
||||||
|
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
|
||||||
|
title="Has Link", type_id="ws",
|
||||||
|
url="https://example.com/one"),
|
||||||
|
Block(id="b2", date="2026-03-01", start="10:00", end="11:00",
|
||||||
|
title="No Link", type_id="ws"),
|
||||||
|
Block(id="b3", date="2026-03-01", start="11:00", end="12:00",
|
||||||
|
title="Also Linked", type_id="ws",
|
||||||
|
url="https://example.com/two"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pdf_bytes = generate_pdf(doc)
|
||||||
|
uris = re.findall(rb'/URI\s*\(([^)]+)\)', pdf_bytes)
|
||||||
|
assert len(uris) == 2, f"Expected 2 URI annotations, got {len(uris)}"
|
||||||
|
urls = sorted(u.decode() for u in uris)
|
||||||
|
assert urls == ['https://example.com/one', 'https://example.com/two']
|
||||||
|
|||||||
Reference in New Issue
Block a user