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