diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm new file mode 100644 index 000000000..9a5dea625 --- /dev/null +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -0,0 +1,142 @@ +package LANraragi::Controller::Api::Stamp; +use Mojo::Base 'Mojolicious::Controller'; + +use Redis; +use Encode; + +use LANraragi::Model::Stamp; +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); + + +sub get_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $stamp_id = $self->req->param('stamp_id'); + + my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp($id, $stamp_id); + + unless ($stamp) { + render_api_response($self, "get_stamp", "The given stamp does not exist."); + return; + } + + $self->render( openapi => { result => $stamp } ); +} + +sub get_stamps_by_page { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $index = $self->stash('index'); + + my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page($id, $index); + + $self->render( openapi => { result => $stamps } ); +} + +sub get_stamped_pages { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + + my ( $indexes, $err ) = LANraragi::Model::Stamp::get_stamped_pages( $id ); + + $self->render( openapi => { result => $indexes } ); +} + +sub add_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $index = $self->stash('index'); + my $content = $self->req->param('content') || ""; + my $position = $self->req->param('position') || ""; + + unless ( defined $index ) { + return render_api_response( $self, "add_stamp", "Archive page." ); + } + + return unless exec_with_lock( + $self, + "stamp-write:$id", + "create_stamp", + $id, + sub { + my ( $created_id, $err ) = LANraragi::Model::Stamp::add_stamp( $id, $index, $content, $position ); + + if ($created_id) { + $self->render( + openapi => { + operation => "add_stamp", + stamp_id => $created_id, + success => 1 + } + ); + } else { + $self->render( + openapi => { + operation => "add_stamp", + stamp_id => $created_id, + success => 0 + } + ); + } + } + ); +} + +sub update_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $stamp_id = $self->req->param('stamp_id'); + my $position = $self->req->param('position') || undef; + my $content = $self->req->param('content') || undef; + + return unless exec_with_lock( + $self, + "stamp-write:$stamp_id", + "update_stamp", + $stamp_id, + sub { + my ( $result, $err ) = LANraragi::Model::Stamp::update_stamp( $id, $stamp_id, $content, $position ); + + if ($result) { + my %stamp = LANraragi::Model::Stamp::get_stamp( $id, $stamp_id ); + my $successMessage = "Updated stamp \"$stamp_id\"!"; + + render_api_response( $self, "update_stamp", undef, $successMessage ); + } else { + render_api_response( $self, "update_stamp", $err ); + } + } + ); + +} + +sub delete_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $stamp_id = $self->req->param('stamp_id'); + + return unless exec_with_lock( + $self, + "stamp-write:$stamp_id", + "delete_stamp", + $stamp_id, + sub { + my ( $result, $err ) = LANraragi::Model::Stamp::remove_stamp($id, $stamp_id); + + if ($result) { + render_api_response( $self, "delete_stamp" ); + } else { + render_api_response( $self, "delete_stamp", "The given stamp does not exist." ); + } + } + ); +} + +1; + diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm new file mode 100644 index 000000000..fd376acfd --- /dev/null +++ b/lib/LANraragi/Model/Stamp.pm @@ -0,0 +1,254 @@ +package LANraragi::Model::Stamp; + +use v5.36; +use experimental 'try'; + +use strict; +use warnings; +use utf8; + +use Redis; + +use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::Redis qw(redis_encode); + + +# get_stamp(id, stamp_id) +# Gets the requested stamp. +# Returns the stamp object. +sub get_stamp { + my ( $id, $stamp_id ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + if ( $redis->hexists($faves_id => $stamp_id) ) { + my $content = $redis->hget($faves_id => $stamp_id); + my %stamp = convert_stamp_to_object($stamp_id, $content); + + return ( \%stamp, $err ); + } + + return (); +} + +# get_stamps_by_page(id) +# Gets the list of pages that have at least one stamp. +# Returns an array of stamps objects. +# TODO Pagination +sub get_stamps_by_page { + my ( $id, $index ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + my $data = get_stamps_data($redis, $faves_id, $index); + my @stamps = convert_stamps_to_object(%$data); + + return ( \@stamps, $err ); +} + +# get_stamped_pages(id) +# Gets the list of pages that have at least one stamp. +# Returns an array of page numbers. +sub get_stamped_pages { + my ( $id ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + my $fields = $redis->hkeys($faves_id); + + my %indexes; + + foreach my $field (@$fields) { + # Extract the page number + my ($index) = split(/:/, $field, 2); + $indexes{$index} = 1; + } + + my @keys = keys %indexes; + + return ( \@keys, $err ); +} + +# add_stamp(id, key, content, position) +# Add the stamp to the page. +# Returns the stamp key. +sub add_stamp { + my ( $id, $index, $content, $position ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $faves_id = "FAVES_" . $id; + my $err = ""; + + unless ( $redis->exists($id) ) { + $err = "$id does not exist in the database."; + $logger->error($err); + $redis->quit; + return ( 0, $err ); + } + + $content = remove_separator($content, "|"); + $position = remove_separator($position, "|"); + + # page:timestamp + # Not sure if this is the right way to make a timestamp in milliseconds, is the only thing that came to my mind + my $key = $index . ":" . int(time() * 1000); + + $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); + + $redis->quit; + + return ( $key, $err ); +} + +# update_stamp(id, key, content, position) +# Removes the stamp from the page. +# Returns 1 on success, 0 on failure alongside an error message. +sub update_stamp { + my ( $id, $key, $content, $position ) = @_; + + my $logger = get_logger( "Stamps", "lanraragi" ); + my $redis = LANraragi::Model::Config->get_redis; + my $err = ""; + my $faves_id = "FAVES_" . $id; + + my $current = $redis->hget($faves_id => $key); + my @c_content = split(/\|/, $current); + + if ( defined $position ) { + $position = remove_separator($position, "|"); + } else { + $position = $c_content[0] + } + + if ( defined $content ) { + $content = remove_separator($content, "|"); + } else { + $content = $c_content[1] + } + + if ( $redis->exists($faves_id) ) { + $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); + $redis->quit; + return ( 1, $err ); + } + + $err = "$faves_id doesn't exist in the database!"; + $logger->warn($err); + $redis->quit; + return ( 0, $err ); +} + +# remove_stamp(id, key) +# Removes the stamp from the page. +# Returns 1 on success, 0 on failure alongside an error message. +sub remove_stamp { + my ( $id, $key ) = @_; + + my $logger = get_logger( "Stamps", "lanraragi" ); + my $redis = LANraragi::Model::Config->get_redis; + my $err = ""; + my $faves_id = "FAVES_" . $id; + + if ( $redis->exists($faves_id) ) { + $redis->hdel($faves_id, $key); + $redis->quit; + return ( 1, $err ); + } + + $err = "$faves_id doesn't exist in the database!"; + $logger->warn($err); + $redis->quit; + return ( 0, $err ); +} + +# Replaces | for " " in the given string +sub remove_separator { + my ($string, $char) = @_; + + # Escape special regex characters in $char + my $escaped_char = quotemeta($char); + + # Replace all occurrences with a space + $string =~ s/$escaped_char/ /g; + + return $string; +} + +# Extracts the stamps related to a page using HSCAN +sub get_stamps_data { + my ($redis, $faves_id, $index) = @_; + + my $cursor = 0; + my %result; + my $pattern = "$index:*"; + my $logger = get_logger( "Stamps", "lanraragi" ); + + # Use a Do While until the cursor goes back to 0 + do { + my ($next_cursor, $data) = $redis->hscan($faves_id, $cursor, 'MATCH', $pattern); + + # Append data to the dictionary + for (my $i = 0; $i < @$data; $i += 2) { + my $field = $data->[$i]; + my $value = $data->[$i + 1]; + + $result{$field} = $value; + } + + $cursor = $next_cursor; + + } while ($cursor != 0); + + return \%result; +} + +# Gets the number of stamps in the page +sub size_stamps_by_page { + my ($redis, $faves_id, $index) = @_; + + my $data = get_stamps_data($redis, $faves_id, $index); + + return scalar keys %$data; +} + +# Converts a stamp register to object +sub convert_stamp_to_object { + my ( $stamp_id, $content ) = @_; + + # Separate the string and classify the fields + my @x = split(/\|/, $content); + my %stamp = ( + id => $stamp_id, + position => $x[0], + content => $x[1], + ); + + return %stamp; +} + +# Converts an array of stamp registers to an array ob objects +sub convert_stamps_to_object { + my (%stamps_raw) = @_; + + my @stamps; + + # Convert stamp registers to objects + foreach my $i (keys %stamps_raw) { + my %stamp = convert_stamp_to_object($i, $stamps_raw{$i}); + push @stamps, \%stamp; + } + + return @stamps; +} + +1; \ No newline at end of file diff --git a/public/css/lrr.css b/public/css/lrr.css index 8cfcdfa39..d92e7296c 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -304,6 +304,34 @@ p#nb { user-select: none; align-self: center; cursor: pointer; + z-index: 22; +} + +.marker { + position: absolute; + width: 24px; + height: 24px; + + background-image: url('../favicon.ico'); /* your icon path */ + background-size: contain; + background-repeat: no-repeat; + background-position: center; + + transform: translate(-50%, -50%); + pointer-events: auto; + z-index: 23; + cursor: pointer; +} + +.focus-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + z-index: 21; + display: none; } .caption-reader { diff --git a/public/js/reader.js b/public/js/reader.js index a3d58807f..4ae5d5621 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -26,6 +26,11 @@ Reader.scrollConfig = { Reader.autoNextPage = false; Reader.autoNextPageCountdownTaskId = undefined; Reader.autoNextPageCountdown = 0; +Reader.markerMode = false; +Reader.markersVisible = false; +Reader.markers = []; +Reader.overlayFiltered = false; +Reader.pageNaviState = true; Reader.initializeAll = function () { Reader.initializeSettings(); @@ -61,6 +66,7 @@ Reader.initializeAll = function () { $(document).on("click.toggle-archive-overlay", "#toggle-archive-overlay", Reader.toggleArchiveOverlay); $(document).on("click.toggle-settings-overlay", "#toggle-settings-overlay", Reader.toggleSettingsOverlay); $(document).on("click.toggle-help", "#toggle-help", Reader.toggleHelp); + $(document).on("click.toggle-stamps", "#toggle-stamps", Reader.toggleStamps); $(document).on("click.toggle-bookmark", ".toggle-bookmark", Reader.toggleBookmark); $(document).on("click.regenerate-archive-cache", "#regenerate-cache", () => { window.location.href = new LRR.apiURL(`/reader?id=${Reader.id}&force_reload`); @@ -133,6 +139,80 @@ Reader.initializeAll = function () { Reader.goToPage(pageNumber); }); + $(document).on("click.reader-image", ".reader-image", (e) => { + if (!Reader.markerMode) return; + + // Compute marker position + // This basically estimates the percentage of the width and legth of the image + // where the user clicked, so later from this percentage can be reversed + // without being affected by if the image got scaled up or down + const img = e.currentTarget; + + const rect = img.getBoundingClientRect(); + + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const xPercent = (clickX / rect.width) * 100; + const yPercent = (clickY / rect.height) * 100; + + const markerData = { + x: xPercent, + y: yPercent, + name: `Marker`, + left: true, + }; + + let page = Reader.currentPage; + + if (Reader.doublePageMode && Reader.currentPage > 0 + && Reader.currentPage < Reader.maxPage) { + if (img.id == "img_doublepage") { + page += 1; + markerData.left = false; + } + } + LRR.showPopUp({ + title: I18N.StampName, + input: "text", + inputPlaceholder: I18N.StampPlaceholder, + inputAttributes: { + autocapitalize: "off", + }, + showCancelButton: true, + reverseButtons: true, + }).then((result) => { + $("#overlay-page").hide(); + Reader.markerMode = false; + Reader.toggleArchiveOverlay(); + if (result.isConfirmed && result.value.trim() !== "") { + Server.callAPI(`/api/stamps/${Reader.id}/${page}?position=${markerData.x},${markerData.y}&content=${result.value}`, "PUT", "Stamp added!", I18N.StampError, + (data) => { + markerData.id = data["stamp_id"]; + markerData.name = result.value; + + Reader.markers.push(markerData); + Reader.renderMarkers(); + } + ); + } + }); + e.stopPropagation(); + }); + + // Press esc to cancel set stamp action + $(document).on("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Escape" && Reader.markerMode) { + $("#overlay-page").hide(); + Reader.markerMode = false; + Reader.toggleArchiveOverlay(); + Reader.pageNaviState = true; + } + }); + $(document).on("click.set-stamp", "#set-stamp", Reader.addStamp); + $(document).on("click.filter-stamped", "#filter-stamped", Reader.filterStampedOverlay); + // Apply full-screen utility // F11 Fullscreen is totally another "Fullscreen", so its support is beyong consideration. @@ -375,7 +455,7 @@ Reader.loadImages = function () { // when click left or right img area change page $(document).on("click", (event) => { // check click Y position is in img Y area - if ($(event.target).closest("#i3").length && !$("#overlay-shade").is(":visible")) { + if ($(event.target).closest("#i3").length && !$("#overlay-shade").is(":visible") && Reader.pageNaviState) { // is click X position is left on screen or right if (event.pageX < $(window).width() / 2) { Reader.changePage(-1, true); @@ -711,6 +791,222 @@ Reader.toggleHelp = function () { // all toggable panes need to return false to avoid scrolling to top }; +Reader.addStamp = function () { + Reader.markerMode = true; + LRR.closeOverlay(); + $("#overlay-page").show(); +}; + +Reader.createMarkerElement = function (markerData, index) { + if (markerData.left) { + const img = document.getElementById("img"); + } else { + const img = document.getElementById("img_doublepage"); + } + + const display = document.getElementById("display"); + const container = document.getElementById("i1"); + + const marker = document.createElement("div"); + marker.className = "marker"; + + // Compute the px coordinates from the percentage based coordinates + const rect = img.getBoundingClientRect(); + const xPx = (markerData.x / 100) * rect.width; + const yPx = (markerData.y / 100) * rect.height; + + const displayRect = display.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + let leftFix = rect.left - containerRect.left; + let topFix = rect.top - containerRect.top; + + if (!markerData.left) { + // Add the width of the left page plus the left and right margin + const img = document.getElementById("img"); + leftFix += img.width+2; + } + + marker.style.left = `${rect.left + xPx - displayRect.left + leftFix}px`; + marker.style.top = `${rect.top + yPx - displayRect.top + topFix}px`; + + marker.title = markerData.name; + marker.dataset.index = index; + + // Edit + let isDragging = false; + + marker.addEventListener("mousedown", (e) => { + e.stopPropagation(); + isDragging = true; + + // So no text gets selected during the D&D + document.body.style.userSelect = "none"; + Reader.pageNaviState = false; + }); + + document.addEventListener("mousemove", (e) => { + if (!isDragging) return; + + const imgRect = img.getBoundingClientRect(); + const dispRect = display.getBoundingClientRect(); + + // Ensure that the stamp remains inside the image + let x = e.clientX - imgRect.left + leftFix; + let y = e.clientY - imgRect.top + topFix; + + x = Math.max(leftFix, Math.min(x, imgRect.width + leftFix)); + y = Math.max(topFix, Math.min(y, imgRect.height + topFix)); + + marker.style.left = `${imgRect.left + x - dispRect.left}px`; + marker.style.top = `${imgRect.top + y - dispRect.top}px`; + }); + + document.addEventListener("mouseup", (e) => { + e.stopPropagation(); + // Each marker individually run this event when on mouseup + // this line ensures that only one of them execute the action + // also a good improvement would be to change this to an attachable event only for the dragged marker + if (!isDragging) return; + + isDragging = false; + document.body.style.userSelect = "auto"; + + const imgRect = img.getBoundingClientRect(); + + let x = e.clientX - imgRect.left; + let y = e.clientY - imgRect.top; + + x = Math.max(0, Math.min(x, imgRect.width)); + y = Math.max(0, Math.min(y, imgRect.height)); + + const xPercent = (x / imgRect.width) * 100; + const yPercent = (y / imgRect.height) * 100; + + const i = marker.dataset.index; + let inputValue = markerData.name; + + LRR.showPopUp({ + title: I18N.StampName, + input: "text", + inputPlaceholder: I18N.StampPlaceholder, + inputAttributes: { + autocapitalize: "off", + }, + inputValue, + showCancelButton: true, + reverseButtons: true, + }).then((result) => { + if (result.isConfirmed && result.value.trim() !== "") { + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}&content=${result.value}&position=${xPercent},${yPercent}`, "PUT", "Stamp updated!", I18N.StampError, + () => { + Reader.markers[i].x = xPercent; + Reader.markers[i].y = yPercent; + Reader.markers[i].name = result.value; + + Reader.pageNaviState = true; + Reader.renderMarkers(); + } + ); + } else { + Reader.pageNaviState = true; + Reader.renderMarkers(); + } + }); + }); + + + // Delete + marker.addEventListener("contextmenu", (e) => { + e.preventDefault(); + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}`, "DELETE", "Stamp deleted!", I18N.StampError, + () => { + const i = marker.dataset.index; + + Reader.markers.splice(i, 1); + Reader.renderMarkers(); + } + ); + }); + + display.appendChild(marker); +} + +Reader.renderMarkers = function () { + // Clean markers + const existing = document.querySelectorAll(".marker"); + existing.forEach(el => el.remove()); + + if (!Reader.markersVisible) return; + + // Draw markers + Reader.markers.forEach((markerData, index) => { + Reader.createMarkerElement(markerData, index); + }); +} + +Reader.toggleStamps = function () { + // Show or hide the markers + Reader.markersVisible = !Reader.markersVisible; + if (Reader.markersVisible) { + $("#toggle-stamps").removeClass('fa-eye-slash').addClass('fa-eye'); + } else { + $("#toggle-stamps").removeClass('fa-eye').addClass('fa-eye-slash'); + } + Reader.renderMarkers(); +} + +Reader.loadStamps = function (currentPage) { + Reader.markers = []; + // Call for the first page + Server.callAPI(`/api/stamps/${Reader.id}/${currentPage}`, "GET", null, I18N.ServerInfoError, + (data) => { + let markerData = {}; + + for (var i = data.result.length - 1; i >= 0; i--) { + markerData = {}; + let x = data.result[i].position.split(",")[0]; + let y = data.result[i].position.split(",")[1]; + markerData.x = x; + markerData.y = y; + markerData.name = data.result[i].content + markerData.id = data.result[i].id + markerData.left = true; + Reader.markers.push(markerData); + } + + if (Reader.doublePageMode && Reader.currentPage > 0 + && Reader.currentPage < Reader.maxPage) { + + // Call for the second page + Server.callAPI(`/api/stamps/${Reader.id}/${currentPage+1}`, "GET", null, I18N.ServerInfoError, + (data) => { + let markerData = {}; + + for (var i = data.result.length - 1; i >= 0; i--) { + markerData = {}; + let x = data.result[i].position.split(",")[0]; + let y = data.result[i].position.split(",")[1]; + markerData.x = x; + markerData.y = y; + markerData.name = data.result[i].content + markerData.id = data.result[i].id + markerData.left = false; + Reader.markers.push(markerData); + } + + // Render markers + Reader.renderMarkers(); + } + ); + } else { + // Render markers + Reader.renderMarkers(); + } + } + ); +} + Reader.toggleBookmark = function (e) { e.preventDefault(); if (!localStorage.getItem("bookmarkCategoryId")) { @@ -897,6 +1193,10 @@ Reader.goToPage = async function (page) { }; Reader.updateProgress = function () { + // Clear markers + Reader.markers = []; + Reader.renderMarkers(); + // Send an API request to update progress on the server if (Reader.authenticateProgress && LRR.isUserLogged()) { Server.updateServerSideProgress(Reader.id, Reader.currentPage + 1); @@ -905,6 +1205,11 @@ Reader.updateProgress = function () { } else if (!Reader.authenticateProgress) { Server.updateServerSideProgress(Reader.id, Reader.currentPage + 1); } + + // Load stamps + if (!Reader.infiniteScroll) { + const stamps = Reader.loadStamps(Reader.currentPage); + } }; Reader.preloadImages = function () { @@ -1238,6 +1543,47 @@ Reader.updateArchiveOverlay = function (forceUpdate = false) { $("#archivePagesOverlay").attr("loaded", "true"); }; +Reader.filterStampedOverlay = function () { + if (Reader.overlayFiltered) { + Reader.overlayFiltered = false; + Reader.updateArchiveOverlay(true); + } else { + Server.callAPI(`/api/stamps/pages/${Reader.id}`, "GET", null, I18N.ServerInfoError, + (data) => { + $("#extract-spinner").hide(); + let pages = data.result.sort(); + + // For each link in the pages array, craft a div and jam it in the overlay. + let htmlBlob = ""; + for (let page = 0; page < pages.length; page++) { + const index = parseInt(pages[page]); + + const thumbCss = (localStorage.cropthumbs === "true") ? "id3" : "id3 nocrop"; + const thumbnailUrl = new LRR.apiURL(`/api/archives/${Reader.id}/thumbnail?page=${index+1}`); + + let thumbnail = ` +
+ ${I18N.ReaderPage(index+1)} + `; + + if (Reader.pageThumbnails.includes(index)) thumbnail += + `
`; + else thumbnail += + ` + `; + + htmlBlob += thumbnail; + } + + // NOTE: This can be slow on huge archives and on slower devices, due to the huge DOM change. + $("#pages-section").html(htmlBlob); + $("#archivePagesOverlay").attr("loaded", "true"); + Reader.overlayFiltered = true; + } + ); + } +} + Reader.generateThumbnails = function () { // Queue a single minion job for thumbnails and check on its progress regularly @@ -1346,3 +1692,8 @@ Reader.handlePaginator = function () { Reader.getFilename = function(index) { return new URLSearchParams(Reader.pages[index].split("?")[1]).get("path"); } + +window.addEventListener("resize", () => { + // Reload the markers everytime the image size changes + Reader.renderMarkers(); +}); diff --git a/templates/i18n.html.tt2 b/templates/i18n.html.tt2 index f7e8f2421..aa11bbe06 100644 --- a/templates/i18n.html.tt2 +++ b/templates/i18n.html.tt2 @@ -140,6 +140,9 @@ I18N.ReaderTocAdded = "[% c.lh("Chapter added!") %]"; I18N.ReaderTocError = "[% c.lh("Error adding/removing chapter:") %]"; I18N.ReaderClearRating = "[% c.lh("Clear Rating") %]"; I18N.UntitledChapter = "[% c.lh("Untitled Chapter") %]"; +I18N.StampName = "[% c.lh("Enter Stamp name:") %]" +I18N.StampPlaceholder = "[% c.lh("Stamp name") %]" +I18N.StampError = "[% c.lh("Error setting up the stamp") %]" I18N.ScriptRunning = "[% c.lh("A script is already running.") %]"; I18N.ScriptRunningDesc = "[% c.lh("Please wait for it to finish before starting a new one.") %]"; diff --git a/templates/reader.html.tt2 b/templates/reader.html.tt2 index a24728d4b..a28ee37ad 100644 --- a/templates/reader.html.tt2 +++ b/templates/reader.html.tt2 @@ -58,6 +58,8 @@

+
+ @@ -112,7 +114,9 @@

[% c.lh("Admin Options") %]

- +
+ +

[% c.lh("Categories") %]

@@ -301,6 +305,7 @@
+
@@ -315,4 +320,4 @@
-[% END %] +[% END %] \ No newline at end of file diff --git a/tests/mocks.pl b/tests/mocks.pl index d5474a8a7..1b6bd054b 100644 --- a/tests/mocks.pl +++ b/tests/mocks.pl @@ -130,7 +130,14 @@ sub setup_redis_mock { "TANK_1589138380":[ "name_World", "28697b96f0ac5777be2614ed10ca47742c9522fa" - ] + ], + "FAVES_be447b58ea66137c415ee306ee2ac44b308ee484": { + "0:1589138380": "0,0|Lorem", + "0:1589138381": "0,0|Ipsum", + "1:1589138380": "0,0|Dolor", + "2:1589138380": "0,0|Sit", + "5:1589138380": "0,0|Amet" + } }) }; @@ -146,7 +153,8 @@ sub setup_redis_mock { # Replace redis' '*' wildcards with regex '.*'s $expr = $expr =~ s/\*/\.\*/gr; - return grep { /$expr/ } keys %datamodel; + + return grep { /^$expr$/ } keys %datamodel; } ); $redis->mock( 'exists', sub { shift; return $_[0] eq "LRR_SEARCHCACHE" ? 0 : 1 } ); @@ -413,6 +421,72 @@ sub setup_redis_mock { } ); + $redis->mock( + 'hscan', # $redis->hscan => get all values that match pattern in datamodel + sub { + # Default values + my ($self, $key, $cursor, @args) = @_; + + my $match; + my $count; + + # Parse optional args (MATCH, COUNT) + while (@args) { + my $arg = shift @args; + if ($arg eq 'MATCH') { + $match = shift @args; + } + elsif ($arg eq 'COUNT') { + $count = shift @args; + } + } + + # Return empty result if key doesn't exist + return ('0', []) unless exists $datamodel{$key}; + + my $hash = $datamodel{$key}; + + # Get all keys + my @keys = keys %$hash; + + # Apply MATCH if provided (convert glob → regex) + if (defined $match) { + my $regex = quotemeta($match); + $regex =~ s/\\\*/.*/g; # * → .* + $regex =~ s/\\\?/.?/g; # ? → .? + @keys = grep { $_ =~ /^$regex$/ } @keys; + } + + # Apply COUNT (just truncate for simplicity) + if (defined $count && $count < @keys) { + @keys = @keys[0 .. $count - 1]; + } + + # Build field-value list + my @result; + for my $k (@keys) { + push @result, $k, $hash->{$k}; + } + + # Always return cursor 0 (no real iteration in mock) + return ('0', \@result); + } + ); + + $redis->mock( + 'hkeys', # $redis->hscan => get keys in hash table in datamodel + sub { + my $self = shift; + my $key = shift; + + unless (exists $datamodel{$key}) { + return [] ; + } + return [ keys %{ $datamodel{$key} } ]; + + } + ); + $redis->fake_module( "Redis", new => sub { $redis } ); } diff --git a/tests/stamp.t b/tests/stamp.t new file mode 100644 index 000000000..0bca5668f --- /dev/null +++ b/tests/stamp.t @@ -0,0 +1,43 @@ +use strict; +use warnings; +use utf8; +use Cwd; + +use Mojo::Base 'Mojolicious'; + +use Test::More; +use Test::Mojo; +use Test::MockObject; +use Mojo::JSON qw (decode_json); +use Data::Dumper; + +use LANraragi::Model::Stamp; +use LANraragi::Model::Config; +use LANraragi::Model::Stats; + +# Mock Redis +my $cwd = getcwd; +require $cwd . "/tests/mocks.pl"; +setup_redis_mock(); + +my $redis = LANraragi::Model::Config->get_redis; + +# Build search hashes +LANraragi::Model::Stats::build_stat_hashes(); + +# Get stamped pages +my ( $indexes, $err ) = LANraragi::Model::Stamp::get_stamped_pages( "be447b58ea66137c415ee306ee2ac44b308ee484" ); +is ( scalar @$indexes, 4, "Page test" ); + +my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page("be447b58ea66137c415ee306ee2ac44b308ee484", 0); +is ( scalar @$stamps, 2, "Stamps by page length test" ); + +my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page("be447b58ea66137c415ee306ee2ac44b308ee484", 1); +is ( $stamps->[0]{"id"}, "1:1589138380", "Stamps by page value test" ); + +my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp("be447b58ea66137c415ee306ee2ac44b308ee484", "0:1589138380"); +is ( %$stamp{"id"}, "0:1589138380", "Get stamp id test" ); +is ( %$stamp{"content"}, "Lorem", "Get stamp content test" ); +is ( %$stamp{"position"}, "0,0", "Get stamp position test" ); + +done_testing(); diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 37e710592..7b256698a 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -23,6 +23,8 @@ tags: description: Endpoints related to OPDS catalog generation and serving. - name: misc description: Other APIs that don't fit a dedicated theme. + - name: stamps + description: Stamps. paths: /opds: @@ -3367,6 +3369,285 @@ paths: application/json: schema: $ref: '#/components/schemas/OperationResponse' + /stamps/{id}: + get: + operationId: getStamp + x-mojo-to: api-stamp#get_stamp + summary: Get Stamp + description: Get a stamp from an Archive. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the archive. + schema: + type: string + - name: stamp_id + in: query + required: True + description: ID of the stamp + schema: + type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + properties: + result: + type: object + properties: + id: + type: string + description: ID of the stamp + content: + type: string + description: Text of the stamp + position: + type: string + description: Position of the stamp in the page + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: The Stamp is currently locked for modification + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + put: + security: + - api_key: [] + operationId: updateStamp + x-mojo-to: api-stamp#update_stamp + summary: 🔑 Update Stamp + description: Update a stamp from an Archive. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the archive. + schema: + type: string + - name: stamp_id + in: query + required: True + description: ID of the stamp + schema: + type: string + - name: content + in: query + required: false + description: Text of the stamp. + schema: + type: string + - name: position + in: query + required: false + description: Position of the stamp in the page. + schema: + type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: The Stamp is currently locked for modification + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + delete: + security: + - api_key: [] + operationId: deleteStamp + x-mojo-to: api-stamp#delete_stamp + summary: 🔑 Delete Stamp + description: Remove a stamp from an Archive. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the archive. + schema: + type: string + - name: stamp_id + in: query + required: True + description: ID of the stamp + schema: + type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: The Stamp is currently locked for modification + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + /stamps/{id}/{index}: + get: + operationId: stampsByPage + x-mojo-to: api-stamp#get_stamps_by_page + summary: 🔑 Get the stamps linked to the page + description: Get the stamps linked to the page. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the archive. + schema: + type: string + - name: index + in: path + required: true + description: Page of the archive. + schema: + type: integer + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + $ref: '#/components/schemas/StampsData' + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + put: + security: + - api_key: [] + operationId: addStamp + x-mojo-to: api-stamp#add_stamp + summary: 🔑 Add a stamp annotation + description: Add a new annotation to the page as a page sticky. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the archive. + schema: + type: string + - name: index + in: path + required: true + description: Page of the archive. + schema: + type: integer + - name: content + in: query + required: false + description: Text of the stamp. + schema: + type: string + - name: position + in: query + required: false + description: Position of the stamp in the page. + schema: + type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - add_stamp + stamp_id: + type: string + description: Stamp ID + success: + type: integer + enum: + - 0 + - 1 + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + /stamps/pages/{id}: + get: + operationId: stampedPages + x-mojo-to: api-stamp#get_stamped_pages + summary: 🔑 Get pages that contain at least one stamp in the archive + description: Get pages that contain at least one stamp in the archive. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the archive. + schema: + type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + type: string + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' components: securitySchemes: api_key: @@ -3783,5 +4064,24 @@ components: search: type: string description: Category search filter + StampsData: + type: object + description: JSON object for the Stamp model + required: + - position + - content + properties: + id: + type: string + description: ID of the stamp + position: + type: string + description: Position of the stamp in the page + content: + type: string + description: Text of the stamp + example: + position: 12,34 + content: Lorem ipsum dolore servers: - url: https://lrr.tvc-16.science/api