From d9be981a2f3c353d6e5b284a35bce2afd93ef8ba Mon Sep 17 00:00:00 2001 From: Efrain Date: Thu, 26 Mar 2026 18:30:49 -0600 Subject: [PATCH 01/17] Add Stamps backend support - WIP --- lib/LANraragi/Controller/Api/Stamp.pm | 145 ++++++++++++ lib/LANraragi/Model/Stamp.pm | 239 ++++++++++++++++++++ public/js/reader.js | 24 ++ templates/reader.html.tt2 | 3 + tools/openapi.yaml | 306 ++++++++++++++++++++++++++ 5 files changed, 717 insertions(+) create mode 100644 lib/LANraragi/Controller/Api/Stamp.pm create mode 100644 lib/LANraragi/Model/Stamp.pm diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm new file mode 100644 index 000000000..49b61f3dd --- /dev/null +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -0,0 +1,145 @@ +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); + + unless (@$stamps) { + render_api_response($self, "get_stamps_by_page", "The given page does not have stamps."); + return; + } + + $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 ); + + unless (@indexes) { + render_api_response( $self, "get_stamped_pages", "The given archive does not have stamps." ); + return; + } + + $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." ); + } + + 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'); + my $content = $self->req->param('content') || ""; + + 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..3301df85e --- /dev/null +++ b/lib/LANraragi/Model/Stamp.pm @@ -0,0 +1,239 @@ +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 ); # return reference (recommended) +} + +# 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) { + # Split on first colon + my ($index) = split(/:/, $field, 2); + $indexes{$index} = 1; + } + + return ( [ keys %indexes ], $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, "|"); + + # Doing this with integers because decimals are a pain in Redis + my $key = $index . ":" . time(); + + $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; + + $content = remove_separator($content, "|"); + $position = remove_separator($position, "|"); + + 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/js/reader.js b/public/js/reader.js index 81deee5e1..b027ff5b9 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -61,6 +61,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.add-stamp", "#add-stamp", Reader.addStamp); $(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`); @@ -711,6 +712,29 @@ Reader.toggleHelp = function () { // all toggable panes need to return false to avoid scrolling to top }; +Reader.addStamp = function () { + let currentTitle = "wejfnowf"; + let page = Reader.currentPage; + LRR.showPopUp({ + title: I18N.ReaderTocPrompt, + input: "text", + inputPlaceholder: currentTitle || I18N.UntitledChapter, + inputAttributes: { + autocapitalize: "off", + }, + showCancelButton: true, + reverseButtons: true, + }).then((result) => { + if (result.isConfirmed && result.value.trim() !== "") { + Server.callAPI(`/api/stamps/${Reader.id}?page=${page}&content=${result.value}`, "PUT", "Stamp added!", I18N.ReaderTocError, + () => Reader.loadContentData().then(() => { + console.log("Success"); + }) + ); + } + }); +}; + Reader.toggleBookmark = function (e) { e.preventDefault(); if (!localStorage.getItem("bookmarkCategoryId")) { diff --git a/templates/reader.html.tt2 b/templates/reader.html.tt2 index a24728d4b..4abd10ffb 100644 --- a/templates/reader.html.tt2 +++ b/templates/reader.html.tt2 @@ -301,6 +301,9 @@
+ [% IF userlogged %] + + [% END %]
diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 37e710592..d6026b470 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,291 @@ paths: application/json: schema: $ref: '#/components/schemas/OperationResponse' + /stamps/{id}: + get: + security: + - api_key: [] + 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: + security: + - api_key: [] + 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: + security: + - api_key: [] + 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 +4070,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 From 10d10cf6d2431ce961a8ccc645ec72eeaeff4f8f Mon Sep 17 00:00:00 2001 From: Efrain Date: Sat, 28 Mar 2026 18:26:43 -0600 Subject: [PATCH 02/17] Add UI for stamps --- lib/LANraragi/Controller/Api/Stamp.pm | 9 +- lib/LANraragi/Model/Stamp.pm | 18 +- public/css/lrr.css | 24 +++ public/js/reader.js | 257 +++++++++++++++++++++++--- templates/reader.html.tt2 | 10 +- 5 files changed, 283 insertions(+), 35 deletions(-) diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index 49b61f3dd..b79ac7487 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -32,11 +32,6 @@ sub get_stamps_by_page { my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page($id, $index); - unless (@$stamps) { - render_api_response($self, "get_stamps_by_page", "The given page does not have stamps."); - return; - } - $self->render( openapi => { result => $stamps } ); } @@ -94,8 +89,8 @@ 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'); - my $content = $self->req->param('content') || ""; + my $position = $self->req->param('position') || undef; + my $content = $self->req->param('content') || undef; return unless exec_with_lock( $self, diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 3301df85e..36027a18c 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -49,7 +49,7 @@ sub get_stamps_by_page { my $data = get_stamps_data($redis, $faves_id, $index); my @stamps = convert_stamps_to_object(%$data); - return ( \@stamps, $err ); # return reference (recommended) + return ( \@stamps, $err ); } # get_stamped_pages(id) @@ -118,8 +118,20 @@ sub update_stamp { my $err = ""; my $faves_id = "FAVES_" . $id; - $content = remove_separator($content, "|"); - $position = remove_separator($position, "|"); + 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}") ); diff --git a/public/css/lrr.css b/public/css/lrr.css index 666dba6bc..3a1dd30f3 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -304,6 +304,30 @@ p#nb { user-select: none; align-self: center; cursor: pointer; + z-index: 22; +} + +.marker { + position: absolute; + width: 10px; + height: 10px; + background-color: blue; + border-radius: 50%; + 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 b027ff5b9..1a40e54d3 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -26,6 +26,10 @@ Reader.scrollConfig = { Reader.autoNextPage = false; Reader.autoNextPageCountdownTaskId = undefined; Reader.autoNextPageCountdown = 0; +Reader.markerMode = false; +Reader.markersVisible = false; +Reader.markers = []; +Reader.overlayFiltered = false; Reader.initializeAll = function () { Reader.initializeSettings(); @@ -61,7 +65,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.add-stamp", "#add-stamp", Reader.addStamp); + $(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`); @@ -134,6 +138,68 @@ 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 = document.getElementById("img"); + + 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` + }; + + let page = Reader.currentPage; + let defaultText = "Default Mark"; + LRR.showPopUp({ + title: I18N.ReaderTocPrompt, + input: "text", + inputPlaceholder: defaultText || I18N.UntitledChapter, + 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.ReaderTocError, + () => Reader.loadContentData().then(() => { + + Reader.markers.push(markerData); + Reader.renderMarkers(); + }) + ); + } + }); + e.stopPropagation(); + }); + + // Press esc to cancel set stamp operation + $(document).on("keydown", (e) => { + if (e.key === "Escape" && Reader.markerMode) { + Reader.markerMode = false; + $("#overlay-page").hide(); + } + e.stopPropagation(); + }); + $(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. @@ -713,28 +779,130 @@ Reader.toggleHelp = function () { }; Reader.addStamp = function () { - let currentTitle = "wejfnowf"; - let page = Reader.currentPage; - LRR.showPopUp({ - title: I18N.ReaderTocPrompt, - input: "text", - inputPlaceholder: currentTitle || I18N.UntitledChapter, - inputAttributes: { - autocapitalize: "off", - }, - showCancelButton: true, - reverseButtons: true, - }).then((result) => { - if (result.isConfirmed && result.value.trim() !== "") { - Server.callAPI(`/api/stamps/${Reader.id}?page=${page}&content=${result.value}`, "PUT", "Stamp added!", I18N.ReaderTocError, - () => Reader.loadContentData().then(() => { - console.log("Success"); - }) - ); - } - }); + Reader.markerMode = true; + LRR.closeOverlay(); + $("#overlay-page").show(); }; +Reader.createMarkerElement = function (markerData, index) { + const img = document.getElementById("img"); + 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(); + + const leftFix = rect.left - containerRect.left; + const topFix = rect.top - containerRect.top; + + 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; + + // Rename + marker.addEventListener("click", (e) => { + e.stopPropagation(); + + LRR.showPopUp({ + title: I18N.ReaderTocPrompt, + input: "text", + inputPlaceholder: markerData.name || I18N.UntitledChapter, + inputAttributes: { + autocapitalize: "off", + }, + 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}`, "PUT", "Stamp updated!", I18N.ReaderTocError, + () => { + const i = marker.dataset.index; + const newName = result.value; + + if (newName !== null && newName.trim() !== "") { + Reader.markers[i].name = newName.trim(); + Reader.renderMarkers(); + } + } + ); + } + }); + }); + + // Delete + marker.addEventListener("contextmenu", (e) => { + e.preventDefault(); + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}`, "DELETE", "Stamp deleted!", I18N.ReaderTocError, + () => { + const i = marker.dataset.index; + + Reader.markers.splice(i, 1); + console.log(Reader.markers); + 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 = []; + 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 + Reader.markers.push(markerData); + } + + // Render markers + Reader.renderMarkers(); + } + ); +} + Reader.toggleBookmark = function (e) { e.preventDefault(); if (!localStorage.getItem("bookmarkCategoryId")) { @@ -898,6 +1066,7 @@ Reader.goToPage = async function (page) { $("#img").attr("src", img); $("#img").attr("data-filename", imgFilename); Reader.showingSinglePage = true; + const stamps = await Reader.loadStamps(Reader.currentPage); } Reader.preloadImages(); @@ -1262,6 +1431,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[0].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 = pages[page]; + + const thumbCss = (localStorage.cropthumbs === "true") ? "id3" : "id3 nocrop"; + const thumbnailUrl = new LRR.apiURL(`/api/archives/${Reader.id}/thumbnail?page=${parseInt(pages[page])+1}`); + + let thumbnail = ` +
+ ${I18N.ReaderPage(parseInt(pages[page])+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 @@ -1370,3 +1580,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/reader.html.tt2 b/templates/reader.html.tt2 index 4abd10ffb..58f09499b 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,9 +305,7 @@
- [% IF userlogged %] - - [% END %] +
From 9ae8eca082856140ac089c507ee86180f0a8c6e8 Mon Sep 17 00:00:00 2001 From: Efrain Date: Sun, 29 Mar 2026 20:12:50 -0600 Subject: [PATCH 03/17] Add support for double pages --- lib/LANraragi/Controller/Api/Stamp.pm | 11 +- lib/LANraragi/Model/Stamp.pm | 8 +- public/js/reader.js | 190 ++++++++++++++++++++++---- templates/i18n.html.tt2 | 3 + templates/reader.html.tt2 | 4 +- 5 files changed, 177 insertions(+), 39 deletions(-) diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index b79ac7487..8bbb67a2c 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -40,14 +40,9 @@ 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 ); + my ( $indexes, $err ) = LANraragi::Model::Stamp::get_stamped_pages( $id ); - unless (@indexes) { - render_api_response( $self, "get_stamped_pages", "The given archive does not have stamps." ); - return; - } - - $self->render( openapi => { result => \@indexes } ); + $self->render( openapi => { result => $indexes } ); } sub add_stamp { @@ -102,7 +97,7 @@ sub update_stamp { if ($result) { my %stamp = LANraragi::Model::Stamp::get_stamp( $id, $stamp_id ); - my $successMessage = "Updated stamp \"$stamp{id}\"!"; + my $successMessage = "Updated stamp \"$stamp_id\"!"; render_api_response( $self, "update_stamp", undef, $successMessage ); } else { diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 36027a18c..383cd479f 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -68,12 +68,14 @@ sub get_stamped_pages { my %indexes; foreach my $field (@$fields) { - # Split on first colon + # Extract the page number my ($index) = split(/:/, $field, 2); $indexes{$index} = 1; } - return ( [ keys %indexes ], $err ); + my @keys = keys %indexes; + + return ( \@keys, $err ); } # add_stamp(id, key, content, position) @@ -97,7 +99,7 @@ sub add_stamp { $content = remove_separator($content, "|"); $position = remove_separator($position, "|"); - # Doing this with integers because decimals are a pain in Redis + # page:timestamp my $key = $index . ":" . time(); $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); diff --git a/public/js/reader.js b/public/js/reader.js index 1a40e54d3..e04050fe5 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -145,7 +145,7 @@ Reader.initializeAll = function () { // 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 = document.getElementById("img"); + const img = e.currentTarget; const rect = img.getBoundingClientRect(); @@ -158,15 +158,23 @@ Reader.initializeAll = function () { const markerData = { x: xPercent, y: yPercent, - name: `Marker` + name: `Marker`, + left: true, }; let page = Reader.currentPage; - let defaultText = "Default Mark"; + + if (Reader.doublePageMode && Reader.currentPage > 0 + && Reader.currentPage < Reader.maxPage) { + if (img.id == "img_doublepage") { + page += 1; + markerData.left = false; + } + } LRR.showPopUp({ - title: I18N.ReaderTocPrompt, + title: I18N.StampName, input: "text", - inputPlaceholder: defaultText || I18N.UntitledChapter, + inputPlaceholder: I18N.StampPlaceholder, inputAttributes: { autocapitalize: "off", }, @@ -177,25 +185,28 @@ Reader.initializeAll = function () { 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.ReaderTocError, - () => Reader.loadContentData().then(() => { + 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 operation + // Press esc to cancel set stamp action $(document).on("keydown", (e) => { + e.stopPropagation(); if (e.key === "Escape" && Reader.markerMode) { - Reader.markerMode = false; $("#overlay-page").hide(); + Reader.markerMode = false; + Reader.toggleArchiveOverlay(); } - e.stopPropagation(); }); $(document).on("click.set-stamp", "#set-stamp", Reader.addStamp); $(document).on("click.filter-stamped", "#filter-stamped", Reader.filterStampedOverlay); @@ -785,7 +796,12 @@ Reader.addStamp = function () { }; Reader.createMarkerElement = function (markerData, index) { - const img = document.getElementById("img"); + 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"); @@ -800,8 +816,14 @@ Reader.createMarkerElement = function (markerData, index) { const displayRect = display.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); - const leftFix = rect.left - containerRect.left; - const topFix = rect.top - containerRect.top; + 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`; @@ -813,18 +835,21 @@ Reader.createMarkerElement = function (markerData, index) { marker.addEventListener("click", (e) => { e.stopPropagation(); + let inputValue = markerData.name; + LRR.showPopUp({ - title: I18N.ReaderTocPrompt, + title: I18N.StampName, input: "text", - inputPlaceholder: markerData.name || I18N.UntitledChapter, + 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}`, "PUT", "Stamp updated!", I18N.ReaderTocError, + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}&content=${result.value}`, "PUT", "Stamp updated!", I18N.StampError, () => { const i = marker.dataset.index; const newName = result.value; @@ -839,15 +864,92 @@ Reader.createMarkerElement = function (markerData, index) { }); }); + // Leaving this code here in case someone wants to attempt to implement the drag and drop before the merge happens + // This logic should replace the previous click event ↑ + /* + 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"; + }); + + 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 ensures that only one of them execute the action + // also a good improvement to change this to an attachable event only for 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; + + LRR.showPopUp({ + title: I18N.ReaderTocPrompt, + input: "text", + inputPlaceholder: markerData.name || I18N.UntitledChapter, + inputAttributes: { + autocapitalize: "off", + }, + 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.ReaderTocError, + () => { + Reader.markers[i].x = xPercent; + Reader.markers[i].y = yPercent; + Reader.markers[i].name = result.value; + + Reader.renderMarkers(); + } + ); + } + }); + }); + */ + // Delete marker.addEventListener("contextmenu", (e) => { e.preventDefault(); - Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}`, "DELETE", "Stamp deleted!", I18N.ReaderTocError, + 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); - console.log(Reader.markers); Reader.renderMarkers(); } ); @@ -882,6 +984,7 @@ Reader.toggleStamps = function () { 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 = {}; @@ -894,11 +997,38 @@ Reader.loadStamps = function (currentPage) { markerData.y = y; markerData.name = data.result[i].content markerData.id = data.result[i].id + markerData.left = true; Reader.markers.push(markerData); } - // Render markers - Reader.renderMarkers(); + 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(); + } } ); } @@ -1066,7 +1196,6 @@ Reader.goToPage = async function (page) { $("#img").attr("src", img); $("#img").attr("data-filename", imgFilename); Reader.showingSinglePage = true; - const stamps = await Reader.loadStamps(Reader.currentPage); } Reader.preloadImages(); @@ -1090,6 +1219,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); @@ -1098,6 +1231,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 () { @@ -1439,19 +1577,19 @@ Reader.filterStampedOverlay = function () { Server.callAPI(`/api/stamps/pages/${Reader.id}`, "GET", null, I18N.ServerInfoError, (data) => { $("#extract-spinner").hide(); - let pages = data.result[0].sort(); + 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 = pages[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=${parseInt(pages[page])+1}`); + const thumbnailUrl = new LRR.apiURL(`/api/archives/${Reader.id}/thumbnail?page=${index+1}`); let thumbnail = `
- ${I18N.ReaderPage(parseInt(pages[page])+1)} + ${I18N.ReaderPage(index+1)} `; if (Reader.pageThumbnails.includes(index)) thumbnail += 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 58f09499b..353479669 100644 --- a/templates/reader.html.tt2 +++ b/templates/reader.html.tt2 @@ -115,8 +115,8 @@
- - + +

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

From dd05fafd8d75831c8143717c7fdf1ce51eb71e73 Mon Sep 17 00:00:00 2001 From: Efrain Date: Mon, 30 Mar 2026 20:52:36 -0600 Subject: [PATCH 04/17] Add Perl tests --- tests/mocks.pl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/mocks.pl b/tests/mocks.pl index d5474a8a7..9b7be5ec3 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" + } }) }; @@ -413,6 +420,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 } ); } From 575451f7e63fb2a66d4a9348ce55505319cc0004 Mon Sep 17 00:00:00 2001 From: Efrain Date: Mon, 30 Mar 2026 21:05:02 -0600 Subject: [PATCH 05/17] Missing test file | Add lock to create stamp | Timestamp now in milliseconds --- lib/LANraragi/Controller/Api/Stamp.pm | 45 ++++++++++++++++----------- lib/LANraragi/Model/Stamp.pm | 3 +- tests/stamp.t | 43 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 tests/stamp.t diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index 8bbb67a2c..121fbac0d 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -57,26 +57,33 @@ sub add_stamp { return render_api_response( $self, "add_stamp", "Archive page." ); } - 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 + 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 { diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 383cd479f..fd376acfd 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -100,7 +100,8 @@ sub add_stamp { $position = remove_separator($position, "|"); # page:timestamp - my $key = $index . ":" . time(); + # 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}") ); 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(); From 536c23f987744ebe92c793f629f5c3e347e9196a Mon Sep 17 00:00:00 2001 From: Efrain Date: Mon, 30 Mar 2026 21:12:39 -0600 Subject: [PATCH 06/17] Remove API key requirement | Fix 500 error in get_stamp --- lib/LANraragi/Controller/Api/Stamp.pm | 2 +- tools/openapi.yaml | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index 121fbac0d..9a5dea625 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -16,7 +16,7 @@ sub get_stamp { my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp($id, $stamp_id); - unless (%$stamp) { + unless ($stamp) { render_api_response($self, "get_stamp", "The given stamp does not exist."); return; } diff --git a/tools/openapi.yaml b/tools/openapi.yaml index d6026b470..7b256698a 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -3371,11 +3371,9 @@ paths: $ref: '#/components/schemas/OperationResponse' /stamps/{id}: get: - security: - - api_key: [] operationId: getStamp x-mojo-to: api-stamp#get_stamp - summary: 🔑 Get Stamp + summary: Get Stamp description: Get a stamp from an Archive. tags: - stamps @@ -3520,8 +3518,6 @@ paths: $ref: '#/components/schemas/OperationResponse' /stamps/{id}/{index}: get: - security: - - api_key: [] operationId: stampsByPage x-mojo-to: api-stamp#get_stamps_by_page summary: 🔑 Get the stamps linked to the page @@ -3621,8 +3617,6 @@ paths: $ref: '#/components/schemas/OperationResponse' /stamps/pages/{id}: get: - security: - - api_key: [] operationId: stampedPages x-mojo-to: api-stamp#get_stamped_pages summary: 🔑 Get pages that contain at least one stamp in the archive From 25fd5d603a07468c8781608a6eccdceb0a0bc695 Mon Sep 17 00:00:00 2001 From: Efrain Date: Tue, 31 Mar 2026 09:55:03 -0600 Subject: [PATCH 07/17] Make stamps draggable to update position --- public/js/reader.js | 150 ++++++++++++++++++-------------------------- 1 file changed, 62 insertions(+), 88 deletions(-) diff --git a/public/js/reader.js b/public/js/reader.js index e04050fe5..9d76e38c8 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -30,6 +30,7 @@ Reader.markerMode = false; Reader.markersVisible = false; Reader.markers = []; Reader.overlayFiltered = false; +Reader.pageNaviState = true; Reader.initializeAll = function () { Reader.initializeSettings(); @@ -206,6 +207,7 @@ Reader.initializeAll = function () { $("#overlay-page").hide(); Reader.markerMode = false; Reader.toggleArchiveOverlay(); + Reader.pageNaviState = true; } }); $(document).on("click.set-stamp", "#set-stamp", Reader.addStamp); @@ -453,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); @@ -831,10 +833,57 @@ Reader.createMarkerElement = function (markerData, index) { marker.title = markerData.name; marker.dataset.index = index; - // Rename - marker.addEventListener("click", (e) => { + // 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({ @@ -849,98 +898,23 @@ Reader.createMarkerElement = function (markerData, index) { reverseButtons: true, }).then((result) => { if (result.isConfirmed && result.value.trim() !== "") { - Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}&content=${result.value}`, "PUT", "Stamp updated!", I18N.StampError, + Server.callAPI(`/api/stamps/${Reader.id}?stamp_id=${markerData.id}&content=${result.value}&position=${xPercent},${yPercent}`, "PUT", "Stamp updated!", I18N.StampError, () => { - const i = marker.dataset.index; - const newName = result.value; + Reader.markers[i].x = xPercent; + Reader.markers[i].y = yPercent; + Reader.markers[i].name = result.value; - if (newName !== null && newName.trim() !== "") { - Reader.markers[i].name = newName.trim(); - Reader.renderMarkers(); - } + Reader.pageNaviState = true; + Reader.renderMarkers(); } ); + } else { + Reader.pageNaviState = true; + Reader.renderMarkers(); } }); }); - - // Leaving this code here in case someone wants to attempt to implement the drag and drop before the merge happens - // This logic should replace the previous click event ↑ - /* - 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"; - }); - - 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 ensures that only one of them execute the action - // also a good improvement to change this to an attachable event only for 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; - - LRR.showPopUp({ - title: I18N.ReaderTocPrompt, - input: "text", - inputPlaceholder: markerData.name || I18N.UntitledChapter, - inputAttributes: { - autocapitalize: "off", - }, - 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.ReaderTocError, - () => { - Reader.markers[i].x = xPercent; - Reader.markers[i].y = yPercent; - Reader.markers[i].name = result.value; - - Reader.renderMarkers(); - } - ); - } - }); - }); - */ + // Delete marker.addEventListener("contextmenu", (e) => { From e619a3f4572f5236512c1ecce5eb9ce30105b2bd Mon Sep 17 00:00:00 2001 From: Efrain Date: Tue, 31 Mar 2026 19:20:59 -0600 Subject: [PATCH 08/17] Fix KEYS mock --- tests/mocks.pl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mocks.pl b/tests/mocks.pl index 9b7be5ec3..1b6bd054b 100644 --- a/tests/mocks.pl +++ b/tests/mocks.pl @@ -153,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 } ); From d001505e8267583b0d53f3247ff60f32cd13e61f Mon Sep 17 00:00:00 2001 From: Efrain Date: Wed, 8 Apr 2026 11:12:53 -0600 Subject: [PATCH 09/17] Remove Frontend --- public/css/lrr.css | 24 --- public/js/reader.js | 353 +------------------------------------- templates/i18n.html.tt2 | 3 - templates/reader.html.tt2 | 7 +- 4 files changed, 2 insertions(+), 385 deletions(-) diff --git a/public/css/lrr.css b/public/css/lrr.css index 3a1dd30f3..666dba6bc 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -304,30 +304,6 @@ p#nb { user-select: none; align-self: center; cursor: pointer; - z-index: 22; -} - -.marker { - position: absolute; - width: 10px; - height: 10px; - background-color: blue; - border-radius: 50%; - 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 9d76e38c8..81deee5e1 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -26,11 +26,6 @@ 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(); @@ -66,7 +61,6 @@ 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`); @@ -139,80 +133,6 @@ 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. @@ -455,7 +375,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") && Reader.pageNaviState) { + if ($(event.target).closest("#i3").length && !$("#overlay-shade").is(":visible")) { // is click X position is left on screen or right if (event.pageX < $(window).width() / 2) { Reader.changePage(-1, true); @@ -791,222 +711,6 @@ 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")) { @@ -1193,10 +897,6 @@ 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); @@ -1205,11 +905,6 @@ 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 () { @@ -1543,47 +1238,6 @@ 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 @@ -1692,8 +1346,3 @@ 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 aa11bbe06..f7e8f2421 100644 --- a/templates/i18n.html.tt2 +++ b/templates/i18n.html.tt2 @@ -140,9 +140,6 @@ 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 353479669..a24728d4b 100644 --- a/templates/reader.html.tt2 +++ b/templates/reader.html.tt2 @@ -58,8 +58,6 @@

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

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

-
- - +

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

@@ -305,7 +301,6 @@
-
From f284ecd550ecf5e29a4393f5c628845203338b83 Mon Sep 17 00:00:00 2001 From: Efrain Date: Thu, 9 Apr 2026 10:18:36 -0600 Subject: [PATCH 10/17] Fix comments --- lib/LANraragi/Model/Stamp.pm | 36 ++++++++++++++++++++++-------------- tools/openapi.yaml | 6 +++--- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index fd376acfd..ab656e47f 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -31,6 +31,8 @@ sub get_stamp { return ( \%stamp, $err ); } + $redis->quit; + return (); } @@ -49,6 +51,8 @@ sub get_stamps_by_page { my $data = get_stamps_data($redis, $faves_id, $index); my @stamps = convert_stamps_to_object(%$data); + $redis->quit; + return ( \@stamps, $err ); } @@ -75,6 +79,8 @@ sub get_stamped_pages { my @keys = keys %indexes; + $redis->quit; + return ( \@keys, $err ); } @@ -121,22 +127,24 @@ sub update_stamp { 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 ( $redis->exists($faves_id) ) { + # Format inputs + 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 ( defined $content ) { + $content = remove_separator($content, "|"); + } else { + $content = $c_content[1] + } - if ( $redis->exists($faves_id) ) { + # Update stamp $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); $redis->quit; return ( 1, $err ); diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 7b256698a..6e95c29bc 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -3386,7 +3386,7 @@ paths: type: string - name: stamp_id in: query - required: True + required: true description: ID of the stamp schema: type: string @@ -3440,7 +3440,7 @@ paths: type: string - name: stamp_id in: query - required: True + required: true description: ID of the stamp schema: type: string @@ -3493,7 +3493,7 @@ paths: type: string - name: stamp_id in: query - required: True + required: true description: ID of the stamp schema: type: string From 6e7320affa24b3be49cc9410be82c1bd5ba2214e Mon Sep 17 00:00:00 2001 From: Efrain Date: Sun, 12 Apr 2026 14:04:28 -0600 Subject: [PATCH 11/17] Fix comments --- lib/LANraragi/Controller/Api/Stamp.pm | 47 ++++++++---------- lib/LANraragi/Model/Stamp.pm | 26 +++------- lib/LANraragi/Utils/String.pm | 15 +++++- tests/mocks.pl | 2 +- tools/openapi.yaml | 70 +++++++++++++-------------- 5 files changed, 77 insertions(+), 83 deletions(-) diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index 9a5dea625..170bb8cad 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -57,33 +57,26 @@ sub add_stamp { 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 - } - ); + 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, + error => $err + } + ); + } } sub update_stamp { @@ -132,7 +125,7 @@ sub delete_stamp { if ($result) { render_api_response( $self, "delete_stamp" ); } else { - render_api_response( $self, "delete_stamp", "The given stamp does not exist." ); + render_api_response( $self, "delete_stamp", $err ); } } ); diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index ab656e47f..9ae317bb1 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -11,6 +11,7 @@ use Redis; use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Redis qw(redis_encode); +use LANraragi::Utils::String qw(remove_separator); # get_stamp(id, stamp_id) @@ -21,7 +22,7 @@ sub get_stamp { my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "FAVES_" . $id; + my $faves_id = "STAMPS_" . $id; my $err = ""; if ( $redis->hexists($faves_id => $stamp_id) ) { @@ -45,7 +46,7 @@ sub get_stamps_by_page { my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "FAVES_" . $id; + my $faves_id = "STAMPS_" . $id; my $err = ""; my $data = get_stamps_data($redis, $faves_id, $index); @@ -64,7 +65,7 @@ sub get_stamped_pages { my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "FAVES_" . $id; + my $faves_id = "STAMPS_" . $id; my $err = ""; my $fields = $redis->hkeys($faves_id); @@ -92,7 +93,7 @@ sub add_stamp { my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "FAVES_" . $id; + my $faves_id = "STAMPS_" . $id; my $err = ""; unless ( $redis->exists($id) ) { @@ -125,7 +126,7 @@ sub update_stamp { my $logger = get_logger( "Stamps", "lanraragi" ); my $redis = LANraragi::Model::Config->get_redis; my $err = ""; - my $faves_id = "FAVES_" . $id; + my $faves_id = "STAMPS_" . $id; if ( $redis->exists($faves_id) ) { # Format inputs @@ -165,7 +166,7 @@ sub remove_stamp { my $logger = get_logger( "Stamps", "lanraragi" ); my $redis = LANraragi::Model::Config->get_redis; my $err = ""; - my $faves_id = "FAVES_" . $id; + my $faves_id = "STAMPS_" . $id; if ( $redis->exists($faves_id) ) { $redis->hdel($faves_id, $key); @@ -179,19 +180,6 @@ sub remove_stamp { 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) = @_; diff --git a/lib/LANraragi/Utils/String.pm b/lib/LANraragi/Utils/String.pm index ab818a12e..09108299d 100644 --- a/lib/LANraragi/Utils/String.pm +++ b/lib/LANraragi/Utils/String.pm @@ -9,7 +9,7 @@ use feature qw(signatures); use String::Similarity; use Exporter 'import'; -our @EXPORT_OK = qw(clean_title trim trim_CRLF trim_url most_similar); +our @EXPORT_OK = qw(clean_title trim trim_CRLF trim_url most_similar remove_separator); # Remove "junk" from titles, turning something like "(c12) [poop (butt)] hardcore handholding [monogolian] [recensored]" into "hardcore handholding" sub clean_title ($title) { @@ -84,4 +84,17 @@ sub most_similar ( $tested_string, @values ) { return $best_index; } +# Replaces $char 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; +} + 1; diff --git a/tests/mocks.pl b/tests/mocks.pl index 1b6bd054b..dd2ea62c3 100644 --- a/tests/mocks.pl +++ b/tests/mocks.pl @@ -131,7 +131,7 @@ sub setup_redis_mock { "name_World", "28697b96f0ac5777be2614ed10ca47742c9522fa" ], - "FAVES_be447b58ea66137c415ee306ee2ac44b308ee484": { + "STAMPS_be447b58ea66137c415ee306ee2ac44b308ee484": { "0:1589138380": "0,0|Lorem", "0:1589138381": "0,0|Ipsum", "1:1589138380": "0,0|Dolor", diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 6e95c29bc..6cade70e7 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -1853,6 +1853,39 @@ paths: application/json: schema: $ref: '#/components/schemas/OperationResponse' + /archives/{id}/stamps: + 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' /search: get: @@ -3561,7 +3594,7 @@ paths: 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. + description: Add a new Stamp to the page at the given coordinates. tags: - stamps parameters: @@ -3615,39 +3648,6 @@ paths: 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: @@ -4076,7 +4076,7 @@ components: description: ID of the stamp position: type: string - description: Position of the stamp in the page + description: Position of the stamp in the page in pixel coordinates content: type: string description: Text of the stamp From a42bc8a25815b4150306765b197c6550c29610da Mon Sep 17 00:00:00 2001 From: Efrain Date: Sat, 18 Apr 2026 21:53:12 -0600 Subject: [PATCH 12/17] Use real milliseconds timestamp --- lib/LANraragi/Model/Stamp.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 9ae317bb1..3952c4682 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -8,6 +8,7 @@ use warnings; use utf8; use Redis; +use Time::HiRes qw(time); use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Redis qw(redis_encode); From 766a49bd86806d6609d470df101833c29e381515 Mon Sep 17 00:00:00 2001 From: EfronC Date: Sun, 26 Apr 2026 12:12:55 -0600 Subject: [PATCH 13/17] Rework Stamps as standalone hash tables | Updated openapi.yml --- lib/LANraragi/Controller/Api/Stamp.pm | 21 +- lib/LANraragi/Model/Stamp.pm | 290 ++++++++++++++++---------- tests/mocks.pl | 33 ++- tests/stamp.t | 6 +- tools/openapi.yaml | 241 ++++++++++----------- 5 files changed, 320 insertions(+), 271 deletions(-) diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index 170bb8cad..8d8f9b1d4 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -10,11 +10,10 @@ 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 $self = shift->openapi->valid_input or return; + my $stamp_id = $self->stash('id'); - my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp($id, $stamp_id); + my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp($stamp_id); unless ($stamp) { render_api_response($self, "get_stamp", "The given stamp does not exist."); @@ -82,8 +81,7 @@ sub add_stamp { 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 $stamp_id = $self->stash('id'); my $position = $self->req->param('position') || undef; my $content = $self->req->param('content') || undef; @@ -93,10 +91,10 @@ sub update_stamp { "update_stamp", $stamp_id, sub { - my ( $result, $err ) = LANraragi::Model::Stamp::update_stamp( $id, $stamp_id, $content, $position ); + my ( $result, $err ) = LANraragi::Model::Stamp::update_stamp( $stamp_id, $content, $position ); if ($result) { - my %stamp = LANraragi::Model::Stamp::get_stamp( $id, $stamp_id ); + my %stamp = LANraragi::Model::Stamp::get_stamp( $stamp_id ); my $successMessage = "Updated stamp \"$stamp_id\"!"; render_api_response( $self, "update_stamp", undef, $successMessage ); @@ -110,9 +108,8 @@ sub update_stamp { sub delete_stamp { - my $self = shift->openapi->valid_input or return; - my $id = $self->stash('id'); - my $stamp_id = $self->req->param('stamp_id'); + my $self = shift->openapi->valid_input or return; + my $stamp_id = $self->stash('id'); return unless exec_with_lock( $self, @@ -120,7 +117,7 @@ sub delete_stamp { "delete_stamp", $stamp_id, sub { - my ( $result, $err ) = LANraragi::Model::Stamp::remove_stamp($id, $stamp_id); + my ( $result, $err ) = LANraragi::Model::Stamp::remove_stamp($stamp_id); if ($result) { render_api_response( $self, "delete_stamp" ); diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 3952c4682..6d07ccd26 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -9,49 +9,64 @@ use utf8; use Redis; use Time::HiRes qw(time); +use Mojo::JSON qw(decode_json encode_json); -use LANraragi::Utils::Logging qw(get_logger); -use LANraragi::Utils::Redis qw(redis_encode); -use LANraragi::Utils::String qw(remove_separator); +use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::Redis qw(redis_encode redis_decode); +use LANraragi::Utils::Generic qw(filter_hash_by_keys); -# get_stamp(id, stamp_id) +# get_stamp(stamp_id) # Gets the requested stamp. # Returns the stamp object. sub get_stamp { - my ( $id, $stamp_id ) = @_; + my ( $stamp_id ) = @_; my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "STAMPS_" . $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); + if ( $stamp_id eq "" ) { + $logger->debug("No stamp ID provided."); + return (); + } - return ( \%stamp, $err ); + unless ( $redis->exists($stamp_id) ) { + $logger->warn("$stamp_id doesn't exist in the database!"); + return (); } + my %stamp = convert_stamp_to_object( $redis, $stamp_id ); + $redis->quit; - return (); + return ( \%stamp, $err ); } -# get_stamps_by_page(id) +# get_stamps_by_page(id, page) # 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 ( $archive_id, $index ) = @_; my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "STAMPS_" . $id; my $err = ""; + my @stamps; + + unless ( $redis->exists($archive_id) ) { + $err = "$archive_id does not exist in the database."; + $logger->error($err); + $redis->quit; + return ( 0, $err ); + } - my $data = get_stamps_data($redis, $faves_id, $index); - my @stamps = convert_stamps_to_object(%$data); + if ( $redis->hexists($archive_id => "stamps") ) { + my @stamp_ids = decode_json($redis->hget( $archive_id, "stamps" )); + my @filtered_stamps = filter_stamps_by_page(@stamp_ids, $index); + @stamps = convert_stamps_to_object($redis, @filtered_stamps); + } $redis->quit; @@ -62,190 +77,237 @@ sub get_stamps_by_page { # Gets the list of pages that have at least one stamp. # Returns an array of page numbers. sub get_stamped_pages { - my ( $id ) = @_; + my ( $archive_id ) = @_; my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "STAMPS_" . $id; my $err = ""; + my @keys; - my $fields = $redis->hkeys($faves_id); + unless ( $redis->exists($archive_id) ) { + $err = "$archive_id does not exist in the database."; + $logger->error($err); + $redis->quit; + return ( 0, $err ); + } - my %indexes; + if ( $redis->hexists($archive_id => "stamps") ) { + my %indexes; + my $stamps = $redis->hget( $archive_id, "stamps" ); + $stamps = deserialize_stamp_list($stamps); - foreach my $field (@$fields) { - # Extract the page number - my ($index) = split(/:/, $field, 2); - $indexes{$index} = 1; - } + if (!defined $stamps) { + $redis->quit(); + $err = "There was a problem deserializing the stamps"; + return ( 0, $err ); + } - my @keys = keys %indexes; + my @stamps = @$stamps; - $redis->quit; + foreach my $stamp (@stamps) { + # Extract the page number + my (undef, $index, undef) = split(/_/, $stamp, 3); + $indexes{$index} = 1; + } + + @keys = keys %indexes; + } + + $redis->quit(); return ( \@keys, $err ); } -# add_stamp(id, key, content, position) +# add_stamp(archive_id, page, content, position) # Add the stamp to the page. # Returns the stamp key. sub add_stamp { - my ( $id, $index, $content, $position ) = @_; + my ( $archive_id, $index, $content, $position ) = @_; my $redis = LANraragi::Model::Config->get_redis; my $logger = get_logger( "Stamps", "lanraragi" ); - my $faves_id = "STAMPS_" . $id; my $err = ""; - unless ( $redis->exists($id) ) { - $err = "$id does not exist in the database."; + unless ( $redis->exists($archive_id) ) { + $err = "$archive_id does not exist in the database."; $logger->error($err); $redis->quit; return ( 0, $err ); } - $content = remove_separator($content, "|"); - $position = remove_separator($position, "|"); + # Page and creation date are saved in the key. + # This one uses Time::HiRes to get timestamp in milliseconds. + my $key = "STAMPS_" . $index . "_" . int(time() * 1000); - # 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); + # Probably unnecessary since this is in ms. + my $isnewkey = 0; + until ($isnewkey) { + if ( $redis->exists($key) ) { + $key = "STAMPS_" . $index . "_" . int(time() * 1000 + 1); + } else { + $isnewkey = 1; + } + } - $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); + $redis->hset( $key, "content", redis_encode($content) ); + $redis->hset( $key, "position", redis_encode($position) ); + # This one is probably redundant, but I'll add it for the purpose of reverse searches, maybe for cache build up. + $redis->hset( $key, "archive_id", redis_encode($archive_id) ); - $redis->quit; + # Add to archive + my $stamps = $redis->hget( $archive_id, "stamps" ); + my @stamps; + + no warnings 'experimental::try'; + try { + eval { @stamps = @{ decode_json($stamps) } }; + if ($@) { + $err = "Couldn't deserialize stamps in DB for $archive_id! Redis returned the following junk data: $stamps"; + $redis->del($key); + $logger->error($err); + $redis->quit; + return ( 0, $err ); + } + push @stamps, $key; + $stamps = encode_json(\@stamps); + } catch ($e) { + $logger->warn( + "Error while updating Stamps: $e -- Will overwrite with a Stamps containing the new data. (This is normal if this ID had no Stamps yet.)" + ); + @stamps = []; + push @stamps, $key; + $stamps = encode_json(\@stamps); + } + $redis->hset( $archive_id, "stamps", $stamps ); + + $redis->quit(); return ( $key, $err ); } -# update_stamp(id, key, content, position) +# update_stamp(id, 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 ( $stamp_id, $content, $position ) = @_; my $logger = get_logger( "Stamps", "lanraragi" ); my $redis = LANraragi::Model::Config->get_redis; my $err = ""; - my $faves_id = "STAMPS_" . $id; - - if ( $redis->exists($faves_id) ) { - # Format inputs - my $current = $redis->hget($faves_id => $key); - my @c_content = split(/\|/, $current); + if ( $redis->exists($stamp_id) ) { if ( defined $position ) { - $position = remove_separator($position, "|"); - } else { - $position = $c_content[0] + $redis->hset( $stamp_id, "position", redis_encode($position) ) } if ( defined $content ) { - $content = remove_separator($content, "|"); - } else { - $content = $c_content[1] + $redis->hset( $stamp_id, "content", redis_encode($content) ) } - # Update stamp - $redis->hset( $faves_id, $key, redis_encode("${position}|${content}") ); - $redis->quit; + $redis->quit(); return ( 1, $err ); } - $err = "$faves_id doesn't exist in the database!"; + $err = "$stamp_id doesn't exist in the database!"; $logger->warn($err); - $redis->quit; + $redis->quit(); return ( 0, $err ); } -# remove_stamp(id, key) +# remove_stamp(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 ( $key ) = @_; my $logger = get_logger( "Stamps", "lanraragi" ); my $redis = LANraragi::Model::Config->get_redis; my $err = ""; - my $faves_id = "STAMPS_" . $id; - if ( $redis->exists($faves_id) ) { - $redis->hdel($faves_id, $key); - $redis->quit; + if ( $redis->exists($key) ) { + # Remove key from archive. + # This should not throw an error, since the stamp should have been linked to the archive at creation. + my $archive_id = $redis->hget( $key, "archive_id" ); + my $stamps = $redis->hget( $archive_id, "stamps" ); + $stamps = deserialize_stamp_list($stamps); + my @stamps = remove_stampid_from_list($stamps, $key); + $redis->hset( $archive_id, "stamps", encode_json(\@stamps) ); + + $redis->del($key); + + $redis->quit(); return ( 1, $err ); } - $err = "$faves_id doesn't exist in the database!"; + $err = "$key doesn't exist in the database!"; $logger->warn($err); - $redis->quit; + $redis->quit(); return ( 0, $err ); } -# 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 ) = @_; + my ( $redis, $stamp_id ) = @_; - # Separate the string and classify the fields - my @x = split(/\|/, $content); - my %stamp = ( - id => $stamp_id, - position => $x[0], - content => $x[1], - ); + my @allowed_keys = ( 'content', 'position' ); + my %stamp = $redis->hgetall($stamp_id); + ( $_ = redis_decode($_) ) for ( $stamp{content}, $stamp{position} ); + %stamp = filter_hash_by_keys( \@allowed_keys, %stamp ); + $stamp{id} = $stamp_id; return %stamp; } # Converts an array of stamp registers to an array ob objects sub convert_stamps_to_object { - my (%stamps_raw) = @_; + my ( $redis, @stamp_ids) = @_; my @stamps; # Convert stamp registers to objects - foreach my $i (keys %stamps_raw) { - my %stamp = convert_stamp_to_object($i, $stamps_raw{$i}); + foreach my $i (@stamp_ids) { + my %stamp = convert_stamp_to_object($redis, $i); push @stamps, \%stamp; } return @stamps; } +sub remove_stampid_from_list { + my ($stamps, $stamp) = @_; + + my @new_stamps = grep { $_ ne $stamp } @$stamps; + + return @new_stamps; +} + +# Returns stamps whose page in STAMPS__ matches. +sub filter_stamps_by_page { + my ($stamps, $page) = @_; + + my @filtered = grep { + my (undef, $index, undef) = split(/_/, $_, 3); + defined $index && $index == $page; + } @$stamps; + + return @filtered; +} + +# Convert JSON string to array. +sub deserialize_stamp_list { + my ($stamps) = @_; + + my $decoded; + eval { + $decoded = decode_json($stamps); + die "There was a problem serializing stamps" unless ref($decoded) eq 'ARRAY'; + }; + + if ($@) { + return undef; + } + + return $decoded; +} + 1; \ No newline at end of file diff --git a/tests/mocks.pl b/tests/mocks.pl index dd2ea62c3..4e5bea2f3 100644 --- a/tests/mocks.pl +++ b/tests/mocks.pl @@ -120,7 +120,8 @@ sub setup_redis_mock { "title": "\u4f7f\u5f92\u3001\u8972\u6765", "file": "package.json", "summary": "", - "lastreadtime": 0 + "lastreadtime": 0, + "stamps": "[\\\"STAMPS_0_1777224824660\\\", \\\"STAMPS_0_1777224824661\\\", \\\"STAMPS_1_1777224824662\\\", \\\"STAMPS_2_1777224824663\\\", \\\"STAMPS_3_1777224824664\\\"]" }, "TANK_1589141306": [ "name_Hello", @@ -131,12 +132,30 @@ sub setup_redis_mock { "name_World", "28697b96f0ac5777be2614ed10ca47742c9522fa" ], - "STAMPS_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" + "STAMPS_0_1777224824660": { + "content": "Lorem", + "position": "0,0", + "archive_id": "be447b58ea66137c415ee306ee2ac44b308ee484" + }, + "STAMPS_0_1777224824661": { + "content": "Ipsum", + "position": "0,0", + "archive_id": "be447b58ea66137c415ee306ee2ac44b308ee484" + }, + "STAMPS_1_1777224824662": { + "content": "Dolor", + "position": "0,0", + "archive_id": "be447b58ea66137c415ee306ee2ac44b308ee484" + }, + "STAMPS_2_1777224824663": { + "content": "Sit", + "position": "0,0", + "archive_id": "be447b58ea66137c415ee306ee2ac44b308ee484" + }, + "STAMPS_3_1777224824664": { + "content": "Amet", + "position": "0,0", + "archive_id": "be447b58ea66137c415ee306ee2ac44b308ee484" } }) }; diff --git a/tests/stamp.t b/tests/stamp.t index 0bca5668f..58d080045 100644 --- a/tests/stamp.t +++ b/tests/stamp.t @@ -33,10 +33,10 @@ my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page("be447b58ea66 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" ); +is ( $stamps->[0]{"id"}, "STAMPS_1_1777224824662", "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" ); +my ( $stamp, $err ) = LANraragi::Model::Stamp::get_stamp("STAMPS_0_1777224824660"); +is ( %$stamp{"id"}, "STAMPS_0_1777224824660", "Get stamp id test" ); is ( %$stamp{"content"}, "Lorem", "Get stamp content test" ); is ( %$stamp{"position"}, "0,0", "Get stamp position test" ); diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 6cade70e7..2900e159e 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -1868,6 +1868,40 @@ paths: description: ID of the archive. schema: type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + $ref: '#/components/schemas/StampsResponse' + '400': + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + /archives/{id}/stamps/{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 @@ -1879,7 +1913,67 @@ paths: result: type: array items: - type: string + $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 Stamp to the page at the given coordinates. + 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: @@ -3414,13 +3508,7 @@ paths: - 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 + description: ID of the stamp. schema: type: string responses: @@ -3429,20 +3517,7 @@ paths: 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 + $ref: '#/components/schemas/StampsData' '400': description: Error response content: @@ -3468,13 +3543,7 @@ paths: - 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 + description: ID of the stamp. schema: type: string - name: content @@ -3521,13 +3590,7 @@ paths: - 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 + description: ID of the stamp. schema: type: string responses: @@ -3549,105 +3612,6 @@ paths: 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 Stamp to the page at the given coordinates. - 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' components: securitySchemes: api_key: @@ -4083,5 +4047,12 @@ components: example: position: 12,34 content: Lorem ipsum dolore + StampsResponse: + type: object + properties: + result: + type: array + items: + type: string servers: - url: https://lrr.tvc-16.science/api From 6d727d1c26c420997456930ea7d84d06fbb19ade Mon Sep 17 00:00:00 2001 From: EfronC Date: Sat, 2 May 2026 21:36:37 -0600 Subject: [PATCH 14/17] Add a validation for page range --- lib/LANraragi/Model/Stamp.pm | 81 ++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 6d07ccd26..5b91e18b9 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -38,7 +38,7 @@ sub get_stamp { my %stamp = convert_stamp_to_object( $redis, $stamp_id ); - $redis->quit; + $redis->quit(); return ( \%stamp, $err ); } @@ -58,17 +58,17 @@ sub get_stamps_by_page { unless ( $redis->exists($archive_id) ) { $err = "$archive_id does not exist in the database."; $logger->error($err); - $redis->quit; + $redis->quit(); return ( 0, $err ); } if ( $redis->hexists($archive_id => "stamps") ) { my @stamp_ids = decode_json($redis->hget( $archive_id, "stamps" )); - my @filtered_stamps = filter_stamps_by_page(@stamp_ids, $index); - @stamps = convert_stamps_to_object($redis, @filtered_stamps); + my @filtered_stamps = filter_stamps_by_page( @stamp_ids, $index ); + @stamps = convert_stamps_to_object( $redis, @filtered_stamps ); } - $redis->quit; + $redis->quit(); return ( \@stamps, $err ); } @@ -84,19 +84,19 @@ sub get_stamped_pages { my $err = ""; my @keys; - unless ( $redis->exists($archive_id) ) { + unless ( $redis->exists( $archive_id ) ) { $err = "$archive_id does not exist in the database."; $logger->error($err); - $redis->quit; + $redis->quit(); return ( 0, $err ); } - if ( $redis->hexists($archive_id => "stamps") ) { + if ( $redis->hexists( $archive_id => "stamps" ) ) { my %indexes; my $stamps = $redis->hget( $archive_id, "stamps" ); - $stamps = deserialize_stamp_list($stamps); + $stamps = deserialize_stamp_list( $stamps ); - if (!defined $stamps) { + if ( !defined $stamps ) { $redis->quit(); $err = "There was a problem deserializing the stamps"; return ( 0, $err ); @@ -106,7 +106,7 @@ sub get_stamped_pages { foreach my $stamp (@stamps) { # Extract the page number - my (undef, $index, undef) = split(/_/, $stamp, 3); + my ( undef, $index, undef ) = split( /_/, $stamp, 3 ); $indexes{$index} = 1; } @@ -128,10 +128,19 @@ sub add_stamp { my $logger = get_logger( "Stamps", "lanraragi" ); my $err = ""; - unless ( $redis->exists($archive_id) ) { + unless ( $redis->exists( $archive_id ) ) { $err = "$archive_id does not exist in the database."; $logger->error($err); - $redis->quit; + $redis->quit(); + return ( 0, $err ); + } + + my $pagecount = $redis->hget( $archive_id => "pagecount" ); + + unless ( int($index) <= int($pagecount) && int($index) > 0 ) { + $err = "Page $index out of range."; + $logger->error($err); + $redis->quit(); return ( 0, $err ); } @@ -141,8 +150,8 @@ sub add_stamp { # Probably unnecessary since this is in ms. my $isnewkey = 0; - until ($isnewkey) { - if ( $redis->exists($key) ) { + until ( $isnewkey ) { + if ( $redis->exists( $key ) ) { $key = "STAMPS_" . $index . "_" . int(time() * 1000 + 1); } else { $isnewkey = 1; @@ -163,20 +172,20 @@ sub add_stamp { eval { @stamps = @{ decode_json($stamps) } }; if ($@) { $err = "Couldn't deserialize stamps in DB for $archive_id! Redis returned the following junk data: $stamps"; - $redis->del($key); + $redis->del( $key ); $logger->error($err); - $redis->quit; + $redis->quit(); return ( 0, $err ); } push @stamps, $key; - $stamps = encode_json(\@stamps); - } catch ($e) { + $stamps = encode_json( \@stamps ); + } catch ( $e ) { $logger->warn( "Error while updating Stamps: $e -- Will overwrite with a Stamps containing the new data. (This is normal if this ID had no Stamps yet.)" ); @stamps = []; push @stamps, $key; - $stamps = encode_json(\@stamps); + $stamps = encode_json( \@stamps ); } $redis->hset( $archive_id, "stamps", $stamps ); @@ -195,13 +204,13 @@ sub update_stamp { my $redis = LANraragi::Model::Config->get_redis; my $err = ""; - if ( $redis->exists($stamp_id) ) { + if ( $redis->exists( $stamp_id ) ) { if ( defined $position ) { - $redis->hset( $stamp_id, "position", redis_encode($position) ) + $redis->hset( $stamp_id, "position", redis_encode( $position ) ) } if ( defined $content ) { - $redis->hset( $stamp_id, "content", redis_encode($content) ) + $redis->hset( $stamp_id, "content", redis_encode( $content ) ) } $redis->quit(); @@ -224,16 +233,16 @@ sub remove_stamp { my $redis = LANraragi::Model::Config->get_redis; my $err = ""; - if ( $redis->exists($key) ) { + if ( $redis->exists( $key ) ) { # Remove key from archive. # This should not throw an error, since the stamp should have been linked to the archive at creation. my $archive_id = $redis->hget( $key, "archive_id" ); my $stamps = $redis->hget( $archive_id, "stamps" ); - $stamps = deserialize_stamp_list($stamps); - my @stamps = remove_stampid_from_list($stamps, $key); - $redis->hset( $archive_id, "stamps", encode_json(\@stamps) ); + $stamps = deserialize_stamp_list( $stamps ); + my @stamps = remove_stampid_from_list( $stamps, $key ); + $redis->hset( $archive_id, "stamps", encode_json( \@stamps ) ); - $redis->del($key); + $redis->del( $key ); $redis->quit(); return ( 1, $err ); @@ -250,7 +259,7 @@ sub convert_stamp_to_object { my ( $redis, $stamp_id ) = @_; my @allowed_keys = ( 'content', 'position' ); - my %stamp = $redis->hgetall($stamp_id); + my %stamp = $redis->hgetall( $stamp_id ); ( $_ = redis_decode($_) ) for ( $stamp{content}, $stamp{position} ); %stamp = filter_hash_by_keys( \@allowed_keys, %stamp ); $stamp{id} = $stamp_id; @@ -266,7 +275,7 @@ sub convert_stamps_to_object { # Convert stamp registers to objects foreach my $i (@stamp_ids) { - my %stamp = convert_stamp_to_object($redis, $i); + my %stamp = convert_stamp_to_object( $redis, $i ); push @stamps, \%stamp; } @@ -274,7 +283,7 @@ sub convert_stamps_to_object { } sub remove_stampid_from_list { - my ($stamps, $stamp) = @_; + my ( $stamps, $stamp ) = @_; my @new_stamps = grep { $_ ne $stamp } @$stamps; @@ -283,10 +292,10 @@ sub remove_stampid_from_list { # Returns stamps whose page in STAMPS__ matches. sub filter_stamps_by_page { - my ($stamps, $page) = @_; + my ( $stamps, $page ) = @_; my @filtered = grep { - my (undef, $index, undef) = split(/_/, $_, 3); + my ( undef, $index, undef ) = split( /_/, $_, 3 ); defined $index && $index == $page; } @$stamps; @@ -295,15 +304,15 @@ sub filter_stamps_by_page { # Convert JSON string to array. sub deserialize_stamp_list { - my ($stamps) = @_; + my ( $stamps ) = @_; my $decoded; eval { - $decoded = decode_json($stamps); + $decoded = decode_json( $stamps ); die "There was a problem serializing stamps" unless ref($decoded) eq 'ARRAY'; }; - if ($@) { + if ( $@ ) { return undef; } From 0798b2368e547ed79886c7ccbf30bc1441e7046b Mon Sep 17 00:00:00 2001 From: EfronC Date: Sun, 3 May 2026 20:31:19 -0600 Subject: [PATCH 15/17] Fix add stamp error when stamps not initialized | Remove stamps when archive is deleted --- lib/LANraragi/Model/Archive.pm | 16 ++++++++++++++++ lib/LANraragi/Model/Stamp.pm | 8 +++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/LANraragi/Model/Archive.pm b/lib/LANraragi/Model/Archive.pm index fb793b80e..ab259f2a4 100644 --- a/lib/LANraragi/Model/Archive.pm +++ b/lib/LANraragi/Model/Archive.pm @@ -393,6 +393,22 @@ sub delete_archive ($id) { LANraragi::Model::Category::remove_from_category( $catid, $id ); } + # Remove Stamps + my $stamps = $redis->hget( $id, "stamps" ); + my @stamps; + + if ( $redis->hexists( $id, "stamps" )) { + eval { @stamps = @{ decode_json($stamps) } }; + if ($@) { + die; + } + foreach my $stamp ( @stamps ) { + $redis->del($stamp); + } + } else { + # Stamps attribute was not setted, do nothing. + } + $redis->del($id); $redis->quit(); diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index 5b91e18b9..bed4bc8f9 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -167,8 +167,7 @@ sub add_stamp { my $stamps = $redis->hget( $archive_id, "stamps" ); my @stamps; - no warnings 'experimental::try'; - try { + if ( $redis->hexists( $archive_id, "stamps" )) { eval { @stamps = @{ decode_json($stamps) } }; if ($@) { $err = "Couldn't deserialize stamps in DB for $archive_id! Redis returned the following junk data: $stamps"; @@ -179,11 +178,10 @@ sub add_stamp { } push @stamps, $key; $stamps = encode_json( \@stamps ); - } catch ( $e ) { + } else { $logger->warn( - "Error while updating Stamps: $e -- Will overwrite with a Stamps containing the new data. (This is normal if this ID had no Stamps yet.)" + "Stamps not initialized -- Will overwrite with a Stamps containing the new data. (This is normal if this ID had no Stamps yet.)" ); - @stamps = []; push @stamps, $key; $stamps = encode_json( \@stamps ); } From c6fe60cb32cb510d3893fd6a5af141bd33fdd877 Mon Sep 17 00:00:00 2001 From: EfronC Date: Mon, 4 May 2026 21:34:50 -0600 Subject: [PATCH 16/17] Add stamps to backup --- lib/LANraragi/Model/Backup.pm | 54 +++++++++++++++++++++++++++++++++-- tests/backup.t | 30 +++++++++++++------ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/lib/LANraragi/Model/Backup.pm b/lib/LANraragi/Model/Backup.pm index 8c45dd482..a7430ae28 100644 --- a/lib/LANraragi/Model/Backup.pm +++ b/lib/LANraragi/Model/Backup.pm @@ -72,6 +72,30 @@ sub build_backup_JSON { push @{ $backup{tankoubons} }, \%tank; } + # Backup stamps + my @stamp_ids = $redis->keys('STAMPS_*'); + + foreach my $stamp_id (@stamp_ids) { + eval { + my %stamp_hash = $redis->hgetall($stamp_id); + my ( $content, $position, $archive_id ) = @stamp_hash{qw(content position archive_id)}; + + ( $_ = redis_decode($_) ) for ( $content, $position, $archive_id ); + ( $_ = trim_CRLF($_) ) for ( $content, $position, $archive_id ); + + my %stamp = ( + stamp_id => $stamp_id, + content => $content, + position => $position, + archive_id => $archive_id + ); + + push @{ $backup{stamps} }, \%stamp; + }; + + $logger->trace("Backing up stamp $stamp_id: $@"); + } + # Backup archives themselves next my @keys = $redis->keys('????????????????????????????????????????'); #40-character long keys only => Archive IDs @@ -80,7 +104,7 @@ sub build_backup_JSON { eval { my %hash = $redis->hgetall($id); - my ( $name, $title, $tags, $summary, $thumbhash ) = @hash{qw(name title tags summary thumbhash)}; + my ( $name, $title, $tags, $summary, $thumbhash, $stamps ) = @hash{qw(name title tags summary thumbhash stamps)}; ( $_ = redis_decode($_) ) for ( $name, $title, $tags, $summary ); ( $_ = trim_CRLF($_) ) for ( $name, $title, $tags, $summary ); @@ -92,7 +116,8 @@ sub build_backup_JSON { tags => $tags, summary => $summary, thumbhash => $thumbhash, - filename => $name + filename => $name, + stamps => $stamps ); push @{ $backup{archives} }, \%arc; @@ -171,6 +196,31 @@ sub restore_from_JSON { $redis->hset( $id, "thumbhash", $thumbhash ); } + if ( defined $archive->{"stamps"} ) { + my $stamps = redis_encode( $archive->{"stamps"} ); + $redis->hset( $id, "stamps", $stamps ); + } else { + $redis->hset( $id, "stamps", "[]" ); + } + + } + } + + foreach my $stamp ( @{ $json->{stamps} } ) { + my $stamp_id = $stamp->{"stamp_id"}; + + my $content = $stamp->{"content"}; + my $position = $stamp->{"position"}; + my $archive_id = $stamp->{"archive_id"}; + + #If the archive exists, restore metadata. + if ( $redis->exists($archive_id) ) { + + ( $_ = redis_encode($_) ) for ( $content, $position, $archive_id ); + + $redis->hset( $stamp_id, "content", $content); + $redis->hset( $stamp_id, "position", $position); + $redis->hset( $stamp_id, "archive_id", $archive_id); } } diff --git a/tests/backup.t b/tests/backup.t index 39718dd68..ea6cb345b 100644 --- a/tests/backup.t +++ b/tests/backup.t @@ -23,15 +23,15 @@ use LANraragi::Model::Backup; # Would've liked to compare JSON strings directly here, but since the key order is non-deterministic it's easier to compare the result hashes. my %expected_backup = %{ decode_json qq({"archives":[ - {"arcid":"be447b58ea66137c415ee306ee2ac44b308ee484","filename":null,"tags":"series:Neon Genesis Evangelion, artist:Yoshiyuki Sadamoto, chapter:1, character:Shinji Ikari, character:Misato Katsuragi, science fiction","thumbhash":null,"title":"\u4f7f\u5f92\u3001\u8972\u6765", "summary":""}, - {"arcid":"e4c422fd10943dc169e3489a38cdbf57101a5f7e","filename":null,"tags":"parody: jojo's bizarre adventure","thumbhash":null,"title":"Rohan Kishibe goes to Gucci", "summary":""}, - {"arcid":"4857fd2e7c00db8b0af0337b94055d8445118630","filename":null,"tags":"artist:shirow masamune","thumbhash":null,"title":"Ghost in the Shell 1.5 - Human-Error Processor vol01ch01", "summary":""}, - {"arcid":"e69e43e1355267f7d32a4f9b7f2fe108d2401ebf","filename":null,"tags":"character:segata sanshiro, male:very cool","thumbhash":null,"title":"Saturn Backup Cartridge - Japanese Manual", "summary":""}, - {"arcid":"e69e43e1355267f7d32a4f9b7f2fe108d2401ebg","filename":null,"tags":"character:segata, female:very cool too","thumbhash":null,"title":"Saturn Backup Cartridge - American Manual", "summary":""}, - {"arcid":"28697b96f0ac5858be2614ed10ca47742c9522fd","filename":null,"tags":"parody:fate grand order, group:wadamemo, artist:wada rco, artbook, full color, male:very cool too","thumbhash":null,"title":"Fate GO MEMO", "summary":""}, - {"arcid":"2810d5e0a8d027ecefebca6237031a0fa7b91eb3","filename":null,"tags":"parody:fate grand order, character:abigail williams, character:artoria pendragon alter, character:asterios, character:ereshkigal, character:gilgamesh, character:hans christian andersen, character:hassan of serenity, character:hector, character:helena blavatsky, character:irisviel von einzbern, character:jeanne alter, character:jeanne darc, character:kiara sessyoin, character:kiyohime, character:lancer, character:martha, character:minamoto no raikou, character:mochizuki chiyome, character:mordred pendragon, character:nitocris, character:oda nobunaga, character:osakabehime, character:penthesilea, character:queen of sheba, character:rin tosaka, character:saber, character:sakata kintoki, character:scheherazade, character:sherlock holmes, character:suzuka gozen, character:tamamo no mae, character:ushiwakamaru, character:waver velvet, character:xuanzang, character:zhuge liang, group:wadamemo, artist:wada rco, artbook, full color","thumbhash":null,"title":"Fate GO MEMO 2", "summary":""}, - {"arcid":"28697b96f0ac5777be2614ed10ca47742c9522fa","filename":null,"tags":"year of shadow, character:vector the crocodile","thumbhash":null,"title":"Find the Computer Room", "summary":""}, - {"arcid":"28697b96f0ac5858be2666ed10ca47742c955555","filename":null,"tags":"medjed, character:doubles guy, character:king of GETs, check this 5","thumbhash":null,"title":"All about Egypt", "summary":"CURSE OF RA"} + {"arcid":"be447b58ea66137c415ee306ee2ac44b308ee484","filename":null,"tags":"series:Neon Genesis Evangelion, artist:Yoshiyuki Sadamoto, chapter:1, character:Shinji Ikari, character:Misato Katsuragi, science fiction","thumbhash":null,"title":"\u4f7f\u5f92\u3001\u8972\u6765", "summary":"", "stamps":"[\\\"STAMPS_0_1777224824660\\\", \\\"STAMPS_0_1777224824661\\\", \\\"STAMPS_1_1777224824662\\\", \\\"STAMPS_2_1777224824663\\\", \\\"STAMPS_3_1777224824664\\\"]"}, + {"arcid":"e4c422fd10943dc169e3489a38cdbf57101a5f7e","filename":null,"tags":"parody: jojo's bizarre adventure","thumbhash":null,"title":"Rohan Kishibe goes to Gucci", "summary":"", "stamps":null}, + {"arcid":"4857fd2e7c00db8b0af0337b94055d8445118630","filename":null,"tags":"artist:shirow masamune","thumbhash":null,"title":"Ghost in the Shell 1.5 - Human-Error Processor vol01ch01", "summary":"", "stamps":null}, + {"arcid":"e69e43e1355267f7d32a4f9b7f2fe108d2401ebf","filename":null,"tags":"character:segata sanshiro, male:very cool","thumbhash":null,"title":"Saturn Backup Cartridge - Japanese Manual", "summary":"", "stamps":null}, + {"arcid":"e69e43e1355267f7d32a4f9b7f2fe108d2401ebg","filename":null,"tags":"character:segata, female:very cool too","thumbhash":null,"title":"Saturn Backup Cartridge - American Manual", "summary":"", "stamps":null}, + {"arcid":"28697b96f0ac5858be2614ed10ca47742c9522fd","filename":null,"tags":"parody:fate grand order, group:wadamemo, artist:wada rco, artbook, full color, male:very cool too","thumbhash":null,"title":"Fate GO MEMO", "summary":"", "stamps":null}, + {"arcid":"2810d5e0a8d027ecefebca6237031a0fa7b91eb3","filename":null,"tags":"parody:fate grand order, character:abigail williams, character:artoria pendragon alter, character:asterios, character:ereshkigal, character:gilgamesh, character:hans christian andersen, character:hassan of serenity, character:hector, character:helena blavatsky, character:irisviel von einzbern, character:jeanne alter, character:jeanne darc, character:kiara sessyoin, character:kiyohime, character:lancer, character:martha, character:minamoto no raikou, character:mochizuki chiyome, character:mordred pendragon, character:nitocris, character:oda nobunaga, character:osakabehime, character:penthesilea, character:queen of sheba, character:rin tosaka, character:saber, character:sakata kintoki, character:scheherazade, character:sherlock holmes, character:suzuka gozen, character:tamamo no mae, character:ushiwakamaru, character:waver velvet, character:xuanzang, character:zhuge liang, group:wadamemo, artist:wada rco, artbook, full color","thumbhash":null,"title":"Fate GO MEMO 2", "summary":"", "stamps":null}, + {"arcid":"28697b96f0ac5777be2614ed10ca47742c9522fa","filename":null,"tags":"year of shadow, character:vector the crocodile","thumbhash":null,"title":"Find the Computer Room", "summary":"", "stamps":null}, + {"arcid":"28697b96f0ac5858be2666ed10ca47742c955555","filename":null,"tags":"medjed, character:doubles guy, character:king of GETs, check this 5","thumbhash":null,"title":"All about Egypt", "summary":"CURSE OF RA", "stamps":null} ], "categories":[ {"archives":["e69e43e1355267f7d32a4f9b7f2fe108d2401ebf","e69e43e1355267f7d32a4f9b7f2fe108d2401ebg"],"catid":"SET_1589141306","name":"Segata Sanshiro","search":""}, @@ -40,6 +40,13 @@ my %expected_backup = "tankoubons":[ {"archives":["28697b96f0ac5858be2666ed10ca47742c955555", "28697b96f0ac5777be2614ed10ca47742c9522fa"],"tankid":"TANK_1589141306","name":"Hello"}, {"archives":["28697b96f0ac5777be2614ed10ca47742c9522fa"],"tankid":"TANK_1589138380","name":"World"} + ], + "stamps":[ + {"archive_id":"be447b58ea66137c415ee306ee2ac44b308ee484","content":"Lorem","position":"0,0","stamp_id":"STAMPS_0_1777224824660"}, + {"archive_id":"be447b58ea66137c415ee306ee2ac44b308ee484","content":"Ipsum","position":"0,0","stamp_id":"STAMPS_0_1777224824661"}, + {"archive_id":"be447b58ea66137c415ee306ee2ac44b308ee484","content":"Dolor","position":"0,0","stamp_id":"STAMPS_1_1777224824662"}, + {"archive_id":"be447b58ea66137c415ee306ee2ac44b308ee484","content":"Sit","position":"0,0","stamp_id":"STAMPS_2_1777224824663"}, + {"archive_id":"be447b58ea66137c415ee306ee2ac44b308ee484","content":"Amet","position":"0,0","stamp_id":"STAMPS_3_1777224824664"} ]}) }; @@ -62,4 +69,9 @@ cmp_deeply( \@sorted_computed, \@sorted_expected, "Backup category comparison" ) cmp_deeply( \@sorted_computed, \@sorted_expected, "Backup tankoubon comparison" ); +@sorted_computed = sort { $a->{stamp_id} cmp $b->{stamp_id} } @{ $computed_backup{"stamps"} }; +@sorted_expected = sort { $a->{stamp_id} cmp $b->{stamp_id} } @{ $expected_backup{"stamps"} }; + +cmp_deeply( \@sorted_computed, \@sorted_expected, "Backup stamps comparison" ); + done_testing(); From e0a264b9f7537acdc809be084adc052466850432 Mon Sep 17 00:00:00 2001 From: EfronC Date: Tue, 5 May 2026 22:28:57 -0600 Subject: [PATCH 17/17] Fix comments | Add get_stamp_archive_id | Update documentation --- lib/LANraragi/Controller/Api/Stamp.pm | 88 ++++++++++++------- lib/LANraragi/Model/Archive.pm | 2 +- lib/LANraragi/Model/Stamp.pm | 31 ++++++- .../api-documentation/archive-api.md | 12 +++ .../api-documentation/stamp-api.md | 17 ++++ .../extending-lanraragi/architecture.md | 6 ++ tools/openapi.yaml | 2 +- 7 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 tools/Documentation/api-documentation/stamp-api.md diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm index 8d8f9b1d4..44aa3da30 100644 --- a/lib/LANraragi/Controller/Api/Stamp.pm +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -5,7 +5,7 @@ use Redis; use Encode; use LANraragi::Model::Stamp; -use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock exec_with_lock_pure); sub get_stamp { @@ -53,29 +53,37 @@ sub add_stamp { my $position = $self->req->param('position') || ""; unless ( defined $index ) { - return render_api_response( $self, "add_stamp", "Archive page." ); + return render_api_response( $self, "add_stamp", "No specified page for the stamp to attach to." ); } - 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, - error => $err + return unless exec_with_lock( + $self, + "archive-write:$id", + "update_archive", + $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, + error => $err + } + ); } - ); - } + } + ); } sub update_stamp { @@ -111,21 +119,35 @@ sub delete_stamp { my $self = shift->openapi->valid_input or return; my $stamp_id = $self->stash('id'); - return unless exec_with_lock( - $self, - "stamp-write:$stamp_id", - "delete_stamp", - $stamp_id, - sub { - my ( $result, $err ) = LANraragi::Model::Stamp::remove_stamp($stamp_id); + my ( $result, $id ) = LANraragi::Model::Stamp::get_stamp_archive_id($stamp_id); - if ($result) { - render_api_response( $self, "delete_stamp" ); - } else { - render_api_response( $self, "delete_stamp", $err ); + if ( $result ) { + my ( $acquired, $response ) = exec_with_lock_pure( + [ "archive-write:$id", "stamp-write:$stamp_id" ], + sub { + my ( $result, $err ) = LANraragi::Model::Stamp::remove_stamp($stamp_id); + + if ( $result ) { + render_api_response( $self, "delete_stamp" ); + } else { + render_api_response( $self, "delete_stamp", $err ); + } } + ); + + if ( !$acquired ) { + $self->render( + json => { + operation => "delete_stamp", + success => 0, + error => "Locked resource" + }, + status => 423 + ); } - ); + } else { + render_api_response( $self, "delete_stamp", $id ); + } } 1; diff --git a/lib/LANraragi/Model/Archive.pm b/lib/LANraragi/Model/Archive.pm index ab259f2a4..98d87f004 100644 --- a/lib/LANraragi/Model/Archive.pm +++ b/lib/LANraragi/Model/Archive.pm @@ -406,7 +406,7 @@ sub delete_archive ($id) { $redis->del($stamp); } } else { - # Stamps attribute was not setted, do nothing. + # Stamps attribute was not set, do nothing. } $redis->del($id); diff --git a/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm index bed4bc8f9..d35b7612e 100644 --- a/lib/LANraragi/Model/Stamp.pm +++ b/lib/LANraragi/Model/Stamp.pm @@ -44,7 +44,7 @@ sub get_stamp { } # get_stamps_by_page(id, page) -# Gets the list of pages that have at least one stamp. +# Gets the list of stamps that are associated with this Archive ID and page. # Returns an array of stamps objects. # TODO Pagination sub get_stamps_by_page { @@ -193,7 +193,7 @@ sub add_stamp { } # update_stamp(id, content, position) -# Removes the stamp from the page. +# Update the stamp's content and position. # Returns 1 on success, 0 on failure alongside an error message. sub update_stamp { my ( $stamp_id, $content, $position ) = @_; @@ -252,7 +252,30 @@ sub remove_stamp { return ( 0, $err ); } -# Converts a stamp register to object +# remove_stamp(key) +# Removes the stamp from the page. +# Returns 1 on success, 0 on failure alongside an error message. +sub get_stamp_archive_id { + my ( $key ) = @_; + + my $logger = get_logger( "Stamps", "lanraragi" ); + my $redis = LANraragi::Model::Config->get_redis; + my $err = ""; + + if ( $redis->exists( $key ) ) { + # Remove key from archive. + # This should not throw an error, since the stamp should have been linked to the archive at creation. + my $archive_id = $redis->hget( $key, "archive_id" ); + return ( 1, $archive_id ); + } + + $err = "$key doesn't exist in the database!"; + $logger->warn($err); + $redis->quit(); + return ( 0, $err ); +} + +# Converts a stamp register to object. sub convert_stamp_to_object { my ( $redis, $stamp_id ) = @_; @@ -265,7 +288,7 @@ sub convert_stamp_to_object { return %stamp; } -# Converts an array of stamp registers to an array ob objects +# Converts an array of stamp registers to an array of objects. sub convert_stamps_to_object { my ( $redis, @stamp_ids) = @_; diff --git a/tools/Documentation/api-documentation/archive-api.md b/tools/Documentation/api-documentation/archive-api.md index 280a04cbd..67f4991be 100644 --- a/tools/Documentation/api-documentation/archive-api.md +++ b/tools/Documentation/api-documentation/archive-api.md @@ -79,3 +79,15 @@ description: Everything dealing with Archives. {% openapi-operation spec="lanraragi-api" path="/archives/{id}/isnew" method="delete" %} [OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) {% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/archives/{id}/stamps" method="get" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/archives/{id}/stamps/{index}" method="get" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/archives/{id}/stamps/{index}" method="put" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} diff --git a/tools/Documentation/api-documentation/stamp-api.md b/tools/Documentation/api-documentation/stamp-api.md new file mode 100644 index 000000000..7574b15f8 --- /dev/null +++ b/tools/Documentation/api-documentation/stamp-api.md @@ -0,0 +1,17 @@ +--- +description: Endpoints related to Stamps. +--- + +# Stamp API + +{% openapi-operation spec="lanraragi-api" path="/stamps/{id}" method="get" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/stamps/{id}" method="put" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/stamps/{id}" method="delete" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} diff --git a/tools/Documentation/extending-lanraragi/architecture.md b/tools/Documentation/extending-lanraragi/architecture.md index f435d3e17..f27e1b4ae 100644 --- a/tools/Documentation/extending-lanraragi/architecture.md +++ b/tools/Documentation/extending-lanraragi/architecture.md @@ -76,6 +76,7 @@ root/ | |- Plugins.pm <- Executes Plugins on archives | |- Reader.pm <- Archive Extraction | |- Search.pm <- Search Engine +| |- Stamp.pm <- Save/Read archive stamps | |- Stats.pm <- Tag Cloud and Statistics | +- Upload.pm <- Handle incoming files (Download System) | +- Plugin <- LRR Plugins are stored here @@ -186,6 +187,11 @@ The base architecture is as follows: | |- **************************************** (2) <- Second archive in the Tankoubon | +- etc. (3, 4, 5...) | +|- STAMPS_x..._xxxxxxxxxxxxx <- A Stamp. STAMPS__. The length is variable depending on the page. +| |- content <- The text body of the stamp. +| |- position <- Normalized coordinates of the page. The coordinates are in 0-100 range with 0,0 being the top left of the image. +| |- archive_id <- ID of the archive the stamp belongs to. For reverse searches. +| |- **************************************** <- 40-character long ID for every logged archive | |- tags <- Saved tags | |- summary <- Summary of the archive, as set by the User diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 2900e159e..4ecb9f579 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -4040,7 +4040,7 @@ components: description: ID of the stamp position: type: string - description: Position of the stamp in the page in pixel coordinates + description: Position of the stamp in the page in normalized coordinates (0-100) content: type: string description: Text of the stamp