Spaces:
Sleeping
Sleeping
Commit ·
74b1b27
1
Parent(s): 8cc6d9b
V0.1.5
Browse files- frontend/src/components/LoadingBar.tsx +50 -0
- frontend/src/components/SaveBeforeLeaveDialog.tsx +54 -0
- frontend/src/lib/version.ts +1 -1
- frontend/src/pages/EditLayoutsPage.tsx +40 -8
- frontend/src/pages/EditReportPage.tsx +43 -8
- frontend/src/pages/ExportPage.tsx +138 -12
- frontend/src/pages/InputDataPage.tsx +103 -6
- frontend/src/pages/ReviewSetupPage.tsx +6 -4
- frontend/src/pages/UploadPage.tsx +43 -1
- server/app/api/routes/sessions.py +26 -1
- server/app/services/data_import.py +204 -39
- server/app/services/pdf_reportlab.py +199 -48
- server/app/services/session_store.py +39 -8
frontend/src/components/LoadingBar.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type LoadingBarProps = {
|
| 2 |
+
active: boolean;
|
| 3 |
+
progress?: number | null;
|
| 4 |
+
label?: string;
|
| 5 |
+
className?: string;
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
function clampProgress(value: number): number {
|
| 9 |
+
if (Number.isNaN(value)) return 0;
|
| 10 |
+
if (value < 0) return 0;
|
| 11 |
+
if (value > 100) return 100;
|
| 12 |
+
return value;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function LoadingBar({
|
| 16 |
+
active,
|
| 17 |
+
progress = null,
|
| 18 |
+
label,
|
| 19 |
+
className = "",
|
| 20 |
+
}: LoadingBarProps) {
|
| 21 |
+
if (!active) return null;
|
| 22 |
+
|
| 23 |
+
const numericProgress = typeof progress === "number" ? clampProgress(progress) : null;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className={className}>
|
| 27 |
+
{label ? (
|
| 28 |
+
<div className="mb-1 text-xs font-medium text-gray-600">
|
| 29 |
+
{label}
|
| 30 |
+
{numericProgress !== null ? ` (${Math.round(numericProgress)}%)` : ""}
|
| 31 |
+
</div>
|
| 32 |
+
) : null}
|
| 33 |
+
<div
|
| 34 |
+
role="progressbar"
|
| 35 |
+
aria-valuemin={0}
|
| 36 |
+
aria-valuemax={100}
|
| 37 |
+
aria-valuenow={numericProgress ?? undefined}
|
| 38 |
+
className="h-2 w-full overflow-hidden rounded-full bg-gray-200"
|
| 39 |
+
>
|
| 40 |
+
<div
|
| 41 |
+
className={[
|
| 42 |
+
"h-full rounded-full bg-blue-600",
|
| 43 |
+
numericProgress === null ? "w-2/5 animate-pulse" : "transition-all duration-200",
|
| 44 |
+
].join(" ")}
|
| 45 |
+
style={numericProgress === null ? undefined : { width: `${numericProgress}%` }}
|
| 46 |
+
/>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
frontend/src/components/SaveBeforeLeaveDialog.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type SaveBeforeLeaveDialogProps = {
|
| 2 |
+
open: boolean;
|
| 3 |
+
onWait: () => void | Promise<void>;
|
| 4 |
+
onProceed: () => void;
|
| 5 |
+
onCancel: () => void;
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export function SaveBeforeLeaveDialog({
|
| 9 |
+
open,
|
| 10 |
+
onWait,
|
| 11 |
+
onProceed,
|
| 12 |
+
onCancel,
|
| 13 |
+
}: SaveBeforeLeaveDialogProps) {
|
| 14 |
+
if (!open) return null;
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/35 px-4">
|
| 18 |
+
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white p-5 shadow-lg">
|
| 19 |
+
<h3 className="text-base font-semibold text-gray-900">
|
| 20 |
+
Changes are still saving
|
| 21 |
+
</h3>
|
| 22 |
+
<p className="mt-2 text-sm text-gray-600">
|
| 23 |
+
Your latest edits may not be saved yet. You can wait for save to
|
| 24 |
+
finish and continue automatically, or leave this page now.
|
| 25 |
+
</p>
|
| 26 |
+
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
| 27 |
+
<button
|
| 28 |
+
type="button"
|
| 29 |
+
onClick={onCancel}
|
| 30 |
+
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 transition"
|
| 31 |
+
>
|
| 32 |
+
Cancel
|
| 33 |
+
</button>
|
| 34 |
+
<button
|
| 35 |
+
type="button"
|
| 36 |
+
onClick={onProceed}
|
| 37 |
+
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700 hover:bg-red-100 transition"
|
| 38 |
+
>
|
| 39 |
+
Leave without waiting
|
| 40 |
+
</button>
|
| 41 |
+
<button
|
| 42 |
+
type="button"
|
| 43 |
+
onClick={() => {
|
| 44 |
+
void onWait();
|
| 45 |
+
}}
|
| 46 |
+
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition"
|
| 47 |
+
>
|
| 48 |
+
Wait and continue
|
| 49 |
+
</button>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
frontend/src/lib/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
export const APP_VERSION = "V0.1.
|
|
|
|
| 1 |
+
export const APP_VERSION = "V0.1.5";
|
frontend/src/pages/EditLayoutsPage.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import { PageHeader } from "../components/PageHeader";
|
|
| 33 |
import { PageShell } from "../components/PageShell";
|
| 34 |
import { InfoMenu } from "../components/InfoMenu";
|
| 35 |
import { ReportPageCanvas } from "../components/ReportPageCanvas";
|
|
|
|
| 36 |
|
| 37 |
export default function EditLayoutsPage() {
|
| 38 |
const [searchParams] = useSearchParams();
|
|
@@ -56,6 +57,9 @@ export default function EditLayoutsPage() {
|
|
| 56 |
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
|
| 57 |
{},
|
| 58 |
);
|
|
|
|
|
|
|
|
|
|
| 59 |
const canModify = Boolean(sessionId) && !isSaving;
|
| 60 |
const saveTimerRef = useRef<number | null>(null);
|
| 61 |
const savePromiseRef = useRef<Promise<void> | null>(null);
|
|
@@ -387,6 +391,28 @@ export default function EditLayoutsPage() {
|
|
| 387 |
navigate(target);
|
| 388 |
}
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
const saveIndicator = useMemo(() => {
|
| 391 |
const base =
|
| 392 |
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
|
@@ -434,7 +460,7 @@ export default function EditLayoutsPage() {
|
|
| 434 |
to={`/report-viewer${sessionQuery}`}
|
| 435 |
onClick={(event) => {
|
| 436 |
event.preventDefault();
|
| 437 |
-
|
| 438 |
}}
|
| 439 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 440 |
>
|
|
@@ -452,7 +478,7 @@ export default function EditLayoutsPage() {
|
|
| 452 |
to={`/input-data${sessionQuery}`}
|
| 453 |
onClick={(event) => {
|
| 454 |
event.preventDefault();
|
| 455 |
-
|
| 456 |
}}
|
| 457 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 458 |
>
|
|
@@ -464,7 +490,7 @@ export default function EditLayoutsPage() {
|
|
| 464 |
to={`/report-viewer${sessionQuery}`}
|
| 465 |
onClick={(event) => {
|
| 466 |
event.preventDefault();
|
| 467 |
-
|
| 468 |
}}
|
| 469 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 470 |
>
|
|
@@ -476,7 +502,7 @@ export default function EditLayoutsPage() {
|
|
| 476 |
to={`/image-placement${sessionQuery}`}
|
| 477 |
onClick={(event) => {
|
| 478 |
event.preventDefault();
|
| 479 |
-
|
| 480 |
}}
|
| 481 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 482 |
>
|
|
@@ -488,7 +514,7 @@ export default function EditLayoutsPage() {
|
|
| 488 |
to={`/edit-report${sessionQuery}`}
|
| 489 |
onClick={(event) => {
|
| 490 |
event.preventDefault();
|
| 491 |
-
|
| 492 |
}}
|
| 493 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 494 |
>
|
|
@@ -505,7 +531,7 @@ export default function EditLayoutsPage() {
|
|
| 505 |
to={`/edit-layouts/templates${sessionQuery}`}
|
| 506 |
onClick={(event) => {
|
| 507 |
event.preventDefault();
|
| 508 |
-
|
| 509 |
}}
|
| 510 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 511 |
>
|
|
@@ -516,7 +542,7 @@ export default function EditLayoutsPage() {
|
|
| 516 |
to={`/export${sessionQuery}`}
|
| 517 |
onClick={(event) => {
|
| 518 |
event.preventDefault();
|
| 519 |
-
|
| 520 |
}}
|
| 521 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 522 |
>
|
|
@@ -653,7 +679,7 @@ export default function EditLayoutsPage() {
|
|
| 653 |
to={`/edit-layouts/templates${sessionQuery}`}
|
| 654 |
onClick={(event) => {
|
| 655 |
event.preventDefault();
|
| 656 |
-
|
| 657 |
}}
|
| 658 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 659 |
>
|
|
@@ -775,6 +801,12 @@ export default function EditLayoutsPage() {
|
|
| 775 |
</section>
|
| 776 |
|
| 777 |
<PageFooter note="Tip: reorder pages here and remove empty pages before export." />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
</PageShell>
|
| 779 |
);
|
| 780 |
}
|
|
|
|
| 33 |
import { PageShell } from "../components/PageShell";
|
| 34 |
import { InfoMenu } from "../components/InfoMenu";
|
| 35 |
import { ReportPageCanvas } from "../components/ReportPageCanvas";
|
| 36 |
+
import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
|
| 37 |
|
| 38 |
export default function EditLayoutsPage() {
|
| 39 |
const [searchParams] = useSearchParams();
|
|
|
|
| 57 |
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
|
| 58 |
{},
|
| 59 |
);
|
| 60 |
+
const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
|
| 61 |
+
string | null
|
| 62 |
+
>(null);
|
| 63 |
const canModify = Boolean(sessionId) && !isSaving;
|
| 64 |
const saveTimerRef = useRef<number | null>(null);
|
| 65 |
const savePromiseRef = useRef<Promise<void> | null>(null);
|
|
|
|
| 391 |
navigate(target);
|
| 392 |
}
|
| 393 |
|
| 394 |
+
function requestNavigation(target: string) {
|
| 395 |
+
if (saveState === "saving" || saveState === "pending" || isSaving) {
|
| 396 |
+
setPendingNavigationTarget(target);
|
| 397 |
+
return;
|
| 398 |
+
}
|
| 399 |
+
void saveAndNavigate(target);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
async function waitForSaveAndContinue() {
|
| 403 |
+
if (!pendingNavigationTarget) return;
|
| 404 |
+
const target = pendingNavigationTarget;
|
| 405 |
+
setPendingNavigationTarget(null);
|
| 406 |
+
await saveAndNavigate(target);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function leaveWithoutWaiting() {
|
| 410 |
+
if (!pendingNavigationTarget) return;
|
| 411 |
+
const target = pendingNavigationTarget;
|
| 412 |
+
setPendingNavigationTarget(null);
|
| 413 |
+
navigate(target);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
const saveIndicator = useMemo(() => {
|
| 417 |
const base =
|
| 418 |
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
|
|
|
| 460 |
to={`/report-viewer${sessionQuery}`}
|
| 461 |
onClick={(event) => {
|
| 462 |
event.preventDefault();
|
| 463 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 464 |
}}
|
| 465 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 466 |
>
|
|
|
|
| 478 |
to={`/input-data${sessionQuery}`}
|
| 479 |
onClick={(event) => {
|
| 480 |
event.preventDefault();
|
| 481 |
+
requestNavigation(`/input-data${sessionQuery}`);
|
| 482 |
}}
|
| 483 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 484 |
>
|
|
|
|
| 490 |
to={`/report-viewer${sessionQuery}`}
|
| 491 |
onClick={(event) => {
|
| 492 |
event.preventDefault();
|
| 493 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 494 |
}}
|
| 495 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 496 |
>
|
|
|
|
| 502 |
to={`/image-placement${sessionQuery}`}
|
| 503 |
onClick={(event) => {
|
| 504 |
event.preventDefault();
|
| 505 |
+
requestNavigation(`/image-placement${sessionQuery}`);
|
| 506 |
}}
|
| 507 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 508 |
>
|
|
|
|
| 514 |
to={`/edit-report${sessionQuery}`}
|
| 515 |
onClick={(event) => {
|
| 516 |
event.preventDefault();
|
| 517 |
+
requestNavigation(`/edit-report${sessionQuery}`);
|
| 518 |
}}
|
| 519 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 520 |
>
|
|
|
|
| 531 |
to={`/edit-layouts/templates${sessionQuery}`}
|
| 532 |
onClick={(event) => {
|
| 533 |
event.preventDefault();
|
| 534 |
+
requestNavigation(`/edit-layouts/templates${sessionQuery}`);
|
| 535 |
}}
|
| 536 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 537 |
>
|
|
|
|
| 542 |
to={`/export${sessionQuery}`}
|
| 543 |
onClick={(event) => {
|
| 544 |
event.preventDefault();
|
| 545 |
+
requestNavigation(`/export${sessionQuery}`);
|
| 546 |
}}
|
| 547 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 548 |
>
|
|
|
|
| 679 |
to={`/edit-layouts/templates${sessionQuery}`}
|
| 680 |
onClick={(event) => {
|
| 681 |
event.preventDefault();
|
| 682 |
+
requestNavigation(`/edit-layouts/templates${sessionQuery}`);
|
| 683 |
}}
|
| 684 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 685 |
>
|
|
|
|
| 801 |
</section>
|
| 802 |
|
| 803 |
<PageFooter note="Tip: reorder pages here and remove empty pages before export." />
|
| 804 |
+
<SaveBeforeLeaveDialog
|
| 805 |
+
open={Boolean(pendingNavigationTarget)}
|
| 806 |
+
onCancel={() => setPendingNavigationTarget(null)}
|
| 807 |
+
onProceed={leaveWithoutWaiting}
|
| 808 |
+
onWait={waitForSaveAndContinue}
|
| 809 |
+
/>
|
| 810 |
</PageShell>
|
| 811 |
);
|
| 812 |
}
|
frontend/src/pages/EditReportPage.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { PageFooter } from "../components/PageFooter";
|
|
| 10 |
import { PageHeader } from "../components/PageHeader";
|
| 11 |
import { PageShell } from "../components/PageShell";
|
| 12 |
import { InfoMenu } from "../components/InfoMenu";
|
|
|
|
| 13 |
|
| 14 |
export default function EditReportPage() {
|
| 15 |
const [searchParams] = useSearchParams();
|
|
@@ -23,6 +24,9 @@ export default function EditReportPage() {
|
|
| 23 |
const [saveState, setSaveState] = useState<
|
| 24 |
"saved" | "saving" | "pending" | "error"
|
| 25 |
>("saved");
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
|
| 28 |
const editorRef = useCallback((node: ReportEditorElement | null) => {
|
|
@@ -45,6 +49,31 @@ export default function EditReportPage() {
|
|
| 45 |
[editorEl, navigate],
|
| 46 |
);
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
useEffect(() => {
|
| 49 |
if (!sessionId) {
|
| 50 |
setError("No active session found. Return to upload to continue.");
|
|
@@ -93,13 +122,13 @@ export default function EditReportPage() {
|
|
| 93 |
useEffect(() => {
|
| 94 |
if (!editorEl) return;
|
| 95 |
const handleClose = () => {
|
| 96 |
-
|
| 97 |
};
|
| 98 |
editorEl.addEventListener("editor-closed", handleClose);
|
| 99 |
return () => {
|
| 100 |
editorEl.removeEventListener("editor-closed", handleClose);
|
| 101 |
};
|
| 102 |
-
}, [editorEl,
|
| 103 |
|
| 104 |
useEffect(() => {
|
| 105 |
if (!editorEl) return;
|
|
@@ -172,7 +201,7 @@ export default function EditReportPage() {
|
|
| 172 |
to={`/report-viewer${sessionQuery}`}
|
| 173 |
onClick={(event) => {
|
| 174 |
event.preventDefault();
|
| 175 |
-
|
| 176 |
}}
|
| 177 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 178 |
>
|
|
@@ -190,7 +219,7 @@ export default function EditReportPage() {
|
|
| 190 |
to={`/input-data${sessionQuery}`}
|
| 191 |
onClick={(event) => {
|
| 192 |
event.preventDefault();
|
| 193 |
-
|
| 194 |
}}
|
| 195 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 196 |
>
|
|
@@ -202,7 +231,7 @@ export default function EditReportPage() {
|
|
| 202 |
to={`/report-viewer${sessionQuery}`}
|
| 203 |
onClick={(event) => {
|
| 204 |
event.preventDefault();
|
| 205 |
-
|
| 206 |
}}
|
| 207 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 208 |
>
|
|
@@ -214,7 +243,7 @@ export default function EditReportPage() {
|
|
| 214 |
to={`/image-placement${sessionQuery}`}
|
| 215 |
onClick={(event) => {
|
| 216 |
event.preventDefault();
|
| 217 |
-
|
| 218 |
}}
|
| 219 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 220 |
>
|
|
@@ -231,7 +260,7 @@ export default function EditReportPage() {
|
|
| 231 |
to={`/edit-layouts${sessionQuery}`}
|
| 232 |
onClick={(event) => {
|
| 233 |
event.preventDefault();
|
| 234 |
-
|
| 235 |
}}
|
| 236 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 237 |
>
|
|
@@ -243,7 +272,7 @@ export default function EditReportPage() {
|
|
| 243 |
to={`/export${sessionQuery}`}
|
| 244 |
onClick={(event) => {
|
| 245 |
event.preventDefault();
|
| 246 |
-
|
| 247 |
}}
|
| 248 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 249 |
>
|
|
@@ -268,6 +297,12 @@ export default function EditReportPage() {
|
|
| 268 |
<report-editor ref={editorRef} data-mode="page" />
|
| 269 |
|
| 270 |
<PageFooter note={`Editing page ${pageIndex + 1}. Changes save automatically.`} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
</PageShell>
|
| 272 |
);
|
| 273 |
}
|
|
|
|
| 10 |
import { PageHeader } from "../components/PageHeader";
|
| 11 |
import { PageShell } from "../components/PageShell";
|
| 12 |
import { InfoMenu } from "../components/InfoMenu";
|
| 13 |
+
import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
|
| 14 |
|
| 15 |
export default function EditReportPage() {
|
| 16 |
const [searchParams] = useSearchParams();
|
|
|
|
| 24 |
const [saveState, setSaveState] = useState<
|
| 25 |
"saved" | "saving" | "pending" | "error"
|
| 26 |
>("saved");
|
| 27 |
+
const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
|
| 28 |
+
string | null
|
| 29 |
+
>(null);
|
| 30 |
|
| 31 |
const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
|
| 32 |
const editorRef = useCallback((node: ReportEditorElement | null) => {
|
|
|
|
| 49 |
[editorEl, navigate],
|
| 50 |
);
|
| 51 |
|
| 52 |
+
const requestNavigation = useCallback(
|
| 53 |
+
(target: string) => {
|
| 54 |
+
if (saveState === "saving" || saveState === "pending") {
|
| 55 |
+
setPendingNavigationTarget(target);
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
void saveAndNavigate(target);
|
| 59 |
+
},
|
| 60 |
+
[saveAndNavigate, saveState],
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
const waitForSaveAndContinue = useCallback(async () => {
|
| 64 |
+
if (!pendingNavigationTarget) return;
|
| 65 |
+
const target = pendingNavigationTarget;
|
| 66 |
+
setPendingNavigationTarget(null);
|
| 67 |
+
await saveAndNavigate(target);
|
| 68 |
+
}, [pendingNavigationTarget, saveAndNavigate]);
|
| 69 |
+
|
| 70 |
+
const leaveWithoutWaiting = useCallback(() => {
|
| 71 |
+
if (!pendingNavigationTarget) return;
|
| 72 |
+
const target = pendingNavigationTarget;
|
| 73 |
+
setPendingNavigationTarget(null);
|
| 74 |
+
navigate(target);
|
| 75 |
+
}, [navigate, pendingNavigationTarget]);
|
| 76 |
+
|
| 77 |
useEffect(() => {
|
| 78 |
if (!sessionId) {
|
| 79 |
setError("No active session found. Return to upload to continue.");
|
|
|
|
| 122 |
useEffect(() => {
|
| 123 |
if (!editorEl) return;
|
| 124 |
const handleClose = () => {
|
| 125 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 126 |
};
|
| 127 |
editorEl.addEventListener("editor-closed", handleClose);
|
| 128 |
return () => {
|
| 129 |
editorEl.removeEventListener("editor-closed", handleClose);
|
| 130 |
};
|
| 131 |
+
}, [editorEl, requestNavigation, sessionQuery]);
|
| 132 |
|
| 133 |
useEffect(() => {
|
| 134 |
if (!editorEl) return;
|
|
|
|
| 201 |
to={`/report-viewer${sessionQuery}`}
|
| 202 |
onClick={(event) => {
|
| 203 |
event.preventDefault();
|
| 204 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 205 |
}}
|
| 206 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 207 |
>
|
|
|
|
| 219 |
to={`/input-data${sessionQuery}`}
|
| 220 |
onClick={(event) => {
|
| 221 |
event.preventDefault();
|
| 222 |
+
requestNavigation(`/input-data${sessionQuery}`);
|
| 223 |
}}
|
| 224 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 225 |
>
|
|
|
|
| 231 |
to={`/report-viewer${sessionQuery}`}
|
| 232 |
onClick={(event) => {
|
| 233 |
event.preventDefault();
|
| 234 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 235 |
}}
|
| 236 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 237 |
>
|
|
|
|
| 243 |
to={`/image-placement${sessionQuery}`}
|
| 244 |
onClick={(event) => {
|
| 245 |
event.preventDefault();
|
| 246 |
+
requestNavigation(`/image-placement${sessionQuery}`);
|
| 247 |
}}
|
| 248 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 249 |
>
|
|
|
|
| 260 |
to={`/edit-layouts${sessionQuery}`}
|
| 261 |
onClick={(event) => {
|
| 262 |
event.preventDefault();
|
| 263 |
+
requestNavigation(`/edit-layouts${sessionQuery}`);
|
| 264 |
}}
|
| 265 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 266 |
>
|
|
|
|
| 272 |
to={`/export${sessionQuery}`}
|
| 273 |
onClick={(event) => {
|
| 274 |
event.preventDefault();
|
| 275 |
+
requestNavigation(`/export${sessionQuery}`);
|
| 276 |
}}
|
| 277 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 278 |
>
|
|
|
|
| 297 |
<report-editor ref={editorRef} data-mode="page" />
|
| 298 |
|
| 299 |
<PageFooter note={`Editing page ${pageIndex + 1}. Changes save automatically.`} />
|
| 300 |
+
<SaveBeforeLeaveDialog
|
| 301 |
+
open={Boolean(pendingNavigationTarget)}
|
| 302 |
+
onCancel={() => setPendingNavigationTarget(null)}
|
| 303 |
+
onProceed={leaveWithoutWaiting}
|
| 304 |
+
onWait={waitForSaveAndContinue}
|
| 305 |
+
/>
|
| 306 |
</PageShell>
|
| 307 |
);
|
| 308 |
}
|
frontend/src/pages/ExportPage.tsx
CHANGED
|
@@ -11,8 +11,22 @@ import { PageFooter } from "../components/PageFooter";
|
|
| 11 |
import { PageHeader } from "../components/PageHeader";
|
| 12 |
import { PageShell } from "../components/PageShell";
|
| 13 |
import { InfoMenu } from "../components/InfoMenu";
|
|
|
|
| 14 |
import { ReportPageCanvas } from "../components/ReportPageCanvas";
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export default function ExportPage() {
|
| 17 |
const [searchParams] = useSearchParams();
|
| 18 |
const sessionId = getSessionId(searchParams.toString());
|
|
@@ -28,6 +42,88 @@ export default function ExportPage() {
|
|
| 28 |
const [incTimestamp, setIncTimestamp] = useState(true);
|
| 29 |
const [previewScale, setPreviewScale] = useState(1);
|
| 30 |
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
useEffect(() => {
|
| 33 |
if (!sessionId) {
|
|
@@ -262,18 +358,33 @@ export default function ExportPage() {
|
|
| 262 |
Download JSON package
|
| 263 |
</button>
|
| 264 |
|
| 265 |
-
<
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
className={[
|
| 270 |
"mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition",
|
| 271 |
-
!sessionId ? "
|
| 272 |
].join(" ")}
|
|
|
|
| 273 |
>
|
| 274 |
<Download className="h-4 w-4" />
|
| 275 |
Download from server
|
| 276 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
</div>
|
| 278 |
|
| 279 |
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
@@ -287,17 +398,23 @@ export default function ExportPage() {
|
|
| 287 |
</div>
|
| 288 |
|
| 289 |
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 290 |
-
<
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
className={[
|
| 295 |
"inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-white font-semibold hover:bg-blue-700 transition",
|
| 296 |
-
!sessionId ? "
|
| 297 |
].join(" ")}
|
|
|
|
| 298 |
>
|
| 299 |
Download PDF (server)
|
| 300 |
-
</
|
| 301 |
<button
|
| 302 |
type="button"
|
| 303 |
onClick={handlePrint}
|
|
@@ -306,6 +423,15 @@ export default function ExportPage() {
|
|
| 306 |
Print / Save PDF (browser)
|
| 307 |
</button>
|
| 308 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
</div>
|
| 310 |
|
| 311 |
</div>
|
|
|
|
| 11 |
import { PageHeader } from "../components/PageHeader";
|
| 12 |
import { PageShell } from "../components/PageShell";
|
| 13 |
import { InfoMenu } from "../components/InfoMenu";
|
| 14 |
+
import { LoadingBar } from "../components/LoadingBar";
|
| 15 |
import { ReportPageCanvas } from "../components/ReportPageCanvas";
|
| 16 |
|
| 17 |
+
type DownloadKind = "serverJson" | "serverPdf";
|
| 18 |
+
|
| 19 |
+
type DownloadState = {
|
| 20 |
+
active: boolean;
|
| 21 |
+
progress: number | null;
|
| 22 |
+
error: string;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const INITIAL_DOWNLOAD_STATE: Record<DownloadKind, DownloadState> = {
|
| 26 |
+
serverJson: { active: false, progress: null, error: "" },
|
| 27 |
+
serverPdf: { active: false, progress: null, error: "" },
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
export default function ExportPage() {
|
| 31 |
const [searchParams] = useSearchParams();
|
| 32 |
const sessionId = getSessionId(searchParams.toString());
|
|
|
|
| 42 |
const [incTimestamp, setIncTimestamp] = useState(true);
|
| 43 |
const [previewScale, setPreviewScale] = useState(1);
|
| 44 |
const previewRef = useRef<HTMLDivElement | null>(null);
|
| 45 |
+
const [downloadState, setDownloadState] = useState(INITIAL_DOWNLOAD_STATE);
|
| 46 |
+
|
| 47 |
+
function setDownloadPartial(kind: DownloadKind, patch: Partial<DownloadState>) {
|
| 48 |
+
setDownloadState((prev) => ({
|
| 49 |
+
...prev,
|
| 50 |
+
[kind]: { ...prev[kind], ...patch },
|
| 51 |
+
}));
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function fetchAndDownload(
|
| 55 |
+
url: string,
|
| 56 |
+
fallbackFilename: string,
|
| 57 |
+
kind: DownloadKind,
|
| 58 |
+
) {
|
| 59 |
+
if (!sessionId) return;
|
| 60 |
+
setDownloadPartial(kind, { active: true, progress: null, error: "" });
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
const response = await fetch(url, { credentials: "same-origin" });
|
| 64 |
+
if (!response.ok) {
|
| 65 |
+
let detail = response.statusText || `Request failed (${response.status})`;
|
| 66 |
+
try {
|
| 67 |
+
const data = (await response.json()) as { detail?: string };
|
| 68 |
+
if (data?.detail) detail = data.detail;
|
| 69 |
+
} catch {
|
| 70 |
+
// keep default
|
| 71 |
+
}
|
| 72 |
+
throw new Error(detail);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const contentLengthRaw = response.headers.get("content-length");
|
| 76 |
+
const totalBytes = contentLengthRaw ? Number(contentLengthRaw) : 0;
|
| 77 |
+
let receivedBytes = 0;
|
| 78 |
+
|
| 79 |
+
let blob: Blob;
|
| 80 |
+
if (response.body) {
|
| 81 |
+
const reader = response.body.getReader();
|
| 82 |
+
const chunks: Uint8Array[] = [];
|
| 83 |
+
|
| 84 |
+
while (true) {
|
| 85 |
+
const { done, value } = await reader.read();
|
| 86 |
+
if (done) break;
|
| 87 |
+
if (!value) continue;
|
| 88 |
+
chunks.push(value);
|
| 89 |
+
receivedBytes += value.length;
|
| 90 |
+
if (totalBytes > 0) {
|
| 91 |
+
setDownloadPartial(kind, {
|
| 92 |
+
progress: Math.min(100, (receivedBytes / totalBytes) * 100),
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
blob = new Blob(chunks, {
|
| 98 |
+
type: response.headers.get("content-type") || "application/octet-stream",
|
| 99 |
+
});
|
| 100 |
+
} else {
|
| 101 |
+
blob = await response.blob();
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const disposition = response.headers.get("content-disposition") || "";
|
| 105 |
+
const match = disposition.match(/filename="?([^"]+)"?/i);
|
| 106 |
+
const filename = match?.[1]?.trim() || fallbackFilename;
|
| 107 |
+
|
| 108 |
+
const objectUrl = URL.createObjectURL(blob);
|
| 109 |
+
const link = document.createElement("a");
|
| 110 |
+
link.href = objectUrl;
|
| 111 |
+
link.download = filename;
|
| 112 |
+
document.body.appendChild(link);
|
| 113 |
+
link.click();
|
| 114 |
+
link.remove();
|
| 115 |
+
URL.revokeObjectURL(objectUrl);
|
| 116 |
+
|
| 117 |
+
setDownloadPartial(kind, { active: false, progress: 100 });
|
| 118 |
+
window.setTimeout(() => {
|
| 119 |
+
setDownloadPartial(kind, { progress: null });
|
| 120 |
+
}, 500);
|
| 121 |
+
} catch (err) {
|
| 122 |
+
const message =
|
| 123 |
+
err instanceof Error ? err.message : "Download failed. Please try again.";
|
| 124 |
+
setDownloadPartial(kind, { active: false, progress: null, error: message });
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
|
| 128 |
useEffect(() => {
|
| 129 |
if (!sessionId) {
|
|
|
|
| 358 |
Download JSON package
|
| 359 |
</button>
|
| 360 |
|
| 361 |
+
<button
|
| 362 |
+
type="button"
|
| 363 |
+
onClick={() => {
|
| 364 |
+
void fetchAndDownload(
|
| 365 |
+
serverExportUrl,
|
| 366 |
+
`repex_report_package_${sessionId}.json`,
|
| 367 |
+
"serverJson",
|
| 368 |
+
);
|
| 369 |
+
}}
|
| 370 |
className={[
|
| 371 |
"mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition",
|
| 372 |
+
!sessionId || downloadState.serverJson.active ? "opacity-50" : "",
|
| 373 |
].join(" ")}
|
| 374 |
+
disabled={!sessionId || downloadState.serverJson.active}
|
| 375 |
>
|
| 376 |
<Download className="h-4 w-4" />
|
| 377 |
Download from server
|
| 378 |
+
</button>
|
| 379 |
+
<LoadingBar
|
| 380 |
+
active={downloadState.serverJson.active}
|
| 381 |
+
progress={downloadState.serverJson.progress}
|
| 382 |
+
label="Downloading JSON package"
|
| 383 |
+
className="mt-3"
|
| 384 |
+
/>
|
| 385 |
+
{downloadState.serverJson.error ? (
|
| 386 |
+
<p className="mt-2 text-xs text-red-600">{downloadState.serverJson.error}</p>
|
| 387 |
+
) : null}
|
| 388 |
</div>
|
| 389 |
|
| 390 |
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
|
|
| 398 |
</div>
|
| 399 |
|
| 400 |
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 401 |
+
<button
|
| 402 |
+
type="button"
|
| 403 |
+
onClick={() => {
|
| 404 |
+
void fetchAndDownload(
|
| 405 |
+
serverPdfUrl,
|
| 406 |
+
`repex_report_${sessionId}.pdf`,
|
| 407 |
+
"serverPdf",
|
| 408 |
+
);
|
| 409 |
+
}}
|
| 410 |
className={[
|
| 411 |
"inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-white font-semibold hover:bg-blue-700 transition",
|
| 412 |
+
!sessionId || downloadState.serverPdf.active ? "opacity-50" : "",
|
| 413 |
].join(" ")}
|
| 414 |
+
disabled={!sessionId || downloadState.serverPdf.active}
|
| 415 |
>
|
| 416 |
Download PDF (server)
|
| 417 |
+
</button>
|
| 418 |
<button
|
| 419 |
type="button"
|
| 420 |
onClick={handlePrint}
|
|
|
|
| 423 |
Print / Save PDF (browser)
|
| 424 |
</button>
|
| 425 |
</div>
|
| 426 |
+
<LoadingBar
|
| 427 |
+
active={downloadState.serverPdf.active}
|
| 428 |
+
progress={downloadState.serverPdf.progress}
|
| 429 |
+
label="Generating and downloading PDF"
|
| 430 |
+
className="mt-3"
|
| 431 |
+
/>
|
| 432 |
+
{downloadState.serverPdf.error ? (
|
| 433 |
+
<p className="mt-2 text-xs text-red-600">{downloadState.serverPdf.error}</p>
|
| 434 |
+
) : null}
|
| 435 |
</div>
|
| 436 |
|
| 437 |
</div>
|
frontend/src/pages/InputDataPage.tsx
CHANGED
|
@@ -17,6 +17,8 @@ import { PageFooter } from "../components/PageFooter";
|
|
| 17 |
import { PageHeader } from "../components/PageHeader";
|
| 18 |
import { PageShell } from "../components/PageShell";
|
| 19 |
import { InfoMenu } from "../components/InfoMenu";
|
|
|
|
|
|
|
| 20 |
|
| 21 |
type FieldDef = {
|
| 22 |
key: keyof TemplateFields;
|
|
@@ -82,6 +84,10 @@ export default function InputDataPage() {
|
|
| 82 |
const [showGeneralColumns, setShowGeneralColumns] = useState(false);
|
| 83 |
const [generalDirty, setGeneralDirty] = useState(false);
|
| 84 |
const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
|
| 86 |
{},
|
| 87 |
);
|
|
@@ -96,6 +102,26 @@ export default function InputDataPage() {
|
|
| 96 |
const excelInputRef = useRef<HTMLInputElement | null>(null);
|
| 97 |
const jsonInputRef = useRef<HTMLInputElement | null>(null);
|
| 98 |
const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
useEffect(() => {
|
| 101 |
if (!sessionId) {
|
|
@@ -128,6 +154,13 @@ export default function InputDataPage() {
|
|
| 128 |
load();
|
| 129 |
}, [sessionId]);
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
async function refreshSession() {
|
| 132 |
if (!sessionId) return;
|
| 133 |
const data = await request<Session>(`/sessions/${sessionId}`);
|
|
@@ -593,6 +626,7 @@ export default function InputDataPage() {
|
|
| 593 |
if (!sessionId) return;
|
| 594 |
setIsUploading(true);
|
| 595 |
setStatus("Uploading data file...");
|
|
|
|
| 596 |
try {
|
| 597 |
const form = new FormData();
|
| 598 |
form.append("file", file);
|
|
@@ -600,12 +634,19 @@ export default function InputDataPage() {
|
|
| 600 |
await refreshSession();
|
| 601 |
setStatus("Data file imported.");
|
| 602 |
setGeneralDirty(false);
|
|
|
|
|
|
|
| 603 |
} catch (err) {
|
| 604 |
const message =
|
| 605 |
err instanceof Error ? err.message : "Failed to import data file.";
|
| 606 |
setStatus(message);
|
|
|
|
|
|
|
| 607 |
} finally {
|
| 608 |
setIsUploading(false);
|
|
|
|
|
|
|
|
|
|
| 609 |
}
|
| 610 |
}
|
| 611 |
|
|
@@ -613,6 +654,7 @@ export default function InputDataPage() {
|
|
| 613 |
if (!sessionId) return;
|
| 614 |
setIsUploading(true);
|
| 615 |
setStatus("Uploading JSON package...");
|
|
|
|
| 616 |
try {
|
| 617 |
const form = new FormData();
|
| 618 |
form.append("file", file);
|
|
@@ -620,12 +662,19 @@ export default function InputDataPage() {
|
|
| 620 |
await refreshSession();
|
| 621 |
setStatus("JSON package imported.");
|
| 622 |
setGeneralDirty(false);
|
|
|
|
|
|
|
| 623 |
} catch (err) {
|
| 624 |
const message =
|
| 625 |
err instanceof Error ? err.message : "Failed to import JSON package.";
|
| 626 |
setStatus(message);
|
|
|
|
|
|
|
| 627 |
} finally {
|
| 628 |
setIsUploading(false);
|
|
|
|
|
|
|
|
|
|
| 629 |
}
|
| 630 |
}
|
| 631 |
|
|
@@ -633,6 +682,7 @@ export default function InputDataPage() {
|
|
| 633 |
if (!sessionId) return;
|
| 634 |
setIsUploading(true);
|
| 635 |
setStatus(`Uploading image ${file.name}...`);
|
|
|
|
| 636 |
const existing = new Set(
|
| 637 |
(session?.uploads?.photos ?? []).map((photo) => photo.id),
|
| 638 |
);
|
|
@@ -653,12 +703,19 @@ export default function InputDataPage() {
|
|
| 653 |
} else {
|
| 654 |
setStatus("Uploaded image. Refresh to see new file.");
|
| 655 |
}
|
|
|
|
|
|
|
| 656 |
} catch (err) {
|
| 657 |
const message =
|
| 658 |
err instanceof Error ? err.message : "Failed to upload image.";
|
| 659 |
setStatus(message);
|
|
|
|
|
|
|
| 660 |
} finally {
|
| 661 |
setIsUploading(false);
|
|
|
|
|
|
|
|
|
|
| 662 |
}
|
| 663 |
}
|
| 664 |
|
|
@@ -689,6 +746,28 @@ export default function InputDataPage() {
|
|
| 689 |
navigate(target);
|
| 690 |
}
|
| 691 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
const saveIndicator = useMemo(() => {
|
| 693 |
const base =
|
| 694 |
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
|
@@ -736,7 +815,7 @@ export default function InputDataPage() {
|
|
| 736 |
to={`/report-viewer${sessionQuery}`}
|
| 737 |
onClick={(event) => {
|
| 738 |
event.preventDefault();
|
| 739 |
-
|
| 740 |
}}
|
| 741 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 742 |
>
|
|
@@ -759,7 +838,7 @@ export default function InputDataPage() {
|
|
| 759 |
to={`/report-viewer${sessionQuery}`}
|
| 760 |
onClick={(event) => {
|
| 761 |
event.preventDefault();
|
| 762 |
-
|
| 763 |
}}
|
| 764 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 765 |
>
|
|
@@ -771,7 +850,7 @@ export default function InputDataPage() {
|
|
| 771 |
to={`/image-placement${sessionQuery}`}
|
| 772 |
onClick={(event) => {
|
| 773 |
event.preventDefault();
|
| 774 |
-
|
| 775 |
}}
|
| 776 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 777 |
>
|
|
@@ -783,7 +862,7 @@ export default function InputDataPage() {
|
|
| 783 |
to={`/edit-report${sessionQuery}`}
|
| 784 |
onClick={(event) => {
|
| 785 |
event.preventDefault();
|
| 786 |
-
|
| 787 |
}}
|
| 788 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 789 |
>
|
|
@@ -795,7 +874,7 @@ export default function InputDataPage() {
|
|
| 795 |
to={`/edit-layouts${sessionQuery}`}
|
| 796 |
onClick={(event) => {
|
| 797 |
event.preventDefault();
|
| 798 |
-
|
| 799 |
}}
|
| 800 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 801 |
>
|
|
@@ -807,7 +886,7 @@ export default function InputDataPage() {
|
|
| 807 |
to={`/export${sessionQuery}`}
|
| 808 |
onClick={(event) => {
|
| 809 |
event.preventDefault();
|
| 810 |
-
|
| 811 |
}}
|
| 812 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 813 |
>
|
|
@@ -860,6 +939,18 @@ export default function InputDataPage() {
|
|
| 860 |
</div>
|
| 861 |
</div>
|
| 862 |
{status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
</section>
|
| 864 |
|
| 865 |
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
|
|
@@ -1482,6 +1573,12 @@ export default function InputDataPage() {
|
|
| 1482 |
</div>
|
| 1483 |
|
| 1484 |
<PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1485 |
</PageShell>
|
| 1486 |
);
|
| 1487 |
}
|
|
|
|
| 17 |
import { PageHeader } from "../components/PageHeader";
|
| 18 |
import { PageShell } from "../components/PageShell";
|
| 19 |
import { InfoMenu } from "../components/InfoMenu";
|
| 20 |
+
import { LoadingBar } from "../components/LoadingBar";
|
| 21 |
+
import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
|
| 22 |
|
| 23 |
type FieldDef = {
|
| 24 |
key: keyof TemplateFields;
|
|
|
|
| 84 |
const [showGeneralColumns, setShowGeneralColumns] = useState(false);
|
| 85 |
const [generalDirty, setGeneralDirty] = useState(false);
|
| 86 |
const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
|
| 87 |
+
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
| 88 |
+
const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
|
| 89 |
+
string | null
|
| 90 |
+
>(null);
|
| 91 |
const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
|
| 92 |
{},
|
| 93 |
);
|
|
|
|
| 102 |
const excelInputRef = useRef<HTMLInputElement | null>(null);
|
| 103 |
const jsonInputRef = useRef<HTMLInputElement | null>(null);
|
| 104 |
const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
| 105 |
+
const uploadTimerRef = useRef<number | null>(null);
|
| 106 |
+
|
| 107 |
+
function clearUploadTimer() {
|
| 108 |
+
if (uploadTimerRef.current !== null) {
|
| 109 |
+
window.clearInterval(uploadTimerRef.current);
|
| 110 |
+
uploadTimerRef.current = null;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function startUploadProgress() {
|
| 115 |
+
clearUploadTimer();
|
| 116 |
+
setUploadProgress(6);
|
| 117 |
+
uploadTimerRef.current = window.setInterval(() => {
|
| 118 |
+
setUploadProgress((prev) => {
|
| 119 |
+
const value = typeof prev === "number" ? prev : 0;
|
| 120 |
+
if (value >= 92) return value;
|
| 121 |
+
return Math.min(92, value + Math.max(1, (92 - value) * 0.08));
|
| 122 |
+
});
|
| 123 |
+
}, 350);
|
| 124 |
+
}
|
| 125 |
|
| 126 |
useEffect(() => {
|
| 127 |
if (!sessionId) {
|
|
|
|
| 154 |
load();
|
| 155 |
}, [sessionId]);
|
| 156 |
|
| 157 |
+
useEffect(
|
| 158 |
+
() => () => {
|
| 159 |
+
clearUploadTimer();
|
| 160 |
+
},
|
| 161 |
+
[],
|
| 162 |
+
);
|
| 163 |
+
|
| 164 |
async function refreshSession() {
|
| 165 |
if (!sessionId) return;
|
| 166 |
const data = await request<Session>(`/sessions/${sessionId}`);
|
|
|
|
| 626 |
if (!sessionId) return;
|
| 627 |
setIsUploading(true);
|
| 628 |
setStatus("Uploading data file...");
|
| 629 |
+
startUploadProgress();
|
| 630 |
try {
|
| 631 |
const form = new FormData();
|
| 632 |
form.append("file", file);
|
|
|
|
| 634 |
await refreshSession();
|
| 635 |
setStatus("Data file imported.");
|
| 636 |
setGeneralDirty(false);
|
| 637 |
+
clearUploadTimer();
|
| 638 |
+
setUploadProgress(100);
|
| 639 |
} catch (err) {
|
| 640 |
const message =
|
| 641 |
err instanceof Error ? err.message : "Failed to import data file.";
|
| 642 |
setStatus(message);
|
| 643 |
+
clearUploadTimer();
|
| 644 |
+
setUploadProgress(null);
|
| 645 |
} finally {
|
| 646 |
setIsUploading(false);
|
| 647 |
+
window.setTimeout(() => {
|
| 648 |
+
setUploadProgress(null);
|
| 649 |
+
}, 500);
|
| 650 |
}
|
| 651 |
}
|
| 652 |
|
|
|
|
| 654 |
if (!sessionId) return;
|
| 655 |
setIsUploading(true);
|
| 656 |
setStatus("Uploading JSON package...");
|
| 657 |
+
startUploadProgress();
|
| 658 |
try {
|
| 659 |
const form = new FormData();
|
| 660 |
form.append("file", file);
|
|
|
|
| 662 |
await refreshSession();
|
| 663 |
setStatus("JSON package imported.");
|
| 664 |
setGeneralDirty(false);
|
| 665 |
+
clearUploadTimer();
|
| 666 |
+
setUploadProgress(100);
|
| 667 |
} catch (err) {
|
| 668 |
const message =
|
| 669 |
err instanceof Error ? err.message : "Failed to import JSON package.";
|
| 670 |
setStatus(message);
|
| 671 |
+
clearUploadTimer();
|
| 672 |
+
setUploadProgress(null);
|
| 673 |
} finally {
|
| 674 |
setIsUploading(false);
|
| 675 |
+
window.setTimeout(() => {
|
| 676 |
+
setUploadProgress(null);
|
| 677 |
+
}, 500);
|
| 678 |
}
|
| 679 |
}
|
| 680 |
|
|
|
|
| 682 |
if (!sessionId) return;
|
| 683 |
setIsUploading(true);
|
| 684 |
setStatus(`Uploading image ${file.name}...`);
|
| 685 |
+
startUploadProgress();
|
| 686 |
const existing = new Set(
|
| 687 |
(session?.uploads?.photos ?? []).map((photo) => photo.id),
|
| 688 |
);
|
|
|
|
| 703 |
} else {
|
| 704 |
setStatus("Uploaded image. Refresh to see new file.");
|
| 705 |
}
|
| 706 |
+
clearUploadTimer();
|
| 707 |
+
setUploadProgress(100);
|
| 708 |
} catch (err) {
|
| 709 |
const message =
|
| 710 |
err instanceof Error ? err.message : "Failed to upload image.";
|
| 711 |
setStatus(message);
|
| 712 |
+
clearUploadTimer();
|
| 713 |
+
setUploadProgress(null);
|
| 714 |
} finally {
|
| 715 |
setIsUploading(false);
|
| 716 |
+
window.setTimeout(() => {
|
| 717 |
+
setUploadProgress(null);
|
| 718 |
+
}, 500);
|
| 719 |
}
|
| 720 |
}
|
| 721 |
|
|
|
|
| 746 |
navigate(target);
|
| 747 |
}
|
| 748 |
|
| 749 |
+
function requestNavigation(target: string) {
|
| 750 |
+
if (saveState === "saving" || saveState === "pending" || isSaving) {
|
| 751 |
+
setPendingNavigationTarget(target);
|
| 752 |
+
return;
|
| 753 |
+
}
|
| 754 |
+
void saveAndNavigate(target);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
async function waitForSaveAndContinue() {
|
| 758 |
+
if (!pendingNavigationTarget) return;
|
| 759 |
+
const target = pendingNavigationTarget;
|
| 760 |
+
setPendingNavigationTarget(null);
|
| 761 |
+
await saveAndNavigate(target);
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
function leaveWithoutWaiting() {
|
| 765 |
+
if (!pendingNavigationTarget) return;
|
| 766 |
+
const target = pendingNavigationTarget;
|
| 767 |
+
setPendingNavigationTarget(null);
|
| 768 |
+
navigate(target);
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
const saveIndicator = useMemo(() => {
|
| 772 |
const base =
|
| 773 |
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
|
|
|
| 815 |
to={`/report-viewer${sessionQuery}`}
|
| 816 |
onClick={(event) => {
|
| 817 |
event.preventDefault();
|
| 818 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 819 |
}}
|
| 820 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 821 |
>
|
|
|
|
| 838 |
to={`/report-viewer${sessionQuery}`}
|
| 839 |
onClick={(event) => {
|
| 840 |
event.preventDefault();
|
| 841 |
+
requestNavigation(`/report-viewer${sessionQuery}`);
|
| 842 |
}}
|
| 843 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 844 |
>
|
|
|
|
| 850 |
to={`/image-placement${sessionQuery}`}
|
| 851 |
onClick={(event) => {
|
| 852 |
event.preventDefault();
|
| 853 |
+
requestNavigation(`/image-placement${sessionQuery}`);
|
| 854 |
}}
|
| 855 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 856 |
>
|
|
|
|
| 862 |
to={`/edit-report${sessionQuery}`}
|
| 863 |
onClick={(event) => {
|
| 864 |
event.preventDefault();
|
| 865 |
+
requestNavigation(`/edit-report${sessionQuery}`);
|
| 866 |
}}
|
| 867 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 868 |
>
|
|
|
|
| 874 |
to={`/edit-layouts${sessionQuery}`}
|
| 875 |
onClick={(event) => {
|
| 876 |
event.preventDefault();
|
| 877 |
+
requestNavigation(`/edit-layouts${sessionQuery}`);
|
| 878 |
}}
|
| 879 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 880 |
>
|
|
|
|
| 886 |
to={`/export${sessionQuery}`}
|
| 887 |
onClick={(event) => {
|
| 888 |
event.preventDefault();
|
| 889 |
+
requestNavigation(`/export${sessionQuery}`);
|
| 890 |
}}
|
| 891 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 892 |
>
|
|
|
|
| 939 |
</div>
|
| 940 |
</div>
|
| 941 |
{status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
|
| 942 |
+
<LoadingBar
|
| 943 |
+
active={isSaving}
|
| 944 |
+
progress={null}
|
| 945 |
+
label="Saving changes"
|
| 946 |
+
className="mt-3"
|
| 947 |
+
/>
|
| 948 |
+
<LoadingBar
|
| 949 |
+
active={isUploading}
|
| 950 |
+
progress={uploadProgress}
|
| 951 |
+
label={status || "Uploading files"}
|
| 952 |
+
className="mt-3"
|
| 953 |
+
/>
|
| 954 |
</section>
|
| 955 |
|
| 956 |
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
|
|
|
|
| 1573 |
</div>
|
| 1574 |
|
| 1575 |
<PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
|
| 1576 |
+
<SaveBeforeLeaveDialog
|
| 1577 |
+
open={Boolean(pendingNavigationTarget)}
|
| 1578 |
+
onCancel={() => setPendingNavigationTarget(null)}
|
| 1579 |
+
onProceed={leaveWithoutWaiting}
|
| 1580 |
+
onWait={waitForSaveAndContinue}
|
| 1581 |
+
/>
|
| 1582 |
</PageShell>
|
| 1583 |
);
|
| 1584 |
}
|
frontend/src/pages/ReviewSetupPage.tsx
CHANGED
|
@@ -46,8 +46,7 @@ export default function ReviewSetupPage() {
|
|
| 46 |
setSession(data);
|
| 47 |
const initial = new Set(data.selected_photo_ids || []);
|
| 48 |
const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id);
|
| 49 |
-
|
| 50 |
-
if (!hasDataFiles && photoIds.length > 0 && initial.size < photoIds.length) {
|
| 51 |
photoIds.forEach((photoId) => initial.add(photoId));
|
| 52 |
}
|
| 53 |
setSelectedPhotoIds(initial);
|
|
@@ -128,9 +127,12 @@ export default function ReviewSetupPage() {
|
|
| 128 |
formData,
|
| 129 |
);
|
| 130 |
setSession(updated);
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
}
|
|
|
|
| 134 |
} catch (err) {
|
| 135 |
const message =
|
| 136 |
err instanceof Error ? err.message : "Failed to upload data file.";
|
|
|
|
| 46 |
setSession(data);
|
| 47 |
const initial = new Set(data.selected_photo_ids || []);
|
| 48 |
const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id);
|
| 49 |
+
if (photoIds.length > 0 && initial.size < photoIds.length) {
|
|
|
|
| 50 |
photoIds.forEach((photoId) => initial.add(photoId));
|
| 51 |
}
|
| 52 |
setSelectedPhotoIds(initial);
|
|
|
|
| 127 |
formData,
|
| 128 |
);
|
| 129 |
setSession(updated);
|
| 130 |
+
const nextSelected = new Set(updated.selected_photo_ids || []);
|
| 131 |
+
const allPhotoIds = (updated.uploads?.photos ?? []).map((photo) => photo.id);
|
| 132 |
+
if (nextSelected.size < allPhotoIds.length) {
|
| 133 |
+
allPhotoIds.forEach((photoId) => nextSelected.add(photoId));
|
| 134 |
}
|
| 135 |
+
setSelectedPhotoIds(nextSelected);
|
| 136 |
} catch (err) {
|
| 137 |
const message =
|
| 138 |
err instanceof Error ? err.message : "Failed to upload data file.";
|
frontend/src/pages/UploadPage.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { formatBytes } from "../lib/format";
|
|
| 16 |
import { formatDocNumber } from "../lib/report";
|
| 17 |
import { setStoredSessionId } from "../lib/session";
|
| 18 |
import { APP_VERSION } from "../lib/version";
|
|
|
|
| 19 |
import type { Session } from "../types/session";
|
| 20 |
|
| 21 |
type StatusTone = "idle" | "info" | "error";
|
|
@@ -31,6 +32,27 @@ export default function UploadPage() {
|
|
| 31 |
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
| 32 |
const [dragActive, setDragActive] = useState(false);
|
| 33 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
const fileSummary = useMemo(() => {
|
| 36 |
if (!selectedFiles.length) {
|
|
@@ -55,6 +77,13 @@ export default function UploadPage() {
|
|
| 55 |
loadSessions();
|
| 56 |
}, []);
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
function handleBrowseClick() {
|
| 59 |
inputRef.current?.click();
|
| 60 |
}
|
|
@@ -84,6 +113,7 @@ export default function UploadPage() {
|
|
| 84 |
setIsSubmitting(true);
|
| 85 |
setUploadStatus("Uploading files...");
|
| 86 |
setStatusTone("idle");
|
|
|
|
| 87 |
|
| 88 |
const formData = new FormData();
|
| 89 |
formData.append("document_no", documentNo.trim());
|
|
@@ -92,9 +122,15 @@ export default function UploadPage() {
|
|
| 92 |
|
| 93 |
try {
|
| 94 |
const session = await postForm<Session>("/sessions", formData);
|
|
|
|
|
|
|
| 95 |
setStoredSessionId(session.id);
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
} catch (err) {
|
|
|
|
|
|
|
| 98 |
const message =
|
| 99 |
err instanceof Error ? err.message : "Upload failed. Please try again.";
|
| 100 |
setUploadStatus(message);
|
|
@@ -335,6 +371,12 @@ export default function UploadPage() {
|
|
| 335 |
{uploadStatus}
|
| 336 |
</p>
|
| 337 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
</section>
|
| 340 |
|
|
|
|
| 16 |
import { formatDocNumber } from "../lib/report";
|
| 17 |
import { setStoredSessionId } from "../lib/session";
|
| 18 |
import { APP_VERSION } from "../lib/version";
|
| 19 |
+
import { LoadingBar } from "../components/LoadingBar";
|
| 20 |
import type { Session } from "../types/session";
|
| 21 |
|
| 22 |
type StatusTone = "idle" | "info" | "error";
|
|
|
|
| 32 |
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
| 33 |
const [dragActive, setDragActive] = useState(false);
|
| 34 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 35 |
+
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
| 36 |
+
const uploadTimerRef = useRef<number | null>(null);
|
| 37 |
+
|
| 38 |
+
function clearUploadTimer() {
|
| 39 |
+
if (uploadTimerRef.current !== null) {
|
| 40 |
+
window.clearInterval(uploadTimerRef.current);
|
| 41 |
+
uploadTimerRef.current = null;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function startUploadProgress() {
|
| 46 |
+
clearUploadTimer();
|
| 47 |
+
setUploadProgress(6);
|
| 48 |
+
uploadTimerRef.current = window.setInterval(() => {
|
| 49 |
+
setUploadProgress((prev) => {
|
| 50 |
+
const value = typeof prev === "number" ? prev : 0;
|
| 51 |
+
if (value >= 92) return value;
|
| 52 |
+
return Math.min(92, value + Math.max(1, (92 - value) * 0.08));
|
| 53 |
+
});
|
| 54 |
+
}, 350);
|
| 55 |
+
}
|
| 56 |
|
| 57 |
const fileSummary = useMemo(() => {
|
| 58 |
if (!selectedFiles.length) {
|
|
|
|
| 77 |
loadSessions();
|
| 78 |
}, []);
|
| 79 |
|
| 80 |
+
useEffect(
|
| 81 |
+
() => () => {
|
| 82 |
+
clearUploadTimer();
|
| 83 |
+
},
|
| 84 |
+
[],
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
function handleBrowseClick() {
|
| 88 |
inputRef.current?.click();
|
| 89 |
}
|
|
|
|
| 113 |
setIsSubmitting(true);
|
| 114 |
setUploadStatus("Uploading files...");
|
| 115 |
setStatusTone("idle");
|
| 116 |
+
startUploadProgress();
|
| 117 |
|
| 118 |
const formData = new FormData();
|
| 119 |
formData.append("document_no", documentNo.trim());
|
|
|
|
| 122 |
|
| 123 |
try {
|
| 124 |
const session = await postForm<Session>("/sessions", formData);
|
| 125 |
+
clearUploadTimer();
|
| 126 |
+
setUploadProgress(100);
|
| 127 |
setStoredSessionId(session.id);
|
| 128 |
+
window.setTimeout(() => {
|
| 129 |
+
navigate(`/processing?session=${encodeURIComponent(session.id)}`);
|
| 130 |
+
}, 120);
|
| 131 |
} catch (err) {
|
| 132 |
+
clearUploadTimer();
|
| 133 |
+
setUploadProgress(null);
|
| 134 |
const message =
|
| 135 |
err instanceof Error ? err.message : "Upload failed. Please try again.";
|
| 136 |
setUploadStatus(message);
|
|
|
|
| 371 |
{uploadStatus}
|
| 372 |
</p>
|
| 373 |
) : null}
|
| 374 |
+
<LoadingBar
|
| 375 |
+
active={isSubmitting}
|
| 376 |
+
progress={uploadProgress}
|
| 377 |
+
label={uploadStatus || "Uploading files"}
|
| 378 |
+
className="mt-3"
|
| 379 |
+
/>
|
| 380 |
</div>
|
| 381 |
</section>
|
| 382 |
|
server/app/api/routes/sessions.py
CHANGED
|
@@ -25,7 +25,10 @@ from ..schemas import (
|
|
| 25 |
from ...services import SessionStore
|
| 26 |
from ...services.session_store import DATA_EXTS, IMAGE_EXTS
|
| 27 |
|
| 28 |
-
from ...services.data_import import
|
|
|
|
|
|
|
|
|
|
| 29 |
from ...services.pdf_reportlab import render_report_pdf
|
| 30 |
|
| 31 |
|
|
@@ -101,6 +104,7 @@ def create_session(
|
|
| 101 |
session = store.add_uploads(session, saved_files)
|
| 102 |
try:
|
| 103 |
session = populate_session_from_data_files(store, session)
|
|
|
|
| 104 |
except Exception:
|
| 105 |
# Do not block upload if data parsing fails.
|
| 106 |
pass
|
|
@@ -114,6 +118,10 @@ def get_session(session_id: str, store: SessionStore = Depends(get_session_store
|
|
| 114 |
if not session:
|
| 115 |
raise HTTPException(status_code=404, detail="Session not found.")
|
| 116 |
store.ensure_sections(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
return _attach_urls(session)
|
| 118 |
|
| 119 |
|
|
@@ -150,6 +158,10 @@ def get_pages(session_id: str, store: SessionStore = Depends(get_session_store))
|
|
| 150 |
session = store.get_session(session_id)
|
| 151 |
if not session:
|
| 152 |
raise HTTPException(status_code=404, detail="Session not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
pages = store.ensure_pages(session)
|
| 154 |
return PagesResponse(pages=pages)
|
| 155 |
|
|
@@ -176,6 +188,10 @@ def get_sections(
|
|
| 176 |
session = store.get_session(session_id)
|
| 177 |
if not session:
|
| 178 |
raise HTTPException(status_code=404, detail="Session not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
sections = store.ensure_sections(session)
|
| 180 |
return SectionsResponse(sections=sections)
|
| 181 |
|
|
@@ -265,6 +281,7 @@ def upload_data_file(
|
|
| 265 |
session = store.add_uploads(session, [saved_file])
|
| 266 |
try:
|
| 267 |
session = populate_session_from_data_files(store, session)
|
|
|
|
| 268 |
except Exception:
|
| 269 |
pass
|
| 270 |
return _attach_urls(session)
|
|
@@ -295,6 +312,10 @@ def upload_photo(
|
|
| 295 |
pass
|
| 296 |
|
| 297 |
session = store.add_uploads(session, [saved_file])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
return _attach_urls(session)
|
| 299 |
|
| 300 |
|
|
@@ -387,6 +408,10 @@ def export_pdf(
|
|
| 387 |
session = store.get_session(session_id)
|
| 388 |
if not session:
|
| 389 |
raise HTTPException(status_code=404, detail="Session not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
sections = store.ensure_sections(session)
|
| 391 |
export_path = Path(store.session_dir(session_id)) / "export.pdf"
|
| 392 |
render_report_pdf(store, session, sections, export_path)
|
|
|
|
| 25 |
from ...services import SessionStore
|
| 26 |
from ...services.session_store import DATA_EXTS, IMAGE_EXTS
|
| 27 |
|
| 28 |
+
from ...services.data_import import (
|
| 29 |
+
populate_session_from_data_files,
|
| 30 |
+
reconcile_session_image_links,
|
| 31 |
+
)
|
| 32 |
from ...services.pdf_reportlab import render_report_pdf
|
| 33 |
|
| 34 |
|
|
|
|
| 104 |
session = store.add_uploads(session, saved_files)
|
| 105 |
try:
|
| 106 |
session = populate_session_from_data_files(store, session)
|
| 107 |
+
session = reconcile_session_image_links(store, session)
|
| 108 |
except Exception:
|
| 109 |
# Do not block upload if data parsing fails.
|
| 110 |
pass
|
|
|
|
| 118 |
if not session:
|
| 119 |
raise HTTPException(status_code=404, detail="Session not found.")
|
| 120 |
store.ensure_sections(session)
|
| 121 |
+
try:
|
| 122 |
+
session = reconcile_session_image_links(store, session)
|
| 123 |
+
except Exception:
|
| 124 |
+
pass
|
| 125 |
return _attach_urls(session)
|
| 126 |
|
| 127 |
|
|
|
|
| 158 |
session = store.get_session(session_id)
|
| 159 |
if not session:
|
| 160 |
raise HTTPException(status_code=404, detail="Session not found.")
|
| 161 |
+
try:
|
| 162 |
+
session = reconcile_session_image_links(store, session)
|
| 163 |
+
except Exception:
|
| 164 |
+
pass
|
| 165 |
pages = store.ensure_pages(session)
|
| 166 |
return PagesResponse(pages=pages)
|
| 167 |
|
|
|
|
| 188 |
session = store.get_session(session_id)
|
| 189 |
if not session:
|
| 190 |
raise HTTPException(status_code=404, detail="Session not found.")
|
| 191 |
+
try:
|
| 192 |
+
session = reconcile_session_image_links(store, session)
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
sections = store.ensure_sections(session)
|
| 196 |
return SectionsResponse(sections=sections)
|
| 197 |
|
|
|
|
| 281 |
session = store.add_uploads(session, [saved_file])
|
| 282 |
try:
|
| 283 |
session = populate_session_from_data_files(store, session)
|
| 284 |
+
session = reconcile_session_image_links(store, session)
|
| 285 |
except Exception:
|
| 286 |
pass
|
| 287 |
return _attach_urls(session)
|
|
|
|
| 312 |
pass
|
| 313 |
|
| 314 |
session = store.add_uploads(session, [saved_file])
|
| 315 |
+
try:
|
| 316 |
+
session = reconcile_session_image_links(store, session)
|
| 317 |
+
except Exception:
|
| 318 |
+
pass
|
| 319 |
return _attach_urls(session)
|
| 320 |
|
| 321 |
|
|
|
|
| 408 |
session = store.get_session(session_id)
|
| 409 |
if not session:
|
| 410 |
raise HTTPException(status_code=404, detail="Session not found.")
|
| 411 |
+
try:
|
| 412 |
+
session = reconcile_session_image_links(store, session)
|
| 413 |
+
except Exception:
|
| 414 |
+
pass
|
| 415 |
sections = store.ensure_sections(session)
|
| 416 |
export_path = Path(store.session_dir(session_id)) / "export.pdf"
|
| 417 |
render_report_pdf(store, session, sections, export_path)
|
server/app/services/data_import.py
CHANGED
|
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import csv
|
| 4 |
import re
|
|
|
|
|
|
|
| 5 |
from datetime import date, datetime
|
| 6 |
from pathlib import Path
|
| 7 |
-
from typing import Dict, Iterable, List, Optional
|
| 8 |
|
| 9 |
from openpyxl import load_workbook
|
| 10 |
import xlrd
|
|
@@ -16,6 +18,18 @@ GENERAL_SHEET = "general information"
|
|
| 16 |
HEADINGS_SHEET = "headings"
|
| 17 |
ITEMS_SHEET = "item spesific"
|
| 18 |
ITEMS_SHEET_ALT = "item specific"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def _normalize_text(value: str) -> str:
|
|
@@ -131,15 +145,43 @@ def _header_map(headers: List[str]) -> Dict[str, List[int]]:
|
|
| 131 |
return mapping
|
| 132 |
|
| 133 |
|
| 134 |
-
def
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
-
def _extract_image_names(value: str) -> List[str]:
|
| 139 |
if not value:
|
| 140 |
return []
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
def _find_reference_value(cells: List[object]) -> str:
|
|
@@ -251,21 +293,30 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
|
|
| 251 |
figure_caption = " - ".join(
|
| 252 |
[value for value in [figure_caption, figure_description] if value]
|
| 253 |
)
|
| 254 |
-
image_names = [
|
| 255 |
-
|
| 256 |
-
_row_value(cells, image_index(
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
if len(image_names) < 2:
|
| 264 |
for cell in cells:
|
| 265 |
value = _cell_to_str(cell)
|
| 266 |
if not value:
|
| 267 |
continue
|
| 268 |
-
candidates = _extract_image_names(
|
|
|
|
|
|
|
| 269 |
for candidate in candidates:
|
| 270 |
if candidate in image_names:
|
| 271 |
continue
|
|
@@ -348,9 +399,13 @@ def _parse_xls(path: Path) -> Dict[str, object]:
|
|
| 348 |
|
| 349 |
|
| 350 |
def _normalize_key(value: str) -> str:
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
|
| 356 |
def _normalize_name(name: str) -> str:
|
|
@@ -358,35 +413,79 @@ def _normalize_name(name: str) -> str:
|
|
| 358 |
|
| 359 |
|
| 360 |
def _normalize_stem(name: str) -> str:
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
|
| 364 |
-
def
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
for item in uploads:
|
| 367 |
name = item.get("name") or ""
|
| 368 |
file_id = item.get("id")
|
| 369 |
if not name or not file_id:
|
| 370 |
continue
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
return
|
| 374 |
|
| 375 |
|
| 376 |
-
def
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
for raw in names:
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
|
| 392 |
def populate_session_from_data_files(
|
|
@@ -463,7 +562,12 @@ def populate_session_from_data_files(
|
|
| 463 |
"figure_caption": item.get("figure_caption", ""),
|
| 464 |
}
|
| 465 |
image_names = item.get("image_names", []) or []
|
| 466 |
-
photo_ids = _photo_ids_for_names(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
for photo_id in photo_ids:
|
| 468 |
if photo_id not in selected_photo_ids:
|
| 469 |
selected_photo_ids.append(photo_id)
|
|
@@ -484,3 +588,64 @@ def populate_session_from_data_files(
|
|
| 484 |
store.set_sections(session, sections)
|
| 485 |
|
| 486 |
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import csv
|
| 4 |
import re
|
| 5 |
+
import unicodedata
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
from datetime import date, datetime
|
| 8 |
from pathlib import Path
|
| 9 |
+
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
| 10 |
|
| 11 |
from openpyxl import load_workbook
|
| 12 |
import xlrd
|
|
|
|
| 18 |
HEADINGS_SHEET = "headings"
|
| 19 |
ITEMS_SHEET = "item spesific"
|
| 20 |
ITEMS_SHEET_ALT = "item specific"
|
| 21 |
+
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
| 22 |
+
IMAGE_NAME_RE = re.compile(
|
| 23 |
+
r"(?i)([^,;\n\r]+?\.(?:jpe?g|png|gif|webp|bmp|tiff?))"
|
| 24 |
+
)
|
| 25 |
+
IMAGE_REF_SPLIT_RE = re.compile(r"[;\n\r]+")
|
| 26 |
+
IMAGE_REF_PREFIX_RE = re.compile(r"^(?:fig(?:ure)?|image)\s*\d*\s*[:\-]\s*", re.IGNORECASE)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class PhotoLookup:
|
| 31 |
+
by_exact: Dict[str, Set[str]]
|
| 32 |
+
by_stem: Dict[str, Set[str]]
|
| 33 |
|
| 34 |
|
| 35 |
def _normalize_text(value: str) -> str:
|
|
|
|
| 145 |
return mapping
|
| 146 |
|
| 147 |
|
| 148 |
+
def _clean_image_ref(value: str) -> str:
|
| 149 |
+
text = str(value or "").strip().strip(" \t\r\n'\"[](){}")
|
| 150 |
+
if not text:
|
| 151 |
+
return ""
|
| 152 |
+
text = text.replace("\\", "/").split("/")[-1]
|
| 153 |
+
text = IMAGE_REF_PREFIX_RE.sub("", text).strip()
|
| 154 |
+
return text
|
| 155 |
|
| 156 |
|
| 157 |
+
def _extract_image_names(value: str, *, allow_stem_without_ext: bool) -> List[str]:
|
| 158 |
if not value:
|
| 159 |
return []
|
| 160 |
+
|
| 161 |
+
found: List[str] = []
|
| 162 |
+
for section in IMAGE_REF_SPLIT_RE.split(str(value)):
|
| 163 |
+
chunks = [section]
|
| 164 |
+
if "," in section:
|
| 165 |
+
chunks = section.split(",")
|
| 166 |
+
|
| 167 |
+
for chunk in chunks:
|
| 168 |
+
cleaned = _clean_image_ref(chunk)
|
| 169 |
+
if not cleaned:
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
explicit = IMAGE_NAME_RE.findall(cleaned)
|
| 173 |
+
if explicit:
|
| 174 |
+
for match in explicit:
|
| 175 |
+
candidate = _clean_image_ref(match)
|
| 176 |
+
if candidate and candidate not in found:
|
| 177 |
+
found.append(candidate)
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
if allow_stem_without_ext and re.search(r"[A-Za-z0-9]", cleaned):
|
| 181 |
+
if cleaned not in found:
|
| 182 |
+
found.append(cleaned)
|
| 183 |
+
|
| 184 |
+
return found
|
| 185 |
|
| 186 |
|
| 187 |
def _find_reference_value(cells: List[object]) -> str:
|
|
|
|
| 293 |
figure_caption = " - ".join(
|
| 294 |
[value for value in [figure_caption, figure_description] if value]
|
| 295 |
)
|
| 296 |
+
image_names: List[str] = []
|
| 297 |
+
for number in range(1, 7):
|
| 298 |
+
raw_value = _row_value(cells, image_index(number))
|
| 299 |
+
if not raw_value:
|
| 300 |
+
continue
|
| 301 |
+
for candidate in _extract_image_names(
|
| 302 |
+
raw_value, allow_stem_without_ext=True
|
| 303 |
+
):
|
| 304 |
+
if candidate in image_names:
|
| 305 |
+
continue
|
| 306 |
+
image_names.append(candidate)
|
| 307 |
+
if len(image_names) >= 6:
|
| 308 |
+
break
|
| 309 |
+
if len(image_names) >= 6:
|
| 310 |
+
break
|
| 311 |
+
|
| 312 |
if len(image_names) < 2:
|
| 313 |
for cell in cells:
|
| 314 |
value = _cell_to_str(cell)
|
| 315 |
if not value:
|
| 316 |
continue
|
| 317 |
+
candidates = _extract_image_names(
|
| 318 |
+
value, allow_stem_without_ext=False
|
| 319 |
+
)
|
| 320 |
for candidate in candidates:
|
| 321 |
if candidate in image_names:
|
| 322 |
continue
|
|
|
|
| 399 |
|
| 400 |
|
| 401 |
def _normalize_key(value: str) -> str:
|
| 402 |
+
text = str(value or "").strip().replace("\\", "/").split("/")[-1]
|
| 403 |
+
if not text:
|
| 404 |
+
return ""
|
| 405 |
+
text = unicodedata.normalize("NFKD", text)
|
| 406 |
+
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
| 407 |
+
text = re.sub(r"\s+", " ", text).strip().lower()
|
| 408 |
+
return re.sub(r"[^a-z0-9]", "", text)
|
| 409 |
|
| 410 |
|
| 411 |
def _normalize_name(name: str) -> str:
|
|
|
|
| 413 |
|
| 414 |
|
| 415 |
def _normalize_stem(name: str) -> str:
|
| 416 |
+
normalized = str(name or "").replace("\\", "/").split("/")[-1].strip()
|
| 417 |
+
if not normalized:
|
| 418 |
+
return ""
|
| 419 |
+
suffix = Path(normalized).suffix.lower()
|
| 420 |
+
if suffix in IMAGE_EXTENSIONS:
|
| 421 |
+
normalized = normalized[: -len(suffix)]
|
| 422 |
+
return _normalize_key(normalized)
|
| 423 |
|
| 424 |
|
| 425 |
+
def _add_lookup_value(mapping: Dict[str, Set[str]], key: str, file_id: str) -> None:
|
| 426 |
+
if not key:
|
| 427 |
+
return
|
| 428 |
+
values = mapping.setdefault(key, set())
|
| 429 |
+
values.add(file_id)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _build_photo_lookup(uploads: List[dict]) -> PhotoLookup:
|
| 433 |
+
exact: Dict[str, Set[str]] = {}
|
| 434 |
+
stem: Dict[str, Set[str]] = {}
|
| 435 |
for item in uploads:
|
| 436 |
name = item.get("name") or ""
|
| 437 |
file_id = item.get("id")
|
| 438 |
if not name or not file_id:
|
| 439 |
continue
|
| 440 |
+
_add_lookup_value(exact, _normalize_name(name), file_id)
|
| 441 |
+
_add_lookup_value(stem, _normalize_stem(name), file_id)
|
| 442 |
+
return PhotoLookup(by_exact=exact, by_stem=stem)
|
| 443 |
|
| 444 |
|
| 445 |
+
def _resolve_photo_id(name: str, lookup: PhotoLookup) -> Optional[str]:
|
| 446 |
+
name = str(name or "").strip()
|
| 447 |
+
if not name:
|
| 448 |
+
return None
|
| 449 |
+
|
| 450 |
+
exact_key = _normalize_name(name)
|
| 451 |
+
stem_key = _normalize_stem(name)
|
| 452 |
+
has_suffix = Path(str(name).replace("\\", "/").split("/")[-1]).suffix.lower() in IMAGE_EXTENSIONS
|
| 453 |
+
|
| 454 |
+
exact_matches = lookup.by_exact.get(exact_key, set())
|
| 455 |
+
stem_matches = lookup.by_stem.get(stem_key, set())
|
| 456 |
+
|
| 457 |
+
if has_suffix and len(exact_matches) == 1:
|
| 458 |
+
return next(iter(exact_matches))
|
| 459 |
+
if len(stem_matches) == 1:
|
| 460 |
+
return next(iter(stem_matches))
|
| 461 |
+
if not has_suffix and len(exact_matches) == 1:
|
| 462 |
+
return next(iter(exact_matches))
|
| 463 |
+
return None
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _collect_image_refs(names: List[str]) -> List[str]:
|
| 467 |
+
refs: List[str] = []
|
| 468 |
for raw in names:
|
| 469 |
+
for candidate in _extract_image_names(
|
| 470 |
+
str(raw), allow_stem_without_ext=True
|
| 471 |
+
):
|
| 472 |
+
if candidate and candidate not in refs:
|
| 473 |
+
refs.append(candidate)
|
| 474 |
+
return refs
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _photo_ids_for_names(names: List[str], lookup: PhotoLookup) -> Tuple[List[str], List[str], List[str]]:
|
| 478 |
+
refs = _collect_image_refs(names)
|
| 479 |
+
ids: List[str] = []
|
| 480 |
+
unresolved: List[str] = []
|
| 481 |
+
for ref in refs:
|
| 482 |
+
resolved = _resolve_photo_id(ref, lookup)
|
| 483 |
+
if resolved:
|
| 484 |
+
if resolved not in ids:
|
| 485 |
+
ids.append(resolved)
|
| 486 |
+
elif ref not in unresolved:
|
| 487 |
+
unresolved.append(ref)
|
| 488 |
+
return ids, unresolved, refs
|
| 489 |
|
| 490 |
|
| 491 |
def populate_session_from_data_files(
|
|
|
|
| 562 |
"figure_caption": item.get("figure_caption", ""),
|
| 563 |
}
|
| 564 |
image_names = item.get("image_names", []) or []
|
| 565 |
+
photo_ids, unresolved_refs, normalized_refs = _photo_ids_for_names(
|
| 566 |
+
image_names, photo_lookup
|
| 567 |
+
)
|
| 568 |
+
template["image_name_refs"] = normalized_refs
|
| 569 |
+
if unresolved_refs:
|
| 570 |
+
template["unresolved_image_refs"] = unresolved_refs
|
| 571 |
for photo_id in photo_ids:
|
| 572 |
if photo_id not in selected_photo_ids:
|
| 573 |
selected_photo_ids.append(photo_id)
|
|
|
|
| 588 |
store.set_sections(session, sections)
|
| 589 |
|
| 590 |
return session
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def reconcile_session_image_links(store: SessionStore, session: dict) -> dict:
|
| 594 |
+
uploads = session.get("uploads", {}).get("photos", []) or []
|
| 595 |
+
if not uploads:
|
| 596 |
+
return session
|
| 597 |
+
|
| 598 |
+
lookup = _build_photo_lookup(uploads)
|
| 599 |
+
sections = store.ensure_sections(session)
|
| 600 |
+
changed = False
|
| 601 |
+
selected_photo_ids = list(session.get("selected_photo_ids") or [])
|
| 602 |
+
|
| 603 |
+
for section in sections:
|
| 604 |
+
pages = section.get("pages") or []
|
| 605 |
+
for page in pages:
|
| 606 |
+
template = page.get("template")
|
| 607 |
+
if not isinstance(template, dict):
|
| 608 |
+
continue
|
| 609 |
+
|
| 610 |
+
refs_raw = template.get("image_name_refs") or []
|
| 611 |
+
refs: List[str]
|
| 612 |
+
if isinstance(refs_raw, str):
|
| 613 |
+
refs = _collect_image_refs([refs_raw])
|
| 614 |
+
elif isinstance(refs_raw, list):
|
| 615 |
+
refs = _collect_image_refs([str(value) for value in refs_raw if value])
|
| 616 |
+
else:
|
| 617 |
+
refs = []
|
| 618 |
+
|
| 619 |
+
if not refs:
|
| 620 |
+
continue
|
| 621 |
+
|
| 622 |
+
resolved_ids, unresolved_refs, normalized_refs = _photo_ids_for_names(
|
| 623 |
+
refs, lookup
|
| 624 |
+
)
|
| 625 |
+
existing = [value for value in (page.get("photo_ids") or []) if isinstance(value, str) and value]
|
| 626 |
+
merged = existing + [photo_id for photo_id in resolved_ids if photo_id not in existing]
|
| 627 |
+
|
| 628 |
+
if merged != existing:
|
| 629 |
+
page["photo_ids"] = merged
|
| 630 |
+
changed = True
|
| 631 |
+
for photo_id in merged:
|
| 632 |
+
if photo_id not in selected_photo_ids:
|
| 633 |
+
selected_photo_ids.append(photo_id)
|
| 634 |
+
if template.get("image_name_refs") != normalized_refs:
|
| 635 |
+
template["image_name_refs"] = normalized_refs
|
| 636 |
+
changed = True
|
| 637 |
+
if unresolved_refs:
|
| 638 |
+
if template.get("unresolved_image_refs") != unresolved_refs:
|
| 639 |
+
template["unresolved_image_refs"] = unresolved_refs
|
| 640 |
+
changed = True
|
| 641 |
+
elif "unresolved_image_refs" in template:
|
| 642 |
+
template.pop("unresolved_image_refs", None)
|
| 643 |
+
changed = True
|
| 644 |
+
|
| 645 |
+
if selected_photo_ids != list(session.get("selected_photo_ids") or []):
|
| 646 |
+
session["selected_photo_ids"] = selected_photo_ids
|
| 647 |
+
changed = True
|
| 648 |
+
|
| 649 |
+
if changed:
|
| 650 |
+
store.update_session(session)
|
| 651 |
+
return session
|
server/app/services/pdf_reportlab.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import math
|
| 4 |
from pathlib import Path
|
| 5 |
import re
|
| 6 |
-
from typing import Iterable, List, Optional
|
| 7 |
|
| 8 |
from reportlab.lib import colors
|
| 9 |
from reportlab.lib.pagesizes import A4
|
|
@@ -15,6 +16,12 @@ from reportlab.pdfgen import canvas
|
|
| 15 |
from .session_store import SessionStore
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def _has_template_content(template: dict | None) -> bool:
|
| 19 |
if not template:
|
| 20 |
return False
|
|
@@ -40,7 +47,7 @@ def _wrap_lines(
|
|
| 40 |
pdf: canvas.Canvas,
|
| 41 |
text: str,
|
| 42 |
width: float,
|
| 43 |
-
max_lines: int,
|
| 44 |
font: str,
|
| 45 |
size: int,
|
| 46 |
) -> List[str]:
|
|
@@ -55,10 +62,10 @@ def _wrap_lines(
|
|
| 55 |
if current:
|
| 56 |
lines.append(" ".join(current))
|
| 57 |
current = []
|
| 58 |
-
if len(lines) >= max_lines:
|
| 59 |
break
|
| 60 |
remaining = word
|
| 61 |
-
while remaining and len(lines) < max_lines:
|
| 62 |
cut = len(remaining)
|
| 63 |
while cut > 1 and pdf.stringWidth(remaining[:cut], font, size) > width:
|
| 64 |
cut -= 1
|
|
@@ -73,10 +80,10 @@ def _wrap_lines(
|
|
| 73 |
else:
|
| 74 |
lines.append(" ".join(current))
|
| 75 |
current = [word]
|
| 76 |
-
if len(lines) >= max_lines:
|
| 77 |
current = []
|
| 78 |
break
|
| 79 |
-
if current and len(lines) < max_lines:
|
| 80 |
lines.append(" ".join(current))
|
| 81 |
return lines
|
| 82 |
|
|
@@ -88,7 +95,7 @@ def _draw_wrapped(
|
|
| 88 |
y: float,
|
| 89 |
width: float,
|
| 90 |
leading: float,
|
| 91 |
-
max_lines: int,
|
| 92 |
font: str,
|
| 93 |
size: int,
|
| 94 |
) -> float:
|
|
@@ -102,6 +109,53 @@ def _draw_wrapped(
|
|
| 102 |
return y
|
| 103 |
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
def _draw_centered_block(
|
| 106 |
pdf: canvas.Canvas,
|
| 107 |
lines: List[str],
|
|
@@ -193,18 +247,22 @@ def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional
|
|
| 193 |
value = _safe_text(raw)
|
| 194 |
if not value:
|
| 195 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
uploads = (session.get("uploads") or {}).get("photos") or []
|
| 197 |
-
|
| 198 |
for item in uploads:
|
| 199 |
if value == (item.get("id") or ""):
|
| 200 |
path = store.resolve_upload_path(session, item.get("id"))
|
| 201 |
if path and path.exists():
|
| 202 |
return path
|
| 203 |
-
name =
|
| 204 |
if not name:
|
| 205 |
continue
|
| 206 |
stem = name.rsplit(".", 1)[0]
|
| 207 |
-
if
|
| 208 |
path = store.resolve_upload_path(session, item.get("id"))
|
| 209 |
if path and path.exists():
|
| 210 |
return path
|
|
@@ -218,32 +276,116 @@ def _draw_image_fit(
|
|
| 218 |
y: float,
|
| 219 |
width: float,
|
| 220 |
height: float,
|
|
|
|
| 221 |
) -> bool:
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
try:
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
iw, ih = reader.getSize()
|
| 233 |
except Exception:
|
| 234 |
try:
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
preserveAspectRatio=True,
|
| 242 |
-
mask="auto",
|
| 243 |
-
)
|
| 244 |
-
return True
|
| 245 |
except Exception:
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
if iw <= 0 or ih <= 0:
|
| 248 |
return False
|
| 249 |
scale = min(width / iw, height / ih)
|
|
@@ -309,7 +451,8 @@ def render_report_pdf(
|
|
| 309 |
red_200 = colors.HexColor("#fecaca")
|
| 310 |
|
| 311 |
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 312 |
-
pdf = canvas.Canvas(str(output_path), pagesize=A4)
|
|
|
|
| 313 |
|
| 314 |
uploads = (session.get("uploads") or {}).get("photos") or []
|
| 315 |
by_id = {item.get("id"): item for item in uploads if item.get("id")}
|
|
@@ -435,7 +578,15 @@ def render_report_pdf(
|
|
| 435 |
logo_y = header_y - (header_h + logo_h) / 2
|
| 436 |
logo_drawn = False
|
| 437 |
if default_logo:
|
| 438 |
-
logo_drawn = _draw_image_fit(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
if not logo_drawn:
|
| 440 |
pdf.setStrokeColor(colors.red)
|
| 441 |
pdf.setLineWidth(1)
|
|
@@ -460,6 +611,7 @@ def render_report_pdf(
|
|
| 460 |
client_logo_y,
|
| 461 |
client_logo_w,
|
| 462 |
client_logo_h,
|
|
|
|
| 463 |
)
|
| 464 |
else:
|
| 465 |
pdf.setStrokeColor(gray_200)
|
|
@@ -660,15 +812,14 @@ def render_report_pdf(
|
|
| 660 |
y -= 3 * mm
|
| 661 |
pdf.setFillColor(gray_50)
|
| 662 |
pdf.setStrokeColor(gray_200)
|
| 663 |
-
cond_lines =
|
| 664 |
pdf,
|
| 665 |
condition or "",
|
| 666 |
-
width - 2 * margin -
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
9,
|
| 670 |
)
|
| 671 |
-
cond_h = max(10 * mm, (len(cond_lines) or 1) *
|
| 672 |
cond_bottom = y - cond_h
|
| 673 |
pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
|
| 674 |
pdf.setLineWidth(2)
|
|
@@ -682,9 +833,9 @@ def render_report_pdf(
|
|
| 682 |
text_center_x,
|
| 683 |
cond_bottom,
|
| 684 |
cond_h,
|
| 685 |
-
|
| 686 |
"Helvetica-Bold",
|
| 687 |
-
|
| 688 |
)
|
| 689 |
y = cond_bottom - 4 * mm
|
| 690 |
|
|
@@ -696,15 +847,14 @@ def render_report_pdf(
|
|
| 696 |
y -= 3 * mm
|
| 697 |
pdf.setFillColor(gray_50)
|
| 698 |
pdf.setStrokeColor(gray_200)
|
| 699 |
-
action_lines =
|
| 700 |
pdf,
|
| 701 |
action or "",
|
| 702 |
-
width - 2 * margin -
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
9,
|
| 706 |
)
|
| 707 |
-
action_h = max(10 * mm, (len(action_lines) or 1) *
|
| 708 |
action_bottom = y - action_h
|
| 709 |
pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
|
| 710 |
pdf.setLineWidth(2)
|
|
@@ -718,9 +868,9 @@ def render_report_pdf(
|
|
| 718 |
text_center_x,
|
| 719 |
action_bottom,
|
| 720 |
action_h,
|
| 721 |
-
|
| 722 |
"Helvetica-Bold",
|
| 723 |
-
|
| 724 |
)
|
| 725 |
y = action_bottom - 4 * mm
|
| 726 |
else:
|
|
@@ -784,6 +934,7 @@ def render_report_pdf(
|
|
| 784 |
image_y,
|
| 785 |
cell_w - 4 * mm,
|
| 786 |
image_h,
|
|
|
|
| 787 |
)
|
| 788 |
if caption_lines:
|
| 789 |
pdf.setFillColor(gray_500)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from io import BytesIO
|
| 4 |
import math
|
| 5 |
from pathlib import Path
|
| 6 |
import re
|
| 7 |
+
from typing import Dict, Iterable, List, Optional, Tuple
|
| 8 |
|
| 9 |
from reportlab.lib import colors
|
| 10 |
from reportlab.lib.pagesizes import A4
|
|
|
|
| 16 |
from .session_store import SessionStore
|
| 17 |
|
| 18 |
|
| 19 |
+
PDF_IMAGE_TARGET_DPI = 120
|
| 20 |
+
PDF_IMAGE_JPEG_QUALITY = 70
|
| 21 |
+
PDF_IMAGE_MAX_LONG_EDGE_PX = 1800
|
| 22 |
+
PDF_IMAGE_PNG_PRESERVE_MAX_PX = 640
|
| 23 |
+
|
| 24 |
+
|
| 25 |
def _has_template_content(template: dict | None) -> bool:
|
| 26 |
if not template:
|
| 27 |
return False
|
|
|
|
| 47 |
pdf: canvas.Canvas,
|
| 48 |
text: str,
|
| 49 |
width: float,
|
| 50 |
+
max_lines: Optional[int],
|
| 51 |
font: str,
|
| 52 |
size: int,
|
| 53 |
) -> List[str]:
|
|
|
|
| 62 |
if current:
|
| 63 |
lines.append(" ".join(current))
|
| 64 |
current = []
|
| 65 |
+
if max_lines is not None and len(lines) >= max_lines:
|
| 66 |
break
|
| 67 |
remaining = word
|
| 68 |
+
while remaining and (max_lines is None or len(lines) < max_lines):
|
| 69 |
cut = len(remaining)
|
| 70 |
while cut > 1 and pdf.stringWidth(remaining[:cut], font, size) > width:
|
| 71 |
cut -= 1
|
|
|
|
| 80 |
else:
|
| 81 |
lines.append(" ".join(current))
|
| 82 |
current = [word]
|
| 83 |
+
if max_lines is not None and len(lines) >= max_lines:
|
| 84 |
current = []
|
| 85 |
break
|
| 86 |
+
if current and (max_lines is None or len(lines) < max_lines):
|
| 87 |
lines.append(" ".join(current))
|
| 88 |
return lines
|
| 89 |
|
|
|
|
| 95 |
y: float,
|
| 96 |
width: float,
|
| 97 |
leading: float,
|
| 98 |
+
max_lines: Optional[int],
|
| 99 |
font: str,
|
| 100 |
size: int,
|
| 101 |
) -> float:
|
|
|
|
| 109 |
return y
|
| 110 |
|
| 111 |
|
| 112 |
+
def _truncate_to_width(
|
| 113 |
+
pdf: canvas.Canvas,
|
| 114 |
+
text: str,
|
| 115 |
+
width: float,
|
| 116 |
+
font: str,
|
| 117 |
+
size: float,
|
| 118 |
+
) -> str:
|
| 119 |
+
if pdf.stringWidth(text, font, size) <= width:
|
| 120 |
+
return text
|
| 121 |
+
ellipsis = "..."
|
| 122 |
+
if pdf.stringWidth(ellipsis, font, size) >= width:
|
| 123 |
+
return ""
|
| 124 |
+
cut = len(text)
|
| 125 |
+
while cut > 0:
|
| 126 |
+
candidate = f"{text[:cut].rstrip()}{ellipsis}"
|
| 127 |
+
if pdf.stringWidth(candidate, font, size) <= width:
|
| 128 |
+
return candidate
|
| 129 |
+
cut -= 1
|
| 130 |
+
return ""
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _fit_wrapped_text(
|
| 134 |
+
pdf: canvas.Canvas,
|
| 135 |
+
text: str,
|
| 136 |
+
width: float,
|
| 137 |
+
font: str,
|
| 138 |
+
preferred_size: float,
|
| 139 |
+
min_size: float = 7.0,
|
| 140 |
+
) -> Tuple[List[str], float, float]:
|
| 141 |
+
size = float(preferred_size)
|
| 142 |
+
while size >= min_size:
|
| 143 |
+
lines = _wrap_lines(pdf, text, width, None, font, size)
|
| 144 |
+
if not lines:
|
| 145 |
+
return [], size, max(9.0, size + 1.0)
|
| 146 |
+
if max(pdf.stringWidth(line, font, size) for line in lines) <= width + 0.1:
|
| 147 |
+
leading = max(9.0, size + 1.0)
|
| 148 |
+
return lines, size, leading
|
| 149 |
+
size -= 0.5
|
| 150 |
+
|
| 151 |
+
lines = _wrap_lines(pdf, text, width, None, font, min_size)
|
| 152 |
+
safe_lines = [
|
| 153 |
+
_truncate_to_width(pdf, line, width, font, min_size) for line in lines
|
| 154 |
+
]
|
| 155 |
+
leading = max(9.0, min_size + 1.0)
|
| 156 |
+
return safe_lines, min_size, leading
|
| 157 |
+
|
| 158 |
+
|
| 159 |
def _draw_centered_block(
|
| 160 |
pdf: canvas.Canvas,
|
| 161 |
lines: List[str],
|
|
|
|
| 247 |
value = _safe_text(raw)
|
| 248 |
if not value:
|
| 249 |
return None
|
| 250 |
+
|
| 251 |
+
def normalize_lookup(value_raw: str) -> str:
|
| 252 |
+
return re.sub(r"[^a-z0-9]", "", str(value_raw or "").strip().lower())
|
| 253 |
+
|
| 254 |
uploads = (session.get("uploads") or {}).get("photos") or []
|
| 255 |
+
lookup_key = normalize_lookup(value)
|
| 256 |
for item in uploads:
|
| 257 |
if value == (item.get("id") or ""):
|
| 258 |
path = store.resolve_upload_path(session, item.get("id"))
|
| 259 |
if path and path.exists():
|
| 260 |
return path
|
| 261 |
+
name = item.get("name") or ""
|
| 262 |
if not name:
|
| 263 |
continue
|
| 264 |
stem = name.rsplit(".", 1)[0]
|
| 265 |
+
if lookup_key in {normalize_lookup(name), normalize_lookup(stem)}:
|
| 266 |
path = store.resolve_upload_path(session, item.get("id"))
|
| 267 |
if path and path.exists():
|
| 268 |
return path
|
|
|
|
| 276 |
y: float,
|
| 277 |
width: float,
|
| 278 |
height: float,
|
| 279 |
+
image_cache: Optional[Dict[Tuple[str, int, int], Tuple[ImageReader, int, int, BytesIO]]] = None,
|
| 280 |
) -> bool:
|
| 281 |
+
def _resample_filter() -> int:
|
| 282 |
+
resampling = getattr(Image, "Resampling", None)
|
| 283 |
+
if resampling is not None:
|
| 284 |
+
return int(resampling.LANCZOS)
|
| 285 |
+
return int(Image.LANCZOS)
|
| 286 |
+
|
| 287 |
+
def _prepare_reader() -> Optional[Tuple[ImageReader, int, int]]:
|
| 288 |
+
if image_cache is None:
|
| 289 |
+
return None
|
| 290 |
+
|
| 291 |
+
key = (
|
| 292 |
+
str(image_path.resolve()),
|
| 293 |
+
max(1, int(round(width))),
|
| 294 |
+
max(1, int(round(height))),
|
| 295 |
+
)
|
| 296 |
+
cached = image_cache.get(key)
|
| 297 |
+
if cached:
|
| 298 |
+
return cached[0], cached[1], cached[2]
|
| 299 |
+
|
| 300 |
try:
|
| 301 |
+
with Image.open(image_path) as source:
|
| 302 |
+
source.load()
|
| 303 |
+
image = source.copy()
|
| 304 |
+
except Exception:
|
| 305 |
+
return None
|
| 306 |
+
|
| 307 |
+
if image.width <= 0 or image.height <= 0:
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
resample = _resample_filter()
|
| 311 |
+
target_w_px = max(1, int(round(width * PDF_IMAGE_TARGET_DPI / 72.0)))
|
| 312 |
+
target_h_px = max(1, int(round(height * PDF_IMAGE_TARGET_DPI / 72.0)))
|
| 313 |
+
image.thumbnail((target_w_px, target_h_px), resample)
|
| 314 |
+
|
| 315 |
+
long_edge = max(image.width, image.height)
|
| 316 |
+
if long_edge > PDF_IMAGE_MAX_LONG_EDGE_PX:
|
| 317 |
+
ratio = PDF_IMAGE_MAX_LONG_EDGE_PX / float(long_edge)
|
| 318 |
+
image = image.resize(
|
| 319 |
+
(
|
| 320 |
+
max(1, int(round(image.width * ratio))),
|
| 321 |
+
max(1, int(round(image.height * ratio))),
|
| 322 |
+
),
|
| 323 |
+
resample,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
suffix = image_path.suffix.lower()
|
| 327 |
+
buffer = BytesIO()
|
| 328 |
+
try:
|
| 329 |
+
if suffix == ".png" and max(image.width, image.height) <= PDF_IMAGE_PNG_PRESERVE_MAX_PX:
|
| 330 |
+
image.save(buffer, format="PNG", optimize=True)
|
| 331 |
+
else:
|
| 332 |
+
if image.mode in ("RGBA", "LA", "P"):
|
| 333 |
+
alpha_source = image.convert("RGBA")
|
| 334 |
+
flattened = Image.new("RGB", alpha_source.size, (255, 255, 255))
|
| 335 |
+
flattened.paste(alpha_source, mask=alpha_source.split()[-1])
|
| 336 |
+
image = flattened
|
| 337 |
+
elif image.mode not in ("RGB", "L"):
|
| 338 |
+
image = image.convert("RGB")
|
| 339 |
+
image.save(
|
| 340 |
+
buffer,
|
| 341 |
+
format="JPEG",
|
| 342 |
+
quality=PDF_IMAGE_JPEG_QUALITY,
|
| 343 |
+
optimize=True,
|
| 344 |
+
progressive=True,
|
| 345 |
+
)
|
| 346 |
+
buffer.seek(0)
|
| 347 |
+
reader = ImageReader(buffer)
|
| 348 |
+
iw, ih = reader.getSize()
|
| 349 |
+
image_cache[key] = (reader, iw, ih, buffer)
|
| 350 |
+
return reader, iw, ih
|
| 351 |
+
except Exception:
|
| 352 |
+
buffer.close()
|
| 353 |
+
return None
|
| 354 |
+
|
| 355 |
+
prepared = _prepare_reader()
|
| 356 |
+
if prepared:
|
| 357 |
+
reader, iw, ih = prepared
|
| 358 |
+
else:
|
| 359 |
+
reader = None
|
| 360 |
+
iw = 0
|
| 361 |
+
ih = 0
|
| 362 |
+
|
| 363 |
+
if reader is None:
|
| 364 |
+
try:
|
| 365 |
+
reader = ImageReader(str(image_path))
|
| 366 |
iw, ih = reader.getSize()
|
| 367 |
except Exception:
|
| 368 |
try:
|
| 369 |
+
img = Image.open(image_path)
|
| 370 |
+
img.load()
|
| 371 |
+
if img.mode not in ("RGB", "L"):
|
| 372 |
+
img = img.convert("RGB")
|
| 373 |
+
reader = ImageReader(img)
|
| 374 |
+
iw, ih = reader.getSize()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
except Exception:
|
| 376 |
+
try:
|
| 377 |
+
pdf.drawImage(
|
| 378 |
+
str(image_path),
|
| 379 |
+
x,
|
| 380 |
+
y,
|
| 381 |
+
width,
|
| 382 |
+
height,
|
| 383 |
+
preserveAspectRatio=True,
|
| 384 |
+
mask="auto",
|
| 385 |
+
)
|
| 386 |
+
return True
|
| 387 |
+
except Exception:
|
| 388 |
+
return False
|
| 389 |
if iw <= 0 or ih <= 0:
|
| 390 |
return False
|
| 391 |
scale = min(width / iw, height / ih)
|
|
|
|
| 451 |
red_200 = colors.HexColor("#fecaca")
|
| 452 |
|
| 453 |
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 454 |
+
pdf = canvas.Canvas(str(output_path), pagesize=A4, pageCompression=1)
|
| 455 |
+
image_cache: Dict[Tuple[str, int, int], Tuple[ImageReader, int, int, BytesIO]] = {}
|
| 456 |
|
| 457 |
uploads = (session.get("uploads") or {}).get("photos") or []
|
| 458 |
by_id = {item.get("id"): item for item in uploads if item.get("id")}
|
|
|
|
| 578 |
logo_y = header_y - (header_h + logo_h) / 2
|
| 579 |
logo_drawn = False
|
| 580 |
if default_logo:
|
| 581 |
+
logo_drawn = _draw_image_fit(
|
| 582 |
+
pdf,
|
| 583 |
+
default_logo,
|
| 584 |
+
logo_x,
|
| 585 |
+
logo_y,
|
| 586 |
+
logo_w,
|
| 587 |
+
logo_h,
|
| 588 |
+
image_cache=image_cache,
|
| 589 |
+
)
|
| 590 |
if not logo_drawn:
|
| 591 |
pdf.setStrokeColor(colors.red)
|
| 592 |
pdf.setLineWidth(1)
|
|
|
|
| 611 |
client_logo_y,
|
| 612 |
client_logo_w,
|
| 613 |
client_logo_h,
|
| 614 |
+
image_cache=image_cache,
|
| 615 |
)
|
| 616 |
else:
|
| 617 |
pdf.setStrokeColor(gray_200)
|
|
|
|
| 812 |
y -= 3 * mm
|
| 813 |
pdf.setFillColor(gray_50)
|
| 814 |
pdf.setStrokeColor(gray_200)
|
| 815 |
+
cond_lines, cond_font_size, cond_leading = _fit_wrapped_text(
|
| 816 |
pdf,
|
| 817 |
condition or "",
|
| 818 |
+
width - 2 * margin - 8 * mm,
|
| 819 |
+
"Helvetica-Bold",
|
| 820 |
+
10.0,
|
|
|
|
| 821 |
)
|
| 822 |
+
cond_h = max(10 * mm, (len(cond_lines) or 1) * cond_leading + 4 * mm)
|
| 823 |
cond_bottom = y - cond_h
|
| 824 |
pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
|
| 825 |
pdf.setLineWidth(2)
|
|
|
|
| 833 |
text_center_x,
|
| 834 |
cond_bottom,
|
| 835 |
cond_h,
|
| 836 |
+
cond_leading,
|
| 837 |
"Helvetica-Bold",
|
| 838 |
+
cond_font_size,
|
| 839 |
)
|
| 840 |
y = cond_bottom - 4 * mm
|
| 841 |
|
|
|
|
| 847 |
y -= 3 * mm
|
| 848 |
pdf.setFillColor(gray_50)
|
| 849 |
pdf.setStrokeColor(gray_200)
|
| 850 |
+
action_lines, action_font_size, action_leading = _fit_wrapped_text(
|
| 851 |
pdf,
|
| 852 |
action or "",
|
| 853 |
+
width - 2 * margin - 8 * mm,
|
| 854 |
+
"Helvetica-Bold",
|
| 855 |
+
10.0,
|
|
|
|
| 856 |
)
|
| 857 |
+
action_h = max(10 * mm, (len(action_lines) or 1) * action_leading + 4 * mm)
|
| 858 |
action_bottom = y - action_h
|
| 859 |
pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
|
| 860 |
pdf.setLineWidth(2)
|
|
|
|
| 868 |
text_center_x,
|
| 869 |
action_bottom,
|
| 870 |
action_h,
|
| 871 |
+
action_leading,
|
| 872 |
"Helvetica-Bold",
|
| 873 |
+
action_font_size,
|
| 874 |
)
|
| 875 |
y = action_bottom - 4 * mm
|
| 876 |
else:
|
|
|
|
| 934 |
image_y,
|
| 935 |
cell_w - 4 * mm,
|
| 936 |
image_h,
|
| 937 |
+
image_cache=image_cache,
|
| 938 |
)
|
| 939 |
if caption_lines:
|
| 940 |
pdf.setFillColor(gray_500)
|
server/app/services/session_store.py
CHANGED
|
@@ -20,6 +20,9 @@ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
| 20 |
DOC_EXTS = {".pdf", ".doc", ".docx"}
|
| 21 |
DATA_EXTS = {".csv", ".xls", ".xlsx"}
|
| 22 |
EXIF_NORMALIZE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
|
|
|
|
|
|
|
|
|
| 23 |
SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
| 24 |
BUILTIN_PAGE_TEMPLATES = [
|
| 25 |
{
|
|
@@ -92,14 +95,42 @@ def _normalize_uploaded_photo(path: Path) -> None:
|
|
| 92 |
try:
|
| 93 |
with Image.open(path) as image:
|
| 94 |
normalized = ImageOps.exif_transpose(image)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
if
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
except (UnidentifiedImageError, OSError, ValueError, TypeError):
|
| 104 |
# Keep original bytes if the file cannot be decoded by Pillow.
|
| 105 |
return
|
|
|
|
| 20 |
DOC_EXTS = {".pdf", ".doc", ".docx"}
|
| 21 |
DATA_EXTS = {".csv", ".xls", ".xlsx"}
|
| 22 |
EXIF_NORMALIZE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
| 23 |
+
UPLOAD_IMAGE_MAX_LONG_EDGE_PX = 2400
|
| 24 |
+
UPLOAD_JPEG_QUALITY = 82
|
| 25 |
+
UPLOAD_WEBP_QUALITY = 80
|
| 26 |
SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
| 27 |
BUILTIN_PAGE_TEMPLATES = [
|
| 28 |
{
|
|
|
|
| 95 |
try:
|
| 96 |
with Image.open(path) as image:
|
| 97 |
normalized = ImageOps.exif_transpose(image)
|
| 98 |
+
|
| 99 |
+
resampling = getattr(Image, "Resampling", None)
|
| 100 |
+
lanczos = resampling.LANCZOS if resampling is not None else Image.LANCZOS
|
| 101 |
+
long_edge = max(normalized.width, normalized.height)
|
| 102 |
+
if long_edge > UPLOAD_IMAGE_MAX_LONG_EDGE_PX:
|
| 103 |
+
ratio = UPLOAD_IMAGE_MAX_LONG_EDGE_PX / float(long_edge)
|
| 104 |
+
normalized = normalized.resize(
|
| 105 |
+
(
|
| 106 |
+
max(1, int(round(normalized.width * ratio))),
|
| 107 |
+
max(1, int(round(normalized.height * ratio))),
|
| 108 |
+
),
|
| 109 |
+
lanczos,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if ext in {".jpg", ".jpeg"}:
|
| 113 |
+
if normalized.mode in ("RGBA", "LA", "P"):
|
| 114 |
+
normalized = normalized.convert("RGB")
|
| 115 |
+
normalized.save(
|
| 116 |
+
path,
|
| 117 |
+
format="JPEG",
|
| 118 |
+
quality=UPLOAD_JPEG_QUALITY,
|
| 119 |
+
optimize=True,
|
| 120 |
+
progressive=True,
|
| 121 |
+
exif=b"",
|
| 122 |
+
)
|
| 123 |
+
elif ext == ".webp":
|
| 124 |
+
if normalized.mode not in ("RGB", "RGBA"):
|
| 125 |
+
normalized = normalized.convert("RGB")
|
| 126 |
+
normalized.save(
|
| 127 |
+
path,
|
| 128 |
+
format="WEBP",
|
| 129 |
+
quality=UPLOAD_WEBP_QUALITY,
|
| 130 |
+
method=6,
|
| 131 |
+
)
|
| 132 |
+
else: # png
|
| 133 |
+
normalized.save(path, format="PNG", optimize=True)
|
| 134 |
except (UnidentifiedImageError, OSError, ValueError, TypeError):
|
| 135 |
# Keep original bytes if the file cannot be decoded by Pillow.
|
| 136 |
return
|