Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 | ||
|
|
6aa958c78f |
+2
-2
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
# This stage is ONLY used for running tests, never deployed to production
|
||||
FROM golang:1.26-alpine AS test-runner
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
|
||||
# Install kepubify for EPUB→KEPUB conversion
|
||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
info:
|
||||
name: Rescan Media Item
|
||||
type: http
|
||||
seq: 9
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
|
||||
headers:
|
||||
- name: ""
|
||||
value: application/json
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
scripts:
|
||||
- type: tests
|
||||
code: |-
|
||||
test_rescan_media_item_success(status, headers, body) {
|
||||
if (status !== 200) {
|
||||
throw new Error("Expected status 200, got " + status);
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
docs: |-
|
||||
## Rescan Media Item
|
||||
|
||||
Re-extracts metadata and cover art for a single media item from the file on disk.
|
||||
|
||||
**Method:** POST
|
||||
|
||||
**Endpoint:** /api/media-items/{id}/rescan
|
||||
@@ -1309,6 +1309,42 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
|
||||
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
scanner := services.NewMediaScanner(mh.db)
|
||||
defer scanner.Close()
|
||||
|
||||
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
@@ -60,6 +60,7 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Admin-only media routes
|
||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
||||
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
||||
|
||||
// Shelf management (protected)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -2078,56 +2079,94 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
||||
// Use pdfcpu API to extract images from first page
|
||||
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
|
||||
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
|
||||
if err == nil {
|
||||
// Check for extracted images in the temp directory
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err == nil && len(entries) > 0 {
|
||||
// Find the largest image (likely the cover)
|
||||
var largestImage string
|
||||
var largestSize int64
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip very small files (likely thumbnails or icons)
|
||||
if info.Size() < 1000 {
|
||||
continue
|
||||
}
|
||||
if info.Size() > largestSize {
|
||||
largestImage = filepath.Join(tmpDir, entry.Name())
|
||||
largestSize = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
if largestImage != "" {
|
||||
// Read the image
|
||||
imageData, err := os.ReadFile(largestImage)
|
||||
if err == nil && len(imageData) > 0 {
|
||||
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
||||
coverPath := pdfPath + ".cover.jpg"
|
||||
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover file: %v", err)
|
||||
}
|
||||
|
||||
return coverPath, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No embedded raster cover found (e.g. vector/text first page) - fall back
|
||||
// to rendering the first page with pdftoppm (poppler-utils).
|
||||
return s.renderPDFCoverPage(pdfPath), nil
|
||||
}
|
||||
|
||||
// renderPDFCoverPage renders the first page of a PDF file to a JPEG image
|
||||
// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg).
|
||||
// Returns the path to the saved cover, or empty string if rendering failed.
|
||||
func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
|
||||
if _, err := exec.LookPath("pdftoppm"); err != nil {
|
||||
fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath)
|
||||
return ""
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "pdf-render-")
|
||||
if err != nil {
|
||||
// No images found or extraction failed - this is OK, just return empty
|
||||
return "", nil
|
||||
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
|
||||
return ""
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
|
||||
}
|
||||
}()
|
||||
|
||||
outPrefix := filepath.Join(tmpDir, "cover")
|
||||
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-r", "150", pdfPath, outPrefix)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for extracted images in the temp directory
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Find the largest image (likely the cover)
|
||||
var largestImage string
|
||||
var largestSize int64
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip very small files (likely thumbnails or icons)
|
||||
if info.Size() < 1000 {
|
||||
continue
|
||||
}
|
||||
if info.Size() > largestSize {
|
||||
largestImage = filepath.Join(tmpDir, entry.Name())
|
||||
largestSize = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
if largestImage == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Read the image
|
||||
imageData, err := os.ReadFile(largestImage)
|
||||
if err != nil || len(imageData) == 0 {
|
||||
return "", nil
|
||||
imageData, err := os.ReadFile(outPrefix + ".jpg")
|
||||
if err != nil || len(imageData) < 1000 {
|
||||
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
||||
coverPath := pdfPath + ".cover.jpg"
|
||||
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover file: %v", err)
|
||||
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return coverPath, nil
|
||||
return coverPath
|
||||
}
|
||||
|
||||
// ComicInfo represents metadata from ComicInfo.xml
|
||||
@@ -2618,6 +2657,51 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
|
||||
return err
|
||||
}
|
||||
|
||||
// RescanMediaItem re-extracts metadata for a single media item and updates it.
|
||||
// It is the per-book rescan used by the Edit Metadata dialog and backfills
|
||||
// covers for items imported before the PDF render fallback existed.
|
||||
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID) error {
|
||||
item, err := s.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media item not found: %w", err)
|
||||
}
|
||||
|
||||
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
|
||||
if err != nil || len(folders) == 0 {
|
||||
return fmt.Errorf("no library folders found for library")
|
||||
}
|
||||
|
||||
folderPaths := make([]string, 0, len(folders))
|
||||
for _, folder := range folders {
|
||||
folderPaths = append(folderPaths, folder.FolderPath)
|
||||
}
|
||||
s.folders = folderPaths
|
||||
|
||||
var fullPath string
|
||||
for _, folder := range folders {
|
||||
candidate := filepath.Join(folder.FolderPath, item.FilePath)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
fullPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if fullPath == "" {
|
||||
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
|
||||
}
|
||||
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat media file: %w", err)
|
||||
}
|
||||
|
||||
if err := s.updateMediaItem(ctx, mediaItemID, fullPath, info); err != nil {
|
||||
return fmt.Errorf("failed to update media item: %w", err)
|
||||
}
|
||||
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
|
||||
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
||||
FilePath: s.getRelativePath(filePath),
|
||||
|
||||
@@ -609,7 +609,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="rescanBook()"
|
||||
:disabled="rescanning"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
|
||||
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="hideMetadataEditor()"
|
||||
class="btn btn-secondary"
|
||||
@@ -625,5 +637,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1336,7 +1336,15 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1344,7 +1352,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "Save</button></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "Save</button></div></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ interface MetadataEditorState {
|
||||
coverFile: Blob | null;
|
||||
coverAction: string;
|
||||
saving: boolean;
|
||||
rescanning: boolean;
|
||||
userRating: number;
|
||||
ratingHover: number;
|
||||
ratingSaving: boolean;
|
||||
@@ -110,6 +111,7 @@ interface MetadataEditorState {
|
||||
generateCover(): Promise<void>;
|
||||
removeCover(): void;
|
||||
saveMetadata(): Promise<void>;
|
||||
rescanBook(): Promise<void>;
|
||||
starFill(i: number): string;
|
||||
ratingText(): string;
|
||||
setRating(value: number): Promise<void>;
|
||||
@@ -149,6 +151,7 @@ Alpine.data("bookDetail", () => {
|
||||
coverFile: null as Blob | null,
|
||||
coverAction: "keep",
|
||||
saving: false,
|
||||
rescanning: false,
|
||||
userRating: 0,
|
||||
ratingHover: 0,
|
||||
ratingSaving: false,
|
||||
@@ -559,6 +562,31 @@ Alpine.data("bookDetail", () => {
|
||||
}
|
||||
},
|
||||
|
||||
async rescanBook() {
|
||||
if (this.rescanning) return;
|
||||
this.rescanning = true;
|
||||
const mediaId = getMediaId();
|
||||
try {
|
||||
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Failed to rescan book");
|
||||
}
|
||||
showToast("Book rescanned successfully", "success");
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} catch (e) {
|
||||
showToast(
|
||||
e instanceof Error ? e.message : "Failed to rescan book",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
this.rescanning = false;
|
||||
}
|
||||
},
|
||||
|
||||
async searchEditorTags() {
|
||||
const libraryId = document.body.getAttribute("data-library-id") || "";
|
||||
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
||||
|
||||
+38
-26
@@ -482,11 +482,14 @@ document.addEventListener("alpine:init", () => {
|
||||
tocItems: [] as any[],
|
||||
mediaItemId: "" as string,
|
||||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||
// Set only by deliberate navigation (page turns, jumps, slider). The
|
||||
// restore at open time and section-load relocations never set it, so
|
||||
// progress saves can only ever write a position the user actually
|
||||
// moved to — never a stale restore clobbering a newer device push.
|
||||
userMoved: false as boolean,
|
||||
// Position last known to be stored (the restore at open time, or the
|
||||
// last successful save). Relocations that don't move from it are never
|
||||
// written back, so a restored position can't clobber a newer device
|
||||
// push — while swipe/scroll paging (handled inside foliate, with no
|
||||
// wrapper method to flag) still saves normally.
|
||||
lastSyncedCfi: "" as string,
|
||||
lastSyncedFraction: -1 as number,
|
||||
lastCfi: "" as string,
|
||||
contextText: "" as string,
|
||||
readingTheme: "light" as string,
|
||||
readingMode: "light" as string,
|
||||
@@ -883,6 +886,7 @@ document.addEventListener("alpine:init", () => {
|
||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||
e.detail;
|
||||
this.hideSelectionPopover();
|
||||
this.lastCfi = cfi || "";
|
||||
this.lastRelocateDetail = {
|
||||
fraction,
|
||||
location,
|
||||
@@ -944,6 +948,11 @@ document.addEventListener("alpine:init", () => {
|
||||
} else {
|
||||
await this.view.init({})
|
||||
}
|
||||
// The position restored above (or the start of the book on a fresh
|
||||
// open) is the baseline: only relocations that actually move from
|
||||
// it may write progress.
|
||||
this.lastSyncedCfi = this.lastCfi;
|
||||
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
|
||||
// The renderer only knows it's a PDF once frames exist (they carry
|
||||
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
||||
// Read it now and apply the saved pointer mode — this also makes the
|
||||
@@ -953,10 +962,15 @@ document.addEventListener("alpine:init", () => {
|
||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||||
}
|
||||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||||
// A bfcache-resurrected page is stale by definition: forbid it from
|
||||
// writing its frozen position back until the user navigates again.
|
||||
// A bfcache-resurrected page is stale by definition: re-baseline to
|
||||
// its frozen position so it can't write that back until the user
|
||||
// actually navigates again.
|
||||
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
||||
if (e.persisted) this.userMoved = false;
|
||||
if (e.persisted) {
|
||||
this.lastSyncedCfi = this.lastCfi;
|
||||
this.lastSyncedFraction =
|
||||
this.lastRelocateDetail?.fraction ?? -1;
|
||||
}
|
||||
});
|
||||
this.fetchReadingSpeed();
|
||||
this.refreshAnnotations();
|
||||
@@ -1557,9 +1571,9 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
},
|
||||
// Fresh reading position from the database — the single source of
|
||||
// truth at open time. Fails soft to a fresh start: the userMoved gate
|
||||
// guarantees merely opening (even at the wrong spot) can never
|
||||
// overwrite the stored position.
|
||||
// truth at open time. Fails soft to a fresh start: change detection
|
||||
// against the restored baseline guarantees merely opening (even at
|
||||
// the wrong spot) can never overwrite the stored position.
|
||||
async fetchSavedLocation(): Promise<{
|
||||
cfi?: string;
|
||||
page?: number;
|
||||
@@ -1591,9 +1605,17 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
},
|
||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||
// Only deliberate navigation writes progress: displaying a restored
|
||||
// position must never overwrite a newer device push.
|
||||
if (!this.userMoved) return;
|
||||
// Only an actual change from the last stored position writes
|
||||
// progress: displaying a restored position must never overwrite a
|
||||
// newer device push. Swipes and scrolls are handled inside foliate
|
||||
// with no wrapper method to flag, so position — not intent — is the
|
||||
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
|
||||
const currentCfi = cfi || "";
|
||||
const changed =
|
||||
currentCfi || this.lastSyncedCfi
|
||||
? currentCfi !== this.lastSyncedCfi
|
||||
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
|
||||
if (!changed) return;
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.saveProgress(fraction, location, cfi);
|
||||
@@ -1644,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
this.lastSyncedCfi = cfi || "";
|
||||
this.lastSyncedFraction = fraction;
|
||||
} catch (_e) {
|
||||
// silent fail — progress save is non-critical
|
||||
}
|
||||
@@ -1734,23 +1758,18 @@ document.addEventListener("alpine:init", () => {
|
||||
saveSettings({ double_page_spread: this.doublePageSpread });
|
||||
},
|
||||
goLeft() {
|
||||
this.userMoved = true;
|
||||
this.view?.goLeft?.();
|
||||
},
|
||||
goRight() {
|
||||
this.userMoved = true;
|
||||
this.view?.goRight?.();
|
||||
},
|
||||
nextPage() {
|
||||
this.userMoved = true;
|
||||
this.view?.next?.();
|
||||
},
|
||||
previousPage() {
|
||||
this.userMoved = true;
|
||||
this.view?.prev?.();
|
||||
},
|
||||
goToFraction(value: string) {
|
||||
this.userMoved = true;
|
||||
this.view?.goToFraction?.(parseFloat(value));
|
||||
},
|
||||
toggleTOC() {
|
||||
@@ -1929,11 +1948,9 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||||
if (item.cfi) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(item.cfi);
|
||||
} else if (item.page != null) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(item.page);
|
||||
} else return;
|
||||
@@ -1962,7 +1979,6 @@ document.addEventListener("alpine:init", () => {
|
||||
goBackToLocation() {
|
||||
const loc = this.backStack.pop();
|
||||
if (!loc) return;
|
||||
this.userMoved = true;
|
||||
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
||||
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
||||
},
|
||||
@@ -1978,7 +1994,6 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToTOCItem(item: any) {
|
||||
if (this.view && item.href) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view.goTo(item.href);
|
||||
this.tocOpen = false;
|
||||
@@ -2106,7 +2121,6 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToPage(index: number) {
|
||||
if (!this.view || typeof index !== "number" || index < 0) return;
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view.goTo(index);
|
||||
this.tocOpen = false;
|
||||
@@ -2114,11 +2128,9 @@ document.addEventListener("alpine:init", () => {
|
||||
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||
if (!this.view) return;
|
||||
if (item.cfi) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view.goTo(item.cfi);
|
||||
} else if (item.page != null && item.page > 0) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||||
this.view.goTo(item.page - 1);
|
||||
|
||||
Reference in New Issue
Block a user