5760 lines
203 KiB
HTML
5760 lines
203 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Files - CrossPoint Reader</title>
|
||
<script src="/js/jszip.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--font-color: #333;
|
||
--bg: #f5f5f5;
|
||
--title-color: #2c3e50;
|
||
--card-bg: #FFF;
|
||
--label-color: #7f8c8d;
|
||
--border-color: #eee;
|
||
--accent-color: rgb(110, 154, 130);
|
||
--accent-color-10: rgba(110, 154, 130, 0.1);
|
||
--accent-hover-color: #5a8c73;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--font-color: #f5f5f5;
|
||
--bg: #333;
|
||
--title-color: #ecf0f1;
|
||
--card-bg: #444;
|
||
--label-color: #bdc3c7;
|
||
--border-color: #555;
|
||
color-scheme: dark;
|
||
}
|
||
}
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
Oxygen, Ubuntu, sans-serif;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
background-color: var(--bg);
|
||
color: var(--font-color);
|
||
}
|
||
h1 {
|
||
color: var(--title-color);
|
||
border-bottom: 2px solid var(--accent-color);
|
||
padding-bottom: 10px;
|
||
}
|
||
h2 {
|
||
color: var(--title-color);
|
||
margin: 0;
|
||
}
|
||
.card {
|
||
background: var(--card-bg);
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin: 15px 0;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.page-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.breadcrumb-inline {
|
||
color: var(--label-color);
|
||
font-size: 1.1em;
|
||
}
|
||
.breadcrumb-inline a {
|
||
color: var(--accent-color);
|
||
text-decoration: none;
|
||
}
|
||
.breadcrumb-inline a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.breadcrumb-inline .sep {
|
||
margin: 0 6px;
|
||
color: var(--label-color);
|
||
}
|
||
.breadcrumb-inline .current {
|
||
color: var(--title-color);
|
||
font-weight: 600;
|
||
}
|
||
.nav-links {
|
||
margin: 20px 0;
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.nav-links a {
|
||
padding: 10px 20px;
|
||
color: var(--font-color);
|
||
text-decoration: none;
|
||
border-radius: 4px;
|
||
}
|
||
.nav-links a.active {
|
||
background-color: var(--accent-color);
|
||
color: white;
|
||
}
|
||
.nav-links a:not(.active):hover {
|
||
background-color: var(--accent-hover-color);
|
||
color: white;
|
||
}
|
||
/* Action buttons */
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.action-btn {
|
||
color: white;
|
||
padding: 10px 16px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.95em;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.upload-action-btn {
|
||
background-color: #27ae60;
|
||
}
|
||
.upload-action-btn:hover {
|
||
background-color: #219a52;
|
||
}
|
||
.folder-action-btn {
|
||
background-color: #f39c12;
|
||
}
|
||
.folder-action-btn:hover {
|
||
background-color: #d68910;
|
||
}
|
||
.delete-action-btn {
|
||
background-color: #e74c3c;
|
||
}
|
||
.delete-action-btn:hover {
|
||
background-color: #c0392b;
|
||
}
|
||
/* Upload modal */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 200;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.modal-overlay.open {
|
||
display: flex;
|
||
}
|
||
.modal {
|
||
background: var(--card-bg);
|
||
border-radius: 8px;
|
||
padding: 25px;
|
||
max-width: 450px;
|
||
width: 90%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.modal.picker-mode {
|
||
max-width: 920px;
|
||
}
|
||
.modal.image-preview-mode {
|
||
max-width: 640px;
|
||
}
|
||
.image-preview-stage {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
overflow: auto;
|
||
margin-bottom: 15px;
|
||
border-radius: 6px;
|
||
}
|
||
.image-preview-stage img {
|
||
max-width: 100%;
|
||
max-height: 65vh;
|
||
object-fit: contain;
|
||
}
|
||
#imagePreviewDownload {
|
||
display: inline-block;
|
||
width: auto;
|
||
text-decoration: none;
|
||
}
|
||
.picker-columns.picker-active {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
}
|
||
.picker-columns.picker-active > .convert-options {
|
||
flex: 0 0 340px;
|
||
width: 340px;
|
||
margin-bottom: 0;
|
||
}
|
||
.picker-columns.picker-active > .image-picker-section {
|
||
flex: 1;
|
||
margin-top: 0;
|
||
max-height: 55vh;
|
||
overflow-y: auto;
|
||
}
|
||
@media (max-width: 850px) {
|
||
.modal.picker-mode { max-width: 95%; }
|
||
.picker-columns.picker-active {
|
||
flex-direction: column;
|
||
}
|
||
.picker-columns.picker-active > .convert-options {
|
||
flex: 0 0 auto;
|
||
width: 100%;
|
||
}
|
||
.picker-columns.picker-active > .image-picker-section {
|
||
max-height: 40vh;
|
||
}
|
||
}
|
||
.modal h3 {
|
||
margin: 0 0 15px 0;
|
||
color: var(--title-color);
|
||
}
|
||
.modal-close {
|
||
float: right;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5em;
|
||
cursor: pointer;
|
||
color: var(--label-color);
|
||
line-height: 1;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.modal-close:hover {
|
||
color: var(--title-color);
|
||
}
|
||
.modal-close.disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
.modal-close.disabled:hover {
|
||
color: var(--label-color);
|
||
}
|
||
.file-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
.file-table th,
|
||
.file-table td {
|
||
padding: 12px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
.file-table th {
|
||
font-weight: 600;
|
||
color: var(--label-color);
|
||
}
|
||
.file-table tr:hover {
|
||
background-color: var(--accent-color-10);
|
||
}
|
||
.epub-badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
background-color: #27ae60;
|
||
color: white;
|
||
border-radius: 10px;
|
||
font-size: 0.75em;
|
||
}
|
||
.folder-badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
background-color: #f39c12;
|
||
color: white;
|
||
border-radius: 10px;
|
||
font-size: 0.75em;
|
||
}
|
||
.file-icon {
|
||
margin-right: 8px;
|
||
}
|
||
.folder-link,
|
||
.file-link {
|
||
word-break: break-word;
|
||
color: var(--accent-color);
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
.folder-link:hover,
|
||
.file-link:hover {
|
||
color: var(--accent-hover-color);
|
||
text-decoration: underline;
|
||
}
|
||
.upload-form {
|
||
margin-bottom: 10px;
|
||
}
|
||
.upload-form input[type="file"] {
|
||
margin: 10px 0;
|
||
width: 100%;
|
||
}
|
||
.upload-form input[type="file"]::file-selector-button {
|
||
background-color: #27ae60;
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 14px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.9em;
|
||
margin-right: 8px;
|
||
}
|
||
.upload-form input[type="file"].has-files::file-selector-button {
|
||
background-color: #95a5a6;
|
||
}
|
||
.drop-zone {
|
||
border: 2px dashed var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 14px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background-color 0.15s;
|
||
}
|
||
.drop-zone.dragover {
|
||
border-color: var(--accent-color);
|
||
background-color: var(--accent-color-10);
|
||
}
|
||
.drop-zone-hint {
|
||
margin: 8px 0 0;
|
||
font-size: 0.85em;
|
||
color: var(--label-color);
|
||
pointer-events: none;
|
||
}
|
||
.drop-zone.dragover .drop-zone-hint {
|
||
color: var(--accent-color);
|
||
}
|
||
.upload-btn {
|
||
background-color: #27ae60;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
width: 100%;
|
||
margin-top: 12px;
|
||
}
|
||
.upload-btn:hover {
|
||
background-color: #219a52;
|
||
}
|
||
.upload-btn.optimize {
|
||
background-color: #9b59b6;
|
||
}
|
||
.upload-btn.optimize:hover {
|
||
background-color: #8e44ad;
|
||
}
|
||
.upload-btn:disabled {
|
||
background-color: #95a5a6;
|
||
cursor: not-allowed;
|
||
}
|
||
/* Convert options */
|
||
.convert-options {
|
||
margin-bottom: 10px;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--accent-color);
|
||
}
|
||
.convert-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.convert-checkbox input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
.convert-checkbox input[type="checkbox"]:focus {
|
||
outline: none;
|
||
box-shadow: none;
|
||
}
|
||
.convert-warning {
|
||
font-size: 0.85em;
|
||
color: #d68910;
|
||
background-color: rgba(214, 137, 16, 0.1);
|
||
border: 1px solid #d68910;
|
||
border-radius: 4px;
|
||
padding: 8px 12px;
|
||
margin-top: 8px;
|
||
margin-bottom: 12px;
|
||
line-height: 1.4;
|
||
text-align: center;
|
||
}
|
||
.convert-info {
|
||
font-size: 0.85em;
|
||
color: var(--label-color);
|
||
margin-top: 8px;
|
||
line-height: 1.4;
|
||
}
|
||
.convert-settings {
|
||
font-size: 0.8em;
|
||
color: var(--label-color);
|
||
margin-top: 6px;
|
||
padding-left: 26px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
.convert-settings span {
|
||
display: inline-block;
|
||
}
|
||
/* Convert row - compact layout with checkbox and advanced options toggle */
|
||
.convert-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
}
|
||
.convert-separator {
|
||
display: none; /* Hidden, using space-between instead */
|
||
}
|
||
.advanced-options-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 0.85em;
|
||
color: var(--label-color);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: color 0.2s;
|
||
}
|
||
.advanced-options-toggle:hover {
|
||
color: var(--accent-color);
|
||
}
|
||
/* Disable hover when disabled (opacity < 1) */
|
||
.advanced-options-toggle[style*="opacity: 0.3"]:hover,
|
||
.advanced-options-toggle[style*="opacity: 0.5"]:hover {
|
||
color: var(--label-color);
|
||
}
|
||
.advanced-options-text {
|
||
font-weight: 500;
|
||
}
|
||
/* Advanced Options */
|
||
.advanced-options-arrow {
|
||
font-size: 0.7em;
|
||
transition: transform 0.2s;
|
||
}
|
||
.advanced-options-arrow.expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
/* Settings content - hidden by default, shown when expanded */
|
||
.advanced-settings-content {
|
||
display: none;
|
||
margin-top: 12px;
|
||
padding-left: 4px;
|
||
}
|
||
.advanced-settings-content.visible {
|
||
display: block;
|
||
}
|
||
.advanced-setting-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
.advanced-setting-row:first-child {
|
||
margin-top: 10px;
|
||
}
|
||
.quality-row {
|
||
flex-wrap: wrap;
|
||
}
|
||
.quality-row .setting-title {
|
||
flex: 1;
|
||
min-width: 100px;
|
||
}
|
||
.setting-label {
|
||
flex-shrink: 0;
|
||
}
|
||
.setting-controls {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
}
|
||
.setting-row-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.setting-title {
|
||
font-size: 0.85em;
|
||
color: var(--title-color);
|
||
font-weight: 500;
|
||
}
|
||
.setting-value {
|
||
font-size: 0.8em;
|
||
color: var(--accent-color);
|
||
font-weight: 600;
|
||
}
|
||
/* Quality Slider */
|
||
.quality-slider-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.quality-slider {
|
||
flex: 1;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: var(--border-color);
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
cursor: pointer;
|
||
}
|
||
.quality-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: var(--accent-color);
|
||
cursor: pointer;
|
||
}
|
||
.quality-slider::-moz-range-thumb {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: var(--accent-color);
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
.quality-input-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: 4px 8px;
|
||
}
|
||
.quality-input {
|
||
width: 40px;
|
||
border: none;
|
||
background: transparent;
|
||
text-align: center;
|
||
font-size: 0.85em;
|
||
color: var(--title-color);
|
||
font-weight: 600;
|
||
}
|
||
.quality-input:focus {
|
||
outline: none;
|
||
}
|
||
.quality-unit {
|
||
font-size: 0.75em;
|
||
color: var(--label-color);
|
||
}
|
||
.quality-slider-container-inline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex: 1;
|
||
max-width: 180px;
|
||
min-width: 0;
|
||
}
|
||
.quality-slider-container-inline .quality-slider {
|
||
flex: 1;
|
||
min-width: 50px;
|
||
}
|
||
.quality-slider-container-inline .quality-input-wrapper {
|
||
padding: 2px 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
.quality-slider-container-inline .quality-input {
|
||
width: 32px;
|
||
font-size: 0.8em;
|
||
}
|
||
/* Quality Presets - Compact Single Row */
|
||
.quality-presets {
|
||
display: flex;
|
||
gap: 0;
|
||
background: var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 3px;
|
||
width: fit-content;
|
||
}
|
||
.quality-preset {
|
||
padding: 6px 14px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 0.75em;
|
||
font-weight: 500;
|
||
color: var(--label-color);
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.quality-preset:hover {
|
||
background: var(--accent-color-10);
|
||
color: var(--accent-color);
|
||
}
|
||
.quality-preset.active {
|
||
background: var(--accent-color);
|
||
color: white;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||
}
|
||
/* Toggle Switch */
|
||
.setting-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.toggle-switch {
|
||
position: relative;
|
||
width: 44px;
|
||
height: 24px;
|
||
flex-shrink: 0;
|
||
}
|
||
.toggle-switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
.toggle-slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
inset: 0;
|
||
background: var(--border-color);
|
||
border-radius: 24px;
|
||
transition: 0.2s;
|
||
}
|
||
.toggle-slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 18px;
|
||
width: 18px;
|
||
left: 3px;
|
||
bottom: 3px;
|
||
background: white;
|
||
border-radius: 50%;
|
||
transition: 0.2s;
|
||
}
|
||
.toggle-switch input:checked + .toggle-slider {
|
||
background: var(--accent-color);
|
||
}
|
||
.toggle-switch input:checked + .toggle-slider:before {
|
||
transform: translateX(20px);
|
||
}
|
||
.setting-desc {
|
||
font-size: 0.75em;
|
||
color: var(--label-color);
|
||
margin-top: 4px;
|
||
line-height: 1.3;
|
||
}
|
||
/* Rotation Buttons */
|
||
.rotation-buttons {
|
||
display: flex;
|
||
gap: 0;
|
||
background: var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 3px;
|
||
}
|
||
.rotation-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px 14px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 0.75em;
|
||
font-weight: 500;
|
||
color: var(--label-color);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.rotation-btn:hover {
|
||
background: var(--accent-color-10);
|
||
color: var(--accent-color);
|
||
}
|
||
.rotation-btn.active {
|
||
background: var(--accent-color);
|
||
color: white;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||
}
|
||
.rotation-btn .rot-icon {
|
||
font-size: 1.1em;
|
||
}
|
||
/* Overlap Selector */
|
||
.overlap-selector {
|
||
display: flex;
|
||
gap: 0;
|
||
background: var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 3px;
|
||
}
|
||
.overlap-btn {
|
||
padding: 6px 14px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 0.75em;
|
||
font-weight: 500;
|
||
color: var(--label-color);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.overlap-btn:hover {
|
||
background: var(--accent-color-10);
|
||
color: var(--accent-color);
|
||
}
|
||
.overlap-btn.active {
|
||
background: var(--accent-color);
|
||
color: white;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||
}
|
||
/* Image Picker Grid Styles */
|
||
.image-picker-section {
|
||
margin-top: 15px;
|
||
padding: 12px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
max-height: 350px;
|
||
overflow-y: auto;
|
||
}
|
||
.image-picker-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.image-picker-title {
|
||
font-size: 0.85em;
|
||
font-weight: 600;
|
||
color: var(--title-color);
|
||
}
|
||
.image-picker-count {
|
||
font-size: 0.75em;
|
||
color: var(--label-color);
|
||
}
|
||
.image-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
.image-item {
|
||
position: relative;
|
||
border: 2px solid var(--border-color);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
background: var(--card-bg);
|
||
}
|
||
.image-item:hover {
|
||
border-color: var(--accent-color);
|
||
transform: scale(1.02);
|
||
}
|
||
.image-item.state-0 {
|
||
border-color: var(--border-color);
|
||
}
|
||
.image-item.state-1 {
|
||
border-color: #3498db;
|
||
background: rgba(52, 152, 219, 0.1);
|
||
}
|
||
.image-item.state-2 {
|
||
border-color: #e74c3c;
|
||
background: rgba(231, 76, 60, 0.1);
|
||
}
|
||
.image-item.state-3 {
|
||
border-color: #9b59b6;
|
||
background: rgba(155, 89, 182, 0.1);
|
||
}
|
||
.image-item.crop-preview {
|
||
border-color: #16a085;
|
||
background: rgba(22, 160, 133, 0.1);
|
||
}
|
||
.image-item.cover-locked {
|
||
border-color: #e67e22;
|
||
background: rgba(230, 126, 34, 0.1);
|
||
cursor: default;
|
||
opacity: 0.85;
|
||
}
|
||
.image-item.cover-locked:hover {
|
||
transform: none;
|
||
border-color: #e67e22;
|
||
}
|
||
.image-item.cover-locked .image-state-badge {
|
||
background: #e67e22;
|
||
}
|
||
.image-item.separator-locked {
|
||
border-color: #1abc9c;
|
||
background: rgba(26, 188, 156, 0.1);
|
||
cursor: default;
|
||
opacity: 0.85;
|
||
}
|
||
.image-item.separator-locked:hover {
|
||
transform: none;
|
||
border-color: #1abc9c;
|
||
}
|
||
.image-item.separator-locked .image-state-badge {
|
||
background: #1abc9c;
|
||
}
|
||
.image-item img {
|
||
width: 100%;
|
||
height: 80px;
|
||
object-fit: contain;
|
||
display: block;
|
||
background: #fff;
|
||
}
|
||
.image-state-badge {
|
||
position: absolute;
|
||
top: 2px;
|
||
right: 2px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 0.65em;
|
||
font-weight: 600;
|
||
color: white;
|
||
}
|
||
.state-0 .image-state-badge {
|
||
background: var(--label-color);
|
||
}
|
||
.state-1 .image-state-badge {
|
||
background: #3498db;
|
||
}
|
||
.state-2 .image-state-badge {
|
||
background: #e74c3c;
|
||
}
|
||
.state-3 .image-state-badge {
|
||
background: #9b59b6;
|
||
}
|
||
.crop-preview .image-state-badge {
|
||
background: #16a085;
|
||
}
|
||
/* Preview Overlay (shown on hover) */
|
||
.image-preview-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 20px; /* Above the filename */
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
z-index: 5;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.image-item:hover .image-preview-overlay {
|
||
opacity: 1;
|
||
}
|
||
/* Split lines - thick colored stripes */
|
||
.split-lines {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
}
|
||
.split-line {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 4px;
|
||
transform: translateX(-50%);
|
||
border-radius: 2px;
|
||
}
|
||
.split-line.split-h {
|
||
background: linear-gradient(180deg, #3498db 0%, #2980b9 100%);
|
||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.5), 0 0 2px rgba(52, 152, 219, 0.8);
|
||
}
|
||
.split-line.split-v {
|
||
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%);
|
||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.5), 0 0 2px rgba(231, 76, 60, 0.8);
|
||
}
|
||
/* Rotate image on hover for rotation states */
|
||
.image-item.rotate-cw:hover img {
|
||
transform: rotate(90deg) scale(0.75);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
.image-item.rotate-ccw:hover img {
|
||
transform: rotate(-90deg) scale(0.75);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
.image-item img {
|
||
transition: transform 0.2s ease;
|
||
}
|
||
.image-name {
|
||
font-size: 0.65em;
|
||
color: var(--label-color);
|
||
padding: 4px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
text-align: center;
|
||
}
|
||
.state-legend {
|
||
display: flex;
|
||
gap: 8px;
|
||
font-size: 0.7em;
|
||
margin: 10px 0;
|
||
flex-wrap: wrap;
|
||
padding: 8px 12px;
|
||
background: rgba(0,0,0,0.02);
|
||
border-radius: 6px;
|
||
align-items: center;
|
||
}
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
}
|
||
.legend-item.locked {
|
||
opacity: 0.7;
|
||
}
|
||
.legend-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
padding: 4px 10px;
|
||
border: 1px solid var(--btn-color);
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: var(--text-color);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
font-size: inherit;
|
||
font-family: inherit;
|
||
}
|
||
.legend-btn:hover {
|
||
background: var(--btn-color);
|
||
color: white;
|
||
}
|
||
.legend-btn .legend-color {
|
||
background: var(--btn-color);
|
||
}
|
||
.legend-color {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 3px;
|
||
}
|
||
.file-info {
|
||
color: var(--label-color);
|
||
font-size: 0.85em;
|
||
margin: 8px 0;
|
||
}
|
||
.no-files {
|
||
text-align: center;
|
||
color: #95a5a6;
|
||
padding: 40px;
|
||
font-style: italic;
|
||
}
|
||
.message {
|
||
padding: 15px;
|
||
border-radius: 4px;
|
||
margin: 15px 0;
|
||
}
|
||
.message.success {
|
||
background-color: #d4edda;
|
||
color: #155724;
|
||
border: 1px solid #c3e6cb;
|
||
}
|
||
.message.error {
|
||
background-color: #f8d7da;
|
||
color: #721c24;
|
||
border: 1px solid #f5c6cb;
|
||
}
|
||
.contents-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.summary-inline {
|
||
color: var(--label-color);
|
||
font-size: 0.9em;
|
||
}
|
||
#progress-container {
|
||
display: none;
|
||
margin-top: 10px;
|
||
}
|
||
#progress-bar {
|
||
width: 100%;
|
||
height: 20px;
|
||
background-color: #e0e0e0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
#progress-fill {
|
||
height: 100%;
|
||
background-color: #27ae60;
|
||
width: 0%;
|
||
transition: width 0.3s;
|
||
}
|
||
#progress-fill.no-transition {
|
||
transition: none !important;
|
||
}
|
||
#progress-text {
|
||
text-align: center;
|
||
margin-top: 5px;
|
||
font-size: 0.9em;
|
||
color: var(--label-color);
|
||
}
|
||
.folder-form {
|
||
margin-top: 10px;
|
||
}
|
||
.folder-input {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 1em;
|
||
margin-bottom: 10px;
|
||
box-sizing: border-box;
|
||
}
|
||
.folder-btn {
|
||
background-color: #f39c12;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
width: 100%;
|
||
}
|
||
.folder-btn:hover {
|
||
background-color: #d68910;
|
||
}
|
||
.folder-btn:disabled {
|
||
background-color: #bdc3c7;
|
||
cursor: not-allowed;
|
||
}
|
||
/* Action button styles */
|
||
.delete-btn,
|
||
.rename-btn,
|
||
.move-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 1.1em;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
transition: all 0.15s;
|
||
}
|
||
.delete-btn {
|
||
color: #95a5a6;
|
||
}
|
||
.delete-btn:hover {
|
||
background-color: #fee;
|
||
color: #e74c3c;
|
||
}
|
||
.rename-btn {
|
||
color: #2980b9;
|
||
}
|
||
.rename-btn:hover {
|
||
background-color: #e8f4fd;
|
||
}
|
||
.move-btn {
|
||
color: #16a085;
|
||
}
|
||
.move-btn:hover {
|
||
background-color: #e6f7f4;
|
||
}
|
||
.actions-col {
|
||
width: 140px;
|
||
text-align: center;
|
||
}
|
||
.action-icon-group {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
}
|
||
/* Failed uploads banner */
|
||
.failed-uploads-banner {
|
||
background-color: #fff3cd;
|
||
border: 1px solid #ffc107;
|
||
border-radius: 4px;
|
||
padding: 15px;
|
||
margin-bottom: 15px;
|
||
display: none;
|
||
}
|
||
.failed-uploads-banner.show {
|
||
display: block;
|
||
}
|
||
.failed-uploads-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.failed-uploads-title {
|
||
font-weight: 600;
|
||
color: #856404;
|
||
margin: 0;
|
||
}
|
||
.dismiss-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.2em;
|
||
cursor: pointer;
|
||
color: #856404;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
.dismiss-btn:hover {
|
||
color: #533f03;
|
||
}
|
||
.failed-file-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #ffe69c;
|
||
}
|
||
.failed-file-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.failed-file-info {
|
||
flex: 1;
|
||
}
|
||
.failed-file-name {
|
||
font-weight: 500;
|
||
color: #856404;
|
||
}
|
||
.failed-file-error {
|
||
font-size: 0.85em;
|
||
color: #856404;
|
||
opacity: 0.8;
|
||
}
|
||
.retry-btn {
|
||
background-color: #ffc107;
|
||
color: #533f03;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.9em;
|
||
font-weight: 600;
|
||
}
|
||
.retry-btn:hover {
|
||
background-color: #e0a800;
|
||
}
|
||
.retry-all-btn {
|
||
background-color: #ffc107;
|
||
color: #533f03;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.95em;
|
||
font-weight: 600;
|
||
margin-top: 10px;
|
||
}
|
||
.retry-all-btn:hover {
|
||
background-color: #e0a800;
|
||
}
|
||
/* Delete modal */
|
||
.delete-warning {
|
||
color: #e74c3c;
|
||
font-weight: 600;
|
||
margin: 10px 0;
|
||
}
|
||
.delete-item-name {
|
||
font-weight: 600;
|
||
color: var(--title-color);
|
||
word-break: break-all;
|
||
}
|
||
.delete-btn-confirm {
|
||
background-color: #e74c3c;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
width: 100%;
|
||
}
|
||
.delete-btn-confirm:hover {
|
||
background-color: #c0392b;
|
||
}
|
||
.delete-btn-cancel {
|
||
background-color: #95a5a6;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
width: 100%;
|
||
margin-top: 10px;
|
||
}
|
||
.delete-btn-cancel:hover {
|
||
background-color: var(--label-color);
|
||
}
|
||
.rename-btn-confirm {
|
||
background-color: #3498db;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
width: 100%;
|
||
}
|
||
.rename-btn-confirm:hover {
|
||
background-color: #2e86c1;
|
||
}
|
||
.move-btn-confirm {
|
||
background-color: #16a085;
|
||
color: white;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
width: 100%;
|
||
}
|
||
.move-btn-confirm:hover {
|
||
background-color: #138d75;
|
||
}
|
||
.loader-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin: 20px 0;
|
||
}
|
||
.loader {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 5px solid #AAA;
|
||
border-bottom-color: transparent;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
box-sizing: border-box;
|
||
animation: rotation 1s linear infinite;
|
||
}
|
||
@keyframes rotation {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
/* Mobile responsive styles */
|
||
@media (max-width: 600px) {
|
||
body {
|
||
padding: 10px;
|
||
font-size: 14px;
|
||
}
|
||
.card {
|
||
line-break: anywhere;
|
||
padding: 12px;
|
||
margin: 10px 0;
|
||
}
|
||
.page-header {
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 10px;
|
||
}
|
||
.page-header-left {
|
||
gap: 8px;
|
||
}
|
||
h1 {
|
||
font-size: 1.3em;
|
||
}
|
||
.breadcrumb-inline {
|
||
font-size: 0.95em;
|
||
}
|
||
.nav-links a {
|
||
padding: 8px 12px;
|
||
margin-right: 6px;
|
||
font-size: 0.9em;
|
||
}
|
||
.action-buttons {
|
||
gap: 6px;
|
||
}
|
||
.action-btn {
|
||
padding: 8px 10px;
|
||
font-size: 0.85em;
|
||
}
|
||
.file-table th,
|
||
.file-table td {
|
||
padding: 8px 6px;
|
||
font-size: 0.9em;
|
||
}
|
||
.file-table th {
|
||
font-size: 0.85em;
|
||
}
|
||
.file-icon {
|
||
margin-right: 4px;
|
||
}
|
||
.epub-badge,
|
||
.folder-badge {
|
||
padding: 2px 5px;
|
||
font-size: 0.65em;
|
||
}
|
||
.contents-header {
|
||
margin-bottom: 8px;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
.summary-inline {
|
||
font-size: 0.8em;
|
||
}
|
||
.modal {
|
||
padding: 15px;
|
||
}
|
||
.modal h3 {
|
||
font-size: 1.1em;
|
||
}
|
||
.actions-col {
|
||
width: 120px;
|
||
}
|
||
.delete-btn,
|
||
.rename-btn,
|
||
.move-btn {
|
||
font-size: 1em;
|
||
padding: 2px 4px;
|
||
}
|
||
.action-icon-group {
|
||
gap: 4px;
|
||
}
|
||
.no-files {
|
||
padding: 20px;
|
||
font-size: 0.9em;
|
||
}
|
||
.handedness-btn {
|
||
padding: 6px 10px;
|
||
}
|
||
.handedness-buttons {
|
||
width: 100%;
|
||
justify-content: center;
|
||
}
|
||
.quality-slider-container-inline {
|
||
max-width: 215px;
|
||
gap: 4px;
|
||
}
|
||
.quality-slider-container-inline .quality-slider {
|
||
min-width: 40px;
|
||
}
|
||
.quality-slider-container-inline .quality-input {
|
||
width: 28px;
|
||
}
|
||
}
|
||
/* Enhanced Logging System Styles */
|
||
.log-section {
|
||
margin-top: 15px;
|
||
display: none;
|
||
}
|
||
.log-section.visible {
|
||
display: block;
|
||
}
|
||
.log-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.log-title {
|
||
font-weight: 600;
|
||
color: var(--title-color);
|
||
font-size: 0.95em;
|
||
}
|
||
.log-container {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
|
||
font-size: 0.8em;
|
||
}
|
||
.log-entry {
|
||
color: var(--font-color);
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: flex-start;
|
||
margin-bottom: 4px;
|
||
line-height: 1.4;
|
||
}
|
||
.log-entry.success {
|
||
color: var(--accent-color);
|
||
}
|
||
.log-entry.error {
|
||
color: #e74c3c;
|
||
}
|
||
.log-entry.warning {
|
||
color: #f39c12;
|
||
}
|
||
.log-entry.info {
|
||
color: #3498db;
|
||
}
|
||
.log-timestamp {
|
||
color: var(--label-color);
|
||
font-size: 0.75em;
|
||
min-width: 52px;
|
||
opacity: 0.7;
|
||
}
|
||
.log-tag {
|
||
font-size: 0.7em;
|
||
font-weight: 600;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
text-transform: uppercase;
|
||
min-width: 56px;
|
||
text-align: center;
|
||
}
|
||
.log-tag.convert {
|
||
background: rgba(110, 154, 130, 0.15);
|
||
color: var(--accent-color);
|
||
}
|
||
.log-tag.split {
|
||
background: rgba(243, 156, 18, 0.15);
|
||
color: #f39c12;
|
||
}
|
||
.log-tag.fix {
|
||
background: rgba(39, 174, 96, 0.15);
|
||
color: #27ae60;
|
||
}
|
||
.log-tag.skip {
|
||
background: rgba(127, 140, 141, 0.15);
|
||
color: #7f8c8d;
|
||
}
|
||
.log-tag.info {
|
||
background: rgba(52, 152, 219, 0.15);
|
||
color: #3498db;
|
||
}
|
||
.log-tag.error {
|
||
background: rgba(231, 76, 60, 0.15);
|
||
color: #e74c3c;
|
||
}
|
||
.log-tag.done {
|
||
background: rgba(39, 174, 96, 0.15);
|
||
color: #27ae60;
|
||
}
|
||
.log-message {
|
||
flex: 1;
|
||
word-break: break-word;
|
||
}
|
||
.log-detail {
|
||
color: var(--label-color);
|
||
font-size: 0.9em;
|
||
}
|
||
.log-summary {
|
||
background: rgba(110, 154, 130, 0.08);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
margin-top: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.log-summary-title {
|
||
background: rgba(110, 154, 130, 0.15);
|
||
padding: 8px 12px;
|
||
font-weight: 600;
|
||
font-size: 0.85em;
|
||
border-bottom: 1px solid var(--border-color);
|
||
color: var(--accent-color);
|
||
}
|
||
.log-summary-table {
|
||
width: 100%;
|
||
font-size: 0.8em;
|
||
}
|
||
.log-summary-table td {
|
||
padding: 6px 12px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
.log-summary-table tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
.log-summary-table td:first-child {
|
||
color: var(--label-color);
|
||
width: 45%;
|
||
}
|
||
.log-summary-table td:last-child {
|
||
color: var(--font-color);
|
||
font-weight: 500;
|
||
text-align: right;
|
||
}
|
||
.log-summary-table .highlight {
|
||
color: var(--accent-color);
|
||
}
|
||
.log-summary-table .saved {
|
||
color: #27ae60;
|
||
}
|
||
.log-summary-table .increased {
|
||
color: #f39c12;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>📚 CrossPoint Reader</h1>
|
||
|
||
<div class="nav-links">
|
||
<a href="/">Home</a>
|
||
<a href="/files" class="active">File Manager</a>
|
||
<a href="/settings">Settings</a>
|
||
<a href="/fonts">Fonts</a>
|
||
</div>
|
||
|
||
<div class="page-header">
|
||
<div class="page-header-left">
|
||
<h2>📁 File Manager</h2>
|
||
</div>
|
||
|
||
<div class="action-buttons">
|
||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
||
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||
<button class="action-btn delete-action-btn" onclick="openDeleteSelectedModal()">🗑️ Delete Selected</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Failed Uploads Banner -->
|
||
<div class="failed-uploads-banner" id="failedUploadsBanner">
|
||
<div class="failed-uploads-header">
|
||
<h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3>
|
||
<button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">×</button>
|
||
</div>
|
||
<div id="failedFilesList"></div>
|
||
<button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="contents-header">
|
||
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
|
||
<span class="summary-inline" id="folder-summary"></span>
|
||
</div>
|
||
|
||
<div id="file-table">
|
||
<div class="loader-container">
|
||
<span class="loader"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<p style="text-align: center; color: #95a5a6; margin: 0;">
|
||
CrossPoint E-Reader • Open Source
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Upload Modal -->
|
||
<div class="modal-overlay" id="uploadModal">
|
||
<div class="modal">
|
||
<button class="modal-close" id="uploadModalClose" onclick="closeUploadModal()">×</button>
|
||
<h3>📤 Upload file</h3>
|
||
<div class="upload-form">
|
||
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
|
||
<div class="drop-zone" id="dropZone">
|
||
<input type="file" id="fileInput" onchange="validateFile()" multiple>
|
||
<p class="drop-zone-hint">⬇ Drop files here — or click to browse</p>
|
||
</div>
|
||
<div class="picker-columns" id="pickerColumns">
|
||
<div class="convert-options" id="convertOptions" style="display:none;">
|
||
<div class="convert-row">
|
||
<label class="convert-checkbox">
|
||
<input type="checkbox" id="convertBeforeUpload" onchange="toggleConvertOptions()">
|
||
<span>Optimize EPUB</span>
|
||
</label>
|
||
<span class="convert-separator">—</span>
|
||
<span class="advanced-options-toggle" id="advancedOptionsToggle" onclick="toggleAdvancedOptions()">
|
||
<span class="advanced-options-arrow" id="advancedOptionsArrow">▶</span>
|
||
<span class="advanced-options-text">Advanced Mode</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;">
|
||
</div>
|
||
|
||
<div class="convert-settings" id="convertSettings" style="display:block;">
|
||
<span>⚫ True-Grayscale</span>
|
||
<span id="convertSizeSummary">📏 Max 480×800px</span>
|
||
<span>📦 85% JPEG</span>
|
||
<span>🔧 Fix SVG</span>
|
||
</div>
|
||
<!-- Advanced Settings Content -->
|
||
<div class="advanced-settings-content" id="advancedSettingsContent">
|
||
<!-- JPEG Quality -->
|
||
<div class="advanced-setting-row quality-row">
|
||
<div class="setting-title">📦 JPEG Quality</div>
|
||
<div class="quality-presets">
|
||
<button class="quality-preset" data-value="30" onclick="setQualityPreset(30)" title="Minimum quality">30%</button>
|
||
<button class="quality-preset" data-value="45" onclick="setQualityPreset(45)" title="Low quality">45%</button>
|
||
<button class="quality-preset" data-value="60" onclick="setQualityPreset(60)" title="Medium-low quality">60%</button>
|
||
<button class="quality-preset" data-value="75" onclick="setQualityPreset(75)" title="Medium quality">75%</button>
|
||
<button class="quality-preset active" data-value="85" onclick="setQualityPreset(85)" title="High quality">85%</button>
|
||
<button class="quality-preset" data-value="95" onclick="setQualityPreset(95)" title="Maximum quality">95%</button>
|
||
</div>
|
||
<!-- Hidden inputs to maintain compatibility -->
|
||
<input type="hidden" id="qualitySlider" value="85">
|
||
<input type="hidden" id="qualityInput" value="85">
|
||
</div>
|
||
<!-- Grayscale Toggle (commented for e-ink optimization, kept for compatibility with other devices/web apps)
|
||
<div class="advanced-setting-row" style="display:none;">
|
||
<div class="setting-toggle">
|
||
<div>
|
||
<div class="setting-title">⚫ True-Grayscale</div>
|
||
<div class="setting-desc">BT.601 luminance formula (recommended for e-ink)</div>
|
||
</div>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" id="grayscaleToggle" checked onchange="updateQualitySettings()">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
-->
|
||
<!-- Auto-crop Margins -->
|
||
<div class="advanced-setting-row">
|
||
<div class="setting-label">
|
||
<div class="setting-title">✂ Auto-crop Margins</div>
|
||
<div class="setting-desc">Trim uniform borders and scale image content to fit</div>
|
||
</div>
|
||
<div class="setting-controls">
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" id="autoCropToggle" onchange="updateQualitySettings()">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<!-- Target Device -->
|
||
<div class="advanced-setting-row" id="deviceSettingRow">
|
||
<div class="setting-title">📱 Target Device</div>
|
||
<div class="setting-controls">
|
||
<div class="rotation-buttons">
|
||
<button class="rotation-btn device-btn active" data-value="auto" onclick="setDeviceTarget('auto')">
|
||
<span id="deviceAutoLabel">Auto</span>
|
||
</button>
|
||
<button class="rotation-btn device-btn" data-value="X4" onclick="setDeviceTarget('X4')">X4</button>
|
||
<button class="rotation-btn device-btn" data-value="X3" onclick="setDeviceTarget('X3')">X3</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Rotation Direction -->
|
||
<div class="advanced-setting-row" id="rotationSettingRow">
|
||
<div class="setting-title">↻ Rotation Direction</div>
|
||
<div class="setting-controls">
|
||
<div class="rotation-buttons">
|
||
<button class="rotation-btn" id="rotationCCW" onclick="setHandedness('left')">
|
||
<span class="rot-icon">↺</span> CCW
|
||
</button>
|
||
<button class="rotation-btn active" id="rotationCW" onclick="setHandedness('right')">
|
||
<span class="rot-icon">↻</span> CW
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Overlap Settings -->
|
||
<div class="advanced-setting-row" id="overlapSettingRow">
|
||
<div class="setting-title">📐 Min Overlap</div>
|
||
<div class="setting-controls">
|
||
<div class="overlap-selector">
|
||
<button class="overlap-btn active" data-value="5" onclick="setOverlap(5)">5%</button>
|
||
<button class="overlap-btn" data-value="10" onclick="setOverlap(10)">10%</button>
|
||
<button class="overlap-btn" data-value="15" onclick="setOverlap(15)">15%</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Conversion Log Export -->
|
||
<div class="advanced-setting-row">
|
||
<div class="setting-label">
|
||
<div class="setting-title">📥 Auto-download Log</div>
|
||
<div class="setting-desc">Export detailed log with statistics</div>
|
||
</div>
|
||
<div class="setting-controls">
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" id="export-log-checkbox">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div><!-- advanced-settings-content -->
|
||
</div><!-- end convert-options -->
|
||
<!-- Image Picker Section - in picker-columns for two-column layout -->
|
||
<div class="image-picker-section" id="imagePickerSection" style="display:none;">
|
||
<div class="image-picker-header">
|
||
<span class="image-picker-title">🖼️ Image Processing Options</span>
|
||
<span class="image-picker-count" id="imagePickerCount">0 images</span>
|
||
</div>
|
||
<div class="state-legend">
|
||
<div class="legend-item locked">
|
||
<span>🔒</span>
|
||
<span>Cover</span>
|
||
</div>
|
||
<div class="legend-item locked">
|
||
<span style="color:#1abc9c">✦</span>
|
||
<span>Separator</span>
|
||
</div>
|
||
<button class="legend-btn" style="--btn-color:var(--label-color)" onclick="applyStateToAll(0)">
|
||
<div class="legend-color"></div>
|
||
<span>Normal</span>
|
||
</button>
|
||
<button class="legend-btn" style="--btn-color:#3498db" onclick="applyStateToAll(1)">
|
||
<div class="legend-color"></div>
|
||
<span>H-Split</span>
|
||
</button>
|
||
<button class="legend-btn" style="--btn-color:#e74c3c" onclick="applyStateToAll(2)">
|
||
<div class="legend-color"></div>
|
||
<span>V-Split</span>
|
||
</button>
|
||
<button class="legend-btn" style="--btn-color:#9b59b6" onclick="applyStateToAll(3)">
|
||
<div class="legend-color"></div>
|
||
<span>Rotate</span>
|
||
</button>
|
||
</div>
|
||
<div class="image-grid" id="imageGrid"></div>
|
||
</div>
|
||
</div><!-- end picker-columns -->
|
||
<!-- Sync compatibility warning - outside picker-columns -->
|
||
<div class="convert-warning" id="convertWarning" style="display:none;" role="alert" aria-live="polite">
|
||
⚠️ Converting will modify files and can break hash‑based sync. ⚠️<br>
|
||
Please back up or disable sync before proceeding.
|
||
</div>
|
||
<!-- Optimize & Upload button - shown when picker is visible, replaces Upload -->
|
||
<button id="startConversionBtn" class="upload-btn optimize" style="display:none;" onclick="startConversionWithImageStates()">Optimize & Upload</button>
|
||
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
||
<button class="delete-btn-cancel" onclick="handleCancelUploadModal()">Cancel</button>
|
||
<div id="progress-container">
|
||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||
<div id="progress-text"></div>
|
||
</div>
|
||
<div class="log-section" id="log-section">
|
||
<div class="log-header">
|
||
<div class="log-title">📋 Conversion Log</div>
|
||
</div>
|
||
<div class="log-container" id="log-container"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- New Folder Modal -->
|
||
<div class="modal-overlay" id="folderModal">
|
||
<div class="modal">
|
||
<button class="modal-close" onclick="closeFolderModal()">×</button>
|
||
<h3>📁 New Folder</h3>
|
||
<div class="folder-form">
|
||
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
|
||
<input type="text" id="folderName" class="folder-input" placeholder="Folder name..." oninput="document.getElementById('createFolderBtn').disabled = !this.value.trim();">
|
||
<button class="folder-btn" id="createFolderBtn" onclick="createFolder()" disabled>Create Folder</button>
|
||
<button class="delete-btn-cancel" onclick="closeFolderModal()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Confirmation Modal -->
|
||
<div class="modal-overlay" id="deleteModal">
|
||
<div class="modal">
|
||
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||
<h3>🗑️ Delete Item(s)</h3>
|
||
<div class="folder-form">
|
||
<p class="delete-warning">⚠️ This action cannot be undone!</p>
|
||
<p class="file-info">Are you sure you want to delete the following item(s)?</p>
|
||
<div id="deleteItemList" style="max-height:240px; overflow:auto; margin-bottom:10px;"></div>
|
||
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
|
||
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rename Modal -->
|
||
<div class="modal-overlay" id="renameModal">
|
||
<div class="modal">
|
||
<button class="modal-close" onclick="closeRenameModal()">×</button>
|
||
<h3>✏️ Rename File</h3>
|
||
<div class="folder-form">
|
||
<p class="file-info">Renaming <strong id="renameItemName"></strong></p>
|
||
<input type="text" id="renameNewName" class="folder-input" placeholder="New file name...">
|
||
<input type="hidden" id="renameItemPath">
|
||
<button class="rename-btn-confirm" onclick="confirmRename()">Rename</button>
|
||
<button class="delete-btn-cancel" onclick="closeRenameModal()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Move Modal -->
|
||
<div class="modal-overlay" id="moveModal">
|
||
<div class="modal">
|
||
<button class="modal-close" onclick="closeMoveModal()">×</button>
|
||
<h3>📂 Move File</h3>
|
||
<div class="folder-form">
|
||
<p class="file-info">Moving <strong id="moveItemName"></strong></p>
|
||
<input type="text" id="moveDestPath" class="folder-input" list="moveFolderOptions" placeholder="/Destination/Folder">
|
||
<datalist id="moveFolderOptions"></datalist>
|
||
<input type="hidden" id="moveItemPath">
|
||
<button class="move-btn-confirm" onclick="confirmMove()">Move</button>
|
||
<button class="delete-btn-cancel" onclick="closeMoveModal()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Image Preview Modal -->
|
||
<div class="modal-overlay" id="imagePreviewModal">
|
||
<div class="modal image-preview-mode">
|
||
<button class="modal-close" onclick="closeImagePreview()">×</button>
|
||
<h3>🖼️ Preview</h3>
|
||
<div class="folder-form">
|
||
<p class="file-info"><strong id="imagePreviewName"></strong></p>
|
||
<div class="image-preview-stage">
|
||
<img id="imagePreviewImg" alt="">
|
||
</div>
|
||
<a id="imagePreviewDownload" class="move-btn-confirm" rel="noopener noreferrer" target="_blank" href="#">Download</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// get current path from query parameter
|
||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||
|
||
if (currentPath !== '/') {
|
||
const leaf = currentPath.split('/').filter(Boolean).pop();
|
||
if (leaf) document.title = leaf + ' - Files - CrossPoint Reader';
|
||
}
|
||
|
||
// Network status monitoring
|
||
let isNetworkOnline = navigator.onLine;
|
||
|
||
// Add network status listeners
|
||
window.addEventListener('online', () => {
|
||
console.log('[Network] Online event fired');
|
||
isNetworkOnline = true;
|
||
showNotification('Network connection restored', 'success');
|
||
});
|
||
|
||
window.addEventListener('offline', () => {
|
||
console.log('[Network] Offline event fired');
|
||
isNetworkOnline = false;
|
||
showNotification('Network connection lost', 'warning');
|
||
});
|
||
|
||
// Initialize network status
|
||
console.log('[Network] Initial status:', isNetworkOnline ? 'online' : 'offline');
|
||
|
||
function escapeHtml(unsafe) {
|
||
return unsafe
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function showNotification(message, type = 'info') {
|
||
// Create notification element if it doesn't exist
|
||
let notification = document.getElementById('notification');
|
||
if (!notification) {
|
||
notification = document.createElement('div');
|
||
notification.id = 'notification';
|
||
notification.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 12px 20px;
|
||
border-radius: 4px;
|
||
color: white;
|
||
font-weight: 500;
|
||
z-index: 10000;
|
||
max-width: 300px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||
transition: all 0.3s ease;
|
||
`;
|
||
document.body.appendChild(notification);
|
||
}
|
||
|
||
// Set styles based on type
|
||
const styles = {
|
||
'success': 'background-color: #27ae60;',
|
||
'error': 'background-color: #e74c3c;',
|
||
'warning': 'background-color: #f39c12;',
|
||
'info': 'background-color: #3498db;'
|
||
};
|
||
notification.style.cssText += styles[type] || styles['info'];
|
||
notification.textContent = message;
|
||
|
||
// Show notification
|
||
notification.style.opacity = '1';
|
||
notification.style.transform = 'translateX(0)';
|
||
|
||
// Auto-hide after 5 seconds
|
||
setTimeout(() => {
|
||
notification.style.opacity = '0';
|
||
notification.style.transform = 'translateX(100%)';
|
||
setTimeout(() => {
|
||
if (notification.parentNode) {
|
||
notification.parentNode.removeChild(notification);
|
||
}
|
||
}, 300);
|
||
}, 5000);
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toLocaleString() + ' ' + sizes[i];
|
||
}
|
||
|
||
async function hydrate() {
|
||
// Fetch CrossPoint version
|
||
fetchVersion();
|
||
|
||
// Close modals when clicking overlay - call proper cleanup functions
|
||
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
||
overlay.addEventListener('click', function(e) {
|
||
if (e.target === overlay) {
|
||
// Call the appropriate close function for each modal to ensure cleanup
|
||
if (overlay.id === 'uploadModal') return closeUploadModal();
|
||
if (overlay.id === 'folderModal') return closeFolderModal();
|
||
if (overlay.id === 'deleteModal') return closeDeleteModal();
|
||
if (overlay.id === 'renameModal') return closeRenameModal();
|
||
if (overlay.id === 'moveModal') return closeMoveModal();
|
||
if (overlay.id === 'imagePreviewModal') return closeImagePreview();
|
||
overlay.classList.remove('open');
|
||
}
|
||
});
|
||
});
|
||
|
||
const breadcrumbs = document.getElementById('directory-breadcrumbs');
|
||
const fileTable = document.getElementById('file-table');
|
||
|
||
const segments = currentPath.split('/').filter(Boolean);
|
||
breadcrumbs.replaceChildren();
|
||
|
||
const appendSep = function() {
|
||
const sep = document.createElement('span');
|
||
sep.className = 'sep';
|
||
sep.textContent = '›';
|
||
breadcrumbs.appendChild(sep);
|
||
};
|
||
|
||
const appendLink = function(label, href) {
|
||
const link = document.createElement('a');
|
||
link.href = href;
|
||
link.textContent = label;
|
||
breadcrumbs.appendChild(link);
|
||
};
|
||
|
||
const appendCurrent = function(label) {
|
||
const current = document.createElement('span');
|
||
current.className = 'current';
|
||
current.textContent = label;
|
||
breadcrumbs.appendChild(current);
|
||
};
|
||
|
||
if (segments.length === 0) {
|
||
appendCurrent('🏠 Home');
|
||
} else {
|
||
appendLink('🏠 Home', '/files');
|
||
segments.forEach(function(segment, index) {
|
||
appendSep();
|
||
if (index === segments.length - 1) {
|
||
appendCurrent(segment);
|
||
} else {
|
||
const path = '/' + segments.slice(0, index + 1).join('/');
|
||
appendLink(segment, '/files?path=' + encodeURIComponent(path));
|
||
}
|
||
});
|
||
}
|
||
|
||
let files = [];
|
||
try {
|
||
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now());
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText);
|
||
}
|
||
files = await response.json();
|
||
} catch (e) {
|
||
console.error(e);
|
||
fileTable.innerHTML = '<div class="no-files">An error occurred while loading the files</div>';
|
||
return;
|
||
}
|
||
|
||
let folderCount = 0;
|
||
let totalSize = 0;
|
||
files.forEach(file => {
|
||
if (file.isDirectory) folderCount++;
|
||
totalSize += file.size;
|
||
});
|
||
|
||
const fileCount = files.length - folderCount;
|
||
const folderLabel = folderCount === 1 ? 'folder' : 'folders';
|
||
const fileLabel = fileCount === 1 ? 'file' : 'files';
|
||
document.getElementById('folder-summary').innerHTML = `${folderCount} ${folderLabel}, ${fileCount} ${fileLabel}, ${formatFileSize(totalSize)}`;
|
||
|
||
if (files.length === 0) {
|
||
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
|
||
} else {
|
||
let fileTableContent = '<table class="file-table">';
|
||
|
||
// Add select-all checkbox column
|
||
fileTableContent += '<tr><th style="width:40px"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this)"></th><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
|
||
|
||
|
||
const sortedFiles = files.sort((a, b) => {
|
||
// Directories first, then epub files, then other files, alphabetically within each group
|
||
if (a.isDirectory && !b.isDirectory) return -1;
|
||
if (!a.isDirectory && b.isDirectory) return 1;
|
||
if (a.isEpub && !b.isEpub) return -1;
|
||
if (!a.isEpub && b.isEpub) return 1;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
sortedFiles.forEach(file => {
|
||
if (file.isDirectory) {
|
||
let folderPath = currentPath;
|
||
if (!folderPath.endsWith("/")) folderPath += "/";
|
||
folderPath += file.name;
|
||
|
||
// Checkbox cell + folder row
|
||
fileTableContent += `<tr class="folder-row">`;
|
||
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(folderPath)}" data-name="${escapeHtml(file.name)}" data-type="folder"></td>`;
|
||
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a></td>`;
|
||
fileTableContent += '<td><span class="folder-badge">FOLDER</span></td>';
|
||
fileTableContent += '<td>-</td>';
|
||
fileTableContent += `<td class="actions-col"><div class="action-icon-group"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></div></td>`;
|
||
fileTableContent += '</tr>';
|
||
} else {
|
||
let filePath = currentPath;
|
||
if (!filePath.endsWith("/")) filePath += "/";
|
||
filePath += file.name;
|
||
|
||
// Checkbox cell + file row
|
||
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
|
||
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
|
||
const fileIsImage = isImageFile(file.name);
|
||
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : (fileIsImage ? '🖼️' : '📄')}</span>`;
|
||
if (fileIsImage) {
|
||
fileTableContent += `<a href="${downloadUrl(filePath)}" class="file-link image-preview-link">${escapeHtml(file.name)}</a>`;
|
||
} else {
|
||
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="${downloadUrl(filePath)}" class="file-link">${escapeHtml(file.name)}</a>`;
|
||
}
|
||
fileTableContent += '</td>';
|
||
fileTableContent += file.isEpub ? '<td><span class="epub-badge">EPUB</span></td>' : `<td>${escapeHtml(file.name.split('.').pop().toUpperCase())}</td>`;
|
||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
|
||
fileTableContent += `<button class="move-btn" onclick="openMoveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}' )" title="Move file">📂</button>`;
|
||
fileTableContent += `<button class="rename-btn" onclick="openRenameModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}' )" title="Rename file">✏️</button>`;
|
||
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
|
||
fileTableContent += `</div></td>`;
|
||
fileTableContent += '</tr>';
|
||
}
|
||
});
|
||
|
||
fileTableContent += '</table>';
|
||
fileTable.innerHTML = fileTableContent;
|
||
}
|
||
}
|
||
|
||
// Image preview
|
||
function isImageFile(name) {
|
||
return /\.(png|jpe?g|bmp|gif|webp)$/i.test(name);
|
||
}
|
||
|
||
function downloadUrl(filePath) {
|
||
return `/download?path=${encodeURIComponent(filePath)}`;
|
||
}
|
||
|
||
function openImagePreview(url, name) {
|
||
const img = document.getElementById('imagePreviewImg');
|
||
document.getElementById('imagePreviewName').textContent = name;
|
||
img.src = url;
|
||
img.alt = name;
|
||
document.getElementById('imagePreviewDownload').href = url;
|
||
document.getElementById('imagePreviewModal').classList.add('open');
|
||
}
|
||
|
||
function closeImagePreview() {
|
||
document.getElementById('imagePreviewModal').classList.remove('open');
|
||
// Clear src to free the decoded image and avoid showing the stale one on reopen.
|
||
document.getElementById('imagePreviewImg').src = '';
|
||
}
|
||
|
||
// Modal functions
|
||
function openUploadModal() {
|
||
// Reset converter variables to defaults
|
||
ENABLE_GRAYSCALE = true;
|
||
JPEG_QUALITY = 85;
|
||
HANDEDNESS = 'right';
|
||
OVERLAP_PERCENT = 5;
|
||
imageStates = {};
|
||
|
||
// Hide convert options when opening modal (no files selected initially)
|
||
const convertOptions = document.getElementById('convertOptions');
|
||
if (convertOptions) {
|
||
convertOptions.style.display = 'none';
|
||
}
|
||
|
||
// Reset rotation and overlap UI
|
||
setHandedness('right');
|
||
setOverlap(5);
|
||
|
||
// Hide log section from previous session
|
||
const logSection = document.getElementById('log-section');
|
||
if (logSection) logSection.classList.remove('visible');
|
||
const logContainer = document.getElementById('log-container');
|
||
if (logContainer) logContainer.innerHTML = '';
|
||
|
||
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
||
document.getElementById('uploadModal').classList.add('open');
|
||
}
|
||
|
||
function handleCancelUploadModal() {
|
||
if (!isUploadInProgress) {
|
||
// No process running: close the modal normally
|
||
closeUploadModal();
|
||
return;
|
||
}
|
||
// Process running: stop it, keep modal open for retry
|
||
operationCancelled = true;
|
||
if (currentUploadWs) { currentUploadWs.close(); currentUploadWs = null; }
|
||
if (currentUploadXhr) { currentUploadXhr.abort(); currentUploadXhr = null; }
|
||
// isUploadInProgress and UI are restored by restoreAfterCancel() from the async handlers
|
||
}
|
||
|
||
function restoreAfterCancel() {
|
||
operationCancelled = false;
|
||
isUploadInProgress = false;
|
||
document.getElementById('uploadModalClose').classList.remove('disabled');
|
||
document.getElementById('fileInput').disabled = false;
|
||
const progressFill = document.getElementById('progress-fill');
|
||
const progressText = document.getElementById('progress-text');
|
||
progressFill.style.width = '0%';
|
||
progressFill.style.backgroundColor = '#e74c3c';
|
||
progressText.style.color = '#e74c3c';
|
||
progressText.textContent = 'Upload cancelled by User!';
|
||
// Re-enable the action button (uploadBtn is always visible at this point;
|
||
// its text is already "Upload" or "Optimize & Upload" depending on checkbox state)
|
||
document.getElementById('uploadBtn').disabled = false;
|
||
}
|
||
|
||
function closeUploadModal() {
|
||
// Prevent closing during upload/conversion
|
||
if (isUploadInProgress) {
|
||
return;
|
||
}
|
||
document.getElementById('uploadModal').classList.remove('open');
|
||
const fileInput = document.getElementById('fileInput');
|
||
fileInput.value = '';
|
||
fileInput.classList.remove('has-files');
|
||
fileInput.disabled = false;
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
uploadBtn.disabled = true;
|
||
uploadBtn.style.display = 'block';
|
||
uploadBtn.textContent = 'Upload';
|
||
uploadBtn.classList.remove('optimize');
|
||
document.getElementById('startConversionBtn').style.display = 'none';
|
||
document.getElementById('progress-container').style.display = 'none';
|
||
document.getElementById('progress-fill').style.width = '0%';
|
||
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
|
||
document.getElementById('convertBeforeUpload').checked = false;
|
||
document.getElementById('convertInfo').style.display = 'none';
|
||
document.getElementById('convertWarning').style.display = 'none';
|
||
// Clear image picker cache and reset layout
|
||
epubImagesCache = [];
|
||
imageStates = {};
|
||
autoCropProtectedPaths = new Set();
|
||
document.getElementById('imagePickerSection').style.display = 'none';
|
||
const imageGrid = document.getElementById('imageGrid');
|
||
if (imageGrid) imageGrid.innerHTML = '';
|
||
document.querySelector('#uploadModal .modal').classList.remove('picker-mode');
|
||
document.getElementById('pickerColumns').classList.remove('picker-active');
|
||
// Hide log section
|
||
if (logSection) logSection.classList.remove('visible');
|
||
// Reset advanced options
|
||
const advancedSettingsContent = document.getElementById('advancedSettingsContent');
|
||
if (advancedSettingsContent) advancedSettingsContent.classList.remove('visible');
|
||
document.getElementById('advancedOptionsArrow').classList.remove('expanded');
|
||
const advancedOptionsToggle = document.getElementById('advancedOptionsToggle');
|
||
if (advancedOptionsToggle) {
|
||
advancedOptionsToggle.style.opacity = '0.5';
|
||
advancedOptionsToggle.style.pointerEvents = 'none';
|
||
}
|
||
// Reset to defaults
|
||
document.getElementById('qualitySlider').value = 85;
|
||
document.getElementById('qualityInput').value = 85;
|
||
const autoCropToggle = document.getElementById('autoCropToggle');
|
||
if (autoCropToggle) autoCropToggle.checked = false;
|
||
setHandedness('right');
|
||
setOverlap(5);
|
||
// Update converter variables
|
||
updateQualitySettings();
|
||
}
|
||
|
||
function updateBatchModeUI(isBatch) {
|
||
const rotationRow = document.getElementById('rotationSettingRow');
|
||
const overlapRow = document.getElementById('overlapSettingRow');
|
||
if (rotationRow) rotationRow.style.display = isBatch ? 'none' : '';
|
||
if (overlapRow) overlapRow.style.display = isBatch ? 'none' : '';
|
||
}
|
||
|
||
function toggleConvertOptions() {
|
||
const checked = document.getElementById('convertBeforeUpload').checked;
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
document.getElementById('convertWarning').style.display = checked ? 'block' : 'none';
|
||
document.getElementById('convertInfo').style.display = checked ? 'block' : 'none';
|
||
// Update button text and style
|
||
if (checked) {
|
||
uploadBtn.textContent = 'Optimize & Upload';
|
||
uploadBtn.classList.add('optimize');
|
||
} else {
|
||
uploadBtn.textContent = 'Upload';
|
||
uploadBtn.classList.remove('optimize');
|
||
// Clear image picker when unchecking
|
||
clearImagePicker();
|
||
}
|
||
// Reset advanced options when toggling off
|
||
if (!checked) {
|
||
const advancedSettingsContent = document.getElementById('advancedSettingsContent');
|
||
if (advancedSettingsContent) advancedSettingsContent.classList.remove('visible');
|
||
document.getElementById('advancedOptionsArrow').classList.remove('expanded');
|
||
}
|
||
// Disable/enable advanced mode toggle
|
||
const advancedOptionsToggle = document.getElementById('advancedOptionsToggle');
|
||
if (advancedOptionsToggle) {
|
||
advancedOptionsToggle.style.opacity = checked ? '1' : '0.5';
|
||
advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none';
|
||
}
|
||
}
|
||
|
||
function toggleAdvancedOptions() {
|
||
// Check if advanced options toggle is enabled (optimize EPUB must be checked)
|
||
const convertEnabled = document.getElementById('convertBeforeUpload').checked;
|
||
if (!convertEnabled) {
|
||
return; // Don't toggle if optimization is disabled
|
||
}
|
||
|
||
const content = document.getElementById('advancedSettingsContent');
|
||
const arrow = document.getElementById('advancedOptionsArrow');
|
||
const isExpanding = !content.classList.contains('visible');
|
||
|
||
content.classList.toggle('visible');
|
||
arrow.classList.toggle('expanded');
|
||
|
||
// When expanding, show image picker if an EPUB is selected
|
||
if (isExpanding) {
|
||
const fileInput = document.getElementById('fileInput');
|
||
const files = fileInput.files;
|
||
if (files.length === 1 && files[0].name.toLowerCase().endsWith('.epub')) {
|
||
showImagePicker(files[0]).catch(err => console.error('Image picker error:', err));
|
||
}
|
||
} else {
|
||
// When collapsing, hide the picker and startConversionBtn
|
||
document.getElementById('imagePickerSection').style.display = 'none';
|
||
document.getElementById('startConversionBtn').style.display = 'none';
|
||
document.getElementById('uploadBtn').style.display = 'block';
|
||
document.getElementById('uploadBtn').disabled = false;
|
||
document.querySelector('#uploadModal .modal').classList.remove('picker-mode');
|
||
document.getElementById('pickerColumns').classList.remove('picker-active');
|
||
}
|
||
}
|
||
|
||
function setQualityPreset(value) {
|
||
document.getElementById('qualitySlider').value = value;
|
||
document.getElementById('qualityInput').value = value;
|
||
// Update active preset
|
||
document.querySelectorAll('.quality-preset').forEach(btn => {
|
||
btn.classList.remove('active');
|
||
if (parseInt(btn.dataset.value, 10) === value) {
|
||
btn.classList.add('active');
|
||
}
|
||
});
|
||
updateQualitySettings();
|
||
}
|
||
|
||
function updateQualitySettings() {
|
||
const quality = document.getElementById('qualitySlider').value;
|
||
const autoCropToggle = document.getElementById('autoCropToggle');
|
||
|
||
// Update displays (if element exists)
|
||
const qualityDisplay = document.getElementById('qualityDisplaySimple');
|
||
if (qualityDisplay) {
|
||
qualityDisplay.textContent = '📦 ' + quality + '% JPEG';
|
||
}
|
||
|
||
// Update converter variables (used by processImage and applyGrayscale)
|
||
JPEG_QUALITY = parseInt(quality, 10);
|
||
ENABLE_GRAYSCALE = true; // Always grayscale for e-ink
|
||
ENABLE_AUTO_CROP = autoCropToggle ? autoCropToggle.checked : false;
|
||
if (isImagePickerVisible()) {
|
||
renderImageGrid();
|
||
}
|
||
}
|
||
|
||
function setHandedness(value) {
|
||
HANDEDNESS = value;
|
||
// Update UI
|
||
document.getElementById('rotationCW').classList.remove('active');
|
||
document.getElementById('rotationCCW').classList.remove('active');
|
||
document.getElementById(value === 'right' ? 'rotationCW' : 'rotationCCW').classList.add('active');
|
||
// Re-render grid to update rotation arrows
|
||
if (isImagePickerVisible()) {
|
||
renderImageGrid();
|
||
}
|
||
}
|
||
|
||
function setOverlap(value) {
|
||
OVERLAP_PERCENT = value;
|
||
// Update UI
|
||
document.querySelectorAll('.overlap-btn').forEach(btn => {
|
||
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
|
||
});
|
||
}
|
||
|
||
function isImagePickerVisible() {
|
||
const pickerSection = document.getElementById('imagePickerSection');
|
||
return pickerSection && pickerSection.style.display !== 'none';
|
||
}
|
||
|
||
function loadPreviewImage(dataUrl) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => reject(new Error('Preview image load failed'));
|
||
img.src = dataUrl;
|
||
});
|
||
}
|
||
|
||
async function createAutoCropPreview(dataUrl, imagePath, width, height) {
|
||
const img = await loadPreviewImage(dataUrl);
|
||
const cropped = createAutoCroppedCanvas(img, imagePath, width, height, true);
|
||
if (!cropped.crop) return null;
|
||
|
||
const maxPreviewW = 220;
|
||
const maxPreviewH = 160;
|
||
const scale = Math.min(maxPreviewW / cropped.width, maxPreviewH / cropped.height, 1);
|
||
const previewW = Math.max(1, Math.round(cropped.width * scale));
|
||
const previewH = Math.max(1, Math.round(cropped.height * scale));
|
||
const previewCanvas = document.createElement('canvas');
|
||
previewCanvas.width = previewW;
|
||
previewCanvas.height = previewH;
|
||
const previewCtx = previewCanvas.getContext('2d');
|
||
previewCtx.imageSmoothingEnabled = true;
|
||
previewCtx.imageSmoothingQuality = 'high';
|
||
previewCtx.fillStyle = '#FFF';
|
||
previewCtx.fillRect(0, 0, previewW, previewH);
|
||
previewCtx.drawImage(cropped.canvas, 0, 0, previewW, previewH);
|
||
|
||
return {
|
||
dataUrl: previewCanvas.toDataURL('image/jpeg', 0.82),
|
||
width: cropped.width,
|
||
height: cropped.height
|
||
};
|
||
}
|
||
|
||
// ============================================================================
|
||
// Image Picker Functions
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Extract images from EPUB for preview
|
||
* Returns array of {path, name, dataUrl, width, height}
|
||
*/
|
||
async function extractImagesForPreview(file) {
|
||
const zip = await JSZip.loadAsync(file);
|
||
const imageExtensions = ['.png', '.gif', '.webp', '.bmp', '.jpg', '.jpeg'];
|
||
|
||
// Collect all image paths first
|
||
const allImages = [];
|
||
for (const [path, fileObj] of Object.entries(zip.files)) {
|
||
if (fileObj.dir) continue;
|
||
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
|
||
if (imageExtensions.includes(ext)) {
|
||
allImages.push(path);
|
||
}
|
||
}
|
||
|
||
// Try to get images in reading order from OPF spine
|
||
let orderedImages = [];
|
||
let coverImagePath = null; // Track cover image
|
||
try {
|
||
// Find OPF file
|
||
let opfPath = null;
|
||
zip.forEach(p => { if (p.toLowerCase().endsWith('.opf')) opfPath = p; });
|
||
|
||
if (opfPath) {
|
||
const opfContent = await zip.files[opfPath].async('string');
|
||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
|
||
|
||
// Detect cover image from OPF
|
||
let coverId = null;
|
||
let m;
|
||
// Try 1: properties="cover-image"
|
||
if (m = opfContent.match(/<item[^>]+id=["']([^"']+)["'][^>]+properties="[^"]*cover-image[^"]*"/)) coverId = m[1];
|
||
if (!coverId && (m = opfContent.match(/<item[^>]+properties="[^"]*cover-image[^"]*"[^>]+id=["']([^"']+)["']/))) coverId = m[1];
|
||
// Try 2: meta name="cover" content="id"
|
||
if (!coverId && (m = opfContent.match(/<meta\s+name=["']cover["']\s+content=["']([^"']+)["']/))) coverId = m[1];
|
||
if (!coverId && (m = opfContent.match(/<meta\s+content=["']([^"']+)["']\s+name=["']cover["']/))) coverId = m[1];
|
||
|
||
// Parse manifest to get id -> href mapping
|
||
const manifest = {};
|
||
const manifestRegex = /<item[^>]+id=["']([^"']+)["'][^>]+href=["']([^"']+)["'][^>]*>/gi;
|
||
let match;
|
||
while ((match = manifestRegex.exec(opfContent)) !== null) {
|
||
const id = match[1];
|
||
const href = match[2];
|
||
const fullPath = opfDir ? opfDir + '/' + href : href;
|
||
manifest[id] = fullPath;
|
||
if (id === coverId) coverImagePath = fullPath;
|
||
}
|
||
// Also check reversed attribute order
|
||
const manifestRegex2 = /<item[^>]+href=["']([^"']+)["'][^>]+id=["']([^"']+)["'][^>]*>/gi;
|
||
while ((match = manifestRegex2.exec(opfContent)) !== null) {
|
||
const href = match[1];
|
||
const id = match[2];
|
||
const fullPath = opfDir ? opfDir + '/' + href : href;
|
||
manifest[id] = fullPath;
|
||
if (id === coverId) coverImagePath = fullPath;
|
||
}
|
||
|
||
// Cover-page reconciliation — if the cover XHTML references a different
|
||
// but byte-identical image, prefer the one actually displayed on the page.
|
||
if (coverImagePath) {
|
||
try {
|
||
let coverXhtmlPath = null;
|
||
const guideM = opfContent.match(/<(?:\w+:)?reference[^>]+type=["']cover["'][^>]+href=["']([^"']+)["']/i) ||
|
||
opfContent.match(/<(?:\w+:)?reference[^>]+href=["']([^"']+)["'][^>]+type=["']cover["']/i);
|
||
if (guideM) {
|
||
coverXhtmlPath = opfDir ? opfDir + '/' + decodeHref(guideM[1]) : decodeHref(guideM[1]);
|
||
}
|
||
if (!coverXhtmlPath) {
|
||
const spineM = opfContent.match(/<(?:\w+:)?itemref[^>]+idref=["']([^"']+)["']/i);
|
||
if (spineM && manifest[spineM[1]]) coverXhtmlPath = manifest[spineM[1]];
|
||
}
|
||
if (coverXhtmlPath && zip.files[coverXhtmlPath]) {
|
||
const coverXhtml = await zip.files[coverXhtmlPath].async('string');
|
||
const imgM = coverXhtml.match(/(?:src|xlink:href)=["']([^"']+)["']/i);
|
||
if (imgM) {
|
||
const href = imgM[1];
|
||
const xDir = coverXhtmlPath.includes('/') ? coverXhtmlPath.substring(0, coverXhtmlPath.lastIndexOf('/')) : '';
|
||
let pageImgPath = href.startsWith('../') ? xDir.split('/').slice(0, -1).join('/') + '/' + href.substring(3)
|
||
: href.startsWith('/') ? href.substring(1)
|
||
: xDir ? xDir + '/' + href : href;
|
||
pageImgPath = pageImgPath.replace(/\/+/g, '/');
|
||
for (const realPath of allImages) {
|
||
if (realPath === pageImgPath || realPath.endsWith('/' + href) || realPath.endsWith(href)) {
|
||
pageImgPath = realPath; break;
|
||
}
|
||
}
|
||
if (pageImgPath !== coverImagePath && allImages.includes(pageImgPath) && zip.files[pageImgPath]) {
|
||
const coverData = await zip.files[coverImagePath].async('arraybuffer');
|
||
const pageData = await zip.files[pageImgPath].async('arraybuffer');
|
||
if (coverData.byteLength === pageData.byteLength) {
|
||
const a = new Uint8Array(coverData);
|
||
const b = new Uint8Array(pageData);
|
||
let identical = true;
|
||
for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { identical = false; break; } }
|
||
if (identical) coverImagePath = pageImgPath;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* non-critical */ }
|
||
}
|
||
|
||
// Parse spine to get reading order
|
||
const spineOrder = [];
|
||
const spineRegex = /<itemref[^>]+idref=["']([^"']+)["'][^>]*>/gi;
|
||
while ((match = spineRegex.exec(opfContent)) !== null) {
|
||
const idref = match[1];
|
||
if (manifest[idref]) spineOrder.push(manifest[idref]);
|
||
}
|
||
|
||
// For each spine item (XHTML), extract images in order
|
||
const seenImages = new Set();
|
||
for (const xhtmlPath of spineOrder) {
|
||
if (!zip.files[xhtmlPath]) continue;
|
||
const xhtmlContent = await zip.files[xhtmlPath].async('string');
|
||
const xhtmlDir = xhtmlPath.includes('/') ? xhtmlPath.substring(0, xhtmlPath.lastIndexOf('/')) : '';
|
||
|
||
// Find all image references
|
||
const imgRegex = /(?:src|xlink:href)=["']([^"']+)["']/gi;
|
||
while ((match = imgRegex.exec(xhtmlContent)) !== null) {
|
||
let imgHref = match[1];
|
||
// Skip non-image references
|
||
if (!imageExtensions.some(ext => imgHref.toLowerCase().endsWith(ext))) continue;
|
||
|
||
// Resolve relative path
|
||
let imgPath;
|
||
if (imgHref.startsWith('../')) {
|
||
// Go up from xhtmlDir
|
||
const parts = xhtmlDir.split('/');
|
||
parts.pop();
|
||
imgPath = parts.join('/') + '/' + imgHref.substring(3);
|
||
} else if (imgHref.startsWith('/')) {
|
||
imgPath = imgHref.substring(1);
|
||
} else {
|
||
imgPath = xhtmlDir ? xhtmlDir + '/' + imgHref : imgHref;
|
||
}
|
||
// Normalize path
|
||
imgPath = imgPath.replace(/\/+/g, '/');
|
||
|
||
// Check if this is actually an image in our list
|
||
for (const realPath of allImages) {
|
||
if (realPath === imgPath || realPath.endsWith('/' + imgHref) || realPath.endsWith(imgHref)) {
|
||
if (!seenImages.has(realPath)) {
|
||
seenImages.add(realPath);
|
||
orderedImages.push(realPath);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add any remaining images that weren't in XHTML files (e.g., unused images)
|
||
for (const imgPath of allImages) {
|
||
if (!seenImages.has(imgPath)) {
|
||
orderedImages.push(imgPath);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to parse reading order, using default:', e);
|
||
}
|
||
|
||
// Fallback to alphabetical if parsing failed
|
||
if (orderedImages.length === 0) {
|
||
orderedImages = [...allImages].sort();
|
||
}
|
||
|
||
// Load image data in order
|
||
const images = [];
|
||
for (const path of orderedImages) {
|
||
const data = await zip.files[path].async('arraybuffer');
|
||
const blob = new Blob([data]);
|
||
const dataUrl = URL.createObjectURL(blob);
|
||
|
||
// Get dimensions
|
||
const dims = await getImageDimensions(data);
|
||
|
||
// Check if this is the cover image
|
||
const isCover = (path === coverImagePath) ||
|
||
path.toLowerCase().includes('cover') && images.length === 0;
|
||
|
||
// Check if this is a separator/ornament (skip cover check)
|
||
const filename = path.split('/').pop();
|
||
let isSeparator = false;
|
||
if (!isCover) {
|
||
try {
|
||
isSeparator = await isSeparatorImage(dataUrl, dims.width, dims.height, filename);
|
||
} catch (e) {
|
||
console.warn('Separator check failed for', filename, e);
|
||
}
|
||
}
|
||
|
||
// Tiny images (<200x200) are locked like separators
|
||
const isTiny = (dims.width < 200 && dims.height < 200);
|
||
const isLockedImage = isCover || isSeparator || isTiny;
|
||
let autoCropPreview = null;
|
||
if (!isLockedImage) {
|
||
try {
|
||
autoCropPreview = await createAutoCropPreview(dataUrl, path, dims.width, dims.height);
|
||
} catch (e) {
|
||
console.warn('Auto-crop preview failed for', path, e);
|
||
}
|
||
}
|
||
|
||
// Images that fit screen can only rotate, not split
|
||
const fitsScreen = (dims.width <= MAX_WIDTH && dims.height <= MAX_HEIGHT);
|
||
|
||
// Split capability - no upscaling allowed
|
||
// H-Split scales width to MAX_HEIGHT (long edge), so needs width >= MAX_HEIGHT
|
||
// V-Split scales height to MAX_HEIGHT, so needs height >= MAX_HEIGHT
|
||
const canHSplit = dims.width >= MAX_HEIGHT;
|
||
const canVSplit = dims.height >= MAX_HEIGHT;
|
||
|
||
images.push({
|
||
path: path,
|
||
name: filename,
|
||
dataUrl: dataUrl,
|
||
autoCropPreview: autoCropPreview,
|
||
width: dims.width,
|
||
height: dims.height,
|
||
isCover: isCover,
|
||
isSeparator: isSeparator || isTiny,
|
||
fitsScreen: fitsScreen,
|
||
canHSplit: canHSplit,
|
||
canVSplit: canVSplit
|
||
});
|
||
}
|
||
|
||
return images;
|
||
}
|
||
|
||
/**
|
||
* Get image dimensions from array buffer
|
||
*/
|
||
function getImageDimensions(data) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(new Blob([data]));
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
resolve({ width: img.width, height: img.height });
|
||
};
|
||
img.onerror = () => {
|
||
URL.revokeObjectURL(url);
|
||
resolve({ width: 0, height: 0 });
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Check if image is a separator/ornament
|
||
* Criteria: small size AND (filename match OR symmetric OR extreme aspect ratio)
|
||
*/
|
||
async function isSeparatorImage(dataUrl, width, height, filename) {
|
||
const MAX_DIMENSION = 150;
|
||
const SYMMETRY_THRESHOLD = 0.85;
|
||
|
||
// First check: must be small in at least one dimension
|
||
const isSmall = (height < MAX_DIMENSION || width < MAX_DIMENSION);
|
||
if (!isSmall) return false;
|
||
|
||
// Filename hints (instant match if small + named correctly)
|
||
const separatorNames = ['separator', 'divider', 'ornament', 'break', 'flourish', 'scene', 'divid', 'decor'];
|
||
const lowerName = filename.toLowerCase();
|
||
if (separatorNames.some(n => lowerName.includes(n))) return true;
|
||
|
||
// Extreme aspect ratio check (>10:1 or <1:10) - these are definitely separators/lines
|
||
const aspectRatio = width / height;
|
||
if (aspectRatio > 10 || aspectRatio < 0.1) return true;
|
||
|
||
// Symmetry check (skip for very thin images - too few pixels)
|
||
if (width < 10 || height < 10) return true; // Very small = separator
|
||
|
||
try {
|
||
const isSymmetric = await checkHorizontalSymmetry(dataUrl, width, height, SYMMETRY_THRESHOLD);
|
||
return isSymmetric;
|
||
} catch (e) {
|
||
console.warn('Symmetry check failed:', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check horizontal symmetry by comparing left and right halves
|
||
*/
|
||
function checkHorizontalSymmetry(dataUrl, width, height, threshold) {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// Use small canvas for performance (max 100px wide)
|
||
const scale = Math.min(1, 100 / width);
|
||
const w = Math.max(2, Math.floor(width * scale)); // Minimum 2px
|
||
const h = Math.max(1, Math.floor(height * scale)); // Minimum 1px
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Draw scaled image
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
const imageData = ctx.getImageData(0, 0, w, h);
|
||
const pixels = imageData.data;
|
||
|
||
// Compare left half with flipped right half
|
||
const halfW = Math.floor(w / 2);
|
||
let matchingPixels = 0;
|
||
let totalPixels = 0;
|
||
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < halfW; x++) {
|
||
const leftIdx = (y * w + x) * 4;
|
||
const rightIdx = (y * w + (w - 1 - x)) * 4;
|
||
|
||
// Compare RGB (ignore alpha)
|
||
const rDiff = Math.abs(pixels[leftIdx] - pixels[rightIdx]);
|
||
const gDiff = Math.abs(pixels[leftIdx + 1] - pixels[rightIdx + 1]);
|
||
const bDiff = Math.abs(pixels[leftIdx + 2] - pixels[rightIdx + 2]);
|
||
|
||
// Allow some tolerance for JPEG artifacts (threshold of 30)
|
||
if (rDiff < 30 && gDiff < 30 && bDiff < 30) {
|
||
matchingPixels++;
|
||
}
|
||
totalPixels++;
|
||
}
|
||
}
|
||
|
||
const symmetryScore = matchingPixels / totalPixels;
|
||
resolve(symmetryScore >= threshold);
|
||
};
|
||
img.onerror = () => resolve(false);
|
||
img.src = dataUrl;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Show image picker after EPUB file selection
|
||
*/
|
||
async function showImagePicker(file) {
|
||
// Check if JSZip is available
|
||
if (typeof JSZip === 'undefined') {
|
||
console.error('JSZip not loaded');
|
||
alert('JSZip library not available. Conversion will proceed without image picker.');
|
||
startConversionWithImageStates();
|
||
return;
|
||
}
|
||
|
||
const pickerSection = document.getElementById('imagePickerSection');
|
||
const imageGrid = document.getElementById('imageGrid');
|
||
const countDisplay = document.getElementById('imagePickerCount');
|
||
|
||
// Reset state
|
||
imageStates = {};
|
||
epubImagesCache = [];
|
||
autoCropProtectedPaths = new Set();
|
||
pendingConversionFile = file;
|
||
|
||
// Extract images
|
||
try {
|
||
const images = await extractImagesForPreview(file);
|
||
epubImagesCache = images;
|
||
autoCropProtectedPaths = new Set(
|
||
images
|
||
.filter(img => img.isCover || img.isSeparator)
|
||
.map(img => img.path)
|
||
);
|
||
|
||
// Initialize all states to 0 (Normal)
|
||
images.forEach(img => {
|
||
imageStates[img.path] = 0;
|
||
});
|
||
|
||
// Build UI
|
||
renderImageGrid();
|
||
|
||
// Count images - covers and separators are locked
|
||
const coverCount = images.filter(img => img.isCover).length;
|
||
const separatorCount = images.filter(img => img.isSeparator).length;
|
||
const lockedCount = coverCount + separatorCount;
|
||
const selectableCount = images.length - lockedCount;
|
||
|
||
if (lockedCount > 0) {
|
||
const lockedParts = [];
|
||
if (coverCount > 0) lockedParts.push(`${coverCount} cover`);
|
||
if (separatorCount > 0) lockedParts.push(`${separatorCount} separator${separatorCount !== 1 ? 's' : ''}`);
|
||
countDisplay.textContent = `${images.length} images (${selectableCount} selectable, ${lockedParts.join(', ')})`;
|
||
} else {
|
||
countDisplay.textContent = `${images.length} image${images.length !== 1 ? 's' : ''} (all selectable)`;
|
||
}
|
||
|
||
// Show picker, hide upload button, show start conversion button
|
||
pickerSection.style.display = 'block';
|
||
document.getElementById('uploadBtn').style.display = 'none';
|
||
document.getElementById('startConversionBtn').style.display = 'block';
|
||
// Enable two-column layout
|
||
document.querySelector('#uploadModal .modal').classList.add('picker-mode');
|
||
document.getElementById('pickerColumns').classList.add('picker-active');
|
||
|
||
} catch (error) {
|
||
console.error('Failed to extract images:', error);
|
||
alert('Failed to preview images: ' + error.message + '\n\nConversion will proceed normally.');
|
||
// Fallback: start conversion directly
|
||
startConversionWithImageStates();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render the image grid with current states
|
||
*/
|
||
function renderImageGrid() {
|
||
const imageGrid = document.getElementById('imageGrid');
|
||
imageGrid.innerHTML = '';
|
||
|
||
const stateLabels = ['', 'H-Split', 'V-Split', 'Rotate'];
|
||
const stateClasses = ['state-0', 'state-1', 'state-2', 'state-3'];
|
||
|
||
epubImagesCache.forEach(img => {
|
||
// Cover images and separators are always locked (no splitting/rotation)
|
||
const isCover = img.isCover;
|
||
const isSeparator = img.isSeparator;
|
||
const state = imageStates[img.path] || 0;
|
||
|
||
const item = document.createElement('div');
|
||
|
||
if (isCover) {
|
||
// Cover image - locked, cannot be split
|
||
item.className = 'image-item cover-locked';
|
||
item.title = `${img.width}×${img.height} - Cover image (locked)`;
|
||
item.innerHTML = `
|
||
<span class="image-state-badge cover-badge">🔒</span>
|
||
<img src="${img.dataUrl}" alt="${img.name}" loading="lazy">
|
||
<div class="image-name">${img.name}</div>
|
||
`;
|
||
} else if (isSeparator) {
|
||
// Separator/ornament - locked, cannot be split
|
||
item.className = 'image-item separator-locked';
|
||
item.title = `${img.width}×${img.height} - Separator (locked)`;
|
||
item.innerHTML = `
|
||
<span class="image-state-badge separator-badge">✦</span>
|
||
<img src="${img.dataUrl}" alt="${img.name}" loading="lazy">
|
||
<div class="image-name">${img.name}</div>
|
||
`;
|
||
} else {
|
||
// All other images are selectable - all modes allowed
|
||
|
||
// Determine which overlay to show based on state
|
||
const showRotation = (state === 1 || state === 3); // H-Split or Rotate
|
||
const showSplitLines = (state === 1 || state === 2); // H-Split or V-Split
|
||
const rotateClass = showRotation ? (HANDEDNESS === 'right' ? 'rotate-cw' : 'rotate-ccw') : '';
|
||
|
||
// Calculate actual number of parts for split preview
|
||
let numParts = 1;
|
||
if (showSplitLines) {
|
||
let finalWidth;
|
||
if (state === 1) {
|
||
// H-Split: scale width to MAX_HEIGHT, rotate, then check width
|
||
const scaledH = Math.round(img.height * (MAX_HEIGHT / img.width));
|
||
finalWidth = scaledH; // After rotation, height becomes width
|
||
} else {
|
||
// V-Split: scale height to MAX_HEIGHT, then check width
|
||
finalWidth = Math.round(img.width * (MAX_HEIGHT / img.height));
|
||
}
|
||
if (finalWidth > MAX_WIDTH) {
|
||
const minOverlapPx = Math.round(MAX_WIDTH * (OVERLAP_PERCENT / 100));
|
||
const maxStep = MAX_WIDTH - minOverlapPx;
|
||
numParts = Math.ceil((finalWidth - minOverlapPx) / maxStep);
|
||
if (numParts < 2) numParts = 2;
|
||
}
|
||
}
|
||
|
||
// Generate split line elements (numParts - 1 lines at evenly distributed positions)
|
||
let splitLinesHtml = '';
|
||
if (showSplitLines && numParts > 1) {
|
||
const lines = [];
|
||
const splitClass = state === 1 ? 'split-h' : 'split-v';
|
||
for (let i = 1; i < numParts; i++) {
|
||
const pos = (i / numParts) * 100;
|
||
lines.push(`<div class="split-line ${splitClass}" style="left:${pos}%"></div>`);
|
||
}
|
||
splitLinesHtml = `<div class="split-lines">${lines.join('')}</div>`;
|
||
}
|
||
|
||
// Build tooltip
|
||
const stateText = stateLabels[state] || 'Normal';
|
||
const partsText = numParts > 1 ? ` (${numParts} parts)` : '';
|
||
const showCropPreview = ENABLE_AUTO_CROP && img.autoCropPreview;
|
||
const previewSrc = showCropPreview ? img.autoCropPreview.dataUrl : img.dataUrl;
|
||
const cropText = showCropPreview ? ` | Auto-crop: ${img.autoCropPreview.width}×${img.autoCropPreview.height}` : '';
|
||
|
||
item.className = `image-item ${stateClasses[state]} ${rotateClass} ${showCropPreview ? 'crop-preview' : ''}`.trim();
|
||
item.onclick = () => cycleImageState(img.path);
|
||
item.title = `${img.width}×${img.height} - ${stateText}${partsText}${cropText}`;
|
||
item.innerHTML = `
|
||
<span class="image-state-badge">${showCropPreview ? '✂' : (stateLabels[state] || '•')}</span>
|
||
<div class="image-preview-overlay">
|
||
${splitLinesHtml}
|
||
</div>
|
||
<img src="${previewSrc}" alt="${img.name}" loading="lazy">
|
||
<div class="image-name">${img.name}</div>
|
||
`;
|
||
}
|
||
|
||
imageGrid.appendChild(item);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Cycle state for a single image
|
||
* All non-locked images: 0 -> 1 -> 2 -> 3 -> 0
|
||
*/
|
||
function cycleImageState(imagePath) {
|
||
const currentState = imageStates[imagePath] || 0;
|
||
imageStates[imagePath] = (currentState + 1) % 4;
|
||
renderImageGrid();
|
||
}
|
||
|
||
/**
|
||
* Apply state to all eligible images based on smart rules
|
||
* 0 = Normal (all selectable images)
|
||
* 1 = H-Split (landscapes that canHSplit)
|
||
* 2 = V-Split (all images that canVSplit - portraits AND landscapes)
|
||
* 3 = Rotate (landscapes that don't fit screen)
|
||
*/
|
||
function applyStateToAll(state) {
|
||
epubImagesCache.forEach(img => {
|
||
// Skip locked images
|
||
if (img.isCover || img.isSeparator) return;
|
||
|
||
const canHSplit = img.canHSplit && !img.fitsScreen;
|
||
const canVSplit = img.canVSplit && !img.fitsScreen;
|
||
const isLandscape = img.width > img.height;
|
||
|
||
if (state === 0) {
|
||
// Normal - applies to all selectable
|
||
imageStates[img.path] = 0;
|
||
} else if (state === 1) {
|
||
// H-Split - all landscapes that can H-Split
|
||
if (isLandscape && canHSplit) {
|
||
imageStates[img.path] = 1;
|
||
}
|
||
} else if (state === 2) {
|
||
// V-Split - all images that can V-Split (portrait and landscape)
|
||
if (canVSplit) {
|
||
imageStates[img.path] = 2;
|
||
}
|
||
} else if (state === 3) {
|
||
// Rotate - landscapes that exceed screen
|
||
if (isLandscape && !img.fitsScreen) {
|
||
imageStates[img.path] = 3;
|
||
}
|
||
}
|
||
});
|
||
renderImageGrid();
|
||
}
|
||
|
||
/**
|
||
* Start conversion with configured image states
|
||
*/
|
||
function startConversionWithImageStates() {
|
||
const pickerSection = document.getElementById('imagePickerSection');
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
const startConversionBtn = document.getElementById('startConversionBtn');
|
||
|
||
// Hide picker and start conversion button, remove two-column layout
|
||
pickerSection.style.display = 'none';
|
||
startConversionBtn.style.display = 'none';
|
||
document.querySelector('#uploadModal .modal').classList.remove('picker-mode');
|
||
document.getElementById('pickerColumns').classList.remove('picker-active');
|
||
|
||
// Show upload button and trigger upload
|
||
uploadBtn.style.display = 'block';
|
||
uploadBtn.disabled = false;
|
||
uploadFile();
|
||
}
|
||
|
||
/**
|
||
* Get processing state for an image path
|
||
* Returns 0 (Normal), 1 (H-Split), 2 (V-Split), or 3 (Rotate)
|
||
*/
|
||
function getImageState(imagePath) {
|
||
return imageStates[imagePath] || 0;
|
||
}
|
||
|
||
/**
|
||
* Get state label for logging
|
||
*/
|
||
function getStateLabel(state) {
|
||
const labels = ['Normal', 'H-Split', 'V-Split', 'Rotate'];
|
||
return labels[state] || 'Normal';
|
||
}
|
||
|
||
// Initialize quality settings handlers
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Delegated so it survives file-table re-renders (avoids inline onclick with untrusted names).
|
||
document.getElementById('file-table').addEventListener('click', function(e) {
|
||
const link = e.target.closest('.image-preview-link');
|
||
if (!link) return;
|
||
e.preventDefault();
|
||
openImagePreview(link.getAttribute('href'), link.textContent);
|
||
});
|
||
|
||
const qualitySlider = document.getElementById('qualitySlider');
|
||
const qualityInput = document.getElementById('qualityInput');
|
||
|
||
// Initialize advanced mode toggle state based on Optimize EPUB checkbox
|
||
const convertCheckbox = document.getElementById('convertBeforeUpload');
|
||
const advancedToggle = document.getElementById('advancedOptionsToggle');
|
||
if (convertCheckbox && advancedToggle) {
|
||
const isOptimizeEnabled = convertCheckbox.checked;
|
||
advancedToggle.style.opacity = isOptimizeEnabled ? '1' : '0.5';
|
||
advancedToggle.style.pointerEvents = isOptimizeEnabled ? 'auto' : 'none';
|
||
}
|
||
|
||
if (qualitySlider && qualityInput) {
|
||
// Initialize converter variables with UI default values
|
||
updateQualitySettings();
|
||
|
||
// Deselect all presets when slider is manually changed
|
||
const deselectPresets = function() {
|
||
document.querySelectorAll('.quality-preset').forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
};
|
||
|
||
qualitySlider.oninput = function() {
|
||
qualityInput.value = this.value;
|
||
deselectPresets();
|
||
updateQualitySettings();
|
||
};
|
||
qualityInput.onchange = function() {
|
||
let v = Math.max(1, Math.min(95, parseInt(this.value, 10) || 85));
|
||
this.value = v;
|
||
qualitySlider.value = v;
|
||
deselectPresets();
|
||
updateQualitySettings();
|
||
};
|
||
qualityInput.onblur = function() {
|
||
this.value = qualitySlider.value;
|
||
};
|
||
}
|
||
});
|
||
|
||
function openFolderModal() {
|
||
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
||
document.getElementById('folderModal').classList.add('open');
|
||
document.getElementById('folderName').value = '';
|
||
setTimeout(() => document.getElementById('folderName').focus(), 50);
|
||
document.getElementById('folderName').onkeydown = (e) => { if (e.key === 'Enter' && e.target.value.trim()) createFolder(); };
|
||
}
|
||
|
||
function closeFolderModal() {
|
||
document.getElementById('folderModal').classList.remove('open');
|
||
}
|
||
|
||
// Toggle select-all checkbox
|
||
function toggleSelectAll(master) {
|
||
const checked = master.checked;
|
||
document.querySelectorAll('.select-item').forEach(cb => {
|
||
cb.checked = checked;
|
||
});
|
||
}
|
||
|
||
function getSelectedItems() {
|
||
const items = [];
|
||
document.querySelectorAll('.select-item:checked').forEach(cb => {
|
||
items.push({
|
||
name: cb.dataset.name || decodeURIComponent(cb.dataset.path).split('/').pop(),
|
||
path: decodeURIComponent(cb.dataset.path),
|
||
isFolder: cb.dataset.type === 'folder'
|
||
});
|
||
});
|
||
return items;
|
||
}
|
||
|
||
// Open delete modal for currently selected checkboxes
|
||
function openDeleteSelectedModal() {
|
||
const items = getSelectedItems();
|
||
if (items.length === 0) {
|
||
alert('Please select at least one item to delete.');
|
||
return;
|
||
}
|
||
openDeleteModalForItems(items);
|
||
}
|
||
|
||
// Open delete modal for a single item (keeps backwards compatibility with per-row delete button)
|
||
function openDeleteModal(name, path, isFolder) {
|
||
openDeleteModalForItems([{ name: name, path: path, isFolder: !!isFolder }]);
|
||
}
|
||
|
||
let deleteItemsGlobal = [];
|
||
|
||
function openDeleteModalForItems(items) {
|
||
deleteItemsGlobal = items;
|
||
const listEl = document.getElementById('deleteItemList');
|
||
listEl.innerHTML = '';
|
||
items.forEach(it => {
|
||
const div = document.createElement('div');
|
||
div.style.marginBottom = '6px';
|
||
div.textContent = (it.isFolder ? '📁 ' : '📄 ') + it.path;
|
||
listEl.appendChild(div);
|
||
});
|
||
document.getElementById('deleteModal').classList.add('open');
|
||
}
|
||
|
||
function closeDeleteModal() {
|
||
document.getElementById('deleteModal').classList.remove('open');
|
||
}
|
||
|
||
function confirmDelete() {
|
||
if (!deleteItemsGlobal || deleteItemsGlobal.length === 0) {
|
||
closeDeleteModal();
|
||
return;
|
||
}
|
||
|
||
const paths = deleteItemsGlobal.map(it => {
|
||
// Ensure path starts with /
|
||
let p = it.path;
|
||
if (!p.startsWith('/')) p = '/' + p;
|
||
return p;
|
||
});
|
||
|
||
const body = 'paths=' + encodeURIComponent(JSON.stringify(paths));
|
||
fetch('/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: body
|
||
}).then(async res => {
|
||
if (res.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
const text = await res.text();
|
||
alert('Failed to delete: ' + text);
|
||
closeDeleteModal();
|
||
}
|
||
}).catch(() => {
|
||
alert('Failed to delete - network error');
|
||
closeDeleteModal();
|
||
});
|
||
}
|
||
|
||
// Helper to clear image picker state
|
||
function clearImagePicker() {
|
||
epubImagesCache = [];
|
||
imageStates = {};
|
||
autoCropProtectedPaths = new Set();
|
||
const imageGrid = document.getElementById('imageGrid');
|
||
if (imageGrid) imageGrid.innerHTML = '';
|
||
const pickerSection = document.getElementById('imagePickerSection');
|
||
if (pickerSection) pickerSection.style.display = 'none';
|
||
// Reset two-column layout
|
||
document.querySelector('#uploadModal .modal')?.classList.remove('picker-mode');
|
||
document.getElementById('pickerColumns')?.classList.remove('picker-active');
|
||
// Hide start conversion, show upload
|
||
const startBtn = document.getElementById('startConversionBtn');
|
||
if (startBtn) startBtn.style.display = 'none';
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
if (uploadBtn) uploadBtn.style.display = 'block';
|
||
}
|
||
|
||
// Set up file input click listener once
|
||
(function setupFileInputListener() {
|
||
const fileInput = document.getElementById('fileInput');
|
||
if (!fileInput) return;
|
||
|
||
fileInput.addEventListener('click', function() {
|
||
// Small delay to allow browser to process click before checking files
|
||
setTimeout(() => {
|
||
if (fileInput.files.length === 0) {
|
||
clearImagePicker();
|
||
document.getElementById('uploadBtn').disabled = true;
|
||
}
|
||
}, 10);
|
||
});
|
||
|
||
// Drag-and-drop: route dropped files through the existing file input so the
|
||
// normal validateFile() pipeline (EPUB optimize options, batch mode, upload)
|
||
// runs unchanged. Assigning input.files via DataTransfer is supported in all
|
||
// current Chromium/Firefox/Safari, which is what the device serves the page to.
|
||
const dropZone = document.getElementById('dropZone');
|
||
if (dropZone) {
|
||
// dragenter/dragleave fire for child elements too; a counter avoids the
|
||
// highlight flickering as the cursor moves over the hint text or input.
|
||
let dragDepth = 0;
|
||
|
||
// An in-flight transfer can't be interrupted, so the zone is inert while
|
||
// uploading: no picker, no hover highlight — matching the disabled upload
|
||
// button and the drop handler's guard below.
|
||
const uploadBusy = () =>
|
||
typeof isUploadInProgress !== 'undefined' && isUploadInProgress;
|
||
|
||
// "click to browse" should work anywhere in the zone, not just on the
|
||
// native button. The hint has pointer-events:none so its clicks retarget
|
||
// to the zone; clicks on the native button retarget to fileInput (shadow
|
||
// DOM) and are skipped here so the picker isn't opened twice.
|
||
dropZone.addEventListener('click', function(e) {
|
||
if (uploadBusy()) return;
|
||
if (e.target !== fileInput) fileInput.click();
|
||
});
|
||
|
||
dropZone.addEventListener('dragenter', function(e) {
|
||
e.preventDefault();
|
||
dragDepth++;
|
||
if (!uploadBusy()) dropZone.classList.add('dragover');
|
||
});
|
||
|
||
dropZone.addEventListener('dragover', function(e) {
|
||
// Required so the browser doesn't navigate to / open the dropped file.
|
||
e.preventDefault();
|
||
});
|
||
|
||
dropZone.addEventListener('dragleave', function(e) {
|
||
e.preventDefault();
|
||
dragDepth = Math.max(0, dragDepth - 1);
|
||
if (dragDepth === 0) dropZone.classList.remove('dragover');
|
||
});
|
||
|
||
dropZone.addEventListener('drop', function(e) {
|
||
e.preventDefault();
|
||
dragDepth = 0;
|
||
dropZone.classList.remove('dragover');
|
||
|
||
// Don't let a drop disrupt an in-flight transfer.
|
||
if (uploadBusy()) return;
|
||
|
||
const dropped = e.dataTransfer && e.dataTransfer.files;
|
||
if (!dropped || dropped.length === 0) return;
|
||
|
||
// Replace the current selection, matching native file-picker semantics.
|
||
const dt = new DataTransfer();
|
||
for (const file of dropped) dt.items.add(file);
|
||
fileInput.files = dt.files;
|
||
|
||
validateFile();
|
||
});
|
||
}
|
||
})();
|
||
|
||
function validateFile() {
|
||
const fileInput = document.getElementById('fileInput');
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
const files = fileInput.files;
|
||
const convertOptions = document.getElementById('convertOptions');
|
||
fileInput.classList.toggle('has-files', files.length > 0);
|
||
|
||
// Show convert options only when at least one selected file is an EPUB.
|
||
const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub'));
|
||
if (files.length > 0 && hasEpub) {
|
||
convertOptions.style.display = 'block';
|
||
} else {
|
||
convertOptions.style.display = 'none';
|
||
// Clear stale checkbox state so the "Optimize & Upload" button doesn't linger
|
||
// when the user re-picks a non-EPUB after having ticked Optimize for an EPUB.
|
||
const cb = document.getElementById('convertBeforeUpload');
|
||
if (cb && cb.checked) {
|
||
cb.checked = false;
|
||
toggleConvertOptions();
|
||
}
|
||
if (files.length === 0) clearImagePicker();
|
||
}
|
||
|
||
if (files.length > 0) {
|
||
// If advanced settings is expanded and single EPUB, show image picker
|
||
const advancedContent = document.getElementById('advancedSettingsContent');
|
||
const convertEnabled = document.getElementById('convertBeforeUpload').checked;
|
||
if (advancedContent.classList.contains('visible') && files.length === 1 && convertEnabled && files[0].name.toLowerCase().endsWith('.epub')) {
|
||
showImagePicker(files[0]).catch(err => console.error('Image picker error:', err));
|
||
} else {
|
||
// New selection no longer matches single-EPUB-with-advanced-expanded —
|
||
// tear down any picker from a previous file so the grid and picker-mode
|
||
// layout don't linger. clearImagePicker is idempotent.
|
||
clearImagePicker();
|
||
}
|
||
|
||
// If multiple files with conversion, inform user about batch mode
|
||
if (files.length > 1 && convertEnabled) {
|
||
const epubCount = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.epub')).length;
|
||
if (epubCount > 0) {
|
||
console.log(`Batch mode: ${epubCount} EPUB(s) will use auto settings`);
|
||
}
|
||
}
|
||
|
||
updateBatchModeUI(files.length > 1);
|
||
uploadBtn.disabled = isUploadInProgress;
|
||
} else {
|
||
updateBatchModeUI(false);
|
||
uploadBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
let failedUploadsGlobal = [];
|
||
let wsConnection = null;
|
||
let isUploadInProgress = false; // Prevent modal close during upload/conversion
|
||
let operationCancelled = false; // Set by Cancel to stop conversion loops and upload async handlers
|
||
let uploadGeneration = 0; // Incremented each uploadFile() call; guards stale restoreAfterCancel()
|
||
let currentUploadWs = null; // Active WebSocket reference for external abort
|
||
let currentUploadXhr = null; // Active XHR reference for external abort
|
||
const WS_PORT = 81;
|
||
const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability
|
||
|
||
// ============================================================================
|
||
// EPUB Image Conversion Functions (from Baseline JPEG Converter)
|
||
// ============================================================================
|
||
|
||
// Device profiles (short edge × long edge in portrait orientation)
|
||
const DEVICE_PROFILES = {
|
||
X4: { width: 480, height: 800, label: 'X4' },
|
||
X3: { width: 528, height: 792, label: 'X3' },
|
||
};
|
||
|
||
// Default conversion settings
|
||
const DEFAULT_DEVICE = 'X4';
|
||
const DEFAULT_MAX_WIDTH = DEVICE_PROFILES[DEFAULT_DEVICE].width;
|
||
const DEFAULT_MAX_HEIGHT = DEVICE_PROFILES[DEFAULT_DEVICE].height;
|
||
const DEFAULT_JPEG_QUALITY = 85;
|
||
const DEFAULT_ENABLE_GRAYSCALE = true;
|
||
const DEFAULT_ENABLE_AUTO_CROP = false;
|
||
// Auto-crop is opt-in because sparse title/logo pages can be zoomed aggressively.
|
||
const CROP_WHITE_THRESHOLD = 245;
|
||
const CROP_BACKGROUND_TOLERANCE = 28;
|
||
const CROP_BACKGROUND_MAX_SPREAD = 24;
|
||
const CROP_EDGE_SAMPLE_SIZE = 12;
|
||
const CROP_PADDING_PX = 8;
|
||
const MIN_CROP_SAVINGS_RATIO = 0.08;
|
||
const MIN_COLOR_CROP_SAVINGS_RATIO = 0.20;
|
||
const MIN_CROP_DIMENSION = 240;
|
||
// Note: Overlap is now always centered distribution (min 5%)
|
||
|
||
// Dynamic conversion settings (updated by UI)
|
||
let DEVICE_TARGET = 'auto'; // 'auto' | 'X4' | 'X3'
|
||
let DETECTED_DEVICE = null; // populated from /api/status
|
||
let ACTIVE_DEVICE = DEFAULT_DEVICE;
|
||
let MAX_WIDTH = DEFAULT_MAX_WIDTH;
|
||
let MAX_HEIGHT = DEFAULT_MAX_HEIGHT;
|
||
let JPEG_QUALITY = DEFAULT_JPEG_QUALITY;
|
||
let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE;
|
||
let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;
|
||
let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)
|
||
let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)
|
||
|
||
// ============================================================================
|
||
// Image Picker State Management
|
||
// ============================================================================
|
||
|
||
let imageStates = {}; // Map: imagePath -> state (0=Normal, 1=H-Split, 2=V-Split, 3=Rotate)
|
||
let epubImagesCache = []; // Cache of extracted images for preview
|
||
let autoCropProtectedPaths = new Set(); // Picker-locked covers/separators should never be auto-cropped
|
||
let pendingConversionFile = null; // File awaiting conversion after image selection
|
||
|
||
// ============================================================================
|
||
// Enhanced Logging System
|
||
// ============================================================================
|
||
|
||
let logStartTime = null;
|
||
let conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 };
|
||
const logSection = document.getElementById('log-section');
|
||
const logContainer = document.getElementById('log-container');
|
||
const exportLogCheckbox = document.getElementById('export-log-checkbox');
|
||
|
||
// CrossPoint version (fetched from API)
|
||
let crosspointVersion = 'Unknown';
|
||
|
||
// Fetch version from API
|
||
async function fetchVersion() {
|
||
try {
|
||
const response = await fetch('/api/status');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
crosspointVersion = data.version || 'Unknown';
|
||
if (data.device === 'X3' || data.device === 'X4') {
|
||
DETECTED_DEVICE = data.device;
|
||
applyDeviceTarget();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch version:', e);
|
||
}
|
||
}
|
||
|
||
// Resolve DEVICE_TARGET ('auto' | 'X4' | 'X3') to a concrete profile and update UI.
|
||
function applyDeviceTarget() {
|
||
const resolved = DEVICE_TARGET === 'auto' ? (DETECTED_DEVICE || DEFAULT_DEVICE) : DEVICE_TARGET;
|
||
const profile = DEVICE_PROFILES[resolved] || DEVICE_PROFILES[DEFAULT_DEVICE];
|
||
ACTIVE_DEVICE = resolved;
|
||
MAX_WIDTH = profile.width;
|
||
MAX_HEIGHT = profile.height;
|
||
|
||
const summary = document.getElementById('convertSizeSummary');
|
||
if (summary) {
|
||
summary.textContent = `📏 Max ${profile.width}×${profile.height}px`;
|
||
}
|
||
document.querySelectorAll('.device-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.value === DEVICE_TARGET);
|
||
});
|
||
const autoLabel = document.getElementById('deviceAutoLabel');
|
||
if (autoLabel) {
|
||
autoLabel.textContent = DETECTED_DEVICE ? `Auto (${DETECTED_DEVICE})` : 'Auto';
|
||
}
|
||
|
||
// Recompute picker classification with new dimensions, then refresh grid.
|
||
if (Array.isArray(epubImagesCache) && epubImagesCache.length > 0) {
|
||
for (const img of epubImagesCache) {
|
||
img.fitsScreen = (img.width <= MAX_WIDTH && img.height <= MAX_HEIGHT);
|
||
img.canHSplit = img.width >= MAX_HEIGHT;
|
||
img.canVSplit = img.height >= MAX_HEIGHT;
|
||
}
|
||
const pickerSection = document.getElementById('imagePickerSection');
|
||
if (pickerSection && pickerSection.style.display !== 'none' && typeof renderImageGrid === 'function') {
|
||
renderImageGrid();
|
||
}
|
||
}
|
||
}
|
||
|
||
function setDeviceTarget(value) {
|
||
DEVICE_TARGET = value;
|
||
applyDeviceTarget();
|
||
}
|
||
|
||
// Batch logging system for multiple files
|
||
let batchLogEntries = [];
|
||
let batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 };
|
||
let batchStartTime = null;
|
||
let isBatchMode = false;
|
||
|
||
// Format bytes to human-readable size (for logging)
|
||
function formatBytes(b) {
|
||
if (!b) return '0 B';
|
||
const k = 1024;
|
||
const s = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||
return (b / Math.pow(k, i)).toFixed(1) + ' ' + s[i];
|
||
}
|
||
|
||
// Get elapsed timestamp since log start
|
||
function getTimestamp() {
|
||
if (!logStartTime) return '[00:00.0]';
|
||
const elapsed = (Date.now() - logStartTime) / 1000;
|
||
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
|
||
const secs = (elapsed % 60).toFixed(1).padStart(4, '0');
|
||
return `[${mins}:${secs}]`;
|
||
}
|
||
|
||
// Main logging function
|
||
function log(message, type = '', tag = '') {
|
||
const entry = document.createElement('div');
|
||
entry.className = 'log-entry ' + type;
|
||
|
||
// Timestamp
|
||
const timestamp = document.createElement('span');
|
||
timestamp.className = 'log-timestamp';
|
||
timestamp.textContent = getTimestamp();
|
||
entry.appendChild(timestamp);
|
||
|
||
// Tag (if provided)
|
||
if (tag) {
|
||
const tagEl = document.createElement('span');
|
||
tagEl.className = 'log-tag ' + tag.toLowerCase();
|
||
tagEl.textContent = tag;
|
||
entry.appendChild(tagEl);
|
||
}
|
||
|
||
// Message
|
||
const msg = document.createElement('span');
|
||
msg.className = 'log-message';
|
||
msg.innerHTML = message;
|
||
entry.appendChild(msg);
|
||
|
||
logContainer.appendChild(entry);
|
||
logContainer.scrollTop = logContainer.scrollHeight;
|
||
}
|
||
|
||
// Log image processing details
|
||
function logImage(name, origW, origH, origFormat, origSize, newW, newH, newSize, wasSplit = false, splitCount = 0, partsInfo = null, imageState = 0) {
|
||
const saved = origSize - newSize;
|
||
const savedPct = ((saved / origSize) * 100).toFixed(0);
|
||
const dims = `${origW}×${origH}`;
|
||
const newDims = `${newW}×${newH}`;
|
||
|
||
// Get state label and color
|
||
const stateLabels = ['', 'H-Split', 'V-Split', 'Rotate'];
|
||
const stateColors = ['', '#3498db', '#e74c3c', '#9b59b6'];
|
||
const stateLabel = stateLabels[imageState] || '';
|
||
const stateColor = stateColors[imageState] || '';
|
||
|
||
if (wasSplit) {
|
||
conversionStats.splits++;
|
||
conversionStats.splitParts += splitCount;
|
||
// Build parts detail string
|
||
let partsDetail = '';
|
||
if (partsInfo && partsInfo.length > 0) {
|
||
const baseName = name.replace(/\.[^.]+$/, '');
|
||
partsDetail = partsInfo.map(p =>
|
||
`${baseName}${p.suffix}.jpg (${p.width}×${p.height}, ${formatBytes(p.size)})`
|
||
).join(', ');
|
||
}
|
||
const savedInfo = saved > 0 ? `, <span style="color:#27ae60">-${savedPct}%</span>` : '';
|
||
const stateIndicator = imageState > 0 ? ` <span style="color:${stateColor};font-weight:600;">[${stateLabel}]</span>` : '';
|
||
log(`<strong>${escapeHtml(name)}</strong>${stateIndicator} <span class="log-detail">(${dims} ${origFormat.toUpperCase()}, ${formatBytes(origSize)}) → ${splitCount} parts${savedInfo}</span>`, '', 'SPLIT');
|
||
if (partsDetail) {
|
||
log(`<span class="log-detail" style="margin-left: 20px;">↳ ${partsDetail}</span>`, '', '');
|
||
}
|
||
} else {
|
||
conversionStats.images++;
|
||
const stateIndicator = imageState > 0 ? ` <span style="color:${stateColor};font-weight:600;">[${stateLabel}]</span>` : '';
|
||
const detail = saved > 0
|
||
? `<span class="log-detail">(${dims} → ${newDims}, ${formatBytes(origSize)} → ${formatBytes(newSize)}, <span style="color:#27ae60">-${savedPct}%</span>)</span>`
|
||
: `<span class="log-detail">(${dims} → ${newDims}, ${formatBytes(newSize)})</span>`;
|
||
log(`<strong>${escapeHtml(name)}</strong>${stateIndicator} ${detail}`, '', 'CONVERT');
|
||
}
|
||
}
|
||
|
||
// Log fix applied
|
||
function logFix(type, detail) {
|
||
conversionStats.fixes++;
|
||
log(`${type}: <span class="log-detail">${detail}</span>`, 'success', 'FIX');
|
||
}
|
||
|
||
// Log skipped item
|
||
function logSkip(name, reason) {
|
||
conversionStats.skipped++;
|
||
log(`${escapeHtml(name)} <span class="log-detail">(${reason})</span>`, '', 'SKIP');
|
||
}
|
||
|
||
// Log error
|
||
function logError(message) {
|
||
conversionStats.errors++;
|
||
log(message, 'error', 'ERROR');
|
||
}
|
||
|
||
// Log summary table
|
||
function logSummary(originalSize, newSize, timeElapsed) {
|
||
const saved = originalSize - newSize;
|
||
const savedPct = ((saved / originalSize) * 100).toFixed(1);
|
||
const totalImages = conversionStats.images + conversionStats.splits;
|
||
const totalOutput = conversionStats.images + conversionStats.splitParts;
|
||
|
||
const summaryHtml = `
|
||
<div class="log-summary">
|
||
<div class="log-summary-title">📊 Conversion Summary</div>
|
||
<table class="log-summary-table">
|
||
<tr><td>Images found</td><td class="highlight">${totalImages}</td></tr>
|
||
<tr><td>Images processed</td><td>${totalOutput}${conversionStats.splitParts > conversionStats.splits ? ` (+${conversionStats.splitParts - conversionStats.splits} from splits)` : ''}</td></tr>
|
||
<tr><td>EPUB repairs</td><td>${conversionStats.fixes > 0 ? conversionStats.fixes + ' fixes applied' : 'None needed'}</td></tr>
|
||
${conversionStats.errors > 0 ? `<tr><td>Errors</td><td style="color:#e74c3c">${conversionStats.errors}</td></tr>` : ''}
|
||
<tr><td>Original size</td><td>${formatBytes(originalSize)}</td></tr>
|
||
<tr><td>Optimized size</td><td>${formatBytes(newSize)}</td></tr>
|
||
<tr><td>Saved</td><td class="${saved > 0 ? 'saved' : 'increased'}">${saved > 0 ? formatBytes(saved) + ' (' + savedPct + '%)' : '+' + formatBytes(-saved)}</td></tr>
|
||
<tr><td>Time</td><td>${timeElapsed.toFixed(1)}s</td></tr>
|
||
</table>
|
||
</div>
|
||
`;
|
||
logContainer.insertAdjacentHTML('beforeend', summaryHtml);
|
||
logContainer.scrollTop = logContainer.scrollHeight;
|
||
}
|
||
|
||
// Clear log
|
||
function clearLog() {
|
||
logContainer.innerHTML = '';
|
||
logStartTime = Date.now();
|
||
conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 };
|
||
}
|
||
|
||
// Start batch logging mode
|
||
function startBatchLog(fileCount) {
|
||
isBatchMode = true;
|
||
batchStartTime = Date.now();
|
||
batchLogEntries = [];
|
||
batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 };
|
||
clearLog();
|
||
logContainer.innerHTML = ''; // Clear display
|
||
log(`Starting batch conversion: ${fileCount} file(s)`, '', 'INFO');
|
||
}
|
||
|
||
// Save current file's log to batch entries
|
||
function saveToFileBatchLog(fileName, succeeded, originalSize = 0, newSize = 0) {
|
||
if (!isBatchMode) return;
|
||
|
||
const entries = Array.from(logContainer.querySelectorAll('.log-entry'));
|
||
batchLogEntries.push({
|
||
fileName: fileName,
|
||
succeeded: succeeded,
|
||
entries: entries,
|
||
stats: { ...conversionStats }
|
||
});
|
||
|
||
// Update batch stats
|
||
batchStats.filesProcessed++;
|
||
if (succeeded) {
|
||
batchStats.filesSucceeded++;
|
||
} else {
|
||
batchStats.filesFailed++;
|
||
}
|
||
batchStats.totalImages += conversionStats.images;
|
||
batchStats.totalSplits += conversionStats.splits;
|
||
batchStats.totalFixes += conversionStats.fixes;
|
||
batchStats.totalErrors += conversionStats.errors;
|
||
// Defaults of 0 keep failure-path callers safe — files that never
|
||
// produced a converted blob contribute nothing to the totals.
|
||
batchStats.totalOriginalSize += originalSize;
|
||
batchStats.totalNewSize += newSize;
|
||
|
||
// Clear for next file
|
||
logContainer.innerHTML = '';
|
||
conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 };
|
||
}
|
||
|
||
// Finalize batch log and export
|
||
function finalizeBatchLog() {
|
||
if (!isBatchMode) return;
|
||
|
||
const batchTime = (Date.now() - batchStartTime) / 1000;
|
||
|
||
// Build consolidated log display
|
||
logContainer.innerHTML = '';
|
||
log(`Starting batch conversion: ${batchStats.filesProcessed} file(s)`, '', 'INFO');
|
||
|
||
// Add all file entries
|
||
batchLogEntries.forEach((fileLog, index) => {
|
||
const fileHeader = document.createElement('div');
|
||
fileHeader.className = 'log-entry';
|
||
fileHeader.style.marginTop = index > 0 ? '15px' : '5px';
|
||
fileHeader.style.borderTop = index > 0 ? '1px solid var(--border-color)' : 'none';
|
||
fileHeader.style.paddingTop = index > 0 ? '10px' : '0';
|
||
fileHeader.innerHTML = `<span class="log-timestamp"></span><span class="log-message"><strong>${escapeHtml(fileLog.fileName)}</strong> — ${fileLog.succeeded ? '<span style="color:#27ae60">✓ Success</span>' : '<span style="color:#e74c3c">✗ Failed</span>'}</span>`;
|
||
logContainer.appendChild(fileHeader);
|
||
|
||
fileLog.entries.forEach(entry => {
|
||
const clone = entry.cloneNode(true);
|
||
logContainer.appendChild(clone);
|
||
});
|
||
});
|
||
|
||
// Aggregate size totals: only emit rows when at least one file was successfully
|
||
// converted (totalOriginalSize stays 0 for batches where conversion was off or
|
||
// every file fell back to original upload).
|
||
const totalSaved = batchStats.totalOriginalSize - batchStats.totalNewSize;
|
||
const totalSavedPct = batchStats.totalOriginalSize > 0
|
||
? ((totalSaved / batchStats.totalOriginalSize) * 100).toFixed(1)
|
||
: '0.0';
|
||
const sizeRowsHtml = batchStats.totalOriginalSize > 0 ? `
|
||
<tr><td>Total original</td><td>${formatBytes(batchStats.totalOriginalSize)}</td></tr>
|
||
<tr><td>Total optimised</td><td>${formatBytes(batchStats.totalNewSize)}</td></tr>
|
||
<tr><td>Total saved</td><td class="${totalSaved > 0 ? 'saved' : 'increased'}">${
|
||
totalSaved > 0
|
||
? `${formatBytes(totalSaved)} (${totalSavedPct}%)`
|
||
: `+${formatBytes(-totalSaved)}`
|
||
}</td></tr>` : '';
|
||
|
||
// Add batch summary
|
||
const batchSummaryHtml = `
|
||
<div class="log-summary">
|
||
<div class="log-summary-title">📊 Batch Conversion Summary</div>
|
||
<table class="log-summary-table">
|
||
<tr><td>Files processed</td><td class="highlight">${batchStats.filesProcessed}</td></tr>
|
||
<tr><td>Successful</td><td style="color:#27ae60">${batchStats.filesSucceeded}</td></tr>
|
||
<tr><td>Failed</td><td style="${batchStats.filesFailed > 0 ? '#e74c3c' : '#7f8c8d'}">${batchStats.filesFailed}</td></tr>
|
||
<tr><td>Total images processed</td><td>${batchStats.totalImages}</td></tr>
|
||
<tr><td>Total splits</td><td>${batchStats.totalSplits}</td></tr>
|
||
<tr><td>Total fixes applied</td><td>${batchStats.totalFixes}</td></tr>
|
||
${batchStats.totalErrors > 0 ? `<tr><td>Total errors</td><td style="color:#e74c3c">${batchStats.totalErrors}</td></tr>` : ''}${sizeRowsHtml}
|
||
<tr><td>Total time</td><td>${batchTime.toFixed(1)}s</td></tr>
|
||
</table>
|
||
</div>
|
||
`;
|
||
logContainer.insertAdjacentHTML('beforeend', batchSummaryHtml);
|
||
logContainer.scrollTop = logContainer.scrollHeight;
|
||
|
||
// Auto-export if checkbox is checked
|
||
if (exportLogCheckbox && exportLogCheckbox.checked) {
|
||
setTimeout(() => {
|
||
exportLogToFile(null, true); // isBatch = true
|
||
}, 200);
|
||
}
|
||
|
||
// Reset batch mode
|
||
isBatchMode = false;
|
||
batchLogEntries = [];
|
||
}
|
||
|
||
// Show/hide log section
|
||
function showLog() {
|
||
logSection.classList.add('visible');
|
||
}
|
||
|
||
function hideLog() {
|
||
logSection.classList.remove('visible');
|
||
}
|
||
|
||
// Generate standardized log filename with date
|
||
function generateLogFilename(isBatch = false) {
|
||
const now = new Date();
|
||
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||
const time = now.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS
|
||
const prefix = isBatch ? 'batch' : 'epub';
|
||
return `${prefix}-conversion-log-${date}_${time}.txt`;
|
||
}
|
||
|
||
// Export log as text file (can be called automatically)
|
||
function exportLogToFile(filename = null, isBatch = false) {
|
||
// Use standardized filename if none provided
|
||
if (!filename) {
|
||
filename = generateLogFilename(isBatch);
|
||
}
|
||
// Extract text from log entries
|
||
const entries = logContainer.querySelectorAll('.log-entry');
|
||
let logText = `CrossPoint Reader ${crosspointVersion} - EPUB Conversion Log\n`;
|
||
logText += `Generated: ${new Date().toLocaleString()}\n`;
|
||
logText += `${'='.repeat(60)}\n\n`;
|
||
|
||
entries.forEach(entry => {
|
||
const timestamp = entry.querySelector('.log-timestamp')?.textContent || '';
|
||
const tag = entry.querySelector('.log-tag')?.textContent || '';
|
||
const message = entry.querySelector('.log-message')?.textContent || entry.textContent;
|
||
|
||
if (tag) {
|
||
logText += `${timestamp} [${tag}] ${message}\n`;
|
||
} else {
|
||
logText += `${timestamp} ${message}\n`;
|
||
}
|
||
});
|
||
|
||
// Extract summary table if present
|
||
const summary = logContainer.querySelector('.log-summary');
|
||
if (summary) {
|
||
logText += `\n${'='.repeat(60)}\n`;
|
||
const title = summary.querySelector('.log-summary-title')?.textContent || 'Summary';
|
||
logText += `${title}\n`;
|
||
logText += `${'-'.repeat(40)}\n`;
|
||
|
||
const rows = summary.querySelectorAll('tr');
|
||
rows.forEach(row => {
|
||
const cells = row.querySelectorAll('td');
|
||
if (cells.length >= 2) {
|
||
logText += `${cells[0].textContent.padEnd(25)} ${cells[1].textContent}\n`;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Create download link
|
||
const blob = new Blob([logText], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename || `epub-conversion-log-${Date.now()}.txt`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// EPUB Utilities — ported from EPUB Optimizer Pro
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
/** Defensive CSS injected into every XHTML <head> — prevents e-ink overflow. */
|
||
const DEFENSIVE_STYLE = '<style type="text/css">img,svg{max-width:100%;height:auto}body{overflow-wrap:break-word}table{max-width:100%;table-layout:fixed}pre,code{white-space:pre-wrap;word-wrap:break-word}*{box-sizing:border-box}</style>';
|
||
|
||
/** Escape a string for safe insertion into XML attribute values / text content. */
|
||
function xmlEscape(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||
|
||
/**
|
||
* Decode a URI-encoded href (e.g., "my%20image.jpg" → "my image.jpg").
|
||
* Handles double-encoding gracefully.
|
||
*/
|
||
function decodeHref(href) {
|
||
try { return decodeURIComponent(href); }
|
||
catch (e) { return href; }
|
||
}
|
||
|
||
/**
|
||
* Safely read a text file from the zip, handling BOM and encoding.
|
||
* Strips UTF-8 BOM. Detects encoding from XML declaration or meta tag.
|
||
*/
|
||
async function safeReadText(fileObj) {
|
||
const raw = await fileObj.async('uint8array');
|
||
|
||
// Detect and strip UTF-8 BOM (EF BB BF)
|
||
let offset = 0;
|
||
if (raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) {
|
||
offset = 3;
|
||
}
|
||
|
||
// Try UTF-8 first (vast majority of EPUBs)
|
||
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
||
try {
|
||
return utf8.decode(raw.subarray(offset));
|
||
} catch (e) { /* not valid UTF-8 */ }
|
||
|
||
// Peek at XML declaration or meta charset for encoding hint
|
||
const ascii = new TextDecoder('ascii', { fatal: false }).decode(raw.subarray(offset, offset + 512));
|
||
const encodingMatch = ascii.match(/encoding=["']([^"']+)["']/i) ||
|
||
ascii.match(/charset=["']?([^"'\s;]+)/i);
|
||
const encoding = encodingMatch ? encodingMatch[1].toLowerCase() : 'windows-1252';
|
||
|
||
try {
|
||
return new TextDecoder(encoding, { fatal: false }).decode(raw.subarray(offset));
|
||
} catch (e) {
|
||
// Last resort: lossy latin1
|
||
return new TextDecoder('iso-8859-1', { fatal: false }).decode(raw.subarray(offset));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Find the canonical OPF path by parsing META-INF/container.xml.
|
||
* Falls back to scanning for any .opf file.
|
||
*/
|
||
async function findOPFPath(zip) {
|
||
try {
|
||
const containerPath = Object.keys(zip.files).find(p => p.toLowerCase() === 'meta-inf/container.xml');
|
||
if (containerPath) {
|
||
const containerXml = await zip.files[containerPath].async('string');
|
||
const match = containerXml.match(/<rootfile[^>]+full-path=["']([^"']+)["']/i);
|
||
if (match && zip.files[match[1]]) return match[1];
|
||
}
|
||
} catch (e) { /* fall through */ }
|
||
let fallback = null;
|
||
zip.forEach(p => { if (!fallback && p.toLowerCase().endsWith('.opf')) fallback = p; });
|
||
return fallback;
|
||
}
|
||
|
||
/**
|
||
* Resolve a relative href against a base file path.
|
||
* Handles multiple ../, ./, absolute /, and bare relative paths.
|
||
*/
|
||
function resolvePath(basePath, href) {
|
||
if (href.startsWith('/')) return href.substring(1);
|
||
href = href.replace(/^\.\//, '');
|
||
const baseDir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : '';
|
||
const baseParts = baseDir ? baseDir.split('/') : [];
|
||
const hrefParts = href.split('/');
|
||
while (hrefParts.length > 0 && hrefParts[0] === '..') {
|
||
hrefParts.shift();
|
||
if (baseParts.length > 0) baseParts.pop();
|
||
}
|
||
const resolved = [...baseParts, ...hrefParts].join('/');
|
||
return resolved.replace(/\/+/g, '/');
|
||
}
|
||
|
||
/**
|
||
* Serialize an XML doc back to string, preserving the original <?xml?> declaration
|
||
* and cleaning up XMLSerializer namespace prefix noise (xmlns:ns0 etc).
|
||
*/
|
||
function safeSerialize(doc, originalContent) {
|
||
let result = new XMLSerializer().serializeToString(doc);
|
||
|
||
// Restore <?xml?> declaration if original had one
|
||
if (originalContent && /^\s*<\?xml\b/.test(originalContent) && !/^\s*<\?xml\b/.test(result)) {
|
||
const declMatch = originalContent.match(/^\s*(<\?xml[^?]*\?>)/);
|
||
if (declMatch) result = declMatch[1] + '\n' + result;
|
||
}
|
||
|
||
// Clean up XMLSerializer namespace prefix noise (xmlns:ns0="..." ns0:attr="...")
|
||
result = result.replace(/ xmlns:ns\d+="[^"]*"/g, '');
|
||
result = result.replace(/ ns\d+:/g, ' ');
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Extract main identifier from OPF for NCX sync. DOMParser with regex fallback.
|
||
*/
|
||
function extractIdentifier(opfContent) {
|
||
let mainIdentifier = null;
|
||
try {
|
||
const doc = new DOMParser().parseFromString(opfContent, 'application/xml');
|
||
if (!doc.querySelector('parsererror')) {
|
||
const pkg = doc.getElementsByTagNameNS('*', 'package')[0];
|
||
const uid = pkg ? pkg.getAttribute('unique-identifier') : null;
|
||
if (uid) {
|
||
const el = [...doc.getElementsByTagNameNS('*', 'identifier')].find(e => e.getAttribute('id') === uid);
|
||
if (el) mainIdentifier = (el.textContent || '').trim();
|
||
}
|
||
if (!mainIdentifier) {
|
||
const el = doc.getElementsByTagNameNS('*', 'identifier')[0];
|
||
if (el) mainIdentifier = (el.textContent || '').trim();
|
||
}
|
||
}
|
||
} catch (e) { /* fall through to regex */ }
|
||
if (!mainIdentifier) {
|
||
const uniqueIdMatch = opfContent.match(/<(?:\w+:)?package[^>]*unique-identifier=["']([^"']+)["']/i);
|
||
if (uniqueIdMatch) {
|
||
const idRegex = new RegExp(`<dc:identifier[^>]*id=["']${uniqueIdMatch[1]}["'][^>]*>([^<]+)</dc:identifier>`, 'i');
|
||
const idMatch = opfContent.match(idRegex);
|
||
if (idMatch) mainIdentifier = idMatch[1].trim();
|
||
}
|
||
if (!mainIdentifier) {
|
||
const firstIdMatch = opfContent.match(/<dc:identifier[^>]*>([^<]+)</i);
|
||
if (firstIdMatch) mainIdentifier = firstIdMatch[1].trim();
|
||
}
|
||
}
|
||
return mainIdentifier;
|
||
}
|
||
|
||
/**
|
||
* Sync NCX dtb:uid with the given identifier. DOMParser with regex fallback.
|
||
*/
|
||
function syncNCXIdentifier(ncxText, mainIdentifier) {
|
||
if (!mainIdentifier) return ncxText;
|
||
let t = ncxText;
|
||
try {
|
||
const doc = new DOMParser().parseFromString(t, 'application/xml');
|
||
if (!doc.querySelector('parsererror')) {
|
||
const meta = [...doc.getElementsByTagNameNS('*', 'meta')].find(m => m.getAttribute('name') === 'dtb:uid');
|
||
if (meta) {
|
||
meta.setAttribute('content', mainIdentifier);
|
||
t = safeSerialize(doc, ncxText);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
t = t.replace(/<meta\s+name=["']dtb:uid["']\s+content=["'][^"']*["']\s*\/?>/gi, `<meta name="dtb:uid" content="${xmlEscape(mainIdentifier)}"/>`);
|
||
}
|
||
return t;
|
||
}
|
||
|
||
/**
|
||
* Fix OPF content: fix media-types, strip svg properties,
|
||
* update split image manifest entries, ensure cover meta.
|
||
* DOMParser with regex fallback.
|
||
*/
|
||
function fixOPF(opfText, opfOriginal, opfDir, splitImages = {}) {
|
||
let t = opfText;
|
||
|
||
try {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(t, 'application/xml');
|
||
if (doc.querySelector('parsererror')) throw new Error('OPF parse failed');
|
||
|
||
const items = [...doc.getElementsByTagNameNS('*', 'item')];
|
||
const manifestEl = doc.getElementsByTagNameNS('*', 'manifest')[0];
|
||
|
||
// Fix media-types for converted images
|
||
for (const item of items) {
|
||
const href = item.getAttribute('href') || '';
|
||
const type = item.getAttribute('media-type') || '';
|
||
if (href.endsWith('.jpg') && type.match(/^image\/(png|gif|webp|bmp)$/)) {
|
||
item.setAttribute('media-type', 'image/jpeg');
|
||
}
|
||
}
|
||
|
||
// Remove 'svg' from properties
|
||
for (const item of items) {
|
||
const props = item.getAttribute('properties') || '';
|
||
if (props.includes('svg')) {
|
||
const newProps = props.split(/\s+/).filter(p => p !== 'svg').join(' ').trim();
|
||
if (newProps) item.setAttribute('properties', newProps);
|
||
else item.removeAttribute('properties');
|
||
}
|
||
}
|
||
|
||
// Update split image hrefs and add manifest entries for parts
|
||
for (const [splitKey, splitInfo] of Object.entries(splitImages)) {
|
||
const parts = splitInfo.parts || splitInfo;
|
||
let origHref = opfDir && splitKey.startsWith(opfDir + '/') ? splitKey.substring(opfDir.length + 1) : splitKey;
|
||
const origHrefJpg = origHref.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg');
|
||
const part1Href = origHrefJpg.replace(/\.jpg$/i, '_part1.jpg');
|
||
|
||
for (const item of items) {
|
||
const h = item.getAttribute('href') || '';
|
||
if (h === origHref || h === origHrefJpg || decodeHref(h) === origHref || decodeHref(h) === origHrefJpg) {
|
||
item.setAttribute('href', part1Href);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (manifestEl) {
|
||
const ns = manifestEl.namespaceURI || 'http://www.idpf.org/2007/opf';
|
||
for (let j = 1; j < parts.length; j++) {
|
||
const p = parts[j];
|
||
const href = opfDir && p.path.startsWith(opfDir + '/') ? p.path.substring(opfDir.length + 1) : p.path;
|
||
const newItem = doc.createElementNS(ns, 'item');
|
||
newItem.setAttribute('id', `img-${p.id}`);
|
||
newItem.setAttribute('href', href);
|
||
newItem.setAttribute('media-type', 'image/jpeg');
|
||
manifestEl.appendChild(newItem);
|
||
}
|
||
}
|
||
}
|
||
|
||
t = safeSerialize(doc, opfOriginal);
|
||
} catch (e) {
|
||
// Regex fallback
|
||
t = t.replace(/(<(?:\w+:)?item\b[^>]*href="[^"]+\.jpg"[^>]*)media-type="image\/(png|gif|webp|bmp)"/g, '$1media-type="image/jpeg"');
|
||
t = t.replace(/(<(?:\w+:)?item\b[^>]*)media-type="image\/(png|gif|webp|bmp)"([^>]*href="[^"]+\.jpg")/g, '$1media-type="image/jpeg"$3');
|
||
t = t.replace(/\s+svg(?=["'\s>])/g, '');
|
||
for (const [splitKey, splitInfo] of Object.entries(splitImages)) {
|
||
const parts = splitInfo.parts || splitInfo;
|
||
let origHref = opfDir && splitKey.startsWith(opfDir + '/') ? splitKey.substring(opfDir.length + 1) : splitKey;
|
||
const origHrefJpg = origHref.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg');
|
||
const part1Href = origHrefJpg.replace(/\.jpg$/i, '_part1.jpg');
|
||
const origImgRegex = new RegExp(`(href=["'])(${escapeRegex(origHref)}|${escapeRegex(origHrefJpg)})(["'])`, 'gi');
|
||
t = t.replace(origImgRegex, `$1${part1Href}$3`);
|
||
let adds = '';
|
||
for (let j = 1; j < parts.length; j++) {
|
||
const p = parts[j];
|
||
const href = opfDir && p.path.startsWith(opfDir + '/') ? p.path.substring(opfDir.length + 1) : p.path;
|
||
adds += `<item id="img-${xmlEscape(p.id)}" href="${xmlEscape(href)}" media-type="image/jpeg"/>\n`;
|
||
}
|
||
if (adds && t.includes('</manifest>')) t = t.replace('</manifest>', adds + '</manifest>');
|
||
}
|
||
}
|
||
|
||
// Ensure cover meta
|
||
const cm = ensureCoverMeta(t);
|
||
if (cm.fixed) t = cm.o;
|
||
|
||
return t;
|
||
}
|
||
|
||
// Fix SVG cover - converts SVG-wrapped covers to plain HTML img tags
|
||
function fixSvgCover(content) {
|
||
const hasSvg = content.includes('<svg') || content.includes('<svg:');
|
||
if (!hasSvg || !content.includes('xlink:href')) return { c: content, fixed: false, count: 0 };
|
||
if (!content.includes('calibre:cover') && !content.includes('name="cover"') && !content.includes('<title>Cover</title>')) return { c: content, fixed: false, count: 0 };
|
||
|
||
try {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(content, 'application/xhtml+xml');
|
||
|
||
if (doc.querySelector('parsererror')) {
|
||
// Fallback to regex
|
||
const m = content.match(/xlink:href=["']([^"']+)["']/);
|
||
if (!m) return { c: content, fixed: false, count: 0 };
|
||
return { c: `<?xml version="1.0" encoding="utf-8"?>
|
||
<!DOCTYPE html>
|
||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
|
||
<head><meta content="text/html; charset=UTF-8" http-equiv="default-style"/><title>Cover</title></head>
|
||
<body><section epub:type="cover"><img style="max-width:100%;height:auto" alt="Cover" src="${m[1]}"/></section></body>
|
||
</html>`, fixed: true, count: 1 };
|
||
}
|
||
|
||
// Find SVG elements - check both standard and namespaced variants
|
||
let imgHref = null;
|
||
const svgNS = 'http://www.w3.org/2000/svg';
|
||
const xlinkNS = 'http://www.w3.org/1999/xlink';
|
||
|
||
// Try to find all SVG elements
|
||
const svgs = [
|
||
...doc.getElementsByTagName('svg'),
|
||
...doc.getElementsByTagNameNS(svgNS, 'svg'),
|
||
...doc.getElementsByTagName('svg:svg')
|
||
];
|
||
|
||
for (const svg of svgs) {
|
||
// Find image element inside - try all variants
|
||
const imageEl = svg.getElementsByTagName('image')[0] ||
|
||
svg.getElementsByTagNameNS(svgNS, 'image')[0] ||
|
||
svg.getElementsByTagName('svg:image')[0];
|
||
|
||
if (imageEl) {
|
||
imgHref = imageEl.getAttributeNS(xlinkNS, 'href') ||
|
||
imageEl.getAttribute('xlink:href') ||
|
||
imageEl.getAttribute('href');
|
||
if (imgHref) break;
|
||
}
|
||
}
|
||
|
||
if (!imgHref) {
|
||
// Fallback to regex
|
||
const m = content.match(/xlink:href=["']([^"']+)["']/);
|
||
if (!m) return { c: content, fixed: false, count: 0 };
|
||
imgHref = m[1];
|
||
}
|
||
|
||
return {
|
||
c: `<?xml version="1.0" encoding="utf-8"?>
|
||
<!DOCTYPE html>
|
||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
|
||
<head><meta content="text/html; charset=UTF-8" http-equiv="default-style"/><title>Cover</title></head>
|
||
<body><section epub:type="cover"><img style="max-width:100%;height:auto" alt="Cover" src="${imgHref}"/></section></body>
|
||
</html>`,
|
||
fixed: true,
|
||
count: 1
|
||
};
|
||
} catch (e) {
|
||
// Fallback to regex
|
||
const m = content.match(/xlink:href=["']([^"']+)["']/);
|
||
if (!m) return { c: content, fixed: false, count: 0 };
|
||
return { c: `<?xml version="1.0" encoding="utf-8"?>
|
||
<!DOCTYPE html>
|
||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
|
||
<head><meta content="text/html; charset=UTF-8" http-equiv="default-style"/><title>Cover</title></head>
|
||
<body><section epub:type="cover"><img style="max-width:100%;height:auto" alt="Cover" src="${m[1]}"/></section></body>
|
||
</html>`, fixed: true, count: 1 };
|
||
}
|
||
}
|
||
|
||
// Fix SVG-wrapped images - unwrap SVG and replace with plain img
|
||
function fixSvgWrappedImages(content) {
|
||
const hasSvg = content.includes('<svg') || content.includes('<svg:');
|
||
if (!hasSvg || !content.includes('xlink:href')) return { c: content, fixed: false, count: 0 };
|
||
|
||
try {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(content, 'application/xhtml+xml');
|
||
|
||
if (doc.querySelector('parsererror')) {
|
||
// Fallback to regex
|
||
let fixedCount = 0;
|
||
const svgImageRegex = /<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*xlink:href=["']([^"']+)["'][^>]*\/?>\s*<\/(?:svg:)?svg>/gi;
|
||
const newContent = content.replace(svgImageRegex, (match, href) => { fixedCount++; return `<img style="max-width:100%;height:auto" src="${href}" alt="" />`; });
|
||
return { c: newContent, fixed: fixedCount > 0, count: fixedCount };
|
||
}
|
||
|
||
const svgNS = 'http://www.w3.org/2000/svg';
|
||
const xlinkNS = 'http://www.w3.org/1999/xlink';
|
||
|
||
const svgElements = [...doc.querySelectorAll('svg'), ...doc.getElementsByTagNameNS(svgNS, 'svg')];
|
||
const uniqueSvgs = [...new Set(svgElements)];
|
||
let fixedCount = 0;
|
||
|
||
for (const svg of uniqueSvgs) {
|
||
const imageEl = svg.querySelector('image[*|href]') || svg.getElementsByTagNameNS(svgNS, 'image')[0] || svg.getElementsByTagNameNS('*', 'image')[0];
|
||
if (!imageEl) continue;
|
||
const href = imageEl.getAttributeNS(xlinkNS, 'href') || imageEl.getAttribute('xlink:href') || imageEl.getAttribute('href');
|
||
if (!href) continue;
|
||
const width = imageEl.getAttribute('width') || svg.getAttribute('width');
|
||
const height = imageEl.getAttribute('height') || svg.getAttribute('height');
|
||
const img = doc.createElementNS('http://www.w3.org/1999/xhtml', 'img');
|
||
img.setAttribute('src', href);
|
||
img.setAttribute('alt', '');
|
||
img.setAttribute('style', 'max-width:100%;height:auto');
|
||
if (width) img.setAttribute('width', width);
|
||
if (height) img.setAttribute('height', height);
|
||
svg.parentNode.replaceChild(img, svg);
|
||
fixedCount++;
|
||
}
|
||
|
||
if (fixedCount === 0) return { c: content, fixed: false, count: 0 };
|
||
return { c: safeSerialize(doc, content), fixed: true, count: fixedCount };
|
||
|
||
} catch (e) {
|
||
// Fallback to regex
|
||
let fixedCount = 0;
|
||
const svgImageRegex = /<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*xlink:href=["']([^"']+)["'][^>]*\/?>\s*<\/(?:svg:)?svg>/gi;
|
||
const newContent = content.replace(svgImageRegex, (match, href) => { fixedCount++; return `<img style="max-width:100%;height:auto" src="${href}" alt="" />`; });
|
||
return { c: newContent, fixed: fixedCount > 0, count: fixedCount };
|
||
}
|
||
}
|
||
|
||
// Ensure cover meta tag exists in OPF — DOMParser with regex fallback
|
||
function ensureCoverMeta(opfString) {
|
||
try {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(opfString, 'application/xml');
|
||
if (doc.querySelector('parsererror')) throw new Error('Parse failed');
|
||
|
||
// Find cover image id: properties="cover-image", or id/href containing "cover"
|
||
let coverId = null;
|
||
const items = [...doc.getElementsByTagNameNS('*', 'item')];
|
||
for (const item of items) {
|
||
const props = item.getAttribute('properties') || '';
|
||
const id = item.getAttribute('id') || '';
|
||
const type = item.getAttribute('media-type') || '';
|
||
if (!type.startsWith('image/')) continue;
|
||
if (props.includes('cover-image')) { coverId = id; break; }
|
||
}
|
||
if (!coverId) {
|
||
for (const item of items) {
|
||
const id = item.getAttribute('id') || '';
|
||
const href = item.getAttribute('href') || '';
|
||
const type = item.getAttribute('media-type') || '';
|
||
if (!type.startsWith('image/')) continue;
|
||
if (id.toLowerCase().includes('cover') || href.toLowerCase().includes('cover')) { coverId = id; break; }
|
||
}
|
||
}
|
||
if (!coverId) return { o: opfString, fixed: false };
|
||
|
||
// Find or create <meta name="cover" content="..."/>
|
||
const metas = [...doc.getElementsByTagNameNS('*', 'meta')];
|
||
const coverMeta = metas.find(m => m.getAttribute('name') === 'cover');
|
||
if (coverMeta) {
|
||
if (coverMeta.getAttribute('content') === coverId) return { o: opfString, fixed: false };
|
||
coverMeta.setAttribute('content', coverId);
|
||
} else {
|
||
const metadata = doc.getElementsByTagNameNS('*', 'metadata')[0];
|
||
if (!metadata) return { o: opfString, fixed: false };
|
||
const ns = metadata.namespaceURI || 'http://www.idpf.org/2007/opf';
|
||
const newMeta = doc.createElementNS(ns, 'meta');
|
||
newMeta.setAttribute('name', 'cover');
|
||
newMeta.setAttribute('content', coverId);
|
||
metadata.appendChild(newMeta);
|
||
}
|
||
return { o: safeSerialize(doc, opfString), fixed: true };
|
||
} catch (e) {
|
||
// Regex fallback
|
||
return ensureCoverMetaRegex(opfString);
|
||
}
|
||
}
|
||
|
||
function ensureCoverMetaRegex(o) {
|
||
let coverId = null, m;
|
||
if (!coverId && (m = o.match(/<\w+:?item[^>]+id="([^"]+)"[^>]+properties="[^"]*cover-image[^"]*"/i))) coverId = m[1];
|
||
if (!coverId && (m = o.match(/<\w+:?item[^>]+properties="[^"]*cover-image[^"]*"[^>]+id="([^"]+)"/i))) coverId = m[1];
|
||
if (!coverId && (m = o.match(/<\w+:?item[^>]*id="([^"]+)"[^>]*href="[^"]*cover[^"]*"[^>]*media-type="image\//i))) coverId = m[1];
|
||
if (!coverId && (m = o.match(/<\w+:?item[^>]*href="[^"]*cover[^"]*"[^>]*id="([^"]+)"[^>]*media-type="image\//i))) coverId = m[1];
|
||
if (!coverId && (m = o.match(/<\w+:?item[^>]*id="([^"]*cover[^"]*)"[^>]*media-type="image\//i))) coverId = m[1];
|
||
if (!coverId && (m = o.match(/<\w+:?item[^>]*media-type="image\/[^"]*"[^>]*id="([^"]*cover[^"]*)"/i))) coverId = m[1];
|
||
if (!coverId) return { o, fixed: false };
|
||
const metaMatch = o.match(/<\w+:?meta\s+name=["']cover["']\s+content=["']([^"']+)["']/i) || o.match(/<\w+:?meta\s+content=["']([^"']+)["']\s+name=["']cover["']/i);
|
||
if (metaMatch) {
|
||
if (metaMatch[1] === coverId && !metaMatch[1].includes('/')) return { o, fixed: false };
|
||
const esc = xmlEscape(coverId);
|
||
o = o.replace(/<\w+:?meta\s+name=["']cover["']\s+content=["'][^"']+["']\s*\/?>/gi, `<meta name="cover" content="${esc}" />`);
|
||
o = o.replace(/<\w+:?meta\s+content=["'][^"']+["']\s+name=["']cover["']\s*\/?>/gi, `<meta name="cover" content="${esc}" />`);
|
||
return { o, fixed: true };
|
||
}
|
||
const idx = o.indexOf('</metadata>');
|
||
if (idx !== -1) return { o: o.substring(0, idx) + ` <meta name="cover" content="${xmlEscape(coverId)}"/>\n </metadata>` + o.substring(idx + 11), fixed: true };
|
||
return { o, fixed: false };
|
||
}
|
||
|
||
// Apply grayscale to canvas image data
|
||
function applyGrayscale(ctx, width, height) {
|
||
if (!ENABLE_GRAYSCALE) return;
|
||
const imageData = ctx.getImageData(0, 0, width, height);
|
||
const data = imageData.data;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
// Alpha-blend against white background before grayscaling (handles transparent PNGs)
|
||
const a = data[i + 3] / 255;
|
||
const blendedR = data[i] * a + 255 * (1 - a);
|
||
const blendedG = data[i + 1] * a + 255 * (1 - a);
|
||
const blendedB = data[i + 2] * a + 255 * (1 - a);
|
||
const gray = Math.round(blendedR * 0.299 + blendedG * 0.587 + blendedB * 0.114);
|
||
data[i] = gray; data[i + 1] = gray; data[i + 2] = gray; data[i + 3] = 255;
|
||
}
|
||
ctx.putImageData(imageData, 0, 0);
|
||
}
|
||
|
||
function shouldSkipAutoCrop(imagePath, width, height, ignoreToggle = false) {
|
||
if (!ignoreToggle && !ENABLE_AUTO_CROP) return true;
|
||
if (autoCropProtectedPaths.has(imagePath)) return true;
|
||
if (width < MIN_CROP_DIMENSION || height < MIN_CROP_DIMENSION) return true;
|
||
return /(^|\/)(cover|thumbnail|thumb|icon)[^/]*\.(jpe?g|png|gif|webp|bmp)$/i.test(imagePath || '');
|
||
}
|
||
|
||
function blendedPixel(data, i) {
|
||
const alpha = data[i + 3] / 255;
|
||
return {
|
||
r: data[i] * alpha + 255 * (1 - alpha),
|
||
g: data[i + 1] * alpha + 255 * (1 - alpha),
|
||
b: data[i + 2] * alpha + 255 * (1 - alpha)
|
||
};
|
||
}
|
||
|
||
function estimateCropBackground(data, width, height) {
|
||
const sampleSize = Math.min(CROP_EDGE_SAMPLE_SIZE, Math.floor(width / 8), Math.floor(height / 8));
|
||
if (sampleSize < 2) return null;
|
||
|
||
// Use corners plus edge midpoints so colored margins work without trimming full-bleed art.
|
||
const samples = [];
|
||
const points = [
|
||
[0, 0],
|
||
[width - sampleSize, 0],
|
||
[0, height - sampleSize],
|
||
[width - sampleSize, height - sampleSize],
|
||
[Math.floor((width - sampleSize) / 2), 0],
|
||
[Math.floor((width - sampleSize) / 2), height - sampleSize],
|
||
[0, Math.floor((height - sampleSize) / 2)],
|
||
[width - sampleSize, Math.floor((height - sampleSize) / 2)]
|
||
];
|
||
|
||
for (const [startX, startY] of points) {
|
||
let r = 0, g = 0, b = 0, count = 0;
|
||
for (let y = startY; y < startY + sampleSize; y++) {
|
||
const row = y * width * 4;
|
||
for (let x = startX; x < startX + sampleSize; x++) {
|
||
const px = blendedPixel(data, row + x * 4);
|
||
r += px.r; g += px.g; b += px.b; count++;
|
||
}
|
||
}
|
||
samples.push({ r: r / count, g: g / count, b: b / count });
|
||
}
|
||
|
||
const avg = samples.reduce((sum, px) => ({
|
||
r: sum.r + px.r,
|
||
g: sum.g + px.g,
|
||
b: sum.b + px.b
|
||
}), { r: 0, g: 0, b: 0 });
|
||
avg.r /= samples.length;
|
||
avg.g /= samples.length;
|
||
avg.b /= samples.length;
|
||
|
||
const maxSpread = samples.reduce((spread, px) => Math.max(
|
||
spread,
|
||
Math.abs(px.r - avg.r),
|
||
Math.abs(px.g - avg.g),
|
||
Math.abs(px.b - avg.b)
|
||
), 0);
|
||
|
||
if (maxSpread > CROP_BACKGROUND_MAX_SPREAD) return null;
|
||
return avg;
|
||
}
|
||
|
||
function isCropContentPixel(px, background) {
|
||
if (background) {
|
||
return Math.abs(px.r - background.r) > CROP_BACKGROUND_TOLERANCE ||
|
||
Math.abs(px.g - background.g) > CROP_BACKGROUND_TOLERANCE ||
|
||
Math.abs(px.b - background.b) > CROP_BACKGROUND_TOLERANCE;
|
||
}
|
||
return px.r < CROP_WHITE_THRESHOLD || px.g < CROP_WHITE_THRESHOLD || px.b < CROP_WHITE_THRESHOLD;
|
||
}
|
||
|
||
function isNearWhiteBackground(background) {
|
||
return background &&
|
||
background.r >= CROP_WHITE_THRESHOLD &&
|
||
background.g >= CROP_WHITE_THRESHOLD &&
|
||
background.b >= CROP_WHITE_THRESHOLD;
|
||
}
|
||
|
||
function findNonWhiteBounds(ctx, width, height) {
|
||
const imageData = ctx.getImageData(0, 0, width, height);
|
||
const data = imageData.data;
|
||
const background = estimateCropBackground(data, width, height);
|
||
let left = width, top = height, right = -1, bottom = -1;
|
||
|
||
for (let y = 0; y < height; y++) {
|
||
const row = y * width * 4;
|
||
for (let x = 0; x < width; x++) {
|
||
const i = row + x * 4;
|
||
const px = blendedPixel(data, i);
|
||
|
||
if (isCropContentPixel(px, background)) {
|
||
if (x < left) left = x;
|
||
if (x > right) right = x;
|
||
if (y < top) top = y;
|
||
if (y > bottom) bottom = y;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (right < left || bottom < top) return null;
|
||
|
||
left = Math.max(0, left - CROP_PADDING_PX);
|
||
top = Math.max(0, top - CROP_PADDING_PX);
|
||
right = Math.min(width - 1, right + CROP_PADDING_PX);
|
||
bottom = Math.min(height - 1, bottom + CROP_PADDING_PX);
|
||
|
||
const cropW = right - left + 1;
|
||
const cropH = bottom - top + 1;
|
||
const savedRatio = 1 - ((cropW * cropH) / (width * height));
|
||
// Colored-background crops need a bigger win to avoid shaving illustrated pages.
|
||
const minSavedRatio = background && !isNearWhiteBackground(background)
|
||
? MIN_COLOR_CROP_SAVINGS_RATIO
|
||
: MIN_CROP_SAVINGS_RATIO;
|
||
if (savedRatio < minSavedRatio) return null;
|
||
|
||
return { x: left, y: top, width: cropW, height: cropH };
|
||
}
|
||
|
||
function createAutoCroppedCanvas(img, imagePath, width, height, ignoreToggle = false) {
|
||
// Build a white-backed source canvas, detect the uniform page/background color
|
||
// from its edges, crop to pixels that differ from that background, then let the
|
||
// normal resize/rotate/split pipeline fit that cropped content to the device.
|
||
// If the image is a cover/icon/small asset, the edges are not uniform, or the
|
||
// crop would save too little, return the original canvas unchanged.
|
||
const sourceCanvas = document.createElement('canvas');
|
||
sourceCanvas.width = width;
|
||
sourceCanvas.height = height;
|
||
const sourceCtx = sourceCanvas.getContext('2d');
|
||
sourceCtx.fillStyle = '#FFF';
|
||
sourceCtx.fillRect(0, 0, width, height);
|
||
sourceCtx.drawImage(img, 0, 0);
|
||
|
||
if (shouldSkipAutoCrop(imagePath, width, height, ignoreToggle)) {
|
||
return { canvas: sourceCanvas, width, height };
|
||
}
|
||
|
||
const crop = findNonWhiteBounds(sourceCtx, width, height);
|
||
if (!crop) {
|
||
return { canvas: sourceCanvas, width, height };
|
||
}
|
||
|
||
const croppedCanvas = document.createElement('canvas');
|
||
croppedCanvas.width = crop.width;
|
||
croppedCanvas.height = crop.height;
|
||
const croppedCtx = croppedCanvas.getContext('2d');
|
||
croppedCtx.fillStyle = '#FFF';
|
||
croppedCtx.fillRect(0, 0, crop.width, crop.height);
|
||
croppedCtx.drawImage(sourceCanvas, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
|
||
|
||
return { canvas: croppedCanvas, width: crop.width, height: crop.height, crop };
|
||
}
|
||
|
||
// Process single image - returns array of {data, suffix} objects
|
||
const IMAGE_LOAD_TIMEOUT_MS = 30000; // 30 second timeout for image loading
|
||
async function processImage(data, imageState = 0, imagePath = '') {
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(new Blob([data]));
|
||
const img = new Image();
|
||
const origSize = data.byteLength;
|
||
// Set up timeout to handle cases where image never loads
|
||
const timeoutId = setTimeout(() => {
|
||
URL.revokeObjectURL(url);
|
||
reject(new Error('Image load timeout'));
|
||
}, IMAGE_LOAD_TIMEOUT_MS);
|
||
|
||
img.onload = async () => {
|
||
clearTimeout(timeoutId);
|
||
URL.revokeObjectURL(url);
|
||
const origW = img.width, origH = img.height;
|
||
const source = createAutoCroppedCanvas(img, imagePath, origW, origH);
|
||
const sourceCanvas = source.canvas;
|
||
const sourceW = source.width;
|
||
const sourceH = source.height;
|
||
const sourceWasCropped = !!source.crop;
|
||
|
||
// imageState: 0=Normal, 1=H-Split (CW/CCW), 2=V-Split, 3=Rotate & Fit
|
||
// ========================================================================
|
||
// STATE 1: H-Split (Rotate + Split) - EXACT COPY FROM index.html
|
||
// Step 1: Scale WIDTH to 800px (keep aspect ratio)
|
||
// Step 2: Rotate 90° CW or CCW based on HANDEDNESS
|
||
// Step 3: If WIDTH > 480, split vertically with overlap
|
||
// ========================================================================
|
||
if (imageState === 1) {
|
||
// Step 1: Scale WIDTH to 800 (this is the key difference!)
|
||
const scale = MAX_HEIGHT / sourceW; // 800 / sourceW
|
||
const scaledW = MAX_HEIGHT; // 800
|
||
const scaledH = Math.round(sourceH * scale);
|
||
|
||
const scaledCanvas = document.createElement('canvas');
|
||
scaledCanvas.width = scaledW;
|
||
scaledCanvas.height = scaledH;
|
||
const scaledCtx = scaledCanvas.getContext('2d');
|
||
scaledCtx.imageSmoothingEnabled = true;
|
||
scaledCtx.imageSmoothingQuality = 'high';
|
||
scaledCtx.fillStyle = '#FFF';
|
||
scaledCtx.fillRect(0, 0, scaledW, scaledH);
|
||
scaledCtx.drawImage(sourceCanvas, 0, 0, sourceW, sourceH, 0, 0, scaledW, scaledH);
|
||
|
||
// Step 2: Rotate 90° CW or CCW
|
||
const rotW = scaledH;
|
||
const rotH = scaledW; // 800
|
||
|
||
const rotCanvas = document.createElement('canvas');
|
||
rotCanvas.width = rotW;
|
||
rotCanvas.height = rotH;
|
||
const rotCtx = rotCanvas.getContext('2d');
|
||
rotCtx.fillStyle = '#FFF';
|
||
rotCtx.fillRect(0, 0, rotW, rotH);
|
||
|
||
const isClockwise = HANDEDNESS === 'right';
|
||
if (isClockwise) {
|
||
// Rotate 90° CW
|
||
rotCtx.translate(rotW, 0);
|
||
rotCtx.rotate(Math.PI / 2);
|
||
} else {
|
||
// Rotate 90° CCW
|
||
rotCtx.translate(0, rotH);
|
||
rotCtx.rotate(-Math.PI / 2);
|
||
}
|
||
rotCtx.drawImage(scaledCanvas, 0, 0);
|
||
rotCtx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform
|
||
applyGrayscale(rotCtx, rotW, rotH);
|
||
|
||
// Step 3: If WIDTH > 480, split vertically
|
||
if (rotW <= MAX_WIDTH) {
|
||
const blob = await new Promise(res => rotCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
resolve({
|
||
parts: [{ data: arrBuf, suffix: '', width: rotW, height: rotH, size: arrBuf.byteLength }],
|
||
meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: rotW, finalH: rotH, finalSize: arrBuf.byteLength, imageState: 1 }
|
||
});
|
||
} else {
|
||
// Split by WIDTH (vertical cuts) - from RIGHT to LEFT for CW, LEFT to RIGHT for CCW
|
||
const parts = [];
|
||
const maxW = MAX_WIDTH; // 480
|
||
|
||
// Centered distribution: calculate numParts first, then distribute evenly
|
||
let overlapPx, step, numParts;
|
||
const minOverlapPx = Math.round(maxW * (OVERLAP_PERCENT / 100)); // Configurable overlap
|
||
const maxStep = maxW - minOverlapPx;
|
||
numParts = Math.ceil((rotW - minOverlapPx) / maxStep);
|
||
if (numParts < 2) numParts = 2;
|
||
// Now calculate step to distribute evenly
|
||
step = Math.round((rotW - maxW) / (numParts - 1));
|
||
overlapPx = maxW - step;
|
||
// Ensure minimum overlap
|
||
if (overlapPx < minOverlapPx) {
|
||
overlapPx = minOverlapPx;
|
||
step = maxW - overlapPx;
|
||
}
|
||
|
||
// Calculate all x positions first to ensure consistency
|
||
const positions = [];
|
||
for (let i = 0; i < numParts; i++) {
|
||
let x;
|
||
if (isClockwise) {
|
||
// CW: right to left - start from right edge
|
||
x = rotW - maxW - (i * step);
|
||
} else {
|
||
// CCW: left to right - start from left edge
|
||
x = i * step;
|
||
}
|
||
// Clamp to valid range
|
||
x = Math.max(0, Math.min(x, rotW - maxW));
|
||
positions.push(x);
|
||
}
|
||
|
||
// Ensure first and last positions are at edges
|
||
if (isClockwise) {
|
||
positions[0] = rotW - maxW; // First part at right edge
|
||
positions[numParts - 1] = 0; // Last part at left edge
|
||
} else {
|
||
positions[0] = 0; // First part at left edge
|
||
positions[numParts - 1] = rotW - maxW; // Last part at right edge
|
||
}
|
||
|
||
for (let i = 0; i < numParts; i++) {
|
||
const x = positions[i];
|
||
const partW = maxW; // Always full width for consistency
|
||
|
||
const partCanvas = document.createElement('canvas');
|
||
partCanvas.width = partW;
|
||
partCanvas.height = rotH;
|
||
const partCtx = partCanvas.getContext('2d');
|
||
// Clear canvas first
|
||
partCtx.fillStyle = '#FFFFFF';
|
||
partCtx.fillRect(0, 0, partW, rotH);
|
||
// Draw the slice
|
||
partCtx.drawImage(rotCanvas, x, 0, partW, rotH, 0, 0, partW, rotH);
|
||
|
||
const blob = await new Promise(res => partCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
parts.push({ data: arrBuf, suffix: `_part${i + 1}`, width: partW, height: rotH, size: arrBuf.byteLength });
|
||
}
|
||
|
||
const totalSize = parts.reduce((sum, p) => sum + p.size, 0);
|
||
resolve({
|
||
parts,
|
||
meta: { origW, origH, origSize, wasSplit: true, splitCount: numParts, rotated: true, finalW: parts[0].width, finalH: parts[0].height, finalSize: totalSize, imageState: 1 }
|
||
});
|
||
}
|
||
}
|
||
// ========================================================================
|
||
// STATE 2: V-Split (Vertical Split, no rotation)
|
||
// Step 1: Scale HEIGHT to 800px (up or down)
|
||
// Step 2: If WIDTH > 480, split vertically with overlap
|
||
// ========================================================================
|
||
else if (imageState === 2) {
|
||
// ALWAYS scale height to 800 (up or down)
|
||
const scale = MAX_HEIGHT / sourceH; // 800 / sourceH
|
||
const scaledW = Math.round(sourceW * scale);
|
||
const scaledH = MAX_HEIGHT; // Always 800
|
||
|
||
const scaledCanvas = document.createElement('canvas');
|
||
scaledCanvas.width = scaledW;
|
||
scaledCanvas.height = scaledH;
|
||
const scaledCtx = scaledCanvas.getContext('2d');
|
||
scaledCtx.imageSmoothingEnabled = true;
|
||
scaledCtx.imageSmoothingQuality = 'high';
|
||
scaledCtx.fillStyle = '#FFF';
|
||
scaledCtx.fillRect(0, 0, scaledW, scaledH);
|
||
scaledCtx.drawImage(sourceCanvas, 0, 0, sourceW, sourceH, 0, 0, scaledW, scaledH);
|
||
applyGrayscale(scaledCtx, scaledW, scaledH);
|
||
|
||
// Check if split needed
|
||
if (scaledW <= MAX_WIDTH) {
|
||
const blob = await new Promise(res => scaledCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
resolve({
|
||
parts: [{ data: arrBuf, suffix: '', width: scaledW, height: scaledH, size: arrBuf.byteLength }],
|
||
meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: scaledW, finalH: scaledH, finalSize: arrBuf.byteLength, imageState: 2 }
|
||
});
|
||
} else {
|
||
// Split by WIDTH (vertical cuts) - LEFT to RIGHT (natural reading order)
|
||
const parts = [];
|
||
const maxW = MAX_WIDTH;
|
||
|
||
// Centered distribution: calculate numParts first, then distribute evenly
|
||
let overlapPx, step, numParts;
|
||
const minOverlapPx = Math.round(maxW * (OVERLAP_PERCENT / 100)); // Configurable overlap
|
||
const maxStep = maxW - minOverlapPx;
|
||
numParts = Math.ceil((scaledW - minOverlapPx) / maxStep);
|
||
if (numParts < 2) numParts = 2;
|
||
// Now calculate step to distribute evenly
|
||
step = Math.round((scaledW - maxW) / (numParts - 1));
|
||
overlapPx = maxW - step;
|
||
// Ensure minimum overlap
|
||
if (overlapPx < minOverlapPx) {
|
||
overlapPx = minOverlapPx;
|
||
step = maxW - overlapPx;
|
||
}
|
||
|
||
// Calculate all x positions first to ensure consistency
|
||
const positions = [];
|
||
for (let i = 0; i < numParts; i++) {
|
||
let x = i * step;
|
||
// Clamp to valid range
|
||
x = Math.max(0, Math.min(x, scaledW - maxW));
|
||
positions.push(x);
|
||
}
|
||
// Ensure last position is at right edge
|
||
positions[0] = 0;
|
||
positions[numParts - 1] = scaledW - maxW;
|
||
|
||
for (let i = 0; i < numParts; i++) {
|
||
const x = positions[i];
|
||
const partW = maxW; // Always full width for consistency
|
||
|
||
const partCanvas = document.createElement('canvas');
|
||
partCanvas.width = partW;
|
||
partCanvas.height = scaledH;
|
||
const partCtx = partCanvas.getContext('2d');
|
||
// Clear canvas first
|
||
partCtx.fillStyle = '#FFFFFF';
|
||
partCtx.fillRect(0, 0, partW, scaledH);
|
||
// Draw the slice
|
||
partCtx.drawImage(scaledCanvas, x, 0, partW, scaledH, 0, 0, partW, scaledH);
|
||
|
||
const blob = await new Promise(res => partCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
parts.push({ data: arrBuf, suffix: `_part${i + 1}`, width: partW, height: scaledH, size: arrBuf.byteLength });
|
||
}
|
||
|
||
const totalSize = parts.reduce((sum, p) => sum + p.size, 0);
|
||
resolve({
|
||
parts,
|
||
meta: { origW, origH, origSize, wasSplit: true, splitCount: numParts, rotated: false, finalW: parts[0].width, finalH: parts[0].height, finalSize: totalSize, imageState: 2 }
|
||
});
|
||
}
|
||
}
|
||
// ========================================================================
|
||
// STATE 3: Rotate & Fit (Rotate 90°, then scale to fit 480x800, no split)
|
||
// ========================================================================
|
||
else if (imageState === 3) {
|
||
// Step 1: Rotate 90° based on handedness
|
||
const rotW = sourceH;
|
||
const rotH = sourceW;
|
||
|
||
const rotCanvas = document.createElement('canvas');
|
||
rotCanvas.width = rotW;
|
||
rotCanvas.height = rotH;
|
||
const rotCtx = rotCanvas.getContext('2d');
|
||
rotCtx.fillStyle = '#FFF';
|
||
rotCtx.fillRect(0, 0, rotW, rotH);
|
||
|
||
const isClockwise = HANDEDNESS === 'right';
|
||
if (isClockwise) {
|
||
rotCtx.translate(rotW, 0);
|
||
rotCtx.rotate(Math.PI / 2);
|
||
} else {
|
||
rotCtx.translate(0, rotH);
|
||
rotCtx.rotate(-Math.PI / 2);
|
||
}
|
||
rotCtx.drawImage(sourceCanvas, 0, 0);
|
||
rotCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
|
||
// Step 2: Scale to fit 480x800 (if needed)
|
||
const fitsInScreen = rotW <= MAX_WIDTH && rotH <= MAX_HEIGHT;
|
||
|
||
if (fitsInScreen && !sourceWasCropped) {
|
||
// Already fits after rotation - just apply grayscale
|
||
applyGrayscale(rotCtx, rotW, rotH);
|
||
const blob = await new Promise(res => rotCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
resolve({
|
||
parts: [{ data: arrBuf, suffix: '', width: rotW, height: rotH, size: arrBuf.byteLength }],
|
||
meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: rotW, finalH: rotH, finalSize: arrBuf.byteLength, imageState: 3 }
|
||
});
|
||
} else {
|
||
// Scale to fit 480x800
|
||
const scale = Math.min(MAX_WIDTH / rotW, MAX_HEIGHT / rotH);
|
||
const newW = Math.round(rotW * scale);
|
||
const newH = Math.round(rotH * scale);
|
||
|
||
const scaledCanvas = document.createElement('canvas');
|
||
scaledCanvas.width = newW;
|
||
scaledCanvas.height = newH;
|
||
const scaledCtx = scaledCanvas.getContext('2d');
|
||
scaledCtx.imageSmoothingEnabled = true;
|
||
scaledCtx.imageSmoothingQuality = 'high';
|
||
scaledCtx.fillStyle = '#FFF';
|
||
scaledCtx.fillRect(0, 0, newW, newH);
|
||
scaledCtx.drawImage(rotCanvas, 0, 0, newW, newH);
|
||
applyGrayscale(scaledCtx, newW, newH);
|
||
|
||
const blob = await new Promise(res => scaledCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
resolve({
|
||
parts: [{ data: arrBuf, suffix: '', width: newW, height: newH, size: arrBuf.byteLength }],
|
||
meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: newW, finalH: newH, finalSize: arrBuf.byteLength, imageState: 3 }
|
||
});
|
||
}
|
||
}
|
||
// ========================================================================
|
||
// STATE 0: Normal processing (scale to fit, no split/rotation)
|
||
// ========================================================================
|
||
else {
|
||
// Normal processing: check if scaling is needed
|
||
const fitsInScreen = sourceW <= MAX_WIDTH && sourceH <= MAX_HEIGHT;
|
||
|
||
if (fitsInScreen && !sourceWasCropped) {
|
||
// Image already fits - just convert to JPEG with grayscale
|
||
const c = document.createElement('canvas');
|
||
c.width = sourceW;
|
||
c.height = sourceH;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = '#FFF';
|
||
ctx.fillRect(0, 0, sourceW, sourceH);
|
||
ctx.drawImage(sourceCanvas, 0, 0);
|
||
applyGrayscale(ctx, sourceW, sourceH);
|
||
|
||
const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
resolve({
|
||
parts: [{ data: arrBuf, suffix: '', width: sourceW, height: sourceH, size: arrBuf.byteLength }],
|
||
meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: sourceW, finalH: sourceH, finalSize: arrBuf.byteLength, imageState: 0 }
|
||
});
|
||
} else {
|
||
// Scale to fit 480x800
|
||
const scale = Math.min(MAX_WIDTH / sourceW, MAX_HEIGHT / sourceH);
|
||
const newW = Math.round(sourceW * scale);
|
||
const newH = Math.round(sourceH * scale);
|
||
|
||
const c = document.createElement('canvas');
|
||
c.width = newW;
|
||
c.height = newH;
|
||
const ctx = c.getContext('2d');
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
ctx.fillStyle = '#FFF';
|
||
ctx.fillRect(0, 0, newW, newH);
|
||
ctx.drawImage(sourceCanvas, 0, 0, newW, newH);
|
||
applyGrayscale(ctx, newW, newH);
|
||
|
||
const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100));
|
||
const arrBuf = await blob.arrayBuffer();
|
||
resolve({
|
||
parts: [{ data: arrBuf, suffix: '', width: newW, height: newH, size: arrBuf.byteLength }],
|
||
meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: newW, finalH: newH, finalSize: arrBuf.byteLength, imageState: 0 }
|
||
});
|
||
}
|
||
}
|
||
};
|
||
img.onerror = () => {
|
||
clearTimeout(timeoutId);
|
||
URL.revokeObjectURL(url);
|
||
reject(new Error('Image load failed'));
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// Convert EPUB file - returns converted blob
|
||
async function convertEpubFile(file, progressCallback) {
|
||
const startTime = Date.now();
|
||
const originalSize = file.size;
|
||
|
||
// Initialize logging
|
||
clearLog();
|
||
showLog();
|
||
log(`<strong>${file.name}</strong> <span class="log-detail">(${formatBytes(originalSize)})</span>`, '', 'INFO');
|
||
log(`Quality: ${JPEG_QUALITY}% | Overlap: ${OVERLAP_PERCENT}% | Rotation: ${HANDEDNESS === 'right' ? 'CW' : 'CCW'} | Grayscale: ${ENABLE_GRAYSCALE ? 'ON' : 'OFF'} | Auto-crop: ${ENABLE_AUTO_CROP ? 'ON' : 'OFF'}`, '', 'INFO');
|
||
|
||
const zip = await JSZip.loadAsync(file);
|
||
const renamed = {};
|
||
zip.forEach(p => {
|
||
const l = p.toLowerCase();
|
||
if (l.match(/\.(png|gif|webp|bmp|jpeg)$/)) {
|
||
renamed[p] = p.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg');
|
||
}
|
||
});
|
||
|
||
const out = new JSZip();
|
||
const entries = Object.entries(zip.files);
|
||
const splitImages = {};
|
||
const xhtmlFiles = {};
|
||
let opfPath = null, opfContent = null;
|
||
let mainIdentifier = null;
|
||
|
||
// Write mimetype FIRST per EPUB OCF spec
|
||
if (zip.files['mimetype']) {
|
||
const mimetypeData = await zip.files['mimetype'].async('arraybuffer');
|
||
out.file('mimetype', mimetypeData, { compression: 'STORE', createFolders: false });
|
||
}
|
||
|
||
// First pass: process images
|
||
for (let i = 0; i < entries.length; i++) {
|
||
if (operationCancelled) throw new Error('Cancelled by user');
|
||
const [path, fileObj] = entries[i];
|
||
if (fileObj.dir || path === 'mimetype') continue;
|
||
const low = path.toLowerCase();
|
||
|
||
if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/)) {
|
||
const data = await fileObj.async('arraybuffer');
|
||
const imageState = getImageState(path);
|
||
|
||
let result;
|
||
try {
|
||
result = await processImage(data, imageState, path);
|
||
} catch (imageError) {
|
||
// Log error but continue with original image
|
||
console.error(`Failed to process image ${path}:`, imageError);
|
||
log(`Warning: Failed to process ${path.split('/').pop()}, using original`, 'warning', 'IMG-ERR');
|
||
|
||
// Use original image data as fallback
|
||
result = {
|
||
parts: [{
|
||
data: data,
|
||
suffix: '',
|
||
width: 0,
|
||
height: 0,
|
||
size: data.byteLength
|
||
}],
|
||
meta: {
|
||
origW: 0,
|
||
origH: 0,
|
||
origSize: data.byteLength,
|
||
wasSplit: false,
|
||
rotated: false,
|
||
finalW: 0,
|
||
finalH: 0,
|
||
finalSize: data.byteLength,
|
||
imageState: imageState,
|
||
processingError: true
|
||
}
|
||
};
|
||
}
|
||
|
||
const parts = result.parts;
|
||
const meta = result.meta;
|
||
|
||
const baseName = path.replace(/\.[^.]+$/, '');
|
||
const newExt = '.jpg';
|
||
|
||
// Log image processing
|
||
const imgName = path.split('/').pop();
|
||
const origFormat = path.split('.').pop();
|
||
logImage(imgName, meta.origW, meta.origH, origFormat, meta.origSize, meta.finalW, meta.finalH, meta.finalSize, meta.wasSplit, meta.splitCount || 0, parts, meta.imageState || 0);
|
||
|
||
if (parts.length === 1 && parts[0].suffix === '') {
|
||
const newPath = renamed[path] || path.replace(/\.[^.]+$/, newExt);
|
||
out.file(newPath, parts[0].data, { compression: 'STORE', createFolders: false });
|
||
} else {
|
||
// Store with full path for collision prevention, but also keep original filename
|
||
const origName = path.split('/').pop();
|
||
const origDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '';
|
||
|
||
// Key by full path to avoid collisions
|
||
splitImages[path] = {
|
||
origName: origName,
|
||
origDir: origDir,
|
||
parts: []
|
||
};
|
||
|
||
for (const part of parts) {
|
||
const partName = baseName.split('/').pop() + part.suffix + newExt;
|
||
const partPath = (path.includes('/') ? path.substring(0, path.lastIndexOf('/') + 1) : '') + partName;
|
||
out.file(partPath, part.data, { compression: 'STORE', createFolders: false });
|
||
// Store metadata for XHTML/OPF updates
|
||
splitImages[path].parts.push({
|
||
path: partPath,
|
||
imgName: partName,
|
||
id: baseName.split('/').pop() + part.suffix,
|
||
suffix: part.suffix
|
||
});
|
||
}
|
||
}
|
||
} else if (low.match(/\.(xhtml|html|htm)$/)) {
|
||
xhtmlFiles[path] = await safeReadText(fileObj);
|
||
} else if (low.endsWith('.opf')) {
|
||
opfPath = path;
|
||
opfContent = await safeReadText(fileObj);
|
||
}
|
||
|
||
if (progressCallback) progressCallback((i / entries.length) * 60);
|
||
}
|
||
|
||
// Second pass: update XHTML using DOMParser
|
||
for (const [xhtmlPath, content] of Object.entries(xhtmlFiles)) {
|
||
if (operationCancelled) throw new Error('Cancelled by user');
|
||
let t = content;
|
||
const r = fixSvgCover(t);
|
||
if (r.fixed) { t = r.c; logFix('SVG cover', xhtmlPath.split('/').pop()); }
|
||
|
||
const r2 = fixSvgWrappedImages(t);
|
||
if (r2.fixed) { t = r2.c; logFix(`SVG images (${r2.count})`, xhtmlPath.split('/').pop()); }
|
||
|
||
// Use DOMParser for all img modifications: remove width/height and handle split images
|
||
try {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(t, 'application/xhtml+xml');
|
||
const parseError = doc.querySelector('parsererror');
|
||
|
||
if (!parseError) {
|
||
let modified = false;
|
||
|
||
// Remove width/height attributes from ALL img tags (dimensions may have changed)
|
||
// This prevents CrossPoint and other readers from using wrong dimensions
|
||
const allImgElements = doc.querySelectorAll('img');
|
||
for (const img of allImgElements) {
|
||
if (img.hasAttribute('width')) { img.removeAttribute('width'); modified = true; }
|
||
if (img.hasAttribute('height')) { img.removeAttribute('height'); modified = true; }
|
||
|
||
const src = img.getAttribute('src');
|
||
if (src) {
|
||
const decodedSrc = decodeHref(src);
|
||
const resolvedSrc = resolvePath(xhtmlPath, decodedSrc);
|
||
|
||
const match = Object.entries(renamed).find(([oldPath]) => resolvedSrc === oldPath);
|
||
|
||
if (match) {
|
||
const [oldPath, newPath] = match;
|
||
img.setAttribute('src', decodedSrc.replace(oldPath.split('/').pop(), newPath.split('/').pop()));
|
||
modified = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle split images with path collision prevention
|
||
if (Object.keys(splitImages).length > 0) {
|
||
// Get XHTML directory for resolving relative paths
|
||
const xhtmlDir = xhtmlPath.includes('/') ? xhtmlPath.substring(0, xhtmlPath.lastIndexOf('/')) : '';
|
||
const rootFolders = ['ops', 'oebps', 'epub', 'content'];
|
||
|
||
for (const [fullPath, splitInfo] of Object.entries(splitImages)) {
|
||
const origName = splitInfo.origName;
|
||
const origDir = splitInfo.origDir;
|
||
const parts = splitInfo.parts;
|
||
const newName = origName.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg');
|
||
|
||
// Extract immediate parent directory for collision prevention
|
||
const splitDirParts = origDir.split('/').filter(p => p);
|
||
const lastDir = splitDirParts.length > 0 ? splitDirParts[splitDirParts.length - 1].toLowerCase() : null;
|
||
const immediateParent = (lastDir && !rootFolders.includes(lastDir)) ? splitDirParts[splitDirParts.length - 1] : null;
|
||
|
||
// Get XHTML's parent directory parts for relative path resolution
|
||
const xhtmlDirParts = xhtmlDir.split('/').filter(p => p);
|
||
|
||
// Find all img elements
|
||
const allImgs = doc.querySelectorAll('img');
|
||
const matchingImgs = [];
|
||
|
||
for (const img of allImgs) {
|
||
const src = img.getAttribute('src') || '';
|
||
const srcParts = src.split('/').filter(p => p && p !== '..' && p !== '.');
|
||
const srcName = srcParts.pop() || '';
|
||
|
||
// Check filename match
|
||
if (srcName !== origName && srcName !== newName) continue;
|
||
|
||
// Path collision prevention with root folder handling
|
||
if (immediateParent) {
|
||
// Image is in a specific subfolder (not root like OPS/OEBPS)
|
||
if (srcParts.length === 0) {
|
||
// src has no path - check if XHTML is in same folder as image
|
||
const xhtmlLastDir = xhtmlDirParts.length > 0 ? xhtmlDirParts[xhtmlDirParts.length - 1] : null;
|
||
if (xhtmlLastDir !== immediateParent) continue;
|
||
} else {
|
||
// src has path - verify parent directory matches
|
||
if (srcParts[srcParts.length - 1] !== immediateParent) continue;
|
||
}
|
||
} else {
|
||
// Image is in root folder (like OEBPS/cover.jpg)
|
||
// Only match if src has NO subfolder path OR points to root folder
|
||
if (srcParts.length > 0) {
|
||
const srcLastDir = srcParts[srcParts.length - 1].toLowerCase();
|
||
if (!rootFolders.includes(srcLastDir)) continue;
|
||
}
|
||
}
|
||
|
||
matchingImgs.push(img);
|
||
}
|
||
|
||
// Process each matching img — Pro's strip+inject approach
|
||
for (const img of matchingImgs) {
|
||
const src = img.getAttribute('src') || '';
|
||
|
||
// Part 1: update src in-place, strip original sizing
|
||
img.setAttribute('src', src.replace(origName, parts[0].imgName).replace(newName, parts[0].imgName));
|
||
|
||
if (parts.length > 1) {
|
||
// Strip original width/height/class that were sized for the unsplit image
|
||
img.removeAttribute('width');
|
||
img.removeAttribute('height');
|
||
img.removeAttribute('class');
|
||
img.setAttribute('style', 'max-width:100%;height:auto');
|
||
|
||
// Neutralize container height constraints that were sized for the original
|
||
let container = img.parentElement;
|
||
const safeContainers = ['div', 'p', 'figure', 'aside', 'section', 'body'];
|
||
while (container && !safeContainers.includes(container.tagName.toLowerCase())) container = container.parentElement;
|
||
const insertTarget = container || img.parentElement;
|
||
// Strip constraining classes/styles from container — they were for the unsplit image
|
||
if (insertTarget && insertTarget.tagName.toLowerCase() !== 'body') {
|
||
insertTarget.removeAttribute('class');
|
||
insertTarget.removeAttribute('style');
|
||
}
|
||
const insertParent = insertTarget.parentElement;
|
||
const insertRef = insertTarget.nextSibling;
|
||
const ns = doc.documentElement.namespaceURI || 'http://www.w3.org/1999/xhtml';
|
||
|
||
// Insert new minimal wrappers for parts 2+ in reading order
|
||
for (let pi = 1; pi < parts.length; pi++) {
|
||
const wrapper = doc.createElementNS(ns, 'div');
|
||
const newImg = doc.createElementNS(ns, 'img');
|
||
const partSrc = src.replace(origName, parts[pi].imgName).replace(newName, parts[pi].imgName);
|
||
newImg.setAttribute('src', partSrc);
|
||
newImg.setAttribute('alt', '');
|
||
newImg.setAttribute('style', 'max-width:100%;height:auto');
|
||
wrapper.appendChild(newImg);
|
||
if (insertRef) insertParent.insertBefore(wrapper, insertRef);
|
||
else insertParent.appendChild(wrapper);
|
||
}
|
||
}
|
||
modified = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Only serialize if we made changes
|
||
if (modified) {
|
||
t = safeSerialize(doc, content);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('DOMParser error for', xhtmlPath, e.message);
|
||
}
|
||
|
||
// Inject universal image constraint — prevents overflow on e-ink displays
|
||
if (t.includes('</head>')) {
|
||
t = t.replace('</head>', DEFENSIVE_STYLE + '</head>');
|
||
}
|
||
|
||
out.file(xhtmlPath, t, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false });
|
||
}
|
||
|
||
// Extract main identifier from OPF using DOMParser with regex fallback
|
||
if (opfContent) {
|
||
mainIdentifier = extractIdentifier(opfContent);
|
||
}
|
||
|
||
// Third pass: update OPF using fixOPF (DOMParser with regex fallback)
|
||
if (opfContent) {
|
||
let t = opfContent;
|
||
for (const [o, n] of Object.entries(renamed)) {
|
||
t = t.split(o.split('/').pop()).join(n.split('/').pop());
|
||
}
|
||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
|
||
t = fixOPF(t, opfContent, opfDir, splitImages);
|
||
if (t !== opfContent) logFix('OPF', 'manifest updated');
|
||
out.file(opfPath, t, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false });
|
||
}
|
||
|
||
// Copy remaining files
|
||
for (const [path, fileObj] of entries) {
|
||
if (operationCancelled) throw new Error('Cancelled by user');
|
||
if (fileObj.dir || path === 'mimetype') continue;
|
||
const low = path.toLowerCase();
|
||
if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/) || low.match(/\.(xhtml|html|htm)$/) || low.endsWith('.opf')) continue;
|
||
|
||
let data = await fileObj.async('arraybuffer');
|
||
if (low.endsWith('.css')) {
|
||
let t = await safeReadText(fileObj);
|
||
for (const [o, n] of Object.entries(renamed)) {
|
||
t = t.split(o.split('/').pop()).join(n.split('/').pop());
|
||
}
|
||
data = new TextEncoder().encode(t);
|
||
} else if (low.endsWith('.ncx')) {
|
||
let t = await safeReadText(fileObj);
|
||
for (const [o, n] of Object.entries(renamed)) {
|
||
t = t.split(o.split('/').pop()).join(n.split('/').pop());
|
||
}
|
||
const oldT = t;
|
||
t = syncNCXIdentifier(t, mainIdentifier);
|
||
if (t !== oldT) logFix('NCX identifier', 'Synced with OPF');
|
||
data = new TextEncoder().encode(t);
|
||
}
|
||
out.file(path, data, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false });
|
||
}
|
||
|
||
if (progressCallback) progressCallback(100);
|
||
|
||
// Generate final blob
|
||
const newBlob = await out.generateAsync({ type: 'blob', mimeType: 'application/epub+zip' });
|
||
const newSize = newBlob.size;
|
||
const timeElapsed = (Date.now() - startTime) / 1000;
|
||
|
||
// Log completion
|
||
log('Conversion complete!', 'success', 'DONE');
|
||
logSummary(originalSize, newSize, timeElapsed);
|
||
|
||
// Auto-export only if NOT in batch mode (batch mode exports at the end)
|
||
if (!isBatchMode && exportLogCheckbox && exportLogCheckbox.checked) {
|
||
setTimeout(() => {
|
||
exportLogToFile(null, false); // isBatch = false for single file
|
||
}, 100);
|
||
}
|
||
|
||
return newBlob;
|
||
}
|
||
|
||
// Get WebSocket URL based on current page location
|
||
function getWsUrl() {
|
||
const host = window.location.hostname;
|
||
return `ws://${host}:${WS_PORT}/`;
|
||
}
|
||
|
||
// Upload file via WebSocket (faster, binary protocol)
|
||
function uploadFileWebSocket(file, onProgress, onComplete, onError) {
|
||
return new Promise((resolve, reject) => {
|
||
const ws = new WebSocket(getWsUrl());
|
||
currentUploadWs = ws;
|
||
let uploadStarted = false;
|
||
let sendingChunks = false;
|
||
let uploadComplete = false; // set only when DONE is received and resolve() called
|
||
|
||
ws.binaryType = 'arraybuffer';
|
||
|
||
ws.onopen = function() {
|
||
console.log('[WS] Connected, starting upload:', file.name);
|
||
// Send start message: START:<filename>:<size>:<path>
|
||
ws.send(`START:${file.name}:${file.size}:${currentPath}`);
|
||
};
|
||
|
||
ws.onmessage = async function(event) {
|
||
const msg = event.data;
|
||
console.log('[WS] Message:', msg);
|
||
|
||
if (msg === 'READY') {
|
||
uploadStarted = true;
|
||
sendingChunks = true;
|
||
|
||
// Small delay to let connection stabilize
|
||
await new Promise(r => setTimeout(r, 50));
|
||
|
||
try {
|
||
// Send file in chunks
|
||
const totalSize = file.size;
|
||
let offset = 0;
|
||
|
||
while (offset < totalSize && ws.readyState === WebSocket.OPEN) {
|
||
const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset);
|
||
const chunk = file.slice(offset, offset + chunkSize);
|
||
const buffer = await chunk.arrayBuffer();
|
||
|
||
// Wait for buffer to clear - more aggressive backpressure
|
||
while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) {
|
||
await new Promise(r => setTimeout(r, 5));
|
||
}
|
||
|
||
if (ws.readyState !== WebSocket.OPEN) {
|
||
throw new Error('WebSocket closed during upload');
|
||
}
|
||
|
||
ws.send(buffer);
|
||
offset += chunkSize;
|
||
|
||
// Update local progress with real transfer progress
|
||
// Server will confirm 100% with DONE message
|
||
if (onProgress) {
|
||
onProgress(offset, totalSize);
|
||
}
|
||
}
|
||
|
||
sendingChunks = false;
|
||
console.log('[WS] All chunks sent, waiting for DONE');
|
||
} catch (err) {
|
||
console.error('[WS] Error sending chunks:', err);
|
||
sendingChunks = false;
|
||
ws.close();
|
||
reject(err);
|
||
}
|
||
} else if (msg.startsWith('PROGRESS:')) {
|
||
// Server confirmed progress - log for debugging but don't update UI
|
||
// (local progress is smoother, server progress causes jumping)
|
||
console.log('[WS] Server progress:', msg);
|
||
} else if (msg === 'DONE') {
|
||
// Show 100% when server confirms completion
|
||
if (onProgress) onProgress(file.size, file.size);
|
||
uploadComplete = true;
|
||
currentUploadWs = null;
|
||
ws.close();
|
||
if (onComplete) onComplete();
|
||
resolve();
|
||
} else if (msg.startsWith('ERROR:')) {
|
||
const error = msg.substring(6);
|
||
ws.close();
|
||
if (onError) onError(error);
|
||
reject(new Error(error));
|
||
}
|
||
};
|
||
|
||
ws.onerror = function(event) {
|
||
console.error('[WS] Error:', event);
|
||
currentUploadWs = null;
|
||
if (!uploadStarted) {
|
||
reject(new Error('WebSocket connection failed'));
|
||
} else if (!sendingChunks) {
|
||
reject(new Error('WebSocket error during upload'));
|
||
} else {
|
||
// Error during chunk sending - reject with appropriate message
|
||
reject(new Error('WebSocket error during file transfer'));
|
||
}
|
||
};
|
||
|
||
ws.onclose = function(event) {
|
||
console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason);
|
||
// Reject for any close before upload was confirmed complete (covers both
|
||
// mid-chunk-send closes and the "all chunks sent, waiting for DONE" window)
|
||
if (!uploadComplete) {
|
||
reject(new Error('WebSocket closed during upload'));
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// Upload file via HTTP (fallback method)
|
||
function uploadFileHTTP(file, onProgress, onComplete, onError) {
|
||
return new Promise((resolve, reject) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
currentUploadXhr = xhr;
|
||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||
|
||
xhr.upload.onprogress = function(e) {
|
||
if (e.lengthComputable && onProgress) {
|
||
onProgress(e.loaded, e.total);
|
||
}
|
||
};
|
||
|
||
xhr.onload = function() {
|
||
currentUploadXhr = null;
|
||
if (xhr.status === 200) {
|
||
if (onComplete) onComplete();
|
||
resolve();
|
||
} else {
|
||
const error = xhr.responseText || 'Upload failed';
|
||
if (onError) onError(error);
|
||
reject(new Error(error));
|
||
}
|
||
};
|
||
|
||
xhr.onerror = function() {
|
||
currentUploadXhr = null;
|
||
const error = 'Network error';
|
||
if (onError) onError(error);
|
||
reject(new Error(error));
|
||
};
|
||
|
||
xhr.onabort = function() {
|
||
currentUploadXhr = null;
|
||
reject(new Error('Upload aborted'));
|
||
};
|
||
|
||
xhr.send(formData);
|
||
});
|
||
}
|
||
|
||
function uploadFile() {
|
||
if (isUploadInProgress) return;
|
||
|
||
const fileInput = document.getElementById('fileInput');
|
||
const files = Array.from(fileInput.files);
|
||
const convertEnabled = document.getElementById('convertBeforeUpload').checked;
|
||
|
||
if (files.length === 0) {
|
||
alert('Please select at least one file!');
|
||
return;
|
||
}
|
||
|
||
// Prevent modal close during upload
|
||
isUploadInProgress = true;
|
||
uploadGeneration++;
|
||
const myGeneration = uploadGeneration;
|
||
document.getElementById('uploadModalClose').classList.add('disabled');
|
||
fileInput.disabled = true;
|
||
|
||
const progressContainer = document.getElementById('progress-container');
|
||
const progressFill = document.getElementById('progress-fill');
|
||
const progressText = document.getElementById('progress-text');
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
|
||
progressContainer.style.display = 'block';
|
||
uploadBtn.disabled = true;
|
||
|
||
let currentIndex = 0;
|
||
const failedFiles = [];
|
||
let useWebSocket = true; // Try WebSocket first
|
||
|
||
// Check if we should use batch logging mode
|
||
const epubFilesToConvert = files.filter(f => f.name.toLowerCase().endsWith('.epub') && convertEnabled);
|
||
const useBatchLog = epubFilesToConvert.length > 1 && exportLogCheckbox && exportLogCheckbox.checked;
|
||
|
||
// Start batch log mode if needed
|
||
if (useBatchLog) {
|
||
startBatchLog(epubFilesToConvert.length);
|
||
showLog();
|
||
}
|
||
|
||
async function uploadNextFile() {
|
||
if (currentIndex >= files.length) {
|
||
// All files processed - show summary
|
||
if (failedFiles.length === 0) {
|
||
progressFill.style.backgroundColor = '#4caf50';
|
||
progressText.textContent = 'All uploads complete!';
|
||
|
||
// Finalize batch log if in batch mode
|
||
if (useBatchLog) {
|
||
finalizeBatchLog();
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2000);
|
||
} else {
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 1000);
|
||
}
|
||
} else {
|
||
progressFill.style.backgroundColor = '#e74c3c';
|
||
const failedList = failedFiles.map(f => f.name).join(', ');
|
||
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
|
||
|
||
// Add upload errors to batch log
|
||
if (useBatchLog) {
|
||
failedFiles.forEach(ff => {
|
||
logError(`Upload failed for ${ff.name}: ${ff.error}`);
|
||
});
|
||
finalizeBatchLog();
|
||
}
|
||
|
||
// Only show banner if THIS upload session had failures
|
||
// Use local failedFiles, not the global shared variable
|
||
if (failedFiles.length > 0) {
|
||
// Accumulate failed uploads to global (don't replace)
|
||
failedUploadsGlobal = failedUploadsGlobal.concat(failedFiles);
|
||
// Clear flag and close modal, then show banner with retry options
|
||
isUploadInProgress = false;
|
||
document.getElementById('uploadModalClose').classList.remove('disabled');
|
||
closeUploadModal();
|
||
showFailedUploadsBanner();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
let file = files[currentIndex];
|
||
const originalFile = file;
|
||
// Reset progress bar instantly without transition when starting a new file
|
||
progressFill.classList.add('no-transition');
|
||
progressFill.style.width = '0%';
|
||
progressFill.style.backgroundColor = '#27ae60';
|
||
// Re-enable transition after a brief delay
|
||
setTimeout(() => progressFill.classList.remove('no-transition'), 50);
|
||
|
||
// Check if file is an EPUB and conversion is enabled
|
||
const isEpub = file.name.toLowerCase().endsWith('.epub');
|
||
const needsConversion = isEpub && convertEnabled;
|
||
let conversionSucceeded = false;
|
||
let conversionFailed = false; // Track if conversion actually failed
|
||
let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded
|
||
let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded
|
||
|
||
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
|
||
const stageText = needsConversion ? 'Converting & uploading' : 'Uploading';
|
||
progressText.style.color = '';
|
||
progressText.textContent = `${stageText} ${file.name} (${currentIndex + 1}/${files.length})${methodText}`;
|
||
|
||
const onProgress = (loaded, total) => {
|
||
const uploadPercent = Math.round((loaded / total) * 100);
|
||
// If conversion succeeded, display goes from 50-100%, otherwise 0-100%
|
||
const displayPercent = conversionSucceeded ? 50 + Math.round(uploadPercent / 2) : uploadPercent;
|
||
progressFill.style.width = displayPercent + '%';
|
||
const prefix = conversionSucceeded ? 'Converting & uploading' : 'Uploading';
|
||
progressText.textContent = `${prefix} ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${uploadPercent}%`;
|
||
};
|
||
|
||
const onComplete = () => {
|
||
// Save file log to batch if in batch mode and this file was converted
|
||
// Consider it successful only if conversion didn't fail
|
||
if (useBatchLog && needsConversion) {
|
||
const ok = !conversionFailed && conversionSucceeded;
|
||
saveToFileBatchLog(file.name, ok, ok ? convOriginalSize : 0, ok ? convNewSize : 0);
|
||
}
|
||
|
||
currentIndex++;
|
||
uploadNextFile();
|
||
};
|
||
|
||
const onError = (error) => {
|
||
// Save failed file log to batch if in batch mode
|
||
if (useBatchLog && needsConversion) {
|
||
logError(`Upload failed: ${error}`);
|
||
// Preserve conversion size totals when the convert step succeeded but
|
||
// the upload failed; otherwise nothing was produced and 0/0 is correct.
|
||
saveToFileBatchLog(file.name, false, convOriginalSize, convNewSize);
|
||
}
|
||
|
||
failedFiles.push({ name: file.name, error: error, file: originalFile });
|
||
|
||
// If network error, mark all remaining files as failed and show retry banner
|
||
if (error.includes('connection failed') || error.includes('Network error') ||
|
||
error.includes('timeout') || error.includes('disconnected')) {
|
||
console.log(`[Network] Network error detected: ${error}`);
|
||
|
||
// Add all remaining files to failed list
|
||
const remainingFiles = files.slice(currentIndex + 1);
|
||
remainingFiles.forEach(remainingFile => {
|
||
failedFiles.push({
|
||
name: remainingFile.name,
|
||
error: 'Network error - upload interrupted',
|
||
file: remainingFile
|
||
});
|
||
});
|
||
|
||
// Show retry banner immediately with all failed files
|
||
failedUploadsGlobal = failedUploadsGlobal.concat(failedFiles);
|
||
isUploadInProgress = false;
|
||
document.getElementById('uploadModalClose').classList.remove('disabled');
|
||
closeUploadModal();
|
||
showFailedUploadsBanner();
|
||
return; // Stop processing
|
||
}
|
||
|
||
currentIndex++;
|
||
uploadNextFile();
|
||
};
|
||
|
||
try {
|
||
// Convert EPUB if needed
|
||
if (needsConversion) {
|
||
progressFill.style.backgroundColor = '#9b59b6'; // Purple for conversion
|
||
progressText.textContent = `Converting ${file.name} (${currentIndex + 1}/${files.length})...`;
|
||
|
||
// Clear log for single file mode, or just add separator for batch mode
|
||
if (!useBatchLog) {
|
||
clearLog();
|
||
showLog();
|
||
}
|
||
|
||
// Snapshot before the `file =` reassignment below, which swaps in the
|
||
// converted blob and makes file.size point at the optimised size.
|
||
const origFileSize = file.size;
|
||
|
||
try {
|
||
const convertedBlob = await convertEpubFile(file, (percent) => {
|
||
// Pass current quality setting to converter
|
||
progressFill.style.width = (percent * 0.5) + '%'; // Conversion takes first 50%
|
||
});
|
||
|
||
// Create new File from converted blob
|
||
file = new File([convertedBlob], file.name, { type: 'application/epub+zip' });
|
||
progressFill.style.backgroundColor = '#27ae60'; // Back to green for upload
|
||
conversionSucceeded = true;
|
||
convOriginalSize = origFileSize;
|
||
convNewSize = convertedBlob.size;
|
||
} catch (convError) {
|
||
if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; }
|
||
console.error('Conversion error:', convError);
|
||
// Log the error
|
||
logError(`Conversion failed: ${convError.message}`);
|
||
log('Uploading original file instead...', 'warning', 'INFO');
|
||
conversionFailed = true;
|
||
|
||
// In single file mode, export error log
|
||
if (!useBatchLog && exportLogCheckbox && exportLogCheckbox.checked) {
|
||
setTimeout(() => {
|
||
exportLogToFile(null, false); // isBatch = false for single file
|
||
}, 100);
|
||
}
|
||
|
||
// If conversion fails, try uploading original file
|
||
progressText.textContent = `Conversion failed, uploading original ${file.name}...`;
|
||
progressFill.style.backgroundColor = '#e67e22'; // Orange for fallback
|
||
// Reset progress bar to 0% for original file upload
|
||
progressFill.style.width = '0%';
|
||
}
|
||
}
|
||
|
||
if (useWebSocket) {
|
||
await uploadFileWebSocket(file, onProgress, null, null);
|
||
} else {
|
||
await uploadFileHTTP(file, onProgress, null, null);
|
||
}
|
||
// Ensure progress bar shows 100% before moving to next file
|
||
progressFill.style.width = '100%';
|
||
progressText.textContent = `Upload complete: ${file.name}`;
|
||
onComplete();
|
||
} catch (error) {
|
||
if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; }
|
||
console.error('Upload error:', error);
|
||
// Log upload error if conversion succeeded but upload failed
|
||
if (conversionSucceeded) {
|
||
logError(`Upload failed: ${error.message}`);
|
||
}
|
||
|
||
if (useWebSocket && error.message === 'WebSocket connection failed') {
|
||
// Fall back to HTTP for all subsequent uploads
|
||
console.log('WebSocket failed, falling back to HTTP');
|
||
useWebSocket = false;
|
||
// Retry this file with HTTP
|
||
try {
|
||
await uploadFileHTTP(file, onProgress, null, null);
|
||
onComplete();
|
||
} catch (httpError) {
|
||
onError(httpError.message);
|
||
}
|
||
} else {
|
||
onError(error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
uploadNextFile();
|
||
}
|
||
|
||
function showFailedUploadsBanner() {
|
||
const banner = document.getElementById('failedUploadsBanner');
|
||
const filesList = document.getElementById('failedFilesList');
|
||
|
||
filesList.innerHTML = '';
|
||
|
||
failedUploadsGlobal.forEach((failedFile, index) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'failed-file-item';
|
||
item.innerHTML = `
|
||
<div class="failed-file-info">
|
||
<div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div>
|
||
<div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div>
|
||
</div>
|
||
<button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button>
|
||
`;
|
||
filesList.appendChild(item);
|
||
});
|
||
|
||
// Ensure retry all button is visible
|
||
const retryAllBtn = banner.querySelector('.retry-all-btn');
|
||
if (retryAllBtn) retryAllBtn.style.display = '';
|
||
|
||
banner.classList.add('show');
|
||
}
|
||
|
||
function dismissFailedUploads() {
|
||
const banner = document.getElementById('failedUploadsBanner');
|
||
banner.classList.remove('show');
|
||
failedUploadsGlobal = [];
|
||
}
|
||
|
||
function retrySingleUpload(index) {
|
||
const failedFile = failedUploadsGlobal[index];
|
||
if (!failedFile) return;
|
||
|
||
// Create a DataTransfer to set the file input
|
||
const dt = new DataTransfer();
|
||
dt.items.add(failedFile.file);
|
||
|
||
const fileInput = document.getElementById('fileInput');
|
||
fileInput.files = dt.files;
|
||
|
||
// Remove this file from failed list
|
||
failedUploadsGlobal.splice(index, 1);
|
||
|
||
// If no more failed files, hide banner
|
||
if (failedUploadsGlobal.length === 0) {
|
||
dismissFailedUploads();
|
||
}
|
||
|
||
// Open modal and trigger upload
|
||
openUploadModal();
|
||
validateFile();
|
||
}
|
||
|
||
function retryAllFailedUploads() {
|
||
if (failedUploadsGlobal.length === 0) return;
|
||
|
||
// Create a DataTransfer with all failed files
|
||
const dt = new DataTransfer();
|
||
failedUploadsGlobal.forEach(failedFile => {
|
||
dt.items.add(failedFile.file);
|
||
});
|
||
|
||
const fileInput = document.getElementById('fileInput');
|
||
fileInput.files = dt.files;
|
||
|
||
// Clear failed files list
|
||
failedUploadsGlobal = [];
|
||
dismissFailedUploads();
|
||
|
||
// Open modal and trigger upload
|
||
openUploadModal();
|
||
validateFile();
|
||
}
|
||
|
||
function createFolder() {
|
||
const folderName = document.getElementById('folderName').value.trim();
|
||
|
||
if (!folderName) {
|
||
alert('Please enter a folder name!');
|
||
return;
|
||
}
|
||
|
||
// Validate folder name
|
||
const validName = /^(?!\.{1,2}$)[^"*:<>?\/\\|]+$/.test(folderName);
|
||
if (!validName) {
|
||
alert('Folder name cannot contain \" * : < > ? / \\ | and must not be . or ..');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('name', folderName);
|
||
formData.append('path', currentPath);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/mkdir', true);
|
||
|
||
xhr.onload = function() {
|
||
if (xhr.status === 200) {
|
||
window.location.reload();
|
||
} else {
|
||
alert('Failed to create folder: ' + xhr.responseText);
|
||
}
|
||
};
|
||
|
||
xhr.onerror = function() {
|
||
alert('Failed to create folder - network error');
|
||
};
|
||
|
||
xhr.send(formData);
|
||
}
|
||
|
||
// Rename functions
|
||
function openRenameModal(name, path) {
|
||
document.getElementById('renameItemName').textContent = '📄 ' + name;
|
||
document.getElementById('renameItemPath').value = path;
|
||
document.getElementById('renameNewName').value = name;
|
||
document.getElementById('renameModal').classList.add('open');
|
||
setTimeout(() => {
|
||
const input = document.getElementById('renameNewName');
|
||
input.focus();
|
||
input.select();
|
||
}, 50);
|
||
}
|
||
|
||
function closeRenameModal() {
|
||
document.getElementById('renameModal').classList.remove('open');
|
||
}
|
||
|
||
function confirmRename() {
|
||
const path = document.getElementById('renameItemPath').value;
|
||
const newName = document.getElementById('renameNewName').value.trim();
|
||
|
||
if (!newName) {
|
||
alert('Please enter a new name.');
|
||
return;
|
||
}
|
||
if (newName.includes('/') || newName.includes('\\')) {
|
||
alert('File name cannot include slashes.');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('path', path);
|
||
formData.append('name', newName);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/rename', true);
|
||
|
||
xhr.onload = function() {
|
||
if (xhr.status === 200) {
|
||
window.location.reload();
|
||
} else {
|
||
alert('Failed to rename: ' + xhr.responseText);
|
||
}
|
||
closeRenameModal();
|
||
};
|
||
|
||
xhr.onerror = function() {
|
||
alert('Failed to rename - network error');
|
||
closeRenameModal();
|
||
};
|
||
|
||
xhr.send(formData);
|
||
}
|
||
|
||
// Move functions
|
||
function normalizePath(path) {
|
||
if (!path) return '/';
|
||
let normalized = path.trim();
|
||
if (!normalized.startsWith('/')) normalized = '/' + normalized;
|
||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||
normalized = normalized.slice(0, -1);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function getParentPath(path) {
|
||
const normalized = normalizePath(path);
|
||
if (normalized === '/') return '/';
|
||
const idx = normalized.lastIndexOf('/');
|
||
return idx <= 0 ? '/' : normalized.slice(0, idx);
|
||
}
|
||
|
||
async function loadMoveFolderOptions() {
|
||
const options = new Set();
|
||
options.add('/');
|
||
const parent = getParentPath(currentPath);
|
||
if (parent) options.add(parent);
|
||
|
||
async function fetchFolders(path) {
|
||
try {
|
||
const response = await fetch('/api/files?path=' + encodeURIComponent(path));
|
||
if (!response.ok) return [];
|
||
return await response.json();
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
const rootFiles = await fetchFolders('/');
|
||
rootFiles.forEach(file => {
|
||
if (file.isDirectory) {
|
||
options.add('/' + file.name);
|
||
}
|
||
});
|
||
|
||
if (currentPath !== '/') {
|
||
const currentFiles = await fetchFolders(currentPath);
|
||
currentFiles.forEach(file => {
|
||
if (file.isDirectory) {
|
||
let folderPath = currentPath;
|
||
if (!folderPath.endsWith('/')) folderPath += '/';
|
||
folderPath += file.name;
|
||
options.add(folderPath);
|
||
}
|
||
});
|
||
}
|
||
|
||
const dataList = document.getElementById('moveFolderOptions');
|
||
dataList.innerHTML = '';
|
||
Array.from(options).sort().forEach(path => {
|
||
const option = document.createElement('option');
|
||
option.value = path;
|
||
dataList.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function openMoveModal(name, path) {
|
||
document.getElementById('moveItemName').textContent = '📄 ' + name;
|
||
document.getElementById('moveItemPath').value = path;
|
||
document.getElementById('moveDestPath').value = currentPath === '/' ? '/' : currentPath;
|
||
document.getElementById('moveModal').classList.add('open');
|
||
loadMoveFolderOptions();
|
||
setTimeout(() => {
|
||
document.getElementById('moveDestPath').focus();
|
||
}, 50);
|
||
}
|
||
|
||
function closeMoveModal() {
|
||
document.getElementById('moveModal').classList.remove('open');
|
||
}
|
||
|
||
function confirmMove() {
|
||
const path = document.getElementById('moveItemPath').value;
|
||
const destPath = normalizePath(document.getElementById('moveDestPath').value);
|
||
|
||
if (!destPath) {
|
||
alert('Please enter a destination folder.');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('path', path);
|
||
formData.append('dest', destPath);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/move', true);
|
||
|
||
xhr.onload = function() {
|
||
if (xhr.status === 200) {
|
||
window.location.reload();
|
||
} else {
|
||
alert('Failed to move: ' + xhr.responseText);
|
||
}
|
||
closeMoveModal();
|
||
};
|
||
|
||
xhr.onerror = function() {
|
||
alert('Failed to move - network error');
|
||
closeMoveModal();
|
||
};
|
||
|
||
xhr.send(formData);
|
||
}
|
||
hydrate();
|
||
</script>
|
||
</body>
|
||
</html>
|