Files
Crosspoint/src/network/html/FilesPage.html
T

5542 lines
194 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Files</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);
margin-bottom: 5px;
}
h2 {
color: var(--title-color);
margin-top: 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;
padding-bottom: 15px;
border-bottom: 2px solid var(--accent-color);
}
.page-header-left {
display: flex;
align-items: baseline;
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(--border-color);
}
.breadcrumb-inline .current {
color: var(--title-color);
font-weight: 500;
}
.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;
}
.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;
margin-left: 8px;
}
.folder-badge {
display: inline-block;
padding: 2px 8px;
background-color: #f39c12;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.file-icon {
margin-right: 8px;
}
.folder-link,
.file-link {
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;
}
.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.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;
}
/* 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;
}
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: var(--title-color);
margin: 0;
}
.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 {
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;
margin-left: 4px;
}
.contents-header {
margin-bottom: 8px;
flex-wrap: wrap;
gap: 4px;
}
.contents-title {
font-size: 1em;
}
.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>
<div class="nav-links">
<a href="/files" class="active">File Manager</a>
<a href="/settings">Settings</a>
<a href="/systeminfo">System Info</a>
</div>
<div class="page-header">
<div class="page-header-left">
<h1>📁 File Manager</h1>
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
</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">&times;</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">
<h2 class="contents-title">Contents</h2>
<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()">&times;</button>
<h3>📤 Upload file</h3>
<div class="upload-form">
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" onchange="validateFile()" multiple>
<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>📏 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>
-->
<!-- 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 hashbased 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()">&times;</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()">&times;</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()">&times;</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()">&times;</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>
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
// 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 and file extension setting
fetchVersion();
await fetchShowFileExtensions();
// 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();
overlay.classList.remove('open');
}
});
});
const breadcrumbs = document.getElementById('directory-breadcrumbs');
const fileTable = document.getElementById('file-table');
let breadcrumbContent = '<span class="sep">/</span>';
if (currentPath === '/') {
breadcrumbContent += '<span class="current">🏠</span>';
} else {
breadcrumbContent += '<a href="/files">🏠</a>';
const pathSegments = currentPath.split('/');
pathSegments.slice(1, pathSegments.length - 1).forEach(function (segment, index) {
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>';
});
breadcrumbContent += '<span class="sep">/</span>';
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
}
breadcrumbs.innerHTML = breadcrumbContent;
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;
});
document.getElementById('folder-summary').innerHTML = `${folderCount} folders, ${files.length - folderCount} files, ${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><span class="folder-badge">FOLDER</span></td>`;
fileTableContent += '<td>Folder</td>';
fileTableContent += '<td>-</td>';
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
fileTableContent += `<button class="move-btn" onclick="openMoveModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Move folder">📂</button>`;
fileTableContent += `<button class="rename-btn" onclick="openRenameModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Rename folder">✏️</button>`;
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button>`;
fileTableContent += `</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>`;
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(displayFileName(file.name))}</a>`;
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
fileTableContent += '</td>';
fileTableContent += `<td>${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;
}
}
// 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');
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');
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 = {};
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;
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;
// Check if grayscaleToggle exists (may be hidden for compatibility with other devices)
const grayscaleToggle = document.getElementById('grayscaleToggle');
const grayscale = grayscaleToggle ? grayscaleToggle.checked : true; // Default to true for e-ink
// 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
}
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 (document.getElementById('imagePickerSection').style.display !== 'none') {
renderImageGrid();
}
}
function setOverlap(value) {
OVERLAP_PERCENT = value;
// Update UI
document.querySelectorAll('.overlap-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
});
}
// ============================================================================
// 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);
// Images that fit screen can only rotate, not split
const fitsScreen = (dims.width <= 480 && dims.height <= 800);
// Split capability - no upscaling allowed
// H-Split scales width to 800, so needs width >= 800
// V-Split scales height to 800, so needs height >= 800
const canHSplit = dims.width >= 800;
const canVSplit = dims.height >= 800;
images.push({
path: path,
name: filename,
dataUrl: dataUrl,
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 = [];
pendingConversionFile = file;
// Extract images
try {
const images = await extractImagesForPreview(file);
epubImagesCache = images;
// 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 800, rotate, then check width
const scaledH = Math.round(img.height * (800 / img.width));
finalWidth = scaledH; // After rotation, height becomes width
} else {
// V-Split: scale height to 800, then check width
finalWidth = Math.round(img.width * (800 / img.height));
}
if (finalWidth > 480) {
const minOverlapPx = Math.round(480 * (OVERLAP_PERCENT / 100));
const maxStep = 480 - 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)` : '';
item.className = `image-item ${stateClasses[state]} ${rotateClass}`.trim();
item.onclick = () => cycleImageState(img.path);
item.title = `${img.width}×${img.height} - ${stateText}${partsText}`;
item.innerHTML = `
<span class="image-state-badge">${stateLabels[state] || '•'}</span>
<div class="image-preview-overlay">
${splitLinesHtml}
</div>
<img src="${img.dataUrl}" 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 () {
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 = {};
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);
});
})();
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/hide convert options based on file selection
if (files.length > 0) {
convertOptions.style.display = 'block';
} else {
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));
}
// 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 = false;
} 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)
// ============================================================================
// Default conversion settings
const DEFAULT_MAX_WIDTH = 480;
const DEFAULT_MAX_HEIGHT = 800;
const DEFAULT_JPEG_QUALITY = 85;
const DEFAULT_ENABLE_GRAYSCALE = true;
// Note: Overlap is now always centered distribution (min 5%)
// Dynamic conversion settings (updated by UI)
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 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 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');
// Show file extensions setting (fetched from API)
let showFileExtensions = false;
async function fetchShowFileExtensions() {
try {
const response = await fetch('/api/settings');
if (response.ok) {
const settings = await response.json();
const entry = settings.find(s => s.key === 'showFileExtensions');
if (entry) showFileExtensions = !!entry.value;
}
} catch (e) {
console.error('Failed to fetch settings:', e);
}
}
function displayFileName(name) {
if (showFileExtensions) return name;
const dot = name.lastIndexOf('.');
return dot > 0 ? name.substring(0, dot) : name;
}
// 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';
}
} catch (e) {
console.error('Failed to fetch version:', e);
}
}
// 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) {
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;
// 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);
});
});
// 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>` : ''}
<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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);
}
// 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;
// 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 / origW; // 800 / origW
const scaledW = MAX_HEIGHT; // 800
const scaledH = Math.round(origH * 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(img, 0, 0, origW, origH, 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 / origH; // 800 / origH
const scaledW = Math.round(origW * 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(img, 0, 0, origW, origH, 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 = origH;
const rotH = origW;
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(img, 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) {
// 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 = origW <= MAX_WIDTH && origH <= MAX_HEIGHT;
if (fitsInScreen) {
// Image already fits - just convert to JPEG with grayscale
const c = document.createElement('canvas');
c.width = origW;
c.height = origH;
const ctx = c.getContext('2d');
ctx.fillStyle = '#FFF';
ctx.fillRect(0, 0, origW, origH);
ctx.drawImage(img, 0, 0);
applyGrayscale(ctx, origW, origH);
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: origW, height: origH, size: arrBuf.byteLength }],
meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: origW, finalH: origH, finalSize: arrBuf.byteLength, imageState: 0 }
});
} else {
// Scale to fit 480x800
const scale = Math.min(MAX_WIDTH / origW, MAX_HEIGHT / origH);
const newW = Math.round(origW * scale);
const newH = Math.round(origH * 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(img, 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;
let totalImageSize = 0;
let totalNewSize = 0;
// 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'}`, '', '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);
totalImageSize += meta.origSize;
totalNewSize += meta.finalSize;
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()); }
for (const [o, n] of Object.entries(renamed)) {
t = t.split(o.split('/').pop()).join(n.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; }
}
// 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(totalImageSize > 0 ? totalImageSize : originalSize, totalNewSize > 0 ? totalNewSize : 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;
try {
// Pre-read file data in larger batches (64KB) to avoid the
// overhead of per-chunk slice() + arrayBuffer() calls, then
// send WS_CHUNK_SIZE frames from the pre-read buffer.
const READ_BATCH = 65536;
const totalSize = file.size;
let offset = 0;
while (offset < totalSize && ws.readyState === WebSocket.OPEN) {
// Read a large batch from the file
const batchEnd = Math.min(offset + READ_BATCH, totalSize);
const batchBuf = await file.slice(offset, batchEnd).arrayBuffer();
let batchOffset = 0;
while (batchOffset < batchBuf.byteLength && ws.readyState === WebSocket.OPEN) {
// Backpressure: yield when the send buffer builds up.
// Keeps the ESP32 from being overwhelmed while avoiding
// the ~4ms minimum of higher setTimeout values.
if (ws.bufferedAmount > WS_CHUNK_SIZE * 2) {
await new Promise(r => setTimeout(r, 1));
}
if (ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket closed during upload');
}
const end = Math.min(batchOffset + WS_CHUNK_SIZE, batchBuf.byteLength);
ws.send(batchBuf.slice(batchOffset, end));
batchOffset = end;
}
offset = batchEnd;
if (onProgress) {
// Cap at 99% — 100% is shown only when server confirms DONE
const reported = Math.min(offset, Math.floor(totalSize * 0.99));
onProgress(reported, totalSize);
}
}
sendingChunks = false;
console.log('[WS] All chunks sent, waiting for DONE');
if (onProgress) onProgress(totalSize, totalSize, 'saving');
} 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() {
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');
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
const uploadMethod = useWebSocket ? 'WS' : 'HTTP';
const stageText = needsConversion ? 'Converting & uploading' : 'Uploading';
progressText.style.color = '';
progressText.textContent = `${stageText} ${file.name} (${currentIndex + 1}/${files.length})`;
console.log(`[Upload] ${file.name} via ${uploadMethod}`);
let uploadStartTime = 0;
const onProgress = (loaded, total, state) => {
if (!uploadStartTime) uploadStartTime = Date.now();
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 + '%';
let speedText = '';
const elapsed = (Date.now() - uploadStartTime) / 1000;
if (elapsed > 0.5 && loaded > 0) {
const kbps = (loaded / 1024) / elapsed;
speedText = kbps >= 1024 ? ` — ${(kbps / 1024).toFixed(1)} MB/s` : ` — ${Math.round(kbps)} KB/s`;
}
if (state === 'saving') {
progressText.textContent = `Saving ${file.name} (${currentIndex + 1}/${files.length})...${speedText}`;
} else {
const prefix = conversionSucceeded ? 'Converting & uploading' : 'Uploading';
progressText.textContent = `${prefix} ${file.name} (${currentIndex + 1}/${files.length}) — ${uploadPercent}%${speedText}`;
}
};
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) {
saveToFileBatchLog(file.name, !conversionFailed && conversionSucceeded);
}
currentIndex++;
uploadNextFile();
};
const onError = (error) => {
// Save failed file log to batch if in batch mode
if (useBatchLog && needsConversion) {
logError(`Upload failed: ${error}`);
saveToFileBatchLog(file.name, false);
}
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();
}
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;
} 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, isFolder = false) {
const icon = isFolder ? '📁' : '📄';
document.getElementById('renameItemName').textContent = icon + ' ' + 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, isFolder = false) {
const icon = isFolder ? '📁' : '📄';
document.getElementById('moveItemName').textContent = icon + ' ' + 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>