diff --git a/lib/LANraragi/Controller/Api/Stamp.pm b/lib/LANraragi/Controller/Api/Stamp.pm new file mode 100644 index 000000000..44aa3da30 --- /dev/null +++ b/lib/LANraragi/Controller/Api/Stamp.pm @@ -0,0 +1,154 @@ +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 exec_with_lock_pure); + + +sub get_stamp { + + my $self = shift->openapi->valid_input or return; + my $stamp_id = $self->stash('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."); + return; + } + + $self->render( openapi => { result => $stamp } ); +} + +sub get_stamps_by_page { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $index = $self->stash('index'); + + my ( $stamps, $err ) = LANraragi::Model::Stamp::get_stamps_by_page($id, $index); + + $self->render( openapi => { result => $stamps } ); +} + +sub get_stamped_pages { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + + my ( $indexes, $err ) = LANraragi::Model::Stamp::get_stamped_pages( $id ); + + $self->render( openapi => { result => $indexes } ); +} + +sub add_stamp { + + my $self = shift->openapi->valid_input or return; + my $id = $self->stash('id'); + my $index = $self->stash('index'); + my $content = $self->req->param('content') || ""; + my $position = $self->req->param('position') || ""; + + unless ( defined $index ) { + return render_api_response( $self, "add_stamp", "No specified page for the stamp to attach to." ); + } + + 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 { + + my $self = shift->openapi->valid_input or return; + my $stamp_id = $self->stash('id'); + my $position = $self->req->param('position') || undef; + my $content = $self->req->param('content') || undef; + + return unless exec_with_lock( + $self, + "stamp-write:$stamp_id", + "update_stamp", + $stamp_id, + sub { + my ( $result, $err ) = LANraragi::Model::Stamp::update_stamp( $stamp_id, $content, $position ); + + if ($result) { + my %stamp = LANraragi::Model::Stamp::get_stamp( $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 $stamp_id = $self->stash('id'); + + my ( $result, $id ) = LANraragi::Model::Stamp::get_stamp_archive_id($stamp_id); + + 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 fb793b80e..98d87f004 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 set, do nothing. + } + $redis->del($id); $redis->quit(); 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/lib/LANraragi/Model/Stamp.pm b/lib/LANraragi/Model/Stamp.pm new file mode 100644 index 000000000..d35b7612e --- /dev/null +++ b/lib/LANraragi/Model/Stamp.pm @@ -0,0 +1,343 @@ +package LANraragi::Model::Stamp; + +use v5.36; +use experimental 'try'; + +use strict; +use warnings; +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 redis_decode); +use LANraragi::Utils::Generic qw(filter_hash_by_keys); + + +# get_stamp(stamp_id) +# Gets the requested stamp. +# Returns the stamp object. +sub get_stamp { + my ( $stamp_id ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $err = ""; + + if ( $stamp_id eq "" ) { + $logger->debug("No stamp ID provided."); + return (); + } + + 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 ( \%stamp, $err ); +} + +# get_stamps_by_page(id, page) +# 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 { + my ( $archive_id, $index ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + 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 ); + } + + 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(); + + return ( \@stamps, $err ); +} + +# get_stamped_pages(id) +# Gets the list of pages that have at least one stamp. +# Returns an array of page numbers. +sub get_stamped_pages { + my ( $archive_id ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $err = ""; + my @keys; + + unless ( $redis->exists( $archive_id ) ) { + $err = "$archive_id does not exist in the database."; + $logger->error($err); + $redis->quit(); + return ( 0, $err ); + } + + if ( $redis->hexists( $archive_id => "stamps" ) ) { + my %indexes; + my $stamps = $redis->hget( $archive_id, "stamps" ); + $stamps = deserialize_stamp_list( $stamps ); + + if ( !defined $stamps ) { + $redis->quit(); + $err = "There was a problem deserializing the stamps"; + return ( 0, $err ); + } + + my @stamps = @$stamps; + + 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(archive_id, page, content, position) +# Add the stamp to the page. +# Returns the stamp key. +sub add_stamp { + my ( $archive_id, $index, $content, $position ) = @_; + + my $redis = LANraragi::Model::Config->get_redis; + my $logger = get_logger( "Stamps", "lanraragi" ); + my $err = ""; + + unless ( $redis->exists( $archive_id ) ) { + $err = "$archive_id does not exist in the database."; + $logger->error($err); + $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 ); + } + + # 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); + + # 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( $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) ); + + # Add to archive + my $stamps = $redis->hget( $archive_id, "stamps" ); + my @stamps; + + 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"; + $redis->del( $key ); + $logger->error($err); + $redis->quit(); + return ( 0, $err ); + } + push @stamps, $key; + $stamps = encode_json( \@stamps ); + } else { + $logger->warn( + "Stamps not initialized -- Will overwrite with a Stamps containing the new data. (This is normal if this ID had no Stamps yet.)" + ); + push @stamps, $key; + $stamps = encode_json( \@stamps ); + } + $redis->hset( $archive_id, "stamps", $stamps ); + + $redis->quit(); + + return ( $key, $err ); +} + +# update_stamp(id, content, position) +# 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 ) = @_; + + my $logger = get_logger( "Stamps", "lanraragi" ); + my $redis = LANraragi::Model::Config->get_redis; + my $err = ""; + + if ( $redis->exists( $stamp_id ) ) { + if ( defined $position ) { + $redis->hset( $stamp_id, "position", redis_encode( $position ) ) + } + + if ( defined $content ) { + $redis->hset( $stamp_id, "content", redis_encode( $content ) ) + } + + $redis->quit(); + return ( 1, $err ); + } + + $err = "$stamp_id doesn't exist in the database!"; + $logger->warn($err); + $redis->quit(); + return ( 0, $err ); +} + +# remove_stamp(key) +# Removes the stamp from the page. +# Returns 1 on success, 0 on failure alongside an error message. +sub remove_stamp { + 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" ); + 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 = "$key doesn't exist in the database!"; + $logger->warn($err); + $redis->quit(); + return ( 0, $err ); +} + +# 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 ) = @_; + + 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 of objects. +sub convert_stamps_to_object { + my ( $redis, @stamp_ids) = @_; + + my @stamps; + + # Convert stamp registers to objects + 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/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/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(); diff --git a/tests/mocks.pl b/tests/mocks.pl index d5474a8a7..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", @@ -130,7 +131,32 @@ sub setup_redis_mock { "TANK_1589138380":[ "name_World", "28697b96f0ac5777be2614ed10ca47742c9522fa" - ] + ], + "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" + } }) }; @@ -146,7 +172,8 @@ sub setup_redis_mock { # Replace redis' '*' wildcards with regex '.*'s $expr = $expr =~ s/\*/\.\*/gr; - return grep { /$expr/ } keys %datamodel; + + return grep { /^$expr$/ } keys %datamodel; } ); $redis->mock( 'exists', sub { shift; return $_[0] eq "LRR_SEARCHCACHE" ? 0 : 1 } ); @@ -413,6 +440,72 @@ sub setup_redis_mock { } ); + $redis->mock( + 'hscan', # $redis->hscan => get all values that match pattern in datamodel + sub { + # Default values + my ($self, $key, $cursor, @args) = @_; + + my $match; + my $count; + + # Parse optional args (MATCH, COUNT) + while (@args) { + my $arg = shift @args; + if ($arg eq 'MATCH') { + $match = shift @args; + } + elsif ($arg eq 'COUNT') { + $count = shift @args; + } + } + + # Return empty result if key doesn't exist + return ('0', []) unless exists $datamodel{$key}; + + my $hash = $datamodel{$key}; + + # Get all keys + my @keys = keys %$hash; + + # Apply MATCH if provided (convert glob → regex) + if (defined $match) { + my $regex = quotemeta($match); + $regex =~ s/\\\*/.*/g; # * → .* + $regex =~ s/\\\?/.?/g; # ? → .? + @keys = grep { $_ =~ /^$regex$/ } @keys; + } + + # Apply COUNT (just truncate for simplicity) + if (defined $count && $count < @keys) { + @keys = @keys[0 .. $count - 1]; + } + + # Build field-value list + my @result; + for my $k (@keys) { + push @result, $k, $hash->{$k}; + } + + # Always return cursor 0 (no real iteration in mock) + return ('0', \@result); + } + ); + + $redis->mock( + 'hkeys', # $redis->hscan => get keys in hash table in datamodel + sub { + my $self = shift; + my $key = shift; + + unless (exists $datamodel{$key}) { + return [] ; + } + return [ keys %{ $datamodel{$key} } ]; + + } + ); + $redis->fake_module( "Redis", new => sub { $redis } ); } diff --git a/tests/stamp.t b/tests/stamp.t new file mode 100644 index 000000000..58d080045 --- /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"}, "STAMPS_1_1777224824662", "Stamps by page value 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" ); + +done_testing(); 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 37e710592..4ecb9f579 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: @@ -1851,6 +1853,133 @@ 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: + $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 + 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' /search: get: @@ -3367,6 +3496,122 @@ paths: application/json: schema: $ref: '#/components/schemas/OperationResponse' + /stamps/{id}: + get: + operationId: getStamp + x-mojo-to: api-stamp#get_stamp + summary: Get Stamp + description: Get a stamp from an Archive. + tags: + - stamps + parameters: + - name: id + in: path + required: true + description: ID of the stamp. + schema: + type: string + responses: + '200': + description: Success response + content: + application/json: + schema: + $ref: '#/components/schemas/StampsData' + '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 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 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' components: securitySchemes: api_key: @@ -3783,5 +4028,31 @@ 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 in normalized coordinates (0-100) + content: + type: string + description: Text of the stamp + 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