Release v1.26.9
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-10 12:45:42 +02:00
parent 9b1e29bb79
commit e6211f1333
7 changed files with 120 additions and 38 deletions

View File

@ -9,6 +9,15 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.26.9] — 2026-09-10
### Fixed
- **PyMuPDF Ingestion Requirement.** Provided actionable installation guidance when `pymupdf` is missing in runtime.
- **Drag & Drop Upload Target & Feedback.** Made the magazine upload dropzone fully clickable, added file selection feedback showing filename and file size, and added active loading states with spinners and disabled controls during upload.
- **Optional Title & Auto-Fill.** Relabeled Title input to optional and auto-fills title from document filename if left blank.
---
## [1.26.8] — 2026-09-09
### Added
@ -3896,7 +3905,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.26.1...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.26.9...HEAD
[1.26.9]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.26.8...v1.26.9
[1.26.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.26.0...v1.26.1
[1.26.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.25.3...v1.26.0
[1.22.3]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.22.2...v1.22.3

View File

@ -1 +1 @@
1.26.8
1.26.9

View File

@ -728,7 +728,8 @@ def _require_fitz():
return fitz
except ImportError as exc: # pragma: no cover - environment guard
raise RuntimeError(
"PyMuPDF is required for magazine ingestion; install pymupdf"
"PyMuPDF is required for magazine ingestion; install pymupdf (pip install pymupdf) "
"or rebuild your Docker container."
) from exc

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.26.8">
<meta name="app-version" content="1.26.9">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.26.8">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.26.8">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.26.8">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.26.8">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.26.8">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.26.8">
<link rel="stylesheet" href="/static/style.css?v=1.26.9">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.26.9">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.26.9">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.26.9">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.26.9">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.26.9">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.26.8"></script>
<script src="/static/loader.js?v=1.26.9"></script>
</body>
</html>

View File

@ -175,9 +175,36 @@ async function magDelete(docId) {
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
function magFormatFilenameAsTitle(filename) {
if (!filename) return '';
const base = String(filename).replace(/\.[^.]+$/, '');
return base
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function magShowDroppedFile(file) {
const noteEl = $('mag-dropzone-note');
if (noteEl) noteEl.textContent = file ? file.name : MAG_DROPZONE_DEFAULT_NOTE;
const titleEl = $('mag-dropzone-title');
const iconEl = $('mag-dropzone-icon');
const titleInput = $('mag-new-title');
if (file) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`;
if (titleEl) titleEl.textContent = 'PDF file selected';
if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon';
// Auto-fill title from filename if title input is empty
if (titleInput && !titleInput.value.trim()) {
titleInput.value = magFormatFilenameAsTitle(file.name);
}
} else {
if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE;
if (titleEl) titleEl.textContent = 'Drop a PDF or click to browse';
if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon';
}
}
let _magDropGuardInstalled = false;
@ -202,14 +229,16 @@ function setupMagDropzone() {
if (zone.dataset.magDropzoneWired) return;
zone.dataset.magDropzoneWired = '1';
// Clicking anywhere in the dropzone triggers the hidden file picker
zone.addEventListener('click', (e) => {
if (!e.target.closest('input') && !e.target.closest('.reader-file-pick')) {
input.click();
}
});
// dragenter AND dragover both need preventDefault() -- some browsers only
// arm the element as a valid drop target once dragenter itself has been
// cancelled, not just the dragover events that follow it. Not gated on
// dataTransfer.types: during a real OS file drag, Chromium does not
// always populate that list synchronously on every one of these events,
// and gating on it here (confirmed live) silently skipped preventDefault
// often enough that neither the hover highlight nor the drop interception
// ever fired -- the browser fell through to opening the file itself.
// cancelled, not just the dragover events that follow it.
const armDropTarget = (e) => { e.preventDefault(); zone.classList.add('dragover'); };
zone.addEventListener('dragenter', armDropTarget);
zone.addEventListener('dragover', armDropTarget);
@ -233,12 +262,36 @@ function setupMagDropzone() {
}
async function magCreate() {
const title = ($('mag-new-title')?.value || '').trim();
const issue = ($('mag-new-issue')?.value || '').trim();
const file = $('mag-new-file')?.files?.[0];
if (!title) return say('Give the magazine a title', 'err');
if (!file) return say('Choose or drop the PDF', 'err');
if (!file) return say('Choose or drop a PDF file first', 'err');
let title = ($('mag-new-title')?.value || '').trim();
if (!title) {
title = magFormatFilenameAsTitle(file.name) || 'Untitled Magazine';
if ($('mag-new-title')) $('mag-new-title').value = title;
}
const issue = ($('mag-new-issue')?.value || '').trim();
const addBtn = $('mag-add-btn');
const dropzone = $('mag-dropzone');
const titleInp = $('mag-new-title');
const issueInp = $('mag-new-issue');
const origBtnText = addBtn ? addBtn.innerHTML : '';
const setUploadingState = (uploading) => {
if (addBtn) {
addBtn.disabled = uploading;
addBtn.innerHTML = uploading
? '<span class="mdi mdi-loading mdi-spin"></span> Uploading PDF...'
: origBtnText;
}
if (titleInp) titleInp.disabled = uploading;
if (issueInp) issueInp.disabled = uploading;
if (dropzone) dropzone.style.pointerEvents = uploading ? 'none' : 'auto';
};
try {
setUploadingState(true);
const { id } = await api('/api/magazine/docs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -246,13 +299,17 @@ async function magCreate() {
});
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file });
say('Magazine added — starting ingestion', 'ok');
if ($('mag-new-title')) $('mag-new-title').value = '';
if ($('mag-new-issue')) $('mag-new-issue').value = '';
if ($('mag-new-file')) $('mag-new-file').value = '';
magShowDroppedFile(null);
setUploadingState(false);
await magOpen(id);
await magIngest();
} catch (e) {
setUploadingState(false);
say('Could not add magazine: ' + e.message, 'err');
}
}
@ -604,7 +661,7 @@ function renderReview() {
'<div class="mag-review-page-controls" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevPage ? '' : 'disabled') +
' onclick="magReviewGoPage(' + prevPage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Article p. ' + (page ?? '—') + '</strong>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextPage ? '' : 'disabled') +
' onclick="magReviewGoPage(' + nextPage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-page-controls mag-review-issue-controls" role="group" aria-label="Whole magazine pages">' +
@ -617,7 +674,7 @@ function renderReview() {
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw graphic</button>' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'heading\')">' +
'<span class="mdi mdi-format-header-1" aria-hidden="true"></span> Headline</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'page_number\')">' +
@ -844,12 +901,23 @@ function setupMagDrawing() {
// without needing to be re-attached.
window.addEventListener('mouseup', (e) => {
if (!_magDrawState) return;
// Capture the release position even when the pointer left the page.
$('mag-review-page-wrap')?.dispatchEvent(new MouseEvent('mousemove', { clientX: e.clientX, clientY: e.clientY }));
const wrap = $('mag-review-page-wrap');
const box = $('mag-draw-rect');
if (wrap && box) {
const rect = wrap.getBoundingClientRect();
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct));
const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct));
const w = Math.abs(xPct - _magDrawState.startXPct);
const h = Math.abs(yPct - _magDrawState.startYPct);
box.hidden = false;
box.style.left = left + '%'; box.style.top = top + '%';
box.style.width = w + '%'; box.style.height = h + '%';
}
_magDrawState = null;
if (!box || box.hidden) return;
const w = parseFloat(box.style.width), h = parseFloat(box.style.height);
const w = parseFloat(box.style.width) || 0, h = parseFloat(box.style.height) || 0;
if (w < 1 || h < 1) { box.hidden = true; return; } // too small to be intentional
magReviewOpenDrawPanel(box);
});
@ -897,8 +965,8 @@ function magReviewOpenDrawPanel(box) {
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
'</div>';
wrap.appendChild(panel);
const top = parseFloat(box.style.top), left = parseFloat(box.style.left);
panel.style.top = Math.min(85, top + parseFloat(box.style.height) + 1) + '%';
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
panel.style.top = Math.min(85, top + height + 1) + '%';
panel.style.left = Math.min(60, left) + '%';
wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => {
r.addEventListener('change', () => {

View File

@ -11,24 +11,24 @@
<div class="card mag-new">
<h3>Add a magazine or paper</h3>
<div class="mag-new-grid">
<label for="mag-new-title">Title
<input id="mag-new-title" type="text" placeholder="Scientific American" autocomplete="off">
<label for="mag-new-title">Title <span class="muted">(optional)</span>
<input id="mag-new-title" type="text" placeholder="Auto-filled from PDF filename if blank" autocomplete="off">
</label>
<label for="mag-new-issue">Issue
<label for="mag-new-issue">Issue <span class="muted">(optional)</span>
<input id="mag-new-issue" type="text" placeholder="September 2026" autocomplete="off">
</label>
</div>
<div class="reader-dropzone" id="mag-dropzone">
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon"></span>
<strong>Drop a PDF</strong>
<div class="reader-dropzone" id="mag-dropzone" style="cursor: pointer;">
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon" id="mag-dropzone-icon"></span>
<strong id="mag-dropzone-title">Drop a PDF or click to browse</strong>
<span class="note" id="mag-dropzone-note">Scanned issues work too &mdash; pages without selectable text are read with OCR.</span>
<label class="btn-secondary btn-sm reader-file-pick" title="Choose a PDF file">
<label class="btn-secondary btn-sm reader-file-pick" title="Choose a PDF file" onclick="event.stopPropagation()">
<span class="mdi mdi-file-upload-outline"></span> Choose file
<input type="file" id="mag-new-file" accept="application/pdf" style="display:none">
</label>
</div>
<div class="mag-new-actions">
<button type="button" class="btn primary" onclick="magCreate()">
<button type="button" class="btn primary" id="mag-add-btn" onclick="magCreate()">
<span class="mdi mdi-plus"></span> Add magazine
</button>
</div>

View File

@ -198,7 +198,10 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Review', exact: true }).click();
await page.getByRole('button', { name: 'Draw image', exact: true }).click();
await page.locator('#mag-review-page-wrap').evaluate(el => { el.style.width = '400px'; });
await page.locator('#mag-review-page-wrap').evaluate(el => {
el.style.width = '400px';
el.scrollIntoView();
});
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842);
await page.mouse.down();