diff --git a/.github/workflows/push-continuous-integration.yml b/.github/workflows/push-continuous-integration.yml index 7e5f1dd93..cce37778c 100644 --- a/.github/workflows/push-continuous-integration.yml +++ b/.github/workflows/push-continuous-integration.yml @@ -10,14 +10,26 @@ on: integration_test_ref: description: 'Integration testing repository git commit hash or branch' required: true - default: 'dfdcd948cbc6d6bbd2db6620d8499bc8698843e3' + default: 'dev-registry/main' env: INTEGRATION_TEST_REPOSITORY: ${{ github.event.inputs.integration_test_repository || 'psilabs-dev/aio-lanraragi' }} - INTEGRATION_TEST_REF: ${{ github.event.inputs.integration_test_ref || 'dfdcd948cbc6d6bbd2db6620d8499bc8698843e3' }} + INTEGRATION_TEST_REF: ${{ github.event.inputs.integration_test_ref || 'dev-registry/main' }} name: "Continuous Integration \U0001F44C\U0001F440" jobs: + eslint: + name: ESLint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + - run: npm ci + - run: npm run lint + testSuite: name: Run Test Suite and Perl Critic runs-on: ubuntu-latest @@ -85,18 +97,29 @@ jobs: run: | mkdir -p "$GITHUB_WORKSPACE/staging" - name: Run integration tests against local LANraragi build - timeout-minutes: 30 + timeout-minutes: 70 run: | cd aio-lanraragi/integration_tests export CI=true uv run pytest tests --log-cli-level=INFO \ --image lanraragi:ci \ --staging "$GITHUB_WORKSPACE/staging" \ + --server-logs "$GITHUB_WORKSPACE/server-logs" \ + --cache-backend valkey \ --playwright \ + --no-rate-limit \ + --dev registry \ --npseed 42 \ - -k "not test_double_page_navigation and not test_handler_resource_management" + -k "not test_double_page_navigation and not test_handler_resource_management and not test_plugin_uninstall_ui and not test_sideloaded_script_lifecycle and not test_search_functionality and not test_ui_nofunmode_login_right_password and not test_ui_enable_nofunmode and not test_validation_carousel_search" env: DOCKER_HOST: unix:///var/run/docker.sock + - name: Upload server logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: server-logs-ubuntu + path: ${{ github.workspace }}/server-logs/ + retention-days: 14 integrationTestsWindows: name: Run Windows Integration Tests @@ -215,13 +238,23 @@ jobs: New-Item -ItemType Directory -Path "$env:GITHUB_WORKSPACE\staging" -Force | Out-Null - name: Run integration tests against local LANraragi build working-directory: aio-lanraragi/integration_tests - timeout-minutes: 50 + timeout-minutes: 100 run: | $env:CI='true' uv run pytest tests ` --log-cli-level=INFO ` --windist "$env:GITHUB_WORKSPACE\LANraragi\win-dist" ` --staging "$env:GITHUB_WORKSPACE\staging" ` + --server-logs "$env:GITHUB_WORKSPACE\server-logs" ` --playwright ` + --no-rate-limit ` + --dev registry ` --npseed 42 ` - -k "not test_double_page_navigation and not test_handler_resource_management" + -k "not test_double_page_navigation and not test_handler_resource_management and not test_plugin_uninstall_ui and not test_sideloaded_script_lifecycle and not test_search_functionality and not test_ui_nofunmode_login_right_password and not test_ui_enable_nofunmode and not test_validation_carousel_search" + - name: Upload server logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: server-logs-windows + path: ${{ github.workspace }}\server-logs\ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 1c4a99df0..9e72f21ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +lib/LANraragi/Plugin/Managed + node_modules content thumb diff --git a/eslint.config.mjs b/eslint.config.mjs index 9425b6c06..b34012c08 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ import js from "@eslint/js"; -import { defineConfig } from "eslint/config"; +import { defineConfig, globalIgnores } from "eslint/config"; import stylistic from "@stylistic/eslint-plugin"; import globals from "globals"; @@ -9,7 +9,6 @@ import globals from "globals"; */ const config = { files: ["**/*.js"], - ignores: ["public/js/vendor/*.js"], plugins: { js, "@stylistic": stylistic, @@ -22,27 +21,10 @@ const config = { globals: { ...globals.browser, ...globals.jquery, - // LANraragi specific - // TODO rework all main scripts to no longer store data in the global scope, probably transition to ES modules - Backup: "readonly", - Batch: "readonly", - Category: "readonly", - Common: "readonly", - Config: "readonly", - Duplicates: "readonly", - Edit: "readonly", - I18N: "readonly", - Index: "readonly", - IndexTable: "readonly", - Logs: "readonly", - LRR: "readonly", - Plugins: "readonly", - Reader: "readonly", - Server: "readonly", - Stats: "readonly", // external packages Awesomplete: "readonly", - marked: "readonly", + Raty: "readonly", + Sortable: "readonly", Swiper: "readonly", tagger: "readonly", tippy: "readonly", @@ -84,4 +66,7 @@ const config = { }, }; -export default defineConfig(config); +export default defineConfig([ + globalIgnores(["public/js/vendor/*.js", "tests/samples/*"]), + config, +]); diff --git a/lib/LANraragi.pm b/lib/LANraragi.pm index f510d53d2..b15e38170 100644 --- a/lib/LANraragi.pm +++ b/lib/LANraragi.pm @@ -23,6 +23,7 @@ use LANraragi::Utils::I18NInitializer; use LANraragi::Model::Search; use LANraragi::Model::Config; +use LANraragi::Model::Plugins; use LANraragi::Model::Setup qw(first_install_actions); use LANraragi::Model::Metrics; @@ -134,6 +135,11 @@ sub startup { # through LRR's rotating logger pipeline. $self->log( get_logger( "Mojolicious", "mojo" ) ); + # Reconcile discovered plugins with Redis state. + my $redis_config = $self->LRR_CONF->get_redis_config; + LANraragi::Model::Plugins::scan_plugins($redis_config); + $redis_config->quit(); + #Plugin listing my @plugins = get_plugins("metadata"); foreach my $pluginfo (@plugins) { diff --git a/lib/LANraragi/Controller/Api/Other.pm b/lib/LANraragi/Controller/Api/Other.pm index 6aad14a73..5e59fead5 100644 --- a/lib/LANraragi/Controller/Api/Other.pm +++ b/lib/LANraragi/Controller/Api/Other.pm @@ -1,12 +1,13 @@ package LANraragi::Controller::Api::Other; use Mojo::Base 'Mojolicious::Controller'; -use Mojo::JSON qw(encode_json decode_json); +use Mojo::JSON qw(encode_json decode_json true false); use Redis; use LANraragi::Model::Stats; use LANraragi::Model::Opds; use LANraragi::Utils::Generic qw(render_api_response); +use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Plugins qw(get_plugin get_plugins use_plugin); sub serve_serverinfo { @@ -90,6 +91,8 @@ sub list_plugins { my $type = $self->stash('type'); my @plugins = get_plugins($type); + my $redis = $self->LRR_CONF->get_redis_config; + my $logger = get_logger( "Plugins", "lanraragi" ); foreach my $plugin (@plugins) { if ( ref( $plugin->{parameters} ) eq 'HASH' ) { @@ -99,8 +102,22 @@ sub list_plugins { } $plugin->{parameters} = \@parameters_array; } + + my $namerds = "LRR_PLUGIN_" . uc( $plugin->{namespace} ); + $plugin->{registry} = $redis->hget( $namerds, "installed_registry" ); + $plugin->{sha256} = $redis->hget( $namerds, "installed_sha256" ); + + # Usually installed_version shouldn't be different from version. + my $installed_version = $redis->hget( $namerds, "installed_version" ); + my $plugin_version = $plugin->{version}; + if ( defined $installed_version && $installed_version ne $plugin_version ) { + $logger->warn( + "Plugin $plugin installed_version='$installed_version' but plugin->version='$plugin_version'" + ); + } } + $redis->quit(); $self->render( openapi => \@plugins ); } diff --git a/lib/LANraragi/Controller/Api/Plugins.pm b/lib/LANraragi/Controller/Api/Plugins.pm new file mode 100644 index 000000000..6aef437b7 --- /dev/null +++ b/lib/LANraragi/Controller/Api/Plugins.pm @@ -0,0 +1,130 @@ +package LANraragi::Controller::Api::Plugins; +use Mojo::Base 'Mojolicious::Controller'; + +use LANraragi::Model::Plugins; +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); +use LANraragi::Utils::Logging qw(get_logger); + +# Install a managed plugin. +sub install_plugin { + my $self = shift->openapi->valid_input or return; + my $body = $self->req->json; + my $namespace = $body->{namespace}; + my $registry_id = $body->{registry}; + my $version = $body->{version}; + my $force = $body->{force} // 0; + + # TODO: maybe consider extending TTL for this sub to 60s if it's not enough. + return unless exec_with_lock( + $self, + "plugin-write:" . uc($namespace), + "install_plugin", + $namespace, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + # Since a plugin namespace can be built-in or managed, we'll need to check redis first. + # If a plugin exists and is not managed, installation may not continue. + # The user will have to remove/uninstall the existing plugin before installing a managed plugin + # by the same namespace. + my $namerds = "LRR_PLUGIN_" . uc($namespace); + if ( $redis->hexists( $namerds, "installed_path" ) ) { + my $source = LANraragi::Model::Plugins::infer_plugin_origin( $namerds, $redis ); + my $currentreg = $redis->hget( $namerds, "installed_registry" ); + my $currentver = $redis->hget( $namerds, "installed_version" ); + + # only managed plugins can be upgraded. + if ( $source ne "managed" ) { + $redis->quit(); + render_api_response( + $self, + "install_plugin", + "Plugin '$namespace' already exists as a $source plugin. Remove it first before installing from a registry." + ); + return; + } + + # cross-registry overwrites require force installation. + my $is_cross_registry = $currentreg && $currentreg ne $registry_id; + if ( $is_cross_registry && !$force ) { + $redis->quit(); + render_api_response( + $self, + "install_plugin", + "Plugin '$namespace' already installed from '$currentreg' (v$currentver). Use force to overwrite." + ); + return; + } + } + + my $logger = get_logger( "Registry", "lanraragi" ); + my $install_error; + my ( $status, $plugmeta, $message ) = eval { + LANraragi::Model::Plugins::install_plugin( + $namespace, $redis, $registry_id, $version + ); + }; + $install_error = $@; + + if ($install_error) { + $redis->quit(); + $logger->error("install_plugin failed for '$namespace': $install_error"); + render_api_response( $self, "install_plugin", "Plugin installation failed." ); + return; + } + + $redis->quit(); + + unless ( $status == 200 ) { + $self->render( + openapi => { operation => "install_plugin", error => $message, success => 0 }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "install_plugin", + success => 1, + name => $plugmeta->{name}, + namespace => $namespace, + version => $plugmeta->{version}, + registry => $plugmeta->{registry}, + sha256 => $plugmeta->{sha256}, + } + ); + }, + ); +} + +# Uninstall a managed plugin. +sub uninstall_plugin { + my $self = shift->openapi->valid_input or return; + my $namespace = $self->stash('plugin_namespace'); + + return unless exec_with_lock( + $self, + "plugin-write:" . uc($namespace), + "uninstall_plugin", + $namespace, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $message ) = LANraragi::Model::Plugins::uninstall_plugin( + $namespace, $redis + ); + $redis->quit(); + + unless ( $status == 200 ) { + $self->render( + openapi => { operation => "uninstall_plugin", error => $message, success => 0 }, + status => $status + ); + return; + } + + render_api_response( $self, "uninstall_plugin" ); + } + ); +} + +1; diff --git a/lib/LANraragi/Controller/Api/Registry.pm b/lib/LANraragi/Controller/Api/Registry.pm new file mode 100644 index 000000000..ac2e4d967 --- /dev/null +++ b/lib/LANraragi/Controller/Api/Registry.pm @@ -0,0 +1,294 @@ +package LANraragi::Controller::Api::Registry; +use Mojo::Base 'Mojolicious::Controller'; + +use LANraragi::Model::Registry; +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); +use LANraragi::Utils::Logging qw(get_logger); + +sub list_registries { + my $self = shift->openapi->valid_input or return; + my $redis = $self->LRR_CONF->get_redis_config; + + my @registries = LANraragi::Model::Registry::get_registry_list($redis); + $redis->quit(); + + $self->render( + openapi => { + operation => "list_registries", + success => 1, + registries => \@registries, + } + ); +} + +# create a registry, which can be of provider github, gitlab, gitea, cdn, or local. +# git providers (github/gitlab/gitea): require url (https) and ref. +# cdn provider: requires url (http or https base URL). +# local provider: requires path (to local registry directory) +sub create_registry { + my $self = shift->openapi->valid_input or return; + my $body = $self->req->json; + my $provider = $body->{provider}; + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Create registry requested (provider: " . ( defined $provider ? $provider : "" ) . ")."); + + return unless exec_with_lock( + $self, + "registry-create", + "create_registry", + "registry", + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my %config = ( name => $body->{name}, provider => $provider ); + + if ( $provider eq "github" || $provider eq "gitlab" || $provider eq "gitea" ) { + $config{url} = $body->{url}; + $config{ref} = $body->{ref}; + } elsif ( $provider eq "cdn" ) { + $config{url} = $body->{url}; + } elsif ( $provider eq "local" ) { + $config{path} = $body->{path}; + } + + my ( $registry_id, $error ) = LANraragi::Model::Registry::create_registry( \%config, $redis ); + $redis->quit(); + + if ($error) { + render_api_response( $self, "create_registry", $error ); + return; + } + + $self->render( + openapi => { + operation => "create_registry", + success => 1, + error => "", + id => $registry_id, + } + ); + } + ); +} + +# Get registry by its registry ID. +sub get_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $redis = $self->LRR_CONF->get_redis_config; + + my ( $registry, $status, $error ) = LANraragi::Model::Registry::get_registry( $registry_id, $redis ); + $redis->quit(); + + unless ($registry) { + $self->render( + openapi => { + operation => "get_registry", + error => $error, + success => 0, + }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "get_registry", + success => 1, + error => "", + registry => $registry, + } + ); +} + +# Update a registry by its ID. +# The registry must exist (otherwise, use create_registry) +sub update_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $body = $self->req->json; + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Update registry requested for '$registry_id'."); + + return unless exec_with_lock( + $self, + "registry-write:$registry_id", + "update_registry", + $registry_id, + sub { + my %updated_registry; + for my $field (qw(name provider url ref path)) { + $updated_registry{$field} = $body->{$field} if exists $body->{$field}; + } + + unless ( %updated_registry ) { + render_api_response( $self, "update_registry", "Nothing to update." ); + return; + } + + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $error ) = LANraragi::Model::Registry::update_registry( + $registry_id, $redis, %updated_registry + ); + $logger->info("Update registry result for '$registry_id': status=$status"); + $redis->quit(); + + unless ( $status == 200 ) { + $logger->warn("Update registry failed for '$registry_id': $error"); + $self->render( + openapi => { operation => "update_registry", error => $error, success => 0 }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "update_registry", + success => 1, + error => "", + id => $registry_id, + } + ); + } + ); +} + +# Delete registry by its registry ID. +sub delete_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Delete registry requested for '$registry_id'."); + + return unless exec_with_lock( + $self, + "registry-write:$registry_id", + "delete_registry", + $registry_id, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $error ) = LANraragi::Model::Registry::delete_registry( + $registry_id, $redis + ); + $redis->quit(); + + unless ( $status == 200 ) { + $logger->warn("Delete registry failed for '$registry_id': $error"); + $self->render( + openapi => { operation => "delete_registry", error => $error, success => 0 }, + status => $status + ); + return; + } + + render_api_response( $self, "delete_registry" ); + } + ); +} + +sub get_default_registry { + my $self = shift->openapi->valid_input or return; + my $redis = $self->LRR_CONF->get_redis_config; + + my $registry_id = LANraragi::Model::Registry::get_default_registry($redis); + $redis->quit(); + + $self->render( + openapi => { + operation => "get_default_registry", + success => 1, + id => $registry_id, + } + ); +} + +sub update_default_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Update default registry to '$registry_id' requested."); + + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $reg_id, $message ) = + LANraragi::Model::Registry::update_default_registry( $registry_id, $redis ); + $redis->quit(); + + unless ( $status == 200 ) { + return $self->render( + openapi => { + operation => "update_default_registry", + success => 0, + error => $message, + }, + status => $status, + ); + } + + return $self->render( + openapi => { + operation => "update_default_registry", + success => 1, + id => $reg_id, + }, + status => 200, + ); +} + +sub remove_default_registry { + my $self = shift->openapi->valid_input or return; + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Remove default registry requested."); + + my $redis = $self->LRR_CONF->get_redis_config; + my $registry_id = LANraragi::Model::Registry::remove_default_registry($redis); + $redis->quit(); + + return $self->render( + openapi => { + operation => "remove_default_registry", + success => 1, + id => $registry_id, + } + ); +} + +# Refresh registry and return registry.json index. +sub refresh_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Refresh registry requested for '$registry_id'."); + + return unless exec_with_lock( + $self, + "registry-write:$registry_id", + "refresh_registry", + $registry_id, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $registry_index, $message ) = LANraragi::Model::Registry::refresh_registry( + $registry_id, $redis + ); + $redis->quit(); + + unless ( $status == 200 ) { + $self->render( + openapi => { operation => "refresh_registry", error => $message, success => 0 }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "refresh_registry", + success => 1, + index => $registry_index, + } + ); + } + ); +} + +1; diff --git a/lib/LANraragi/Controller/Edit.pm b/lib/LANraragi/Controller/Edit.pm index a7f8c3a65..9151736ad 100644 --- a/lib/LANraragi/Controller/Edit.pm +++ b/lib/LANraragi/Controller/Edit.pm @@ -10,13 +10,19 @@ use Mojo::Util qw(xml_escape); use LANraragi::Utils::Generic qw(generate_themes_header); use LANraragi::Utils::Redis qw(redis_decode); use LANraragi::Utils::Plugins qw(get_plugins); +use LANraragi::Model::Tankoubon; sub index { my $self = shift; - #Does the passed file exist in the database? + # Does the passed file exist in the database? my $id = $self->req->param('id'); + # Tankoubon IDs follow the pattern TANK_\d{10} + if ( $id && $id =~ /^TANK_/ ) { + return $self->edit_tankoubon($id); + } + my $redis = $self->LRR_CONF->get_redis; if ( $redis->exists($id) ) { @@ -41,6 +47,7 @@ sub index { file => decode_utf8($file), thumbhash => $thumbhash, plugins => \@pluginlist, + is_tank => 0, title => $self->LRR_CONF->get_htmltitle, descstr => $self->LRR_DESC, csshead => generate_themes_header($self), @@ -51,4 +58,38 @@ sub index { } } +sub edit_tankoubon { + my ( $self, $id ) = @_; + + my %metadata = LANraragi::Model::Tankoubon::fetch_metadata_fields($id); + + unless (%metadata) { + $self->redirect_to('index'); + return; + } + + my ( $total, $filtered, %tank ) = LANraragi::Model::Tankoubon::get_tankoubon( $id, 1 ); + my @archives = @{ $tank{archives} // [] }; + my @full_data = @{ $tank{full_data} // [] }; + + my $name = $metadata{name} // ""; + my $tags = $metadata{tags} // ""; + my $summary = $metadata{summary} // ""; + + $self->render( + template => "edit", + id => $id, + arctitle => xml_escape($name), + tags => xml_escape($tags), + summary => xml_escape($summary), + is_tank => 1, + archives => \@archives, + archive_data => \@full_data, + title => $self->LRR_CONF->get_htmltitle, + descstr => $self->LRR_DESC, + csshead => generate_themes_header($self), + version => $self->LRR_VERSION + ); +} + 1; diff --git a/lib/LANraragi/Controller/Plugins.pm b/lib/LANraragi/Controller/Plugins.pm index 7dd44d56d..9583bb9b8 100644 --- a/lib/LANraragi/Controller/Plugins.pm +++ b/lib/LANraragi/Controller/Plugins.pm @@ -141,10 +141,6 @@ sub save_config { } elsif ( ref( $pluginfo->{parameters} ) eq 'HASH' ) { - # TODO: remove this line (and the ARRAY check above) - # after plugins with array parameters are deprecated - $redis->del($namerds); - #Loop through the namespaced request parameters foreach my $key ( keys %{ $pluginfo->{parameters} } ) { diff --git a/lib/LANraragi/Controller/Reader.pm b/lib/LANraragi/Controller/Reader.pm index 7d1fd209b..3ce449493 100644 --- a/lib/LANraragi/Controller/Reader.pm +++ b/lib/LANraragi/Controller/Reader.pm @@ -36,6 +36,7 @@ sub index { use_local => $self->LRR_CONF->enable_localprogress, auth_progress => $self->LRR_CONF->enable_authprogress, id => $id, + is_tank => ( $id =~ /^TANK_/ ? 1 : 0 ), arc_categories => \@arc_categories, categories => \@categories, csshead => generate_themes_header($self), diff --git a/lib/LANraragi/Controller/Tankoubon.pm b/lib/LANraragi/Controller/Tankoubon.pm deleted file mode 100644 index f5721eb73..000000000 --- a/lib/LANraragi/Controller/Tankoubon.pm +++ /dev/null @@ -1,33 +0,0 @@ -package LANraragi::Controller::Tankoubon; -use Mojo::Base 'Mojolicious::Controller'; - -use utf8; -use URI::Escape; -use Redis; -use Encode; -use Mojo::Util qw(xml_escape); - -use LANraragi::Utils::Generic qw(generate_themes_header); -use LANraragi::Utils::Redis qw(redis_decode); - -# Go through the archives in the content directory and build the template at the end. -sub index { - - my $self = shift; - my $redis = $self->LRR_CONF->get_redis; - my $force = 0; - - my $userlogged = $self->LRR_CONF->enable_pass == 0 || $self->session('is_logged'); - - $redis->quit(); - - $self->render( - template => "tankoubon", - title => $self->LRR_CONF->get_htmltitle, - descstr => $self->LRR_DESC, - csshead => generate_themes_header($self), - version => $self->LRR_VERSION - ); -} - -1; diff --git a/lib/LANraragi/Model/Archive.pm b/lib/LANraragi/Model/Archive.pm index a9c9a265b..2e93d059b 100644 --- a/lib/LANraragi/Model/Archive.pm +++ b/lib/LANraragi/Model/Archive.pm @@ -111,7 +111,7 @@ sub generate_page_thumbnails { # Get the number of pages in the archive my $redis = LANraragi::Model::Config->get_redis; - my $pages = $redis->hget( $id, "pagecount" ); + my $pages = $redis->hget( $id, "pagecount" ) // 0; my $subfolder = substr( $id, 0, 2 ); my $thumbname = "$thumbdir/$subfolder/$id.$format"; diff --git a/lib/LANraragi/Model/Plugins.pm b/lib/LANraragi/Model/Plugins.pm index ae59ba43f..45a73f5fe 100644 --- a/lib/LANraragi/Model/Plugins.pm +++ b/lib/LANraragi/Model/Plugins.pm @@ -8,8 +8,12 @@ use warnings; use utf8; use feature 'fc'; +use Cwd qw(getcwd); +use Digest::SHA qw(sha256_hex); +use File::Path qw(make_path); use Redis; use Encode; +use Mojo::File; use Mojo::JSON qw(decode_json encode_json); use Mojo::UserAgent; use Data::Dumper; @@ -20,9 +24,22 @@ use LANraragi::Utils::Generic qw(exec_with_lock_pure); use LANraragi::Utils::Archive qw(extract_thumbnail); use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Tags qw(rewrite_tags split_tags_to_array); -use LANraragi::Utils::Plugins qw(get_plugin_parameters get_plugin); +use LANraragi::Utils::Plugins qw(get_plugin_parameters get_plugin register_plugin unregister_plugin); +use LANraragi::Utils::PluginState qw(record_load_success signal_uninstalled signal_updated); use LANraragi::Utils::Redis qw(redis_decode); -use LANraragi::Utils::Path qw(create_path); +use LANraragi::Utils::Path qw(create_path package_to_path); +use LANraragi::Utils::Registry qw( + fetch_registry_resource + find_package_conflict + find_namespace_conflict + validate_registry_artifact_path + resolve_max_version + MANAGED_TYPE_DIRS +); +use LANraragi::Model::Registry; + +# Max plugin file size for slurp (files will/should never reach this size anyways but stops OOM) +use constant MAX_PLUGIN_FILE_SIZE => 100 * 1024 * 1024; # 100 MB # Sub used by Auto-Plugin. sub exec_enabled_plugins_on_file ($id) { @@ -314,4 +331,484 @@ sub has_old_style_params (%params) { return ( exists $params{'customargs'} ); } +# Install a plugin from a registry whose index has been refreshed and cached. +# namespace, registry_id, version are required to identify the plugin to install. +# LRR assumes managed plugins are hash-style, and doesn't handle signature +# changes from one plugin version to another. +sub install_plugin { + my ( $namespace, $redis, $registry_id, $version ) = @_; + my $logger = get_logger( "Registry", "lanraragi" ); + my $namerds = "LRR_PLUGIN_" . uc($namespace); + + # registry validation + # check that registry exists, and that registry index exists. + my ( $registry, $lookup_status, $lookup_error ) = + LANraragi::Model::Registry::get_registry( $registry_id, $redis ); + return ( $lookup_status, undef, $lookup_error ) unless $registry; + my ($registry_timestamp) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$registry_timestamp"; + unless ( $redis->exists($registry_index_key) ) { + return ( 409, undef, "No registry index cached. Run refresh first." ); + } + + # Plugin installable validation + my $registry_index_json = $redis->get($registry_index_key); + my $registry_index = decode_json($registry_index_json); + my $registry_plugins = $registry_index->{plugins}; + unless ( $registry_plugins->{$namespace} ) { + return ( 404, undef, "Plugin '$namespace' not found in registry." ); + } + my $plugin_record = $registry_plugins->{$namespace}; + unless ( ref $plugin_record->{versions} eq "HASH" && keys %{ $plugin_record->{versions} } ) { + return ( 404, undef, "No versions found for plugin '$namespace'." ); + } + $version = resolve_max_version($plugin_record) unless defined $version; + unless ( $plugin_record->{versions}{$version} ) { + return ( 404, undef, "Version '$version' not found for plugin '$namespace'." ); + } + + # Get the plugin metadata by version and validate artifact path + my $plugin_metadata = $plugin_record->{versions}{$version}; + my $artifact_path = $plugin_metadata->{artifact}; + my ( $artifact_valid, $artifact_error ) = validate_registry_artifact_path($artifact_path); + unless ( $artifact_valid ) { + return ( 400, undef, $artifact_error ); + } + + my $abs_installed_path; + if ( $redis->hexists( $namerds, "installed_path" ) ) { + $abs_installed_path = resolve_installed_path( $redis->hget( $namerds, "installed_path" ) ); + } + + # Retrieve get the plugin contents + my ( $fetch_status, $plugin_content, $fetch_error ) = + fetch_registry_resource( $registry, $artifact_path, MAX_PLUGIN_FILE_SIZE ); + unless ( $fetch_status == 200 ) { + $logger->warn( + "Failed to fetch plugin artifact '$artifact_path' for '$namespace' from registry '$registry_id': $fetch_error" + ); + return ( $fetch_status, undef, $fetch_error ); + } + + # Post-retrieval validation and extract installation data + my $plugin_type = $plugin_record->{type}; + my ( $validated, $error ) = validate_managed_plugin( $plugin_content, $namespace, $plugin_metadata, $plugin_type, $abs_installed_path ); + if ($error) { + $logger->warn("Managed plugin validation failed for '$namespace' from registry '$registry_id': $error"); + return ( 422, undef, $error ); + } + my $install_dir = $validated->{install_dir}; + my $install_path = $validated->{install_path}; + my $install_relpath = package_to_path( $validated->{package} ); + + # Begin installation with rollback + $logger->info("Installing plugin '$namespace' v$version from registry '$registry_id'"); + make_path($install_dir) unless -d $install_dir; + my $op_desc = "install of plugin '$namespace' (version=$version, registry=$registry_id)"; + my @undo; + my $do_rollback = sub { + my ($reason) = @_; + $logger->error("$op_desc failed: $reason; attempting rollback"); + while ( my $entry = pop @undo ) { + my ( $stage, $undo_sub ) = @$entry; + my $undo_err = $undo_sub->(); + if ( defined $undo_err ) { + $logger->error("rollback of $op_desc failed during $stage: $undo_err"); + return ( 500, undef, + "Plugin '$namespace' failed and rollback was incomplete; manual cleanup may be required." ); + } + } + return; + }; + + my $backup_path; + my $require_attempted = 0; + if ( -e $install_path ) { + # stage: create backup of plugin + # rollback: restore from backup; if require was attempted, reload old plugin + $backup_path = "$install_path.rollback"; + unlink $backup_path if -e $backup_path; + unless ( rename $install_path, $backup_path ) { + my $err = "$!"; + $logger->error("Cannot back up existing artifact at $install_path: $err"); + return ( 500, undef, "Cannot back up existing artifact: $err" ); + } + push @undo, [ + "restore old_plugin_metadata artifact from $backup_path", + sub { + return "$!" unless rename( $backup_path, $install_path ); + return unless $require_attempted; + delete $INC{$install_relpath}; + my $ok = eval { no warnings 'redefine'; require $install_relpath; 1 }; + $ok ? undef : "$@"; + }, + ]; + } + + # stage: ensure new plugin is written to install_path + # rollback: remove install_path file + eval { Mojo::File->new($install_path)->spew($plugin_content) }; + if ($@) { + my $err = $@; + if ( my @resp = $do_rollback->("Cannot write plugin file: $err") ) { + return @resp; + } + return ( 500, undef, "Cannot write plugin file during installation: $err" ); + } + push @undo, [ + "unlink artifact at $install_path", + sub { + return unless -e $install_path; + unlink($install_path) ? undef : "$!"; + }, + ]; + + # At this point, the file operation part of installation is complete! + $logger->info("Installed plugin '$namespace' to $install_path"); + + # stage: update database with new plugin provenance + # rollback: restore old plugin provenance + my %old_plugin_metadata; + for my $field (qw(installed_path installed_version installed_registry installed_sha256 type)) { + my $val = $redis->hget( $namerds, $field ); + $old_plugin_metadata{$field} = $val if defined $val; + } + my $provenance_script = <<~'LUA'; + if redis.call("EXISTS", KEYS[1]) == 0 then + return 0 + end + redis.call("HSET", KEYS[2], "installed_path", ARGV[1]) + redis.call("HSET", KEYS[2], "installed_version", ARGV[2]) + redis.call("HSET", KEYS[2], "installed_registry", ARGV[3]) + redis.call("HSET", KEYS[2], "installed_sha256", ARGV[4]) + redis.call("HSET", KEYS[2], "type", ARGV[5]) + return 1 + LUA + my $restore_script = <<~'LUA'; + redis.call("HDEL", KEYS[1], "installed_path", "installed_version", "installed_registry", "installed_sha256", "type") + for i = 1, #ARGV, 2 do + redis.call("HSET", KEYS[1], ARGV[i], ARGV[i + 1]) + end + return 1 + LUA + my $provenance_written = eval { + $redis->eval( + $provenance_script, 2, $registry_id, $namerds, + $install_relpath, + $plugin_metadata->{version}, + $registry_id, + $plugin_metadata->{sha256}, + $plugin_type + ); + }; + if ($@) { + my $err = $@; + if ( my @resp = $do_rollback->("Redis error during provenance write: $err") ) { + return @resp; + } + return ( 500, undef, "Redis error while writing provenance." ); + } + unless ($provenance_written) { + if ( my @resp = $do_rollback->("Registry was deleted during install") ) { + return @resp; + } + return ( 409, undef, "Registry was deleted during install." ); + } + push @undo, [ + ( exists $old_plugin_metadata{installed_path} + ? "restore old_plugin_metadata provenance for $namerds" + : "clear provenance for $namerds" ), + sub { + my @argv; + for my $field (qw(installed_path installed_version installed_registry installed_sha256 type)) { + push @argv, $field, $old_plugin_metadata{$field} if exists $old_plugin_metadata{$field}; + } + eval { $redis->eval( $restore_script, 1, $namerds, @argv ) }; + $@ ? "$@" : undef; + }, + ]; + + # stage: reload plugin module + $require_attempted = 1; + delete $INC{$install_relpath}; + eval { require $install_relpath }; + my $require_error = $@; + if ($require_error) { + delete $INC{$install_relpath}; # clear out undef resulting from require failure + if ( my @resp = $do_rollback->("Plugin '$namespace' failed to load: $require_error") ) { + return @resp; + } + return ( 422, undef, "Plugin '$namespace' failed to load: $require_error" ); + } + + if ( $backup_path && -e $backup_path ) { + unlink $backup_path or $logger->warn("Could not remove rollback backup at $backup_path: $!"); + } + + # post-install signalling + signal_updated( $redis, $namespace ); + record_load_success( $redis, $namespace ); + $logger->debug("Plugin registered for '$namespace'"); + + my %installed_meta = ( + name => $plugin_metadata->{name}, + version => $plugin_metadata->{version}, + registry => $registry_id, + sha256 => $plugin_metadata->{sha256}, + ); + return ( 200, \%installed_meta, undef ); +} + +# Uninstall a plugin by deleting it from disk and cleaning up Redis. +# Does not remove configuration settings. +sub uninstall_plugin { + my ( $namespace, $redis ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + my $namerds = "LRR_PLUGIN_" . uc($namespace); + $logger->info("Uninstalling plugin '$namespace'"); + + # Ensure an existing install path for uninstall + my $abs_installed_path; + if ( $redis->hexists( $namerds, "installed_path" ) ) { + $abs_installed_path = resolve_installed_path( $redis->hget( $namerds, "installed_path" ) ); + } + unless ($abs_installed_path) { + return ( 404, "Plugin '$namespace' has no install path recorded." ); + } + + # We don't touch builtin plugins! + my $plugin_origin = infer_plugin_origin( $namerds, $redis ); + if ( $plugin_origin eq "builtin" ) { + return ( 403, "Cannot uninstall built-in plugin '$namespace'." ); + } + + if ( -e $abs_installed_path ) { + unlink $abs_installed_path or do { + return ( 500, "Couldn't delete plugin file: $!" ); + }; + $logger->info("Deleted plugin file: $abs_installed_path"); + } else { + $logger->warn("Plugin '$namespace' file not found at $abs_installed_path -- cleaning up Redis only."); + } + + unregister_plugin( $redis, $namespace ); + signal_uninstalled( $redis, $namespace ); + $logger->info("Uninstalled plugin: '$namespace'"); + + return ( 200, undef ); +} + +# Reconcile discovered plugins with Redis registration state. +sub scan_plugins { + my ($redis) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Scanning plugins..."); + + # Build a hash of discovered namespace -> arrayref of class/file_path hashrefs, + # skip on validation/general failures + my %discovered_ns_map; + my @discovered_plugins = LANraragi::Utils::Plugins::plugins(); + foreach my $class (@discovered_plugins) { + # Module::Pluggable may discover non-plugin classes; skip those + unless ( $class->can('plugin_info') ) { + $logger->warn("Non-plugin class detected; skipping."); + next; + } + my %plugin_info; + eval { %plugin_info = $class->plugin_info() }; + if ($@) { + $logger->warn("Plugin $class failed plugin_info(): $@"); + next; + } + my $namespace = $plugin_info{namespace}; + unless ($namespace) { + $logger->warn("Plugin $class has no namespace, skipping."); + next; + } + my $plugin_type = $plugin_info{type}; + unless ($plugin_type) { + $logger->warn("Plugin $class (namespace '$namespace') has no type, skipping."); + next; + } + my $filepath = package_to_path($class); + push @{ $discovered_ns_map{$namespace} }, { class => $class, file_path => $filepath, type => $plugin_type }; + } + $logger->debug("Discovered " . scalar( keys %discovered_ns_map ) . " plugin namespace(s)."); + + # Warn on filepath duplicates per namespace + foreach my $namespace ( keys %discovered_ns_map ) { + if ( @{ $discovered_ns_map{$namespace} } > 1 ) { + my $duplicate_paths = join( ", ", map { $_->{file_path} } @{ $discovered_ns_map{$namespace} } ); + $logger->warn("Duplicate namespace '$namespace' found in: $duplicate_paths"); + } + } + + # Warn on case collisions (Redis keys are case-insensitive by convention) + my %uc_map; + foreach my $namespace ( keys %discovered_ns_map ) { + push @{ $uc_map{ uc($namespace) } }, $namespace; + } + foreach my $uc_key ( keys %uc_map ) { + if ( @{ $uc_map{$uc_key} } > 1 ) { + my $namespaces = join( ", ", @{ $uc_map{$uc_key} } ); + $logger->warn("Namespace case collision (shared Redis key LRR_PLUGIN_$uc_key): $namespaces"); + } + } + + # Register first plugin of every discovered namespace + foreach my $namespace ( keys %discovered_ns_map ) { + next if @{ $discovered_ns_map{$namespace} } > 1; + + my $first_entry = $discovered_ns_map{$namespace}[0]; + my $plugin_path = $first_entry->{file_path}; + my $plugin_type = $first_entry->{type}; + my $namerds = "LRR_PLUGIN_" . uc($namespace); + my $recorded_path = $redis->hget( $namerds, "installed_path" ); + my $recorded_type = $redis->hget( $namerds, "type" ); + + if ( defined $recorded_path + && $recorded_path eq $plugin_path + && defined $recorded_type + && $recorded_type eq $plugin_type ) { + $logger->debug("Plugin already registered and consistent, skipping: $namespace"); + next; # skip if database already tracks said path and type + } + + $logger->debug("Plugin '$namespace': setting installed_path to '$plugin_path' (type=$plugin_type)."); + register_plugin( $redis, $namespace, $plugin_path, $plugin_type ); + } + + # Clean up orphaned Redis keys (installed_path set, but no matching discovered plugin) + my @all_plugin_keys = $redis->keys("LRR_PLUGIN_*"); + my %discovereduc = map { uc($_) => 1 } keys %discovered_ns_map; + $logger->debug("Orphan scan: " . scalar @all_plugin_keys . " Redis keys, " . scalar( keys %discovereduc ) . " discovered."); + + foreach my $plugin_key (@all_plugin_keys) { + + # extract the namespace from redis key + my ($nspart) = $plugin_key =~ /^LRR_PLUGIN_(.+)$/; + unless ($nspart) { + $logger->warn("Unexpected Redis key format: '$plugin_key', skipping."); + next; + } + + # if plugin in redis is not discovered on disk, remove provenance from redis. + my $discovered = $discovereduc{$nspart}; + unless ( $discovered ) { + if ( $redis->hexists( $plugin_key, "installed_path" ) ) { + my $installed_path = $redis->hget( $plugin_key, "installed_path" ); + if ( -e resolve_installed_path($installed_path) ) { + $logger->warn("Plugin key '$plugin_key' (installed_path: $installed_path) not discovered but file exists -- skipping removal."); + next; + } + $logger->warn("Orphaned plugin key '$plugin_key' (installed_path: $installed_path) -- plugin not discovered. Clearing provenance."); + } else { + $logger->warn("Orphaned plugin key '$plugin_key' -- plugin not discovered. Clearing provenance."); + } + unregister_plugin( $redis, $nspart ); + signal_uninstalled( $redis, $nspart ); + } + } + + $logger->info("Plugin scan complete."); +} + +# Infer plugin origin from the recorded install path. +# Returns one of "managed", "sideloaded", or "builtin". +sub infer_plugin_origin { + my ( $namerds, $redis ) = @_; + + if ( $redis->hexists( $namerds, "installed_path" ) ) { + my $path = $redis->hget( $namerds, "installed_path" ); + if ( $path ) { + return "managed" if $path =~ m{Plugin/Managed/}; + return "sideloaded" if $path =~ m{Plugin/Sideloaded/}; + } + } + + return "builtin"; +} + +# Validate downloaded plugin content against registry metadata and filesystem state. +# Covers managed plugins only (installed via registry into Plugin/Managed/). +sub validate_managed_plugin { + my ( $content, $namespace, $plugmeta, $plugin_type, $abs_installed_path ) = @_; + + my $plugname = $plugmeta->{name}; + my $plugver = $plugmeta->{version}; + my $artifact_path = $plugmeta->{artifact}; + my $expected_checksum = $plugmeta->{sha256}; + + return ( undef, "Plugin '$namespace' is missing required field 'name'." ) unless $plugname; + return ( undef, "Plugin '$namespace' is missing required field 'version'." ) unless $plugver; + return ( undef, "Plugin '$namespace' is missing required field 'artifact'." ) unless $artifact_path; + return ( undef, "Plugin '$namespace' is missing required field 'type'." ) unless $plugin_type; + return ( undef, "Plugin '$namespace' is missing required field 'sha256'." ) unless defined $expected_checksum && $expected_checksum ne ""; + + my $actual_checksum = sha256_hex($content); + if ( $actual_checksum ne $expected_checksum ) { + return ( undef, "SHA-256 mismatch: expected $expected_checksum, got $actual_checksum" ); + } + + # A valid package name does not guarantee valid syntax; + # post-install require (in install_plugin) catches that at load time. + my ($pkg) = $content =~ /^package\s+(LANraragi::Plugin::\S+)\s*;/m; + unless ($pkg) { + return ( undef, "Plugin file doesn't declare a LANraragi::Plugin:: package." ); + } + + my $typedir = MANAGED_TYPE_DIRS->{$plugin_type}; + unless ($typedir) { + return ( undef, "Unknown plugin type '$plugin_type'." ); + } + + # Extract filename (basename via regex; File::Basename not imported here). + my ($filename) = $artifact_path =~ m{([^/]+)$}; + unless ($filename) { + return ( undef, "Can't extract filename from path: $artifact_path" ); + } + unless ( $filename =~ /^[A-Za-z0-9_-]+\.pm$/ ) { + return ( undef, "Invalid plugin filename: $filename" ); + } + + my $install_dir = getcwd() . "/lib/LANraragi/Plugin/Managed/$typedir"; + my $install_path = "$install_dir/$filename"; + + if ( defined $abs_installed_path && $abs_installed_path ne $install_path ) { + return ( undef, + "Plugin '$namespace' changed type between installed and registry versions; registry violates type invariance." ); + } + + my ($stem) = $filename =~ /^(.+)\.pm$/; + my $expectedpkg = "LANraragi::Plugin::Managed::${typedir}::${stem}"; + if ( $pkg ne $expectedpkg ) { + return ( undef, "Package mismatch -- declared '$pkg' but expected '$expectedpkg'." ); + } + + # Skip install_path itself so upgrades don't self-conflict. + my $conflict = find_package_conflict( $pkg, $install_path ); + if ($conflict) { + return ( undef, "Package '$pkg' already exists in $conflict." ); + } + + my $nsconflict = find_namespace_conflict( $namespace, $install_path ); + if ($nsconflict) { + return ( undef, "Namespace '$namespace' already exists in $nsconflict." ); + } + + if ( -e $install_path && ( !defined $abs_installed_path || $abs_installed_path ne $install_path ) ) { + return ( undef, "Install path is already occupied: $install_path" ); + } + + return ( { install_path => $install_path, install_dir => $install_dir, package => $pkg }, undef ); +} + +# Get absolute path from an installed_path +sub resolve_installed_path { + my ($installed_path) = @_; + return getcwd() . "/lib/" . $installed_path; +} + 1; diff --git a/lib/LANraragi/Model/Registry.pm b/lib/LANraragi/Model/Registry.pm new file mode 100644 index 000000000..0b4e22d66 --- /dev/null +++ b/lib/LANraragi/Model/Registry.pm @@ -0,0 +1,313 @@ +package LANraragi::Model::Registry; + +use strict; +use warnings; +use utf8; + +use Mojo::JSON qw(decode_json); + +use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::Registry qw( + fetch_registry_resource + validate_registry_index +); + +# Max registry index size for slurp (files will/should never reach this size anyways but stops OOM) +use constant MAX_REGISTRY_INDEX_SIZE => 100 * 1024 * 1024; # 100 MB + +# Fields valid per registry provider. +my %PROVIDER_FIELDS = ( + github => [qw(name provider url ref)], + gitlab => [qw(name provider url ref)], + gitea => [qw(name provider url ref)], + cdn => [qw(name provider url)], + local => [qw(name provider path)], +); + +# Fields that must be removed when switching providers. +# Computed as: source fields minus the target provider's valid fields. +my %STALE_FIELDS = do { + my @source_fields = qw(provider url ref path); + map { + my $provider = $_; + my %valid_set = map { $_ => 1 } @{ $PROVIDER_FIELDS{$provider} }; + $provider => [ grep { !$valid_set{$_} } @source_fields ]; + } keys %PROVIDER_FIELDS; +}; + +# Create a registry entry with a generated REG_{timestamp} ID. +# Returns ( $registry_id, undef ) or ( undef, $error_message ). +sub create_registry { + my ( $config, $redis ) = @_; + my $name = $config->{name}; + my $provider = $config->{provider}; + + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->debug("Creating registry (provider: $provider)"); + + # Atomically claim an unused ID and populate registry hash in one go. + my $script = <<~'LUA'; + if redis.call("EXISTS", KEYS[1]) == 1 then + return 0 + end + redis.call("HSET", KEYS[1], unpack(ARGV)) + return 1 + LUA + + # Prepare fields + my @field_args; + my @valid_fields = @{ $PROVIDER_FIELDS{$provider} }; + foreach my $field (@valid_fields) { + push @field_args, $field, $config->{$field}; + } + my $now = time; + push @field_args, "created", $now, "updated", $now; + + # Start running script until a registry is created + my $registry_id; + my $offset = 0; + until ( $registry_id ) { + my $candidate = "REG_" . ( time() + $offset ); + my $claimed = eval { $redis->eval( + $script, 1, $candidate, + @field_args + ) }; + if ($@) { + $logger->error("Redis error during registry id claim: $@"); + return ( undef, "Redis error while creating registry." ); + } + if ($claimed) { + $registry_id = $candidate; + } else { + $offset++; + } + } + $logger->info("Created registry '$registry_id' (name: $name, provider: $provider)"); + return ( $registry_id, undef ); +} + +# Returns ( \%config, $status, $message ) +sub get_registry { + my ( $registry_id, $redis ) = @_; + + unless ( defined $registry_id && $registry_id =~ /^REG_\d{10}$/ ) { + return ( undef, 400, "Registry ID is malformed." ); + } + my %config = $redis->hgetall($registry_id); + return ( undef, 404, "This registry doesn't exist." ) unless %config; + $config{id} = $registry_id; + return ( \%config, 200, undef ); +} + +sub get_registry_list { + my ($redis) = @_; + + # Sort by timestamp for deterministic order across multiple registries. + my @result; + my @reg_ids = $redis->keys("REG_??????????"); + foreach my $key ( sort @reg_ids ) { + my ( $config ) = get_registry( $key, $redis ); + push @result, $config if $config; # skip if deleted between keys() and hgetall + } + + return @result; +} + +# Update mutable fields on an existing registry. +# Partial updates through updated_registry are accepted. +sub update_registry { + my ( $registry_id, $redis, %updated_registry ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->debug("Updating registry '$registry_id'"); + + # Argument validation + if ( !%updated_registry ) { + return ( 400, "No fields provided to update."); + } + + # Target registry existence validation + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, $lookup_error ) unless $registry; + my ($suffix) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$suffix"; + + # Source registry field validation + my %current_registry = %$registry; + my $updated_registry_provider = $updated_registry{provider}; + my $current_registry_provider = $current_registry{provider}; + my $target_registry_provider = defined $updated_registry_provider ? $updated_registry_provider : $current_registry_provider; + my %valid_set = map { $_ => 1 } @{ $PROVIDER_FIELDS{$target_registry_provider} }; + my @invalid_fields = sort grep { !$valid_set{$_} } keys %updated_registry; + if (@invalid_fields) { + return ( 400, "Fields not valid for provider '$target_registry_provider': " . join( ", ", @invalid_fields ) ); + } + + # Partial updates may omit fields already stored; merge before validating. + my %merged = ( %current_registry, %updated_registry ); + if ( $target_registry_provider eq "github" || $target_registry_provider eq "gitlab" || $target_registry_provider eq "gitea" ) { + return ( 400, "Git registry needs a URL." ) unless $merged{url}; + return ( 400, "Git registry needs a ref." ) unless $merged{ref}; + } elsif ( $target_registry_provider eq "cdn" ) { + return ( 400, "CDN registry needs a URL." ) unless $merged{url}; + } elsif ( $target_registry_provider eq "local" ) { + return ( 400, "Local registry needs a path." ) unless $merged{path}; + } + + # Prepare fields to remove/set. + my @fields_to_remove; # remove stale fields whenever provider changes + my @fields_to_set; # apply only changes from updated_registry + if ( exists $updated_registry{provider} && $updated_registry_provider ne $current_registry_provider ) { + $logger->info("Provider change on '$registry_id': '$current_registry_provider' -> '$target_registry_provider'; removing stale fields."); + @fields_to_remove = @{ $STALE_FIELDS{$target_registry_provider} }; + } + foreach my $field ( keys %updated_registry ) { + my $updated_value = $updated_registry{$field}; + my $current_value = $current_registry{$field}; + if ( defined $current_value && $current_value eq $updated_value ) { + $logger->debug("Skipping unchanged field '$field' on '$registry_id'"); + next; + } + $logger->debug("Setting field '$field' on '$registry_id'"); + push @fields_to_set, $field, $updated_value; + } + if ( !@fields_to_set && !@fields_to_remove ) { + $logger->debug("No fields to update."); + return ( 200, undef ); + } + push @fields_to_set, "updated", time; + + # Remove from fields_to_remove, + # set from fields_to_set, + # remove index. + my $script = <<~'LUA'; + local remove_count = tonumber(ARGV[1]) + local idx = 2 + for _ = 1, remove_count do + redis.call("HDEL", KEYS[1], ARGV[idx]) + idx = idx + 1 + end + while idx + 1 <= #ARGV do + redis.call("HSET", KEYS[1], ARGV[idx], ARGV[idx + 1]) + idx = idx + 2 + end + redis.call("DEL", KEYS[2]) + LUA + eval { + $redis->eval( + $script, 2, $registry_id, $registry_index_key, + scalar @fields_to_remove, + @fields_to_remove, + @fields_to_set + ); + }; + if ( my $err = $@ ) { + $logger->error("Redis error during registry update for '$registry_id': $err"); + return ( 500, "Redis error while updating registry." ); + } + + return ( 200, undef ); +} + +# Delete a registry and its cached index. +sub delete_registry { + my ( $registry_id, $redis ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, $lookup_error ) unless $registry; + + my ($suffix) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$suffix"; + + # Delete registry + index key + my $script = <<~'LUA'; + redis.call("DEL", KEYS[1]) + redis.call("DEL", KEYS[2]) + LUA + + eval { $redis->eval( $script, 2, $registry_id, $registry_index_key ) }; + if ($@) { + $logger->error("Redis error during registry delete for '$registry_id': $@"); + return ( 500, "Redis error while deleting registry." ); + } + + if ( ( $redis->hget( 'LRR_CONFIG', 'default_registry' ) || "" ) eq $registry_id ) { + $redis->hdel( 'LRR_CONFIG', 'default_registry' ); + $logger->info("Cleared default-registry pointer that referenced deleted '$registry_id'."); + } + + $logger->info("Deleted registry '$registry_id'."); + + return ( 200, undef ); +} + +# Get the configured default registry id, or empty string if unset. +sub get_default_registry { + my ($redis) = @_; + return $redis->hget( 'LRR_CONFIG', 'default_registry' ) || ""; +} + +# Set the configured default registry to $registry_id. +# Returns ( $status_code, $registry_id, $message ). +sub update_default_registry { + my ( $registry_id, $redis ) = @_; + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, $registry_id, $lookup_error ) unless $registry; + $redis->hset( 'LRR_CONFIG', 'default_registry', $registry_id ); + return ( 200, $registry_id, "success" ); +} + +# Clear the configured default registry. Returns the previously-set id (empty string if unset). +sub remove_default_registry { + my ($redis) = @_; + my $registry_id = $redis->hget( 'LRR_CONFIG', 'default_registry' ) || ""; + $redis->hdel( 'LRR_CONFIG', 'default_registry' ); + return $registry_id; +} + +sub refresh_registry { + my ( $registry_id, $redis ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, undef, $lookup_error ) unless $registry; + my ($suffix) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$suffix"; + + # Fetch and validate registry index + my ( $status, $registry_content, $message ) = fetch_registry_index(%$registry); + unless ( $status == 200 ) { + return ( $status, undef, $message ); + } + my $registry_index = eval { decode_json($registry_content) }; + if ($@) { + my $error = "Invalid registry.json: $@"; + $logger->warn("Registry '$registry_id': failed to decode registry index: $@"); + return ( 400, undef, $error ); + } + my $validation_error = validate_registry_index($registry_index); + if ($validation_error) { + $logger->warn("Registry '$registry_id': registry index failed validation: $validation_error"); + return ( 400, undef, $validation_error ); + } + + # Update registry index + eval { $redis->set( $registry_index_key, $registry_content ) }; + if ($@) { + $logger->error("Redis error during registry refresh for '$registry_id': $@"); + return ( 500, undef, "Redis error while refreshing registry." ); + } + + return ( 200, $registry_index, undef ); +} + +# Fetch registry.json from a configured registry source. +sub fetch_registry_index { + my %registry_config = @_; + return fetch_registry_resource( \%registry_config, "registry.json", MAX_REGISTRY_INDEX_SIZE ); +} + +1; diff --git a/lib/LANraragi/Model/Search.pm b/lib/LANraragi/Model/Search.pm index beed4a9a8..8726318a0 100644 --- a/lib/LANraragi/Model/Search.pm +++ b/lib/LANraragi/Model/Search.pm @@ -37,6 +37,7 @@ sub do_search ( $filter, $category_id, $start, $sortkey, $sortorder, $newonly, $ return ( -1, -1, () ); } + $filter = $filter // ""; my $tankcount = $redis->scard("LRR_TANKGROUPED") + 0; # Get tank ids count diff --git a/lib/LANraragi/Model/Tankoubon.pm b/lib/LANraragi/Model/Tankoubon.pm index b0fdcebc5..e3256da0f 100644 --- a/lib/LANraragi/Model/Tankoubon.pm +++ b/lib/LANraragi/Model/Tankoubon.pm @@ -238,7 +238,7 @@ sub update_tankoubon ( $tank_id, $data ) { my ( $result, $err ) = update_metadata( $tank_id, $data ); if ($result) { - my ( $result, $err ) = update_archive_list( $tank_id, $data ); + ( $result, $err ) = update_archive_list( $tank_id, $data ); } return ( $result, $err ); @@ -258,7 +258,8 @@ sub update_metadata ( $tank_id, $data ) { my $err = ""; my $name = $data->{"metadata"}->{"name"} || undef; my $summary = exists $data->{"metadata"}->{"summary"} ? $data->{"metadata"}->{"summary"} : undef; - my $tags = exists $data->{"metadata"}->{"tags"} ? $data->{"metadata"}->{"tags"} : undef; + my $tags = exists $data->{"metadata"}->{"tags"} ? $data->{"metadata"}->{"tags"} : undef; + my $append = $data->{"metadata"}->{"append"} // 0; if ( $redis->exists($tank_id) ) { if ( defined $name ) { @@ -270,7 +271,7 @@ sub update_metadata ( $tank_id, $data ) { } if ( defined $tags ) { - set_tank_tags( $tank_id, $tags ); + set_tank_tags( $tank_id, $tags, $append ); } $redis->quit; @@ -332,7 +333,10 @@ sub update_archive_list ( $tank_id, $data ) { # Make removed archives visible in search again unless other tanks contain them foreach my $arc_id (@diff) { - unless ( get_tankoubons_containing_archive($arc_id) ) { + # Have to filter out $tank_id here since it still contains the archive at this point (we haven't called exec, so zrem didn't run) + # (This case isn't covered by unit tests as they don't mock multi properly) + my @other_tanks = grep { $_ ne $tank_id } get_tankoubons_containing_archive($arc_id); + unless ( @other_tanks ) { $redis_search->sadd( "LRR_TANKGROUPED", $arc_id ); } } diff --git a/lib/LANraragi/Utils/Database.pm b/lib/LANraragi/Utils/Database.pm index 1c9ca528f..a1256afa0 100644 --- a/lib/LANraragi/Utils/Database.pm +++ b/lib/LANraragi/Utils/Database.pm @@ -307,7 +307,7 @@ sub build_tank_json ($id) { $aggregate_isnew = $aggregate_isnew || (%$archive_info{isnew} eq "true"); $aggregate_pagecount = $aggregate_pagecount + %$archive_info{pagecount}; $aggregate_size = $aggregate_size + %$archive_info{size}; - $latest_readtime = max( $latest_readtime, %$archive_info{lastreadtime} ); + $latest_readtime = max( $latest_readtime, %$archive_info{lastreadtime} // 0); } chop $aggregate_names; diff --git a/lib/LANraragi/Utils/I18N.pm b/lib/LANraragi/Utils/I18N.pm index b917c2cd8..97107e6eb 100644 --- a/lib/LANraragi/Utils/I18N.pm +++ b/lib/LANraragi/Utils/I18N.pm @@ -6,12 +6,14 @@ use utf8; use base 'Locale::Maketext'; use Locale::Maketext::Lexicon { + de => [ Gettext => "../../locales/template/de.po" ], en => [ Gettext => "../../locales/template/en.po" ], es => [ Gettext => "../../locales/template/es.po" ], zh => [ Gettext => "../../locales/template/zh.po" ], "zh-cn" => [ Gettext => "../../locales/template/zh.po" ], fr => [ Gettext => "../../locales/template/fr.po" ], id => [ Gettext => "../../locales/template/id.po" ], + ja => [ Gettext => "../../locales/template/ja.po" ], ko => [ Gettext => "../../locales/template/ko.po" ], no => [ Gettext => "../../locales/template/nb_NO.po" ], nb => [ Gettext => "../../locales/template/nb_NO.po" ], diff --git a/lib/LANraragi/Utils/Minion.pm b/lib/LANraragi/Utils/Minion.pm index 44ffc15c4..666a8077f 100644 --- a/lib/LANraragi/Utils/Minion.pm +++ b/lib/LANraragi/Utils/Minion.pm @@ -145,7 +145,7 @@ sub add_tasks { } # Add page number to note field so it can be fetched by the API - $job->note( $i => "processed", total_pages => $pages ); + $job->note( $i => "processed", total_pages => $pages, id => $id ); } }; diff --git a/lib/LANraragi/Utils/Path.pm b/lib/LANraragi/Utils/Path.pm index 9a03431c6..7ec1c6bac 100644 --- a/lib/LANraragi/Utils/Path.pm +++ b/lib/LANraragi/Utils/Path.pm @@ -15,7 +15,7 @@ use POSIX qw(strerror); use constant IS_UNIX => ( $Config{osname} ne 'MSWin32' ); use Exporter 'import'; -our @EXPORT_OK = qw(create_path create_path_or_die open_path open_path_or_die date_modified compat_path unlink_path find_path get_archive_path rename_path move_path); +our @EXPORT_OK = qw(create_path create_path_or_die open_path open_path_or_die date_modified compat_path unlink_path find_path get_archive_path rename_path move_path package_to_path path_to_package); BEGIN { if ( !IS_UNIX ) { @@ -108,6 +108,19 @@ sub get_archive_path ( $redis, $id ) { return create_path( $redis->hget( $id, "file" ) ); } +# Convert a Perl package name to a filesystem path (e.g. "LANraragi::Plugin::Foo" -> "LANraragi/Plugin/Foo.pm"). +sub package_to_path( $package ) { + return join( "/", split( /::/, $package ) ) . ".pm"; +} + +# Convert a filesystem path to a Perl package name (e.g. "LANraragi/Plugin/Foo.pm" -> "LANraragi::Plugin::Foo"). +sub path_to_package( $path ) { + my $package = $path; + $package =~ s/\.pm$//; + $package =~ s|/|::|g; + return $package; +} + # Build the error message for a failed file operation containing details about the file's properties # including any hidden Windows-side errors. # Usage: die "Failed operation for $file: " . _file_error_msg($file); diff --git a/lib/LANraragi/Utils/PluginState.pm b/lib/LANraragi/Utils/PluginState.pm new file mode 100644 index 000000000..f59f7ee84 --- /dev/null +++ b/lib/LANraragi/Utils/PluginState.pm @@ -0,0 +1,114 @@ +package LANraragi::Utils::PluginState; + +use strict; +use warnings; +use utf8; + +use Exporter 'import'; + +our @EXPORT_OK = qw( + signal_updated + signal_uninstalled + record_load_success + record_load_failure + plugin_needs_reload + should_skip_reload +); + +# Utilities for handling various cases related to plugin registration and usability states. +# When a plugin is added/removed from LRR, its status must be synchronized across workers. + +my %LOADED_GEN; +my %LOAD_FAILED; + +# signal_updated( $redis, $namespace ) +# If a namespace is updated, the updated survivor should synchronize to all workers. +# Workers must update to the new plugin via INC reload. +# Workers which previously failed to load plugin may re-attempt to load the updated plugin. +sub signal_updated { + my ( $redis, $namespace ) = @_; + my $namespace_uc = uc($namespace); + my $namerds = "LRR_PLUGIN_" . $namespace_uc; + + $redis->hincrby( $namerds, "installed_generation", 1 ); + + delete $LOADED_GEN{$namespace_uc}; + delete $LOAD_FAILED{$namespace_uc}; +} + +# signal_uninstalled( $redis, $namespace ) +# If a namespace is uninstalled, synchronize to all workers. +# Workers may no longer load the plugin even if it is present in INC cache, and are expected to drop the plugin from cache. +sub signal_uninstalled { + my ( $redis, $namespace ) = @_; + my $namespace_uc = uc($namespace); + my $namerds = "LRR_PLUGIN_" . $namespace_uc; + + $redis->hdel( $namerds, "installed_generation" ); + + delete $LOADED_GEN{$namespace_uc}; + delete $LOAD_FAILED{$namespace_uc}; +} + +# record_load_success( $redis, $namespace ) +# If a plugin loaded successfully, update local worker cache. +# Future reload attempts for the current registered plugin will be skipped. +sub record_load_success { + my ( $redis, $namespace ) = @_; + my $namespace_uc = uc($namespace); + my $generation = _registered_generation( $namespace_uc, $redis ); + + return unless defined $generation; + + $LOADED_GEN{$namespace_uc} = $generation; + delete $LOAD_FAILED{$namespace_uc}; +} + +# record_load_failure( $redis, $namespace ) +# If a plugin failed to load, update local worker cache. +# Future attempts to load plugin will be skipped. +sub record_load_failure { + my ( $redis, $namespace ) = @_; + my $namespace_uc = uc($namespace); + my $generation = _registered_generation( $namespace_uc, $redis ); + + return unless defined $generation; + + $LOAD_FAILED{$namespace_uc} = $generation; +} + +# plugin_needs_reload( $redis, $namespace ) +# Check if a plugin requires a INC-reload. +sub plugin_needs_reload { + my ( $redis, $namespace ) = @_; + my $namespace_uc = uc($namespace); + my $generation = _registered_generation( $namespace_uc, $redis ); + + return 0 unless defined $generation; + + return ( $LOADED_GEN{$namespace_uc} // -1 ) != $generation; +} + +# should_skip_reload( $redis, $namespace ) +# Check if a plugin failed to load. +# Failure state is reset on plugin updates. +sub should_skip_reload { + my ( $redis, $namespace ) = @_; + my $namespace_uc = uc($namespace); + my $generation = _registered_generation( $namespace_uc, $redis ); + + return 0 unless defined $generation; + + return ( $LOAD_FAILED{$namespace_uc} // -1 ) == $generation; +} + +sub _registered_generation { + my ( $namespace_uc, $redis ) = @_; + + my $namerds = "LRR_PLUGIN_" . $namespace_uc; + + return unless $redis->hexists( $namerds, "installed_generation" ); + return $redis->hget( $namerds, "installed_generation" ); +} + +1; diff --git a/lib/LANraragi/Utils/Plugins.pm b/lib/LANraragi/Utils/Plugins.pm index 92bb8fba8..a81cf615c 100644 --- a/lib/LANraragi/Utils/Plugins.pm +++ b/lib/LANraragi/Utils/Plugins.pm @@ -5,8 +5,15 @@ use warnings; use utf8; use Mojo::JSON qw(decode_json); -use LANraragi::Utils::Redis qw(redis_decode); use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::PluginState qw( + plugin_needs_reload + record_load_failure + record_load_success + should_skip_reload +); +use LANraragi::Utils::Path qw(path_to_package); +use LANraragi::Utils::Redis qw(redis_decode); # Plugin system ahoy - this makes the LANraragi::Utils::Plugins::plugins method available # Don't call this method directly - Rely on LANraragi::Utils::Plugins::get_plugins instead @@ -16,19 +23,46 @@ use Module::Pluggable require => 1, search_path => ['LANraragi::Plugin']; # This mostly contains the glue for parameters w/ Redis, the meat of Plugin execution is in Model::Plugins. use Exporter 'import'; our @EXPORT_OK = - qw(get_plugins get_downloader_for_url get_plugin get_enabled_plugins get_plugin_parameters is_plugin_enabled use_plugin); + qw(get_plugins get_downloader_for_url get_plugin get_enabled_plugins get_plugin_parameters is_plugin_enabled use_plugin register_plugin unregister_plugin read_registered_plugins); -# Get metadata of all plugins with the defined type. Returns an array of hashes. +# Get metadata of all registered plugins with the defined type. Returns an array of hashes. sub get_plugins { my $type = shift; - my @plugins = plugins; + my $redis = LANraragi::Model::Config->get_redis_config; + my $logger = get_logger( "Plugin System", "lanraragi" ); + my %registered = read_registered_plugins($redis); my @validplugins; - foreach my $plugin (@plugins) { + + # Skip plugins which are not registered by Redis. + foreach my $ns_uc ( sort keys %registered ) { + my $installed_path = $registered{$ns_uc}; + my $plugin = path_to_package($installed_path); + + if ( plugin_needs_reload( $redis, $ns_uc ) && !should_skip_reload( $redis, $ns_uc ) ) { + delete $INC{$installed_path}; + } + + my $loaded = eval { + no warnings 'redefine'; + require $installed_path; + 1; + }; + unless ($loaded) { + record_load_failure( $redis, $ns_uc ); + $logger->warn("Skipping plugin '$plugin' while listing type '$type': require '$installed_path' failed: $@"); + next; + } + record_load_success( $redis, $ns_uc ); # Check that the metadata sub is there before invoking it if ( $plugin->can('plugin_info') ) { - my %pluginfo = $plugin->plugin_info(); + my %pluginfo; + eval { %pluginfo = $plugin->plugin_info() }; + if ($@) { + $logger->warn("Skipping plugin '$plugin' while listing type '$type': plugin_info() failed: $@"); + next; + } if ( $type eq 'script' ) { next if ( !$plugin->can('run_script') ); } elsif ( $type eq 'metadata' ) { next if ( !$plugin->can('get_tags') ); } @@ -36,9 +70,12 @@ sub get_plugins { elsif ( $type eq 'login' ) { next if ( !$plugin->can('do_login') ); } if ( $pluginfo{type} eq $type || $type eq "all" ) { push( @validplugins, \%pluginfo ); } + } else { + $logger->warn("Skipping plugin '$plugin' while listing type '$type': class has no plugin_info()."); } } + $redis->quit(); return @validplugins; } @@ -78,27 +115,44 @@ sub get_enabled_plugins { return @enabled; } -#Look for a plugin by namespace. +# Look for (and optionally reloads) a registered plugin by uc-normalized namespace for invokation. sub get_plugin { - my $name = shift; - - #Go through plugins to find one with a matching namespace - my @plugins = plugins; + my $name = shift; + my $name_uc = uc($name); + my $logger = get_logger( "Plugin System", "lanraragi" ); + + # Plugin must have a discovered installed_path to be callable. + # Uninstall hdels installed_path while preserving user config; gating on + # key-existence alone would let uninstalled namespaces remain callable. + my $redis = LANraragi::Model::Config->get_redis_config; + my $installed_path = read_registered_plugin_path( $redis, $name ); + unless ($installed_path) { + $redis->quit(); + return 0; + } - foreach my $plugin (@plugins) { - my $namespace = ""; - eval { - my %pluginfo = $plugin->plugin_info(); - $namespace = $pluginfo{namespace}; + # Check if plugin needs (re)loading. + if ( plugin_needs_reload( $redis, $name_uc ) && !should_skip_reload( $redis, $name_uc ) ) { + delete $INC{$installed_path}; + my $ok = eval { + no warnings 'redefine'; + require $installed_path; + 1; }; - - if ( $name eq $namespace ) { - return $plugin; + if ($ok) { + record_load_success( $redis, $name_uc ); + $logger->info("Reloaded plugin '$name' in worker $$"); + } else { + record_load_failure( $redis, $name_uc ); + $logger->warn("Failed to reload plugin '$name': $@"); + $redis->quit(); + return 0; } } - return 0; + $redis->quit(); + return path_to_package($installed_path); } # Get the parameters for the specified plugin, either default values or input by the user in the settings page. @@ -166,18 +220,78 @@ sub get_plugin_parameters { return %args; } +# Register a validated plugin into the database. +# A plugin should be registered after any type of discovery or installation, +sub register_plugin { + my ( $redis, $namespace, $installed_path, $type ) = @_; + + # enforce relative path being under LANraragi/Plugin + # basically should never happen + unless ( $installed_path =~ m{^LANraragi/Plugin/} ) { + die "register_plugin: installed_path must be under LANraragi/Plugin/, got '$installed_path'"; + } + + my $namerds = "LRR_PLUGIN_" . uc($namespace); + $redis->hset( $namerds, "installed_path", $installed_path, "type", $type ); + return $installed_path; +} + +# Unregister a registered plugin from the database. +# Should be called during removal of a plugin or when the plugin +# could no longer be found. +sub unregister_plugin { + my ( $redis, $namespace ) = @_; + + my $namerds = "LRR_PLUGIN_" . uc($namespace); + $redis->hdel( + $namerds, + "installed_path", + "installed_version", + "installed_registry", + "installed_sha256", + "type", + ); +} + +# Return a map of namespace -> installed_path of all registered plugins. +sub read_registered_plugins { + my $redis = shift; + + my @keys = $redis->keys("LRR_PLUGIN_*"); + my %registered; + foreach my $key (@keys) { + next unless $redis->hexists( $key, "installed_path" ); + my ($namespace_uc) = $key =~ /^LRR_PLUGIN_(.+)$/; + next unless defined $namespace_uc; + $registered{$namespace_uc} = $redis->hget( $key, "installed_path" ); + } + + return %registered; +} + +# Return the installed_path for a registered plugin. +sub read_registered_plugin_path { + my $redis = shift; + my $namespace = shift; + + my $namerds = "LRR_PLUGIN_" . uc($namespace); + return unless $redis->hexists( $namerds, "installed_path" ); + return $redis->hget( $namerds, "installed_path" ); +} + sub is_plugin_enabled { my $namespace = shift; my $redis = LANraragi::Model::Config->get_redis_config; my $namerds = "LRR_PLUGIN_" . uc($namespace); + my $enabled = 0; if ( $redis->hexists( $namerds, "enabled" ) ) { - return ( $redis->hget( $namerds, "enabled" ) ); + $enabled = $redis->hget( $namerds, "enabled" ); } $redis->quit(); - return 0; + return $enabled; } # Shorthand method to use a plugin by name. diff --git a/lib/LANraragi/Utils/Registry.pm b/lib/LANraragi/Utils/Registry.pm new file mode 100644 index 000000000..66104194f --- /dev/null +++ b/lib/LANraragi/Utils/Registry.pm @@ -0,0 +1,321 @@ +package LANraragi::Utils::Registry; + +use strict; +use warnings; +use utf8; + +use Cwd qw(abs_path getcwd); +use File::Find; +use Mojo::Util qw(url_escape); + +use Mojo::File; +use Mojo::UserAgent; +use SemVer; + +use LANraragi::Utils::Logging qw(get_logger); + +use Exporter 'import'; +our @EXPORT_OK = qw( + fetch_registry_resource + find_namespace_conflict + find_package_conflict + resolve_max_version + validate_registry_artifact_path + validate_registry_index + MANAGED_TYPE_DIRS +); + +# Maps plugin_info type values to directory names under Plugin/Managed/. +use constant MANAGED_TYPE_DIRS => { + metadata => "Metadata", + download => "Download", + login => "Login", + script => "Scripts", +}; + +# Allowed-field whitelists for registry.json schema. +my @ALLOWED_ROOT_FIELDS = qw(version generated_at plugins); +my @ALLOWED_PLUGIN_FIELDS = qw(namespace type versions); +my @ALLOWED_VERSION_FIELDS = qw(version name author description artifact sha256 published_at); +my @REQUIRED_VERSION_FIELDS = qw(name author description artifact sha256 published_at); + +# Resolve a git URL to a raw file URL for a given provider +# (Should) support github, gitlab, and gitea/codeberg providers but who knows +sub resolve_git_raw_url { + my ( $provider, $url, $ref, $path ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + # TODO: this just needs to be tested more (maybe with a Gitlab + Gitea repo) + my ( $host, $owner, $repo ); + # TODO(REVIEW): audit + if ( $url =~ m{^https?://([^/]+)/(.+)/([^/]+?)(?:\.git)?$} ) { + ( $host, $owner, $repo ) = ( $1, $2, $3 ); + } else { + $logger->error("Cannot parse git URL: $url"); + return; + } + + my $escaped_path = join( "/", map { url_escape($_) } split( m{/}, $path ) ); # TODO(REVIEW): audit + my $escaped_ref = url_escape($ref); + return "https://raw.githubusercontent.com/$owner/$repo/$escaped_ref/$escaped_path" if ( $provider eq "github" ); + return "https://$host/$owner/$repo/-/raw/$escaped_ref/$escaped_path" if ( $provider eq "gitlab" ); + return "https://$host/api/v1/repos/$owner/$repo/raw/$escaped_path?ref=$escaped_ref" if ( $provider eq "gitea" ); + + $logger->error("Unknown registry provider '$provider' for URL: $url"); + return; +} + +# Resolve a CDN registry base URL plus a registry-relative path into a fetchable URL. +# Accepts http:// or https://. Trailing slashes on the base are tolerated. +sub resolve_cdn_artifact_url { + my ( $base_url, $path ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + # TODO(REVIEW): audit + unless ( defined $base_url && $base_url =~ m{^https?://}i ) { + $logger->error( "CDN base URL must use http or https scheme: " . ( $base_url // "" ) ); + return; + } + + $base_url =~ s{/+\z}{}; # strip the URL of its trailing slashes + my $escaped_path = join( "/", map { url_escape($_) } grep { length $_ } split( m{/}, $path ) ); + return "$base_url/$escaped_path"; +} + +# Transport adapter: fetch a registry-relative resource from any registry provider. +# Returns ( $status, $body, $error ) + $status 200 on success. +sub fetch_registry_resource { + my ( $registry_config, $relpath, $max_size ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + my $provider = $registry_config->{provider}; + + if ( $provider eq "local" ) { + my ( $file_canon, $resolve_error ) = + resolve_local_registry_artifact_path( $registry_config->{path}, $relpath ); + if ($resolve_error) { + $logger->warn("Local registry resolution failed for '$relpath': $resolve_error"); + return ( 400, undef, $resolve_error ); + } + return ( 400, undef, "Resource is not a regular file: $file_canon" ) unless ( -f $file_canon ); + my $filesize = -s $file_canon; + + return ( 400, undef, "Resource is empty: $file_canon" ) if ( $filesize == 0 ); + return ( 400, undef, "Resource too large: $file_canon ($filesize bytes, max $max_size)" ) if ( defined $max_size && $filesize > $max_size ); + + my $content = eval { Mojo::File->new($file_canon)->slurp }; + return ( 500, undef, "Cannot read resource: $@" ) unless ( defined $content ); + return ( 200, $content, undef ); + } + + if ( $provider eq "github" || $provider eq "gitlab" || $provider eq "gitea" ) { + my $url = resolve_git_raw_url( + $provider, $registry_config->{url}, + $registry_config->{ref}, $relpath + ); + return ( 400, undef, "Cannot resolve git URL for $relpath" ) unless ($url); + + $logger->info("Fetching registry resource from $url"); + my $ua = Mojo::UserAgent->new; + $ua->max_response_size($max_size) if defined $max_size; + my $res = eval { $ua->get($url)->result }; + return ( 502, undef, "Cannot reach registry: $@" ) unless ( defined $res ); + return ( 502, undef, "Failed to fetch resource: HTTP " . $res->code ) unless ( $res->is_success ); + return ( 200, $res->body, undef ); + } + + if ( $provider eq "cdn" ) { + my $url = resolve_cdn_artifact_url( $registry_config->{url}, $relpath ); + return ( 400, undef, "Cannot resolve CDN URL for $relpath" ) unless ($url); + + $logger->info("Fetching registry resource from $url"); + my $ua = Mojo::UserAgent->new; + $ua->max_response_size($max_size) if defined $max_size; + my $res = eval { $ua->get($url)->result }; + return ( 502, undef, "Cannot reach registry: $@" ) unless ( defined $res ); + return ( 502, undef, "Failed to fetch resource: HTTP " . $res->code ) unless ( $res->is_success ); + return ( 200, $res->body, undef ); + } + + return ( 400, undef, "Unknown registry provider: $provider" ); +} + +# Scan Plugin/ for a .pm file declaring the given package name +sub find_package_conflict { + my ( $package_name, $skip_path ) = @_; + + return _find_conflict( + $skip_path, + sub { + my ($filepath) = @_; + my $content = eval { Mojo::File->new($filepath)->slurp } or return; + return $content =~ /^package\s+\Q$package_name\E\s*;/m; + } + ); +} + +# Scan Plugin/ for a .pm file declaring the given namespace +sub find_namespace_conflict { + my ( $namespace, $skip_path ) = @_; + + return _find_conflict( + $skip_path, + sub { + my ($filepath) = @_; + my $content = eval { Mojo::File->new($filepath)->slurp } or return; + return $content =~ /namespace\s*=>\s*['"]\Q$namespace\E['"]/i; + } + ); +} + +# strictly check registry index satisfies a bunch of registry spec-related conditions... +sub validate_registry_index { + my ( $index ) = @_; + + return "Invalid registry.json: root must be an object." unless ( ref $index eq "HASH" ); + + my %allowed_root = map { $_ => 1 } @ALLOWED_ROOT_FIELDS; + foreach my $field ( keys %{$index} ) { + return "Invalid registry.json: unknown root field '$field'." unless ( $allowed_root{$field} ); + } + + return "Invalid registry.json: registry version must be 1." unless ( defined $index->{version} && $index->{version} == 1 ); + return "Invalid registry.json: 'generated_at' is required." unless ( defined $index->{generated_at} && $index->{generated_at} ne "" ); + return "Invalid registry.json: 'generated_at' must be a UTC RFC3339 timestamp." unless ( is_valid_registry_timestamp( $index->{generated_at} ) ); + return "Invalid registry.json: 'plugins' must be an object." unless ( ref $index->{plugins} eq "HASH" ); + + foreach my $namespace ( sort keys %{ $index->{plugins} } ) { + my $plugin = $index->{plugins}{$namespace}; + return "Invalid registry.json: plugin '$namespace' must be an object." unless ( ref $plugin eq "HASH" ); + + my %allowed_plugin = map { $_ => 1 } @ALLOWED_PLUGIN_FIELDS; + foreach my $field ( keys %{$plugin} ) { + return "Invalid registry.json: plugin '$namespace' has unknown field '$field'." unless ( $allowed_plugin{$field} ); + } + + return "Invalid registry.json: plugin key '$namespace' must match inner namespace." unless ( defined $plugin->{namespace} && $plugin->{namespace} eq $namespace ); + return "Invalid registry.json: plugin namespace '$namespace'" . + " must match ^[a-z0-9_-]+\$ (lowercase only)." unless ( $namespace =~ /\A[a-z0-9_-]+\z/ ); + return "Invalid registry.json: plugin '$namespace' has invalid type '$plugin->{type}'." unless ( defined $plugin->{type} && MANAGED_TYPE_DIRS->{ $plugin->{type} } ); + return "Invalid registry.json: plugin '$namespace' 'versions' must be a non-empty object." unless ( ref $plugin->{versions} eq "HASH" && keys %{ $plugin->{versions} } ); + + foreach my $version_key ( sort keys %{ $plugin->{versions} } ) { + + # Explicitly enforce SemVer 2.0.0 syntax. + return "Invalid registry.json: plugin '$namespace'" . + " version key '$version_key' is not a valid SemVer 2.0.0 string." if ( $version_key =~ /^v/i ); + my $semver_ok = eval { SemVer->new($version_key); 1 }; + return "Invalid registry.json: plugin '$namespace'" . + " version key '$version_key' is not a valid SemVer 2.0.0 string." unless ($semver_ok); + my $version = $plugin->{versions}{$version_key}; + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' must be an object." unless ( ref $version eq "HASH" ); + + my %allowed_version = map { $_ => 1 } @ALLOWED_VERSION_FIELDS; + foreach my $field ( keys %{$version} ) { + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' has unknown field '$field'." unless ( $allowed_version{$field} ); + } + + return "Invalid registry.json: plugin '$namespace'" . + " version key '$version_key' must match inner version." unless ( defined $version->{version} && $version->{version} eq $version_key ); + foreach my $required (@REQUIRED_VERSION_FIELDS) { + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' is missing '$required'." unless ( defined $version->{$required} && $version->{$required} ne "" ); + } + + my ( $artifact_valid, $artifact_error ) = validate_registry_artifact_path( $version->{artifact} ); + return $artifact_error unless ($artifact_valid); + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' sha256 must be 64 lowercase hexadecimal characters." unless ( $version->{sha256} =~ /\A[a-f0-9]{64}\z/ ); + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' published_at must be a UTC RFC3339 timestamp." unless ( is_valid_registry_timestamp( $version->{published_at} ) ); + } + } + + return; +} + +sub validate_registry_artifact_path { + my ($plugpath) = @_; + + return ( undef, "Invalid registry.json: version artifact is required." ) unless ( defined $plugpath && $plugpath ne "" ); + return ( undef, "Invalid registry.json: artifact path contains a null byte." ) if ( index( $plugpath, "\0" ) >= 0 ); + return ( undef, "Invalid registry.json: artifact path must be relative." ) if ( Mojo::File->new($plugpath)->is_abs ); + return ( undef, "Invalid registry.json: artifact path" . + " must not contain '.' or '..' segments." ) if ( grep { $_ eq "." || $_ eq ".." } @{ Mojo::File->new($plugpath)->to_array } ); + + return ( 1, undef ); +} + +# Resolve local registry root (the directory), including through any symlinks. +# abs_path is used for symlink canonicalization. +sub resolve_local_registry_artifact_path { + my ( $registry_root, $plugpath ) = @_; + + my $resolved_registry_root = abs_path($registry_root); + return ( undef, "Invalid local registry path: $registry_root" ) unless ( $resolved_registry_root && -d $resolved_registry_root ); + + my $candidate = Mojo::File->new($resolved_registry_root)->child( @{ Mojo::File->new($plugpath)->to_array } )->to_string; + return ( undef, "Plugin file not found: $candidate" ) unless ( -e $candidate ); + + my $resolved_artifact = abs_path($candidate); + return ( undef, "Failed to resolve plugin artifact path: $plugpath" ) unless ( $resolved_artifact ); + + my $root_prefix = $resolved_registry_root =~ m{/\z} ? $resolved_registry_root : "$resolved_registry_root/"; + return ( undef, "Plugin artifact path escapes registry root: $plugpath" ) unless ( index( $resolved_artifact, $root_prefix ) == 0 ); + + return ( $resolved_artifact, undef ); +} + +# Check timestamp is (stylistically) of the form "9999-99-99T99:99:99Z". +sub is_valid_registry_timestamp { + my ($timestamp) = @_; + return $timestamp =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/; +} + +# Return the SemVer-greatest version key from a plugin record's versions map. +# $plugin_record: the plugin record hashref (must have a non-empty 'versions' map). +# Pure function, no Redis. +sub resolve_max_version { + my ($plugin_record) = @_; + + my @keys = keys %{ $plugin_record->{versions} }; + my ($max) = sort { SemVer->new($b) <=> SemVer->new($a) } @keys; + return $max; +} + + +# Scan Plugin/ directory for a .pm file matching the given criteria. +# $skip_path: optional absolute filepath to exclude +# $match_fn: coderef($filepath, $fh) -> bool; return true if filepath conflicts. +sub _find_conflict { + my ( $skip_path, $match_fn ) = @_; + + my $plugin_dir = getcwd() . "/lib/LANraragi/Plugin"; + my $conflict; + + return unless -d $plugin_dir; + + find( + { wanted => sub { + return if $conflict; + return unless /\.pm$/; + return if $skip_path && $_ eq $skip_path; + + if ( $match_fn->($_) ) { + $conflict = $_; + } + }, + no_chdir => 1, + follow_fast => 1, + }, + $plugin_dir + ); + + return $conflict; +} + +1; diff --git a/lib/LANraragi/Utils/Routing.pm b/lib/LANraragi/Utils/Routing.pm index 969ede0b2..ae9b8b9c1 100644 --- a/lib/LANraragi/Utils/Routing.pm +++ b/lib/LANraragi/Utils/Routing.pm @@ -129,8 +129,6 @@ sub apply_routes { $logged_in->get('/logs/mojo')->to('logging#print_mojo'); $logged_in->get('/logs/redis')->to('logging#print_redis'); - $logged_in->get('/tankoubons')->to('tankoubon#index'); - $logged_in->get('/duplicates')->to('duplicates#index'); # Metrics API (not part of OpenAPI spec, serves Prometheus format) diff --git a/locales/template/de.po b/locales/template/de.po index c72911b9b..c1e3ff46d 100644 --- a/locales/template/de.po +++ b/locales/template/de.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-05 03:27+0200\n" -"PO-Revision-Date: 2026-05-07 17:24+0000\n" -"Last-Translator: Tim Fischbach \n" +"PO-Revision-Date: 2026-05-19 05:11+0000\n" +"Last-Translator: Tim Fischbach \n" "Language-Team: German \n" "Language: de\n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.17.1\n" +"X-Generator: Weblate 2026.6.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -21,7 +21,7 @@ msgstr "" msgid "You can backup your existing database here, or restore an existing backup." msgstr "" -"Hier kannst du deine bestehende Datenbank sichern oder eine bestehende " +"Hier können sie ihre bestehende Datenbank sichern oder eine bestehende " "Sicherung wiederherstellen." msgid "Backuping allows you to download a JSON file containing all your categories and archive IDs, and their matching metadata." @@ -56,18 +56,18 @@ msgstr "" msgid "You can apply modifications to multiple archives in one go here." msgstr "" -"Hier kannst du Veränderungen von mehreren Archiven zugleich durchführen." +"Hier können sie Veränderungen von mehreren Archiven zugleich durchführen." msgid "Select what you'd like to do, check archives you want to use it on, and get rolling!" msgstr "" -"Wähle aus, was du machen möchtest, wähle die Archive aus, die du verändern " -"möchtest und leg los!" +"Wählen sie aus, was sie gerne machen möchten, wählen sie die Archive aus, " +"die sie verändern möchten und legen sie los!" msgid "Archives with no tags have been pre-checked." msgstr "Archive ohne Genres sind bereits ausgewählt." msgid "Task :" -msgstr "Genre:" +msgstr "Aufgabe:" msgid "Use Plugin" msgstr "Nutze Erweiterungen" @@ -76,7 +76,7 @@ msgid "Remove New Flag" msgstr "Entferne \"Neu\" Vermerk" msgid "Apply Tag Rules" -msgstr "Wende Genre-Regeln an" +msgstr "Wende Tag-Regeln an" msgid "Add To Category" msgstr "Zu Kategorie hinzufügen" @@ -97,8 +97,8 @@ msgstr "" msgid "Some external services may temporarily ban your machine for excessive loads if you call a plugin too many times!" msgstr "" -"Einige externe Services können deine IP für exzessive Belastungen bannen, " -"wenn du eine Erweiterung zu häufig ausführst!" +"Einige externe Services können ihre IP für exzessive Auslastungen bannen, " +"wenn sie eine Erweiterung zu häufig ausführen!" msgid "Make sure to set a suitable timeout between archives using this picker if the plugin you want to use is concerned." msgstr "" @@ -110,11 +110,10 @@ msgstr "Überschreibe Globale Erweiterungsargumente" msgid "This will apply the following Tag Rules to the selected Archives." msgstr "" -"Diese Aktion wendet die folgenden Genre-Regeln auf die ausgewählten Archive " -"an." +"Diese Aktion wendet die folgenden Tag-Regeln auf die ausgewählten Archive an." msgid "You can edit your Tag Rules in Server Configuration." -msgstr "Du kannst die Genre-Regeln in den Servereinstellungen ändern." +msgstr "Sie können die Tag Regeln in den Servereinstellungen ändern." msgid "Server Configuration" msgstr "Servereinstellungen" @@ -269,7 +268,7 @@ msgid "Archive Files" msgstr "Archiv Dateien" msgid "Tags and Thumbnails" -msgstr "Genres und Miniaturbilder" +msgstr "Tags und Miniaturanaixhten" msgid "Background Workers" msgstr "Hintergrundarbeiter" @@ -740,7 +739,7 @@ msgid "Add Archives" msgstr "Archive hinzufügen" msgid "Batch Operations" -msgstr "Stapeloperationen" +msgstr "Stapelverarbeitungseinstellungen" msgid "Settings" msgstr "Einstellungen" @@ -797,6 +796,9 @@ msgstr "Thumbnails zuschneiden" msgid "If enabled, thumbnails that don't fit a regular A4 page will be cropped to only show the left side." msgstr "" +"Wenn diese Option aktiviert ist, werden Miniaturansichten, die nicht auf " +"eine normale A4-Seite passen, so beschnitten, dass nur die linke Seite " +"angezeigt wird." msgid "Hide completed" msgstr "Ausblendung erfolgreich" @@ -875,16 +877,16 @@ msgstr "" "schon dabei bist? " msgid "Select Archives" -msgstr "" +msgstr "Wähle Archive" msgid "Run Batch Operations on selection" -msgstr "" +msgstr "Stapelverarbeitung für diese Auswahl ausführen" msgid "Clear selection" -msgstr "" +msgstr "Auswahl aufheben" msgid "Select All in Page" -msgstr "" +msgstr "Alles auf der Seite auswählen" # ------End of Index.html.tt2------ # ------Start of Login.html.tt2------ @@ -1049,19 +1051,20 @@ msgid "Archive Overview" msgstr "" msgid "Admin Options" -msgstr "" +msgstr "Admineinstellungen" msgid "Set this Page as Thumbnail" -msgstr "" +msgstr "Diese Seite als Miniaturansicht setzen" msgid "Set the currently opened page as the thumbnail for this archive." msgstr "" +"Setze die aktuell angezeigte Seite als Miniaturansicht für dieses Archiv." msgid "Clean Archive Cache" -msgstr "" +msgstr "Archiv-Cache bereinigen" msgid "Edit Archive Metadata" -msgstr "" +msgstr "Archiv-Metadaten bearbeiten" msgid "Delete Archive" msgstr "Lösche das Archiv" @@ -1070,189 +1073,199 @@ msgid "Categories" msgstr "Kategorien" msgid "Add to : " -msgstr "" +msgstr "Hinzufügen zu: " msgid " -- No Category -- " msgstr "" msgid "Add Archive to Category" -msgstr "" +msgstr "Archiv zu Kategorie hinzufügen" msgid "Pages" -msgstr "" +msgstr "Seiten" msgid "Working on it..." -msgstr "" +msgstr "Ich arbeite daran..." msgid "You can navigate between pages using:" -msgstr "" +msgstr "Sie können zwischen den Seiten navigieren, indem Sie:" msgid "The arrow icons" -msgstr "" +msgstr "Die Pfeilsymbole" msgid "The a/d keys" -msgstr "" +msgstr "Die a/d Tasten" msgid "Your keyboard arrows (and the spacebar)" -msgstr "" +msgstr "Ihre Keyboardpfeiltasten (und die Leertaste)" msgid "Touching the left/right side of the image." -msgstr "" +msgstr "Linke/Rechte Seite des Bilds berühren." msgid "Other keyboard shortcuts:" -msgstr "" +msgstr "Weitere Tastenkombinationen:" msgid "M: toggle manga mode (right-to-left reading)" -msgstr "" +msgstr "M: Manga Modus umschalten (von Rechts nach Links lesen)" msgid "O: show advanced reader options." -msgstr "" +msgstr "O: Fortgeschrittene Reader-Einstellungen anzeigen." msgid "P: toggle double page mode" -msgstr "" +msgstr "P: Doppelseitenmodus umschalten" msgid "Q: bring up the thumbnail index and archive options." -msgstr "" +msgstr "Q: Miniaturansichtsindex und Archivoptionen anzeigen lassen." msgid "R: open a random archive." -msgstr "" +msgstr "R: zufälliges Archiv öffnen." msgid "F: toggle fullscreen mode" -msgstr "" +msgstr "F: Vollbildmodus umschalten" msgid "B: toggle bookmark" -msgstr "" +msgstr "B: Lesezeichen umschalten" msgid "To return to the archive index, touch the arrow pointing down or use Backspace." msgstr "" +"Um auf die Archivseite zurückzukehren, berühren sie den nach unten Zeigenden " +"Pfeil oder benutzen sie die Backspace-Taste." msgid "Reader Options" -msgstr "" +msgstr "Reader-Optionen" msgid "Those options save automatically -- Click around and find out!" msgstr "" +"Diese Einstellungen speichern sich automatisch -- Klicke herum und probiere " +"es aus!" msgid "Fit display to" -msgstr "" +msgstr "Anzeige anpassen an" msgid "Container" -msgstr "" +msgstr "Container" msgid "Width" -msgstr "" +msgstr "Breite" msgid "Height" -msgstr "" +msgstr "Höhe" msgid "Container Width (in pixels or percentage)" -msgstr "" +msgstr "Containerbreite (In Pixeln oder Prozent)" msgid "The default value is 1200px, or 90% in Double Page Mode." -msgstr "" +msgstr "Der Standardwert beträgt 1200px, oder 90% im Doppelseitenmodus." msgid "Apply" -msgstr "" +msgstr "Übernehmen" msgid "Page Rendering" -msgstr "" +msgstr "Seitenrendering" msgid "Single" -msgstr "" +msgstr "Einzel" msgid "Double" -msgstr "" +msgstr "Doppelt" msgid "Reading Direction" msgstr "" msgid "Left to Right" -msgstr "" +msgstr "Von Links nach Rechts" msgid "Right to Left" -msgstr "" +msgstr "Von Rechts nach Links" msgid "How many images to preload" -msgstr "" +msgstr "Anzahl der Bilder, welche vorgeladen werden sollen" msgid "The default is two images." -msgstr "" +msgstr "Der Standardwert sind zwei Bilder." msgid "Header" msgstr "" msgid "Visible" -msgstr "" +msgstr "Sichtbar" msgid "Hidden" -msgstr "" +msgstr "Versteckt" msgid "Show Archive Overlay by default" -msgstr "" +msgstr "Zeige Archiv-Overlay standardmäßig" msgid "This will show the overlay with thumbnails every time you open a new Reader page." msgstr "" +"Diese Einstellung wird das Overlay mit Miniaturansichten jedes Mal anzeigen, " +"wenn sie eine neue Readerseite öffnen." msgid "Enabled" -msgstr "" +msgstr "Aktiviert" msgid "Disabled" -msgstr "" +msgstr "Deaktiviert" msgid "Progression Tracking" -msgstr "" +msgstr "Fortschrittsverfolgung" msgid "Disabling tracking will restart reading from page one every time you reopen the reader." msgstr "" +"Wenn sie die Fortschrittsverfolgung ausschalten, wird der Reader jedes Mal " +"die erste Seite anzeigen, wenn sie den Reader erneut öffnen." msgid "Infinite Scrolling" -msgstr "" +msgstr "Unendliches Scrollen" msgid "Display all images in a vertical view in the same page." -msgstr "" +msgstr "Zeige alle Bilder in einer Vertikalen Ansicht auf der selben Seite an." msgid "Help" msgstr "Hilfe" msgid "Reading Direction" -msgstr "" +msgstr "Leserichtung" msgid "Archive Overview" -msgstr "" +msgstr "Archiv-Übersicht" msgid "FullScreen" -msgstr "" +msgstr "Vollbild" msgid "Toggle Bookmark" -msgstr "" +msgstr "Lesezeichen umschalten" # ------End of Reader.html.tt2------ # ------Start of Stats.html.tt2------ msgid "Library Statistics" -msgstr "" +msgstr "Bibliothek Statistiken" msgid "Archives on record" -msgstr "" +msgstr "aufgezeichnete Archive" msgid "Different tags existing" -msgstr "" +msgstr "Unterschiedliche Tags existieren" msgid "in content folder" -msgstr "" +msgstr "im Content-Ordner" msgid "pages read" -msgstr "" +msgstr "Seiten gelesen" msgid "Tag Cloud" -msgstr "" +msgstr "Tag-Cloud" msgid "Asking the great powers that be for your tag statistics..." -msgstr "" +msgstr "Die Mächtigen um deine Tag-Statistiken bitten..." msgid "Detailed Stats" -msgstr "" +msgstr "Detaillierte Statistiken" msgid "(These statistics only show tags that appear at least twice in your database.)" msgstr "" +"(Diese Statistiken zeigen nur Tags an, welche mindestens zwei Mal in deiner " +"Datenbank vorkommen.)" msgid "Return to Library" msgstr "" @@ -1260,49 +1273,60 @@ msgstr "" # ------End of Stats.html.tt2------ # ------Start of Upload.html.tt2------ msgid "Upload Center" -msgstr "" +msgstr "Upload Center" msgid "Adding Archives to the Library" -msgstr "" +msgstr "Füge Archive zur Bibliothek hinzu" msgid "Add files to your LANraragi instance from your computer, or the Internet directly." msgstr "" +"Fügen sie Dateien zu ihrer LANraragi Instanz über ihren PC oder über das " +"Internet hinzu." msgid "Add uploaded files to category:" -msgstr "" +msgstr "Hochgeladene Dateien zu folgender Kategorie hinzufügen:" msgid " -- No Category -- " -msgstr "" +msgstr " -- Keine Kategorie -- " msgid "From your computer" -msgstr "" +msgstr "Von ihrem PC" msgid "You can drag and drop files into this window, or click the upload button." msgstr "" +"Sie können Dateien via Drag and Drop in dieses Fenster oder über den Upload-" +"Button hochladen." msgid "Add from your computer" -msgstr "" +msgstr "Von ihrem PC hinzufügen" msgid "From the Internet" -msgstr "" +msgstr "Aus dem Internet" msgid "You can download files from remote URLs directly into LANraragi from here." -msgstr "" +msgstr "Sie können hier Dateien über URLs direkt mit LANraragi herunterladen." msgid "Download jobs will keep going even if you close this window!" msgstr "" +"Downloads werden weitergeführt, auch wenn sie dieses Fenster schließen " +"sollten!" msgid "Type in your URLs (separated by a newline), and click the download button." msgstr "" +"Bitte geben sie ihre URLs ein (getrennt durch eine neue Zeile) und klicken " +"sie anschließend den Download Button." msgid "If a Downloader plugin is compatible with the URL, it'll be automatically used." msgstr "" +"Falls ein Downloader " +"Plugin mit der URL kompatibel sein sollte, wird dieser automatisch " +"verwendet." msgid "URL(s) to download:" -msgstr "" +msgstr "URL(s) zum Herunterladen:" msgid "Add from URL(s)" -msgstr "" +msgstr "Von URL(s) hinzufügen" msgid "Return to Library" msgstr "Zurück zur Bibliothek" @@ -1318,91 +1342,95 @@ msgstr "" # ------Stary of i18n.html.tt2------ msgid "Backup restored!" -msgstr "" +msgstr "Backup wiederhergestellt!" msgid "An error occured while restoring the backup.
Please check the server logs and that your JSON is correctly formatted." msgstr "" +"Ein Fehler ist während der Backup Wiederherstellung aufgetreten.
Bitte " +"überprüfen sie die Server-Logs und stellen sie sicher, dass ihre JSON " +"korrekt formatiert ist." msgid "Backup generation in progress..." -msgstr "" +msgstr "Backup wird erstellt..." msgid "Uploading backup file..." -msgstr "" +msgstr "Lade Backup-Datei hoch..." msgid "Backup complete! Download will start automatically." -msgstr "" +msgstr "Backup abgeschlossen! Der Download beginnt in Kürze automatisch." msgid "Couldn't load the complete archive list! Please reload the page." msgstr "" +"Konnte nicht die gesamte Archivliste laden! Bitte laden sie die Seite neu." msgid "Couldn't load the tag statistics! Please reload the page." -msgstr "" +msgstr "Konnte die Tag-Statistiken nicht laden. Bitte laden sie die Seite neu." msgid "Error getting untagged archives!" -msgstr "" +msgstr "Fehler: Konnte nicht getaggte Archive nicht laden!" msgid "Are you sure you want to delete this archive?" -msgstr "" +msgstr "Sind sie sich sicher, dass sie dieses Archiv löschen möchten?" msgid "Are you sure you want to delete the selected archives?" -msgstr "" +msgstr "Sind sie sich sicher, dass sie diese Archive löschen möchten?" msgid "This action cannot be undone!" -msgstr "" +msgstr "Diese Aktion kann nicht rückgängig gemacht werden!" msgid "This action (truly) cannot be undone!" -msgstr "" +msgstr "Diese Aktion kann (wirklich) nicht rückgängig gemacht werden!" msgid "Yes, delete it!" -msgstr "" +msgstr "Ja, lösche es!" msgid "No, keep it!" -msgstr "" +msgstr "Nein, behalte es!" msgid "Error while deleting cache! Check application logs." -msgstr "" +msgstr "Fehler: Konnte Cache nicht löschen. Siehe Applikations-Logs." msgid "Error while processing request" -msgstr "" +msgstr "Fehler: Konnte Anfrage nicht bearbeiten" msgid "Error checking Minion job status" -msgstr "" +msgstr "Fehler: Konnte Minion job Status nicht überprüfen" msgid "Saved!" -msgstr "" +msgstr "Gespeichert!" msgid "Error saving data" -msgstr "" +msgstr "Fehler: Konnte Daten nicht speichern" msgid "Started Batch Operation..." -msgstr "" +msgstr "Starte Stapelverarbeitung..." msgid "Batch Operation complete!" -msgstr "" +msgstr "Stapelverarbeitung abgeschlossen!" msgid "An error occured during batch tagging!" -msgstr "" +msgstr "Ein Fehler ist während der Tagging Stapelverarbeitung aufgetreten!" msgid "Please check application logs." -msgstr "" +msgstr "Bitte Applikations-Logs überprüfen." msgid "Error! Terminating session." -msgstr "" +msgstr "Fehler! Session wird terminiert." msgid "Sleeping for \${x} seconds." -msgstr "" +msgstr "Warte ${x} Sekunden." msgid "Error while processing ID \${id} (\${msg})" -msgstr "" +msgstr "Fehler bei der Verarbeitung der ID ${id} (${msg})" msgid "Processed ID \${id} with \"\${plug}\" (Added tags: \${tags})" -msgstr "" +msgstr "ID ${id} mit „${plug}“ verarbeitet (Hinzugefügte Tags: ${tags})" msgid "Deleted ID \${id} (Filename: \${filename})" -msgstr "" +msgstr "ID ${id} gelöscht (Dateiname: ${filename})" msgid "Replaced tags for ID \${id} (New tags: \${tags})" -msgstr "" +msgstr "Tags für ID ${id} ersetzt (Neue Tags: ${tags})" msgid "Added ID \${id} to category \${category}! (\${msg})" msgstr "" @@ -1418,136 +1446,156 @@ msgstr "" msgid "Reloading page in 5 seconds to account for deleted archives..." msgstr "" +"Die Seite wird in 5 Sekunden neu geladen, um gelöschte Archive zu " +"berücksichtigen..." msgid "Cancelling Batch Operation..." -msgstr "" +msgstr "Breche Stapelverarbeitung ab..." msgid "Enter a name for the new category" -msgstr "" +msgstr "Bitte geben sie den Namen der neuen Kategorie ein" msgid "My Category" -msgstr "" +msgstr "Meine Kategorie" msgid "Please enter a category name." -msgstr "" +msgstr "Bitte geben sie einen Kategorienamen ein." msgid "No category" -msgstr "" +msgstr "Keine Kategorie" msgid "Error getting categories from server" -msgstr "" +msgstr "Fehler: Konnte Kategorien nicht vom Server laden" msgid "Error modifying category" -msgstr "" +msgstr "Fehler: Konnte Kategorie nicht modifizieren" msgid "The category will be deleted permanently." -msgstr "" +msgstr "Diese Kategorie wird dauerhaft gelöscht." msgid "Category deleted!" -msgstr "" +msgstr "Kategorie gelöscht!" msgid "Error deleting category" -msgstr "" +msgstr "Fehler: Konnte Kategorie nicht löschen" msgid "Writing a Predicate" -msgstr "" +msgstr "Schreibe ein Prädikat" msgid "Predicates follow the same syntax as searches in the Archive Index. Check the Documentation for more information." msgstr "" +"Prädikate folgen derselben Syntax wie Suchanfragen im Archivindex. Weitere " +"Informationen finden Sie in der Dokumentation." msgid "Background Worker restarted!" -msgstr "" +msgstr "Background Worker neu gestartet!" msgid "Error restarting Worker:" -msgstr "" +msgstr "Fehler: Konnte Worker nicht neu starten:" msgid "Content folder rescan started!" -msgstr "" +msgstr "Content Ordner wird erneut gescannt!" msgid "Error starting content folder rescan:" -msgstr "" +msgstr "Fehler: Konnte Content Ordner nicht erneut scannen:" msgid "Error while querying Shinobu status:" -msgstr "" +msgstr "Fehler: Konnte Shinobu Status nicht ermitteln:" msgid "About Plugins" -msgstr "" +msgstr "Über die Plugins" msgid "You can use plugins to automatically fetch metadata for this archive.
Just select a plugin from the dropdown and hit Go!
Some plugins might provide an optional argument for you to specify. If that's the case, a textbox will be available to input said argument." msgstr "" +"Sie können Plugins verwenden, um Metadaten für dieses Archiv automatisch " +"abzurufen.
Wählen Sie einfach ein Plugin aus der Dropdown-Liste aus " +"und klicken Sie auf „Los“!
Einige Plugins bieten möglicherweise ein " +"optionales Argument an, das Sie angeben können. In diesem Fall steht Ihnen " +"ein Textfeld zur Eingabe dieses Arguments zur Verfügung." msgid "Metadata saved!" -msgstr "" +msgstr "Metadaten gespeichert!" msgid "Error saving archive metadata" -msgstr "" +msgstr "Fehler: Konnte Metadaten nicht speichern" msgid "Error while fetching tags" -msgstr "" +msgstr "Fehler: Konnte Tags nicht laden" msgid "Archive title changed to" -msgstr "" +msgstr "Archivtitel geändert zu" msgid "Archive summary updated!" -msgstr "" +msgstr "Archivzusammenfassung aktualisiert!" msgid "Added the following tags" -msgstr "" +msgstr "Folgende Tags wurden hinzugefügt" msgid "No new tags added!" -msgstr "" +msgstr "Keine neuen Tags hinzugefügt!" # Do not translate _START_, _END_ or _TOTAL_. msgid "Showing _START_ to _END_ of _TOTAL_ ancient chinese lithographies." msgstr "" +"Zeige _START_ bis _END_ von _TOTAL_ antiken chinesischen Lithografien an." msgid "No archives to show you! Try uploading some?" msgstr "" +"Es sind keine Archive vorhanden! Versuchen Sie doch, " +"einige hochzuladen?" msgid "Welcome to LANraragi \${version}!" -msgstr "" +msgstr "Willkommen bei LANraragi ${version}!" msgid "If you want to perform advanced operations on an archive, remember to just right-click its name. Happy reading!" msgstr "" +"Falls sie erweiterte Archivfunktionen benutzen möchten, klicken sie den " +"Archivnamen via Rechtsklick an. Viel Spaß beim Lesen!" msgid "Error getting basic server info." -msgstr "" +msgstr "Fehler: Konnte Server Informationen nicht abrufen." msgid "You're running in Debug Mode!" -msgstr "" +msgstr "Sie benutzen den Debug Modus!" msgid "Advanced server statistics can be viewed here." msgstr "" +"Erweiterte Serverstatistiken können hier eingesehen " +"werden." msgid "Enter a tag namespace for this column" -msgstr "" +msgstr "Geben sie einen Tag-Namespace für diese Spalte ein" msgid "Enter a full namespace without the colon, e.g \"artist\"." msgstr "" +"Geben sie einen kompletten Namespace ohne Doppelpunkt an, z.B. \"Künstler\"." msgid "If you have multiple tags with the same namespace, only the last one will be shown in the column." msgstr "" +"Falls sie mehrere Tags im selben Namespace angelegt haben, wird nur der " +"letzte Tag in der Spalte angezeigt." msgid "Tag namespace" -msgstr "" +msgstr "Tag-Namespace" msgid "Please enter a tag namespace." -msgstr "" +msgstr "Bitte geben sie einen Tag-Namespace ein." msgid "Randomly Picked" -msgstr "" +msgstr "Zufällig ausgewählt" msgid "New Archives" -msgstr "" +msgstr "Neue Archive" msgid "Untagged Archives" -msgstr "" +msgstr "Nicht getaggte Archive" msgid "On Deck" -msgstr "" +msgstr "Als Nächstes" msgid "Error getting carousel data!" -msgstr "" +msgstr "Fehler: Konnte carousel Daten nicht einfordern!" msgid "Edit this column" msgstr "Diese Spalte bearbeiten" @@ -1559,46 +1607,46 @@ msgid "Tags" msgstr "Tags" msgid "Header" -msgstr "" +msgstr "Kopfzeile" msgid "A new version of LANraragi (\${version}) is available!" -msgstr "" +msgstr "Eine neue LANraragi Version (${version}) ist verfügbar!" msgid "Click here to check it out." -msgstr "" +msgstr "Klicken sie hier, um mehr zu erfahren." msgid "Error getting changelog for new version" -msgstr "" +msgstr "Fehler: Konnte Changelog der neuen Version nicht ermitteln" msgid "Couldn't load data for \${id}!" msgstr "" msgid "Github API rate limit exceeded." -msgstr "" +msgstr "GitHub API Ratenlimit überschritten." msgid "Github API returned status: \${status}" -msgstr "" +msgstr "Github API gibt folgenden Status zurück: ${status}" msgid "This archive isn't in any category." -msgstr "" +msgstr "Dieses Archiv besitzt keine Kategorie." msgid "No Categories yet..." -msgstr "" +msgstr "Bisher keine Kategorien vorhanden..." msgid "Remove rating" -msgstr "" +msgstr "Bewertung entfernen" msgid "Click here to display new archives only." -msgstr "" +msgstr "Klicken sie hier, um nur neue Archive anzeigen zu lassen." msgid "Click here to display untagged archives only." -msgstr "" +msgstr "Klicken sie hier, um nur nicht getaggte Archive anzeigen zu lassen." msgid "Click here to display archives in this category only." -msgstr "" +msgstr "Klicken sie hier, um nur die Archive dieser Kategorie anzuzeigen." msgid "Your Reading Progression is now saved on the server!" -msgstr "" +msgstr "Ihr Lesefortschritt wird ab jetzt auf dem Server gespeichert!" msgid "You seem to have some local progression hanging around -- Please wait warmly while we migrate it to the server for you." msgstr "" @@ -1880,3 +1928,101 @@ msgid "Comma-separated list of tag namespaces to exclude from search suggestions msgstr "" "Komma-getrennte Liste der Namespace Tags, welche von den Suchvorschlägen und " "Tag Statistiken ausgeschlossen sind." + +msgid "Archives with no tags or from your last selection have been pre-checked." +msgstr "" +"Archive ohne Tags und Archive aus ihrer letzten Auswahl wurden markiert." + +msgid "Discard Selection" +msgstr "Auswahl verwerfen" + +msgid "Displaying ${n} Archives from previous selection." +msgstr "Zeige ${n} Archive aus ihrer letzten Auswahl." + +msgid "Tankoubons" +msgstr "Tankoubons" + +msgid "No Tankoubons in your library yet." +msgstr "Aktuell sind keine Tankoubons in ihrer Bibliothek vorhanden." + +msgid "Archives" +msgstr "Archive" + +msgid "No Archives in your library yet." +msgstr "Aktuell sind keine Archive in ihrer Bibliothek vorhanden." + +msgid "Clients will use this list to filter out noisy tags from autocomplete and tag clouds." +msgstr "" +"Clients werden diese Liste nutzen, um irrelevante Tags aus der " +"Autovervollständigung und den Tag-Clouds herauszufiltern." + +msgid "Group Tankoubons" +msgstr "Gruppierte Tankoubons" + +msgid "If enabled, Archives belonging to a Tankoubon will show as a single item." +msgstr "" +"Wenn diese Option aktiviert ist, werden Tankoubon verknüpfte Archive als " +"einzelnes Item angezeigt." + +msgid "Index Settings" +msgstr "Index Einstellungen" + +msgid "Display Mode" +msgstr "Anzeigemodus" + +msgid "Compact" +msgstr "Kompakt" + +msgid "Thumbnail" +msgstr "Miniaturansicht" + +msgid "Merge Archives into Tankoubon" +msgstr "Archive in einen Tankoubon zusammenführen" + +msgid "Rating" +msgstr "Bewertung" + +msgid "Set Rating" +msgstr "bewerten" + +msgid "Clear Rating" +msgstr "Bewertung löschen" + +msgid " -- Select Rating -- " +msgstr " -- Bewertung auswählen -- " + +msgid "N: toggle auto next page" +msgstr "N: automatischen Seitenwechsel umschalten" + +msgid "G: go to page number" +msgstr "G: zu bestimmter Seitennummer wechseln" + +msgid "Auto next page interval in seconds" +msgstr "Intervall für automatischen Seitenwechsel in Sekunden" + +msgid "The default is 10 seconds." +msgstr "Der Standardwert beträgt 10 Sekunden." + +msgid "Auto Next Page" +msgstr "Automatischer Seitenwechsel" + +msgid "New archive" +msgstr "Neues Archiv" + +msgid "Archive read" +msgstr "Archiv gelesen" + +msgid "Tankoubon (collection)" +msgstr "Tankoubon (Kollektion)" + +msgid "Pages read / Total pages" +msgstr "Seiten gelesen / Gesamtanzahl Seiten" + +msgid "Pages read / Total pages / Archives in Tankoubon" +msgstr "Seiten gelesen / Gesamtanzahl Seiten / Archive in Tankoubons" + +msgid "Link copied successfully." +msgstr "Link erfolgreich kopiert." + +msgid "Copy failed." +msgstr "Kopieren fehlgeschlagen." diff --git a/locales/template/en.po b/locales/template/en.po index d9aa08e87..56936015f 100644 --- a/locales/template/en.po +++ b/locales/template/en.po @@ -630,6 +630,9 @@ msgstr "Editing %1 by %2" msgid "Editing %1" msgstr "Editing %1" +msgid "Editing %1 (Tankoubon)" +msgstr "Editing %1 (Tankoubon)" + msgid "Current File Name:" msgstr "Current File Name:" @@ -663,6 +666,24 @@ msgstr "Using a Plugin will save any modifications to archive metadata you might msgid "Save Metadata" msgstr "Save Metadata" +msgid "Delete Tankoubon" +msgstr "Delete Tankoubon" + +msgid "Read Tankoubon" +msgstr "Read Tankoubon" + +msgid "Archives:" +msgstr "Archives:" + +msgid "Add Archive to Tankoubon:" +msgstr "Add Archive to Tankoubon:" + +msgid "Archive ID (40-character long)" +msgstr "Archive ID (40-character long)" + +msgid "Add" +msgstr "Add" + msgid "Read Archive" msgstr "Read Archive" @@ -1001,6 +1022,9 @@ msgstr "Clean Archive Cache" msgid "Edit Archive Metadata" msgstr "Edit Archive Metadata" +msgid "Edit Tankoubon" +msgstr "Edit Tankoubon" + msgid "Delete Archive" msgstr "Delete Archive" @@ -1435,6 +1459,24 @@ msgstr "Writing a Predicate" msgid "Predicates follow the same syntax as searches in the Archive Index. Check the Documentation for more information." msgstr "Predicates follow the same syntax as searches in the Archive Index. Check the Documentation for more information." +msgid "Error saving tankoubon metadata" +msgstr "Error saving tankoubon metadata" + +msgid "Remove from Tankoubon" +msgstr "Remove from Tankoubon" + +msgid "Are you sure you want to delete this tankoubon? The archives will remain in your library but will no longer be grouped." +msgstr "Are you sure you want to delete this tankoubon? The archives will remain in your library but will no longer be grouped." + +msgid "Tankoubon deleted" +msgstr "Tankoubon deleted" + +msgid "Error deleting tankoubon" +msgstr "Error deleting tankoubon" + +msgid "Error adding archive to Tankoubon" +msgstr "Error adding archive to Tankoubon" + msgid "New archive" msgstr "New archive" @@ -1471,6 +1513,12 @@ msgstr "About Plugins" msgid "You can use plugins to automatically fetch metadata for this archive.
Just select a plugin from the dropdown and hit Go!
Some plugins might provide an optional argument for you to specify. If that's the case, a textbox will be available to input said argument." msgstr "You can use plugins to automatically fetch metadata for this archive.
Just select a plugin from the dropdown and hit Go!
Some plugins might provide an optional argument for you to specify. If that's the case, a textbox will be available to input said argument." +msgid "About Tankoubons" +msgstr "About Tankoubons" + +msgid "Tankoubons are unified collections of Archives.
You can change the order of the contained Archives here by dragging the handles on the left, as well as remove Archives from the Tankoubon.
Make sure to click Save once you're happy with the changes." +msgstr "Tankoubons are unified collections of Archives.
You can change the order of the contained Archives here by dragging the handles on the left, as well as remove Archives from the Tankoubon.
Make sure to click Save once you're happy with the changes." + msgid "Metadata saved!" msgstr "Metadata saved!" @@ -1838,6 +1886,9 @@ msgstr "Error creating Tankoubon" msgid "Error adding archives to Tankoubon" msgstr "Error adding archives to Tankoubon" +msgid "This will merge \${n} Archives into Tankoubon \${name}. Proceed?" +msgstr "This will merge \${n} Archives into Tankoubon \${name}. Proceed?" + msgid "Merge selection into a Tankoubon" msgstr "Merge selection into a Tankoubon" diff --git a/locales/template/fr.po b/locales/template/fr.po index 3f7c0d560..447782cc0 100644 --- a/locales/template/fr.po +++ b/locales/template/fr.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-04 20:02+0200\n" -"PO-Revision-Date: 2026-05-07 17:34+0000\n" +"PO-Revision-Date: 2026-05-27 23:29+0000\n" "Last-Translator: Difegue <8237712+Difegue@users.noreply.github.com>\n" "Language-Team: French \n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 5.17.1\n" +"X-Generator: Weblate 2026.6.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -2016,3 +2016,202 @@ msgstr "G : Aller à une page spécifique" msgid "Go to page:" msgstr "Aller à la page:" + +msgid "Archives with no tags or from your last selection have been pre-checked." +msgstr "" +"Les Archives sans tags ou provenant de votre dernière sélection ont été " +"préselectionnées." + +msgid "Discard Selection" +msgstr "Abandonner la sélection" + +msgid "Displaying ${n} Archives from previous selection." +msgstr "Affichage de ${n} Archives provenant de la dernière sélection." + +msgid "Tankoubons" +msgstr "Tankoubons" + +msgid "No Tankoubons in your library yet." +msgstr "Pas encore de Tankoubons dans votre bibliothèque." + +msgid "Archives" +msgstr "Archives" + +msgid "No Archives in your library yet." +msgstr "Pas encore d'Archives dans votre bibliothèque." + +msgid "Editing %1 (Tankoubon)" +msgstr "Modification de %1 (Tankoubon)" + +msgid "Delete Tankoubon" +msgstr "Supprimer le Tankoubon" + +msgid "Read Tankoubon" +msgstr "Lire le Tankoubon" + +msgid "Archives:" +msgstr "Archives :" + +msgid "Add Archive to Tankoubon:" +msgstr "Ajouter une Archive au Tankoubon :" + +msgid "Archive ID (40-character long)" +msgstr "ID de l'Archive (40 caractères)" + +msgid "Add" +msgstr "Ajouter" + +msgid "Group Tankoubons" +msgstr "Regrouper par Tankoubon" + +msgid "If enabled, Archives belonging to a Tankoubon will show as a single item." +msgstr "" +"Si activé, les archives qui appartienent à un Tankoubon seront affichées " +"comme un seul élément." + +msgid "Index Settings" +msgstr "Paramètres de l'index" + +msgid "Display Mode" +msgstr "Mode d'affichage" + +msgid "Compact" +msgstr "Compact" + +msgid "Thumbnail" +msgstr "Miniatures" + +msgid "Select Archives" +msgstr "Sélectionner des éléments" + +msgid "Run Batch Operations on selection" +msgstr "Exécuter des Opérations en série sur la sélection" + +msgid "Merge Archives into Tankoubon" +msgstr "Fusionner les archives dans un Tankoubon" + +msgid "Clear selection" +msgstr "Vider la sélection" + +msgid "Select All in Page" +msgstr "Sélectionner toute la page" + +msgid "Edit Tankoubon" +msgstr "Modifier le Tankoubon" + +msgid "S: set a Stamp" +msgstr "S : Ajouter un Tampon" + +msgid "Toggle Stamps" +msgstr "Afficher/cacher les Tampons" + +msgid "Set Stamp" +msgstr "Poser un Tampon" + +msgid "Filter stamped pages" +msgstr "Filtrer les pages tamponnées" + +msgid "Error saving tankoubon metadata" +msgstr "Erreur lors de la sauvegarde des métadonnées du Tankoubon" + +msgid "Remove from Tankoubon" +msgstr "Retirer du Tankoubon" + +msgid "Are you sure you want to delete this tankoubon? The archives will remain in your library but will no longer be grouped." +msgstr "" +"Voulez-vous vraiment supprimer ce Tankoubon ? Les Archives resteront dans " +"votre bibliothèque mais ne seront plus groupées ensemble." + +msgid "Tankoubon deleted" +msgstr "Tankoubon supprimé" + +msgid "Error deleting tankoubon" +msgstr "Erreur lors de la suppression du Tankoubon" + +msgid "Error adding archive to Tankoubon" +msgstr "Erreur lors de l'ajout de l'archive au Tankoubon" + +msgid "New archive" +msgstr "Nouvelle archive" + +msgid "Archive read" +msgstr "Archive lue" + +msgid "Tankoubon (collection)" +msgstr "Tankoubon (collection)" + +msgid "Pages read / Total pages" +msgstr "Pages lues / Pages au total" + +msgid "Pages read / Total pages / Archives in Tankoubon" +msgstr "Pages lues / Pages au total / Archives contenues dans le Tankoubon" + +msgid "About Tankoubons" +msgstr "A propos des Tankoubons" + +msgid "Tankoubons are unified collections of Archives.
You can change the order of the contained Archives here by dragging the handles on the left, as well as remove Archives from the Tankoubon.
Make sure to click Save once you're happy with the changes." +msgstr "" +"Les Tankoubons sont des collections d'Archives.
Vous pouvez changer " +"l'ordre des archives contenues ici en glissant les poignées à la gauche des " +"noms, ainsi qu'ajouter/supprimer des archives au Tankoubon.
N'oubliez " +"pas de cliquer Sauvegarder lorsque vous en avez terminé." + +msgid "Selection" +msgstr "Sélection" + +msgid "\${n} selected" +msgstr "${n} sélectionnés" + +msgid "Click Archives to add them to the selection. Your selection carries over across searches." +msgstr "" +"Cliquez sur des archives pour les ajouter à la sélection. Votre séléction " +"reste en mémoire si vous faites une nouvelle recherche." + +msgid "Exit selection mode? Your current selection will be lost. " +msgstr "Sortir du mode de sélection ? Votre sélection actuelle sera perdue. " + +msgid "Add to selection" +msgstr "Ajouter à la sélection" + +msgid "Remove from selection" +msgstr "Retirer de la sélection" + +msgid "Enter a name for the new Tankoubon." +msgstr "Entrez un nom pour le nouveau Tankoubon." + +msgid "This will contain all the selected Archives as a single item in your Library." +msgstr "" +"Cet élément contiendra toutes les Archives que vous avez sélectionnées comme " +"un seul élément dans votre bibliothèque." + +msgid "Please enter a name." +msgstr "Veuillez entrer un nom." + +msgid "Added \${n} Archives to \${tank}!" +msgstr "Ajouté ${n} archives à ${tank} !" + +msgid "Error creating Tankoubon" +msgstr "Erreur lors de la création du Tankoubon" + +msgid "Error adding archives to Tankoubon" +msgstr "Erreur lors de l'ajout des archives au Tankoubon" + +msgid "This will merge \${n} Archives into Tankoubon \${name}. Proceed?" +msgstr "" +"Cette opération va ajouter ${n} archives au Tankoubon ${name}. Voulez-vous " +"continuer ?" + +msgid "Merge selection into a Tankoubon" +msgstr "Fusionner la sélection dans un Tankoubon" + +msgid "Hide completed Archives" +msgstr "Cacher les archives lues" + +msgid "Enter Stamp name:" +msgstr "Entrez le nom du Tampon :" + +msgid "Stamp name" +msgstr "Nom du Tampon" + +msgid "Error setting up the Stamp" +msgstr "Erreur lors de la création du Tampon" diff --git a/locales/template/id.po b/locales/template/id.po index 432688e6d..33b29dcb4 100644 --- a/locales/template/id.po +++ b/locales/template/id.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-26 06:02+0200\n" -"PO-Revision-Date: 2026-03-26 08:09+0000\n" +"PO-Revision-Date: 2026-05-25 16:11+0000\n" "Last-Translator: Slasar41 \n" "Language-Team: Indonesian \n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 5.17-dev\n" +"X-Generator: Weblate 2026.6.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -156,7 +156,8 @@ msgid "Categories" msgstr "" msgid "Categories appear at the top of your window when browsing the Library." -msgstr "Kategori muncul di bagian atas jendela Anda saat menjelajahi Pustaka." +msgstr "" +"Kategori muncul di bagian atas jendela Anda saat menjelajahi Perpustakaan." msgid "There are two distinct kinds:" msgstr "Ada dua jenis yang berbeda:" @@ -169,7 +170,7 @@ msgstr "" msgid "Dynamic Categories contain all archives matching a given predicate, and automatically update alongside your library." msgstr "" "Kategori Dinamis berisi semua arsip yang cocok dengan predikat tertentu dan " -"dimutakhirkan secara otomatis bersamaan dengan pustaka Anda." +"dimutakhirkan secara otomatis bersamaan dengan perpustakaan Anda." msgid "You can create new categories here or edit existing ones." msgstr "" @@ -366,7 +367,8 @@ msgstr "PHI" msgid "Slang for Message of the Day. Appears on top of the main Library view." msgstr "" -"Singkatan dari Pesan Hari Ini. Muncul di bagian atas tampilan Pustaka utama." +"Singkatan dari Pesan Hari Ini. Muncul di bagian atas tampilan Perpustakaan " +"utama." msgid "Archives per page" msgstr "Arsip per halaman" @@ -784,12 +786,16 @@ msgstr "Pangkas keluku" msgid "If enabled, thumbnails that don't fit a regular A4 page will be cropped to only show the left side." msgstr "" +"Jika diaktifkan, keluku yang tidak muat di halaman A4 biasa akan dipotong " +"hanya untuk menampilkan sisi kiri." msgid "Hide completed" -msgstr "" +msgstr "Sembunyikan yang sudah dibaca" msgid "If enabled, Archives you've already completed will be hidden from the list." msgstr "" +"Jika diaktifkan, arsip yang sudah Anda baca habis akan disembunyikan dari " +"daftar." msgid "Go to Page:" msgstr "Pergi ke Halaman:" @@ -858,16 +864,16 @@ msgstr "" "fiturnya!
Selagi di sana, periksa juga pilihan konfigurasi lainnya. " msgid "Select Archives" -msgstr "" +msgstr "Pilih Arsip" msgid "Run Batch Operations on selection" -msgstr "" +msgstr "Jalankan Batch Operations pada arsip yang dipilih" msgid "Clear selection" -msgstr "" +msgstr "Bersihkan pilihan" msgid "Select All in Page" -msgstr "" +msgstr "Pilih Semua di Halaman Ini" # ------End of Index.html.tt2------ # ------Start of Login.html.tt2------ @@ -1108,7 +1114,7 @@ msgid "F: toggle fullscreen mode" msgstr "F: beralih ke mode layar penuh" msgid "B: toggle bookmark" -msgstr "B: tambahkan penanda" +msgstr "B: tandai/batal tandai penanda" msgid "To return to the archive index, touch the arrow pointing down or use Backspace." msgstr "" @@ -1221,7 +1227,7 @@ msgstr "Tambahkan Penanda" # ------End of Reader.html.tt2------ # ------Start of Stats.html.tt2------ msgid "Library Statistics" -msgstr "Statistik Pustaka" +msgstr "Statistik Perpustakaan" msgid "Archives on record" msgstr "arsip yang tercatat" @@ -1258,7 +1264,7 @@ msgid "Upload Center" msgstr "Pusat Unggahan" msgid "Adding Archives to the Library" -msgstr "Menambahkan Arsip ke Pustaka" +msgstr "Menambahkan Arsip ke Perpustakaan" msgid "Add files to your LANraragi instance from your computer, or the Internet directly." msgstr "" @@ -1308,7 +1314,7 @@ msgid "Add from URL(s)" msgstr "Tambahkan dari URL" msgid "Return to Library" -msgstr "Kembali ke Pustaka" +msgstr "Kembali ke Perpustakaan" # ------End of Upload.html.tt2------ msgid "Rescan Archive Directory" @@ -1325,23 +1331,23 @@ msgstr "Cadangan dipulihkan!" msgid "An error occured while restoring the backup.
Please check the server logs and that your JSON is correctly formatted." msgstr "" -"Terjadi galat memulihkan cadangan.
Harap periksa pencatatan peladen dan " -"pastikan JSON Anda diformat dengan benar." +"Terjadi galat memulihkan cadangan.
Silakan periksa pencatatan peladen " +"dan pastikan JSON Anda diformat dengan benar." msgid "Backup generation in progress..." -msgstr "" +msgstr "Cadangan sedang dibuat..." msgid "Uploading backup file..." -msgstr "" +msgstr "Cadangan sedang diunggah..." msgid "Backup complete! Download will start automatically." -msgstr "" +msgstr "Cadangan selesai! Unduhan akan dimulai secara otomatis." msgid "Couldn't load the complete archive list! Please reload the page." -msgstr "Tidak dapat memuat daftar arsip lengkap! Harap muat ulang halaman." +msgstr "Tidak dapat memuat daftar arsip lengkap! Silakan muat ulang halaman." msgid "Couldn't load the tag statistics! Please reload the page." -msgstr "Tidak dapat memuat statistik tag! Harap muat ulang halaman." +msgstr "Tidak dapat memuat statistik tag! Silakan muat ulang halaman." msgid "Error getting untagged archives!" msgstr "Galat mengambil arsip yang tidak memiliki tag!" @@ -1389,7 +1395,7 @@ msgid "An error occured during batch tagging!" msgstr "Terjadi galat menambahkan tag tumpak!" msgid "Please check application logs." -msgstr "Harap periksa pencatatan aplikasi." +msgstr "Silakan periksa pencatatan aplikasi." msgid "Error! Terminating session." msgstr "Galat! Mengakhiri sesi." @@ -1436,7 +1442,7 @@ msgid "My Category" msgstr "Kategori Saya" msgid "Please enter a category name." -msgstr "Harap masukkan nama kategori." +msgstr "Silakan masukkan nama kategori." msgid "No category" msgstr "Tidak ada kategori" @@ -1555,7 +1561,7 @@ msgid "Tag namespace" msgstr "Tag ruang nama" msgid "Please enter a tag namespace." -msgstr "Harap masukkan tag ruang nama." +msgstr "Silakan masukkan tag ruang nama." msgid "Randomly Picked" msgstr "Dipilih Secara Acak" @@ -1625,7 +1631,7 @@ msgstr "Kemajuan Membaca Anda sekarang disimpan di peladen!" msgid "You seem to have some local progression hanging around -- Please wait warmly while we migrate it to the server for you." msgstr "" -"Sepertinya Anda memiliki kemajuan lokal yang tertinggal — Harap tunggu " +"Sepertinya Anda memiliki kemajuan lokal yang tertinggal — Silakan tunggu " "sementara kami memindahkannya ke peladen." msgid "Error while migrating local progression to server" @@ -1697,7 +1703,7 @@ msgid "A script is already running." msgstr "Ada skrip yang berjalan." msgid "Please wait for it to finish before starting a new one." -msgstr "Harap tunggu hingga selesai sebelum menjalankan yang baru." +msgstr "Silakan tunggu hingga selesai sebelum menjalankan yang baru." msgid "An error occured while running the script" msgstr "Terjadi galat menjalankan skrip" @@ -1769,8 +1775,8 @@ msgstr "" msgid "Archive metadata has been deleted properly.
Please delete the file manually before returning to the archive index." msgstr "" -"Metadata arsip telah berhasil dihapus.
Harap hapus berkas secara manual " -"sebelum kembali ke indeks arsip." +"Metadata arsip telah berhasil dihapus.
Silakan hapus berkas secara " +"manual sebelum kembali ke indeks arsip." msgid "Archive successfully deleted. Redirecting you..." msgstr "Arsip berhasil dihapus. Mengarahkan Anda..." @@ -1815,12 +1821,13 @@ msgstr "Deteksi Duplikat" msgid "This page allows you to search for potential duplicates in your library." msgstr "" -"Halaman ini memungkinkan Anda mencari kemungkinan duplikat di pustaka Anda." +"Halaman ini memungkinkan Anda mencari kemungkinan duplikat di perpustakaan " +"Anda." msgid "Clicking \"Start searching\" will start a background job to scan the entire library for dupes." msgstr "" "Mengklik \"Mulai pencarian\" akan memulai tugas latar belakang memindai " -"seluruh pustaka untuk mencari duplikat." +"seluruh perpustakaan untuk mencari duplikat." msgid "After which, you can select which archives to delete, and then delete them in bulk." msgstr "" @@ -1906,7 +1913,7 @@ msgid "Starting auto next page failed!" msgstr "Gagal memulai halaman berikutnya otomatis!" msgid "Please set the auto next page interval to a positive number." -msgstr "Harap atur jeda halaman berikutnya otomatis ke angka positif." +msgstr "Silakan atur jeda halaman berikutnya otomatis ke angka positif." msgid "The maximum value allowed is 4GB." msgstr "Nilai maksimum yang diizinkan adalah 4GB." @@ -1995,3 +2002,136 @@ msgid "Clients will use this list to filter out noisy tags from autocomplete and msgstr "" "Klien akan menggunakan daftar ini untuk menyaring tag yang tidak relevan " "dari pelengkapan otomatis dan awan tag." + +msgid "Archives with no tags or from your last selection have been pre-checked." +msgstr "" +"Arsip tanpa tag atau dari pilihan terakhir Anda telah dicentang sebelumnya." + +msgid "Discard Selection" +msgstr "Batal Pilihan" + +msgid "Displaying ${n} Archives from previous selection." +msgstr "Menampilkan ${n} arsip dari pilihan sebelumnya." + +msgid "Tankoubons" +msgstr "Tankoubon" + +msgid "No Tankoubons in your library yet." +msgstr "Belum ada Tankoubon di perpustakaan Anda." + +msgid "Archives" +msgstr "Arsip" + +msgid "No Archives in your library yet." +msgstr "Belum ada arsip di perpustakaan Anda." + +msgid "Group Tankoubons" +msgstr "Kelompokkan Tankoubon" + +msgid "If enabled, Archives belonging to a Tankoubon will show as a single item." +msgstr "" +"Jika diaktifkan, beberapa arsip yang merupakan bagian dari Tankoubon yang " +"sama akan digabung menjadi satu butir." + +msgid "Index Settings" +msgstr "Pengaturan Indeks" + +msgid "Display Mode" +msgstr "Mode Tampilan" + +msgid "Compact" +msgstr "Ringkas" + +msgid "Thumbnail" +msgstr "Keluku" + +msgid "Merge Archives into Tankoubon" +msgstr "Gabungkan beberapa arsip menjadi satu Tankoubon" + +msgid "G: go to page number" +msgstr "G: buka halaman tertentu" + +msgid "S: set a Stamp" +msgstr "S: beri Stempel" + +msgid "Toggle Stamps" +msgstr "Tampilkan/Sembunyikan Stempel" + +msgid "Set Stamp" +msgstr "Beri Stempel" + +msgid "Filter stamped pages" +msgstr "Saring halaman berstempel" + +msgid "New archive" +msgstr "Arsip baru" + +msgid "Archive read" +msgstr "Arsip dibaca" + +msgid "Tankoubon (collection)" +msgstr "Tankoubon (koleksi)" + +msgid "Pages read / Total pages" +msgstr "Halaman dibaca / Jumlah halaman" + +msgid "Pages read / Total pages / Archives in Tankoubon" +msgstr "Halaman dibaca / Jumlah halaman / Arsip dalam Tankoubon" + +msgid "Go to page:" +msgstr "Buka halaman:" + +msgid "Selection" +msgstr "Pilihan" + +msgid "\${n} selected" +msgstr "${n} dipilih" + +msgid "Click Archives to add them to the selection. Your selection carries over across searches." +msgstr "" +"Klik arsip untuk menambahkannya ke pilihan. Pilihan Anda akan terbawa di " +"seluruh pencarian." + +msgid "Exit selection mode? Your current selection will be lost. " +msgstr "Keluar dari mode pilihan? Pilihan Anda saat ini akan hilang. " + +msgid "Add to selection" +msgstr "Tambahkan ke pilihan" + +msgid "Remove from selection" +msgstr "Hapus dari pilihan" + +msgid "Enter a name for the new Tankoubon." +msgstr "Masukkan nama untuk Tankoubon baru." + +msgid "This will contain all the selected Archives as a single item in your Library." +msgstr "" +"Ini akan menggabungkan semua arsip yang dipilih menjadi satu butir di " +"perpustakaan Anda." + +msgid "Please enter a name." +msgstr "Silakan masukkan nama." + +msgid "Added \${n} Archives to \${tank}!" +msgstr "${n} arsip ditambahkan ke ${tank}!" + +msgid "Error creating Tankoubon" +msgstr "Galat membuat Tankoubon" + +msgid "Error adding archives to Tankoubon" +msgstr "Galat menambahkan arsip ke Tankoubon" + +msgid "Merge selection into a Tankoubon" +msgstr "Gabungkan pilihan ke dalam Tankoubon" + +msgid "Hide completed Archives" +msgstr "Sembunyikan arsip yang sudah dibaca habis" + +msgid "Enter Stamp name:" +msgstr "Masukkan nama Stempel:" + +msgid "Stamp name" +msgstr "Nama stempel" + +msgid "Error setting up the Stamp" +msgstr "Galat mengatur Stempel" diff --git a/locales/template/ja.po b/locales/template/ja.po index 1742509e9..b134a39ab 100644 --- a/locales/template/ja.po +++ b/locales/template/ja.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-13 21:13+0200\n" -"PO-Revision-Date: 2025-11-20 07:51+0000\n" -"Last-Translator: tomo \n" +"PO-Revision-Date: 2026-05-26 14:00+0000\n" +"Last-Translator: 四迷二葉亭 \n" "Language-Team: Japanese \n" "Language: ja\n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 5.15-dev\n" +"X-Generator: Weblate 2026.6.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -20,27 +20,28 @@ msgid "Database Backup/Restore" msgstr "" msgid "You can backup your existing database here, or restore an existing backup." -msgstr "既存のデータベースのバックアップまたは復元を行います。" +msgstr "データベースのバックアップまたは復元を行います。" msgid "Backuping allows you to download a JSON file containing all your categories and archive IDs, and their matching metadata." msgstr "バックアップすると、すべてのカテゴリとアーカイブID、それに対応するメタデータ" "を含むJSONファイルをダウンロードできます。" msgid "Restoring from a backup will restore this metadata, for IDs which already exist in your database." -msgstr "バックアップから復元すると、このメタデータはあなたのデータベース内に既に存" -"在しているIDに対して復元されます。" +msgstr "" +"バックアップから復元すると、このメタデータは あなたのデータベース内に既に" +"存在しているIDに対して復元されます。" msgid "(Categories will always be restored)" msgstr "(カテゴリは常に復元されます)" msgid "Backup Database" -msgstr "データベースの保存" +msgstr "データベースのバックアップ" msgid "Restore Backup" msgstr "バックアップの復元" msgid "Restoring your backup ..." -msgstr "バックアップからの復元 ..." +msgstr "バックアップを復元中です..." msgid "Return to Library" msgstr "" @@ -51,16 +52,16 @@ msgid "Batch Operations" msgstr "" msgid "You can apply modifications to multiple archives in one go here." -msgstr "複数のアーカイブを一括して修正を行うことができます。" +msgstr "複数のアーカイブをまとめて編集できます。" msgid "Select what you'd like to do, check archives you want to use it on, and get rolling!" -msgstr "やりたいことを選び、変更したいアーカイブを選択し、始めましょう!" +msgstr "操作を選択し、対象のアーカイブにチェックを入れて実行してください!" msgid "Archives with no tags have been pre-checked." msgstr "タグのないアーカイブは事前にチェックされます。" msgid "Task :" -msgstr "タスク:" +msgstr "タスク:" msgid "Use Plugin" msgstr "プラグインの使用" @@ -78,62 +79,66 @@ msgid "Delete Archive" msgstr "" msgid "Read Archive" -msgstr "" +msgstr "アーカイブを読む" msgid "Use plugin :" -msgstr "使用するプラグイン:" +msgstr "使用するプラグイン:" msgid "Timeout (max 20s):" -msgstr "タイムアウト(最大20秒):" +msgstr "タイムアウト (最大20秒):" msgid "This plugin recommends a cooldown of -1 seconds." -msgstr "このプラグインでは、 -1 " -"秒の待機時間を推奨しています。" +msgstr "" +"このプラグインは -1 秒の待機時間を推奨しています" +"。" msgid "Some external services may temporarily ban your machine for excessive loads if you call a plugin too many times!" -msgstr "外部サービスの中には、プラグインを何度も使用すると高負担で一時的にマシンを使" -"用禁止にするものがあります!" +msgstr "" +"プラグインを短時間に何度も実行すると、一部の外部サービスから一時的にアクセス" +"制限を受ける場合があります!" msgid "Make sure to set a suitable timeout between archives using this picker if the plugin you want to use is concerned." -msgstr "このピッカーを使ってアーカイブ間のタイムアウトを適切に設定してください" -"。使用したいプラグインによっては重要です。" +msgstr "" +"使用するプラグインに応じて、この設定でアーカイブ間の適切な タイムアウト を設定してください。" msgid "Override Plugin Global Arguments" msgstr "プラグインのグローバル引数を上書きする" msgid "This will apply the following Tag Rules to the selected Archives." -msgstr "これにより、選択したアーカイブに次のタグ ルールが適用されます。" +msgstr "以下のタグルールが選択したアーカイブに適用されます。" msgid "You can edit your Tag Rules in Server Configuration." -msgstr "タグルールはサーバー設定で編集できます。" +msgstr "タグルールはサーバー設定画面で編集できます。" msgid "Server Configuration" msgstr "サーバー設定" msgid "This removes the \"new\" flag from the selected archives." -msgstr "選択したアーカイブから「new」フラグを削除します。" +msgstr "選択したアーカイブの「新着」フラグを解除します。" msgid "Add to Category :" msgstr "カテゴリに追加 :" msgid "This will delete both metadata and matching files from your system! Please use with caution." -msgstr "これはメタデータと一致するファイルの両方をあなたのシステムから削除します!十" +msgstr "" +"これにより、システムからメタデータと該当するファイルの両方が削除されます!十" "分注意して使用してください。" msgid "Check/Uncheck all" msgstr "すべてチェック/すべてのチェックを外す" msgid "Start Task" -msgstr "タスクスタート" +msgstr "タスク開始" msgid "Cancel" msgstr "キャンセル" msgid "Start another job" -msgstr "別のジョブを開始" +msgstr "別の処理を開始" msgid "Processed out of " -msgstr " のうち を処理済み" +msgstr " / を処理済" msgid "Preparing your data." msgstr "データを準備しています。" @@ -150,125 +155,137 @@ msgid "Categories" msgstr "" msgid "Categories appear at the top of your window when browsing the Library." -msgstr "ライブラリーを閲覧する際、ウィンドウの上部にカテゴリーが表示されます。" +msgstr "ライブラリ閲覧時、カテゴリはウィンドウ上部に表示されます。" msgid "There are two distinct kinds:" -msgstr "" +msgstr "2種類あります :" msgid "Static Categories are arbitrary collections of Archives, where you can add as many items as you want." msgstr "" +"静的カテゴリは、アーカイブを任意にまとめたもので、アイテムを自由に追加できま" +"す。" msgid "Dynamic Categories contain all archives matching a given predicate, and automatically update alongside your library." msgstr "" +"動的カテゴリには、指定した条件に一致するすべてのアーカイブが含まれ、" +"ライブラリの更新にあわせて自動的に更新されます。" msgid "You can create new categories here or edit existing ones." -msgstr "" +msgstr "ここでは新しいカテゴリを作成したり、既存のカテゴリを編集したりできます。" msgid "Select a category in the combobox below to edit its name, the archives it contains, or its predicate." msgstr "" +"下のコンボボックスからカテゴリを選択して、名前・含まれるアーカイブ・条件式を" +"編集します。" msgid "All your modifications are saved automatically." -msgstr "" +msgstr "変更内容はすべて自動的に保存されます。" msgid "Category:" -msgstr "" +msgstr "カテゴリ :" msgid "Name:" -msgstr "" +msgstr "名前 :" msgid "Predicate:" -msgstr "" +msgstr "条件式 :" msgid "Pin this Category" -msgstr "" +msgstr "このカテゴリをピン留めする" msgid "Delete Category" -msgstr "" +msgstr "カテゴリを削除" msgid "If you select a Static Category, your archives will appear here so you can add/remove them from the category." msgstr "" +"的カテゴリを選択すると、ここにアーカイブ一覧が表示され、カテゴリへの追加・削" +"除を行えます。" msgid "Return to Library" msgstr "" msgid "Store Bookmarks in this Category" -msgstr "" +msgstr "このカテゴリにブックマークを保存する" msgid "Error linking bookmark button:" -msgstr "" +msgstr "ブックマークボタンのリンクにエラーが発生しました :" msgid "Error unlinking bookmark button:" -msgstr "" +msgstr "ブックマークのリンク解除ボタンでエラーが発生しました :" msgid "Error getting bookmark category:" -msgstr "" +msgstr "ブックマークのカテゴリーを取得できませんでした :" msgid "New Static Category" -msgstr "" +msgstr "新規静的カテゴリー" msgid "New Dynamic Category" -msgstr "" +msgstr "新規動的カテゴリー" # ------End of Category.html.tt2------ # ------Start of Config.html.tt2------ msgid "Admin Settings" -msgstr "" +msgstr "管理者設定" msgid "LANraragi" -msgstr "" +msgstr "LANraragi" msgid "Version %1 %2" -msgstr "" +msgstr "バージョン %1 - %2" msgid "Select a category to show the matching settings." -msgstr "" +msgstr "カテゴリを選択して設定を表示します。" msgid "Save Settings" -msgstr "" +msgstr "設定を保存" msgid "Plugin Configuration" msgstr "" msgid "Database Backup/Restore" -msgstr "データベースバックアップ・復元" +msgstr "データベースのバックアップ/復元" msgid "Return to Library" msgstr "" msgid "Global Settings" -msgstr "" +msgstr "一般設定" msgid "Theme" -msgstr "" +msgstr "テーマ" msgid "Security" -msgstr "" +msgstr "セキュリティ" msgid "Archive Files" -msgstr "" +msgstr "アーカイブファイル" msgid "Tags and Thumbnails" -msgstr "" +msgstr "タグとサムネイル" msgid "Background Workers" -msgstr "" +msgstr "バックグラウンド処理" # ------End of Config.html.tt2------ # ------Start of Config_Files.html.tt2------ msgid "Archive Directory" -msgstr "" +msgstr "アーカイブディレクトリ" msgid "Directory where the archives will be located. It will be created if it doesn't exist." -msgstr "" +msgstr "アーカイブが保存されるディレクトリ。存在しない場合は作成されます。" msgid "Make sure the OS user running LANraragi has read access to this directory." msgstr "" +"LANraragi を実行している OS ユーザーが、このディレクトリへの読み取り権限を持" +"っていることを確認してください。" msgid "Synology eCryptFS Compatibility Mode" -msgstr "" +msgstr "Synology eCryptFS 互換モード" msgid "If enabled, LANraragi will cutoff archive filenames to 143 bytes, which is the max accepted by eCryptFS." msgstr "" +"有効にすると、LANraragi は eCryptFS の制限に合わせて、アーカイブのファイル名" +"を 143 バイト以内に短縮します。" msgid "Rescan Archive Directory" msgstr "" @@ -277,343 +294,403 @@ msgid "Click this button to trigger a rescan of the Archive Directory in case yo msgstr "" msgid "Maximum
Cache Size" -msgstr "" +msgstr "最大
キャッシュサイズ" msgid "In MBs. The cache contains recently viewed pages, for faster subsequent reading." msgstr "" +"MBで指定。キャッシュには最近表示したページが保存されており、次回の表示を高速" +"化します。" msgid "It is automatically emptied when it grows past this specified size." -msgstr "" +msgstr "指定したサイズを超えると、自動的に削除されます。" msgid "Clear Cache" -msgstr "" +msgstr "キャッシュをクリア" msgid "Current Size:" -msgstr "" +msgstr "現在のサイズ :" msgid "Clear the cache manually by clicking this button." -msgstr "" +msgstr "このボタンでキャッシュを手動でクリアできます。" msgid "Reset Search Cache" -msgstr "" +msgstr "検索キャッシュをリセット" msgid "The last searches done in the archive index are cached for faster loads." msgstr "" +"アーカイブ一覧で実行した最近の検索結果は、表示を高速化するためにキャッシュさ" +"れます。" msgid "If something went wrong with said cache, you can reset it by clicking this button." -msgstr "" +msgstr "キャッシュに問題が発生した場合は、このボタンからリセットできます。" msgid "Clear NEW flags" -msgstr "" +msgstr "新規フラグをクリア" msgid "Newly uploaded archives are marked as \"new\" in the index until you\'ve opened them." msgstr "" +"新しくアップロードされたアーカイブは、開くまで一覧で「新着」として表示されま" +"す。" msgid "If you want to clear those flags, click this button." -msgstr "" +msgstr "これらのフラグを解除するには、このボタンをクリックしてください。" msgid "Replace duplicated archives" -msgstr "" +msgstr "重複するアーカイブを置き換える" msgid "If enabled, LANraragi will overwrite old archives when a newer one (with the same name) is uploaded through the Web Uploader or the Download System." msgstr "" +"有効にすると、Web アップローダーまたはダウンロードシステムから同名の新しい" +"アーカイブがアップロードされたとき、LANraragi は既存のアーカイブを上書きしま" +"す。" msgid "This will delete metadata for old files when they're replaced! Use with caution." msgstr "" +"ファイルを置き換える際、古いファイルのメタデータは削除されます。使用には注意" +"してください。" # ------End of Config_Files.html.tt2------ # ------Start of Config_Global.html.tt2------ msgid "Site Title" -msgstr "" +msgstr "サイト名" msgid "The site title appears on most pages as...their title." -msgstr "" +msgstr "サイト名は、各ページのタイトルとして使用されます。" msgid "MOTD" -msgstr "" +msgstr "本日のお知らせ" msgid "Slang for Message of the Day. Appears on top of the main Library view." -msgstr "" +msgstr "本日のお知らせです。ライブラリのメイン画面上部に表示されます。" msgid "Archives per page" -msgstr "" +msgstr "1ページあたりのアーカイブ数" msgid "Number of archives shown on a page in the main list." -msgstr "" +msgstr "メイン一覧の1ページに表示されるアーカイブ数です。" msgid "Resize Images in Reader" -msgstr "" +msgstr "リーダーで画像サイズを調整" msgid "If enabled, pages exceeding a certain size will be resized when viewed to save bandwidth." msgstr "" +"有効にすると、通信量を抑えるため、一定サイズを超えるページは表示時に縮小され" +"ます。" msgid "This option can potentially consume a lot of RAM if enabled and used on large images! Use with caution." msgstr "" +"この設定を有効にすると、大きな画像で大量のメモリを消費する可能性があります。" +"注意して使用してください。" msgid "Image Size Threshold" -msgstr "" +msgstr "画像サイズのしきい値" msgid "(in KBs.) Maximum size an image can reach before being resized." -msgstr "" +msgstr "(KB単位)このサイズを超えた画像はリサイズされます。" msgid "Resize Quality" -msgstr "" +msgstr "リサイズ時の画質" msgid "Quality of the resized images. Less quality = Smaller image. (0-100)" -msgstr "" +msgstr "リサイズ後の画像品質です。値を低くすると画像サイズが小さくなります。(0~100)" msgid "Clientside Progress Tracking" -msgstr "" +msgstr "クライアント側で進行状況を追跡" msgid "Enabling this option will save reading progression on the browser (through localStorage) instead of the server." -msgstr "" +msgstr "この設定を有効にすると、閲覧状況はサーバーではなくブラウザに保存されます。" msgid "Consider toggling this option if you're sharing the LANraragi instance with multiple users!" msgstr "" +"LANraragi を複数人で共有している場合は、この設定を有効にすることをおすすめし" +"ます!" msgid "Debug Mode" -msgstr "" +msgstr "デバッグモード" msgid "Enabling Debug Mode will show more logs and disable update nagging." msgstr "" +"デバッグモードを有効にすると、より詳細なログが表示され、アップデート通知が無" +"効になります。" msgid "Fully effective after restarting LANraragi." msgstr "" msgid "Clean Database" -msgstr "" +msgstr "データベースをクリーンアップ" msgid "Enable Metrics" -msgstr "" +msgstr "統計情報を有効化" msgid "Enable metrics collection and serve them through the metrics endpoint in the Prometheus exposition format." msgstr "" +"統計情報の収集を有効化し、Prometheus 形式で metrics エンドポイントを公開しま" +"す。" msgid "Cleaning the database will remove entries that aren't on your filesystem." msgstr "" +"データベースをクリーンアップすると、実際には存在しないファイルのエントリが削" +"除されます。" msgid "Reset Database" -msgstr "" +msgstr "データベースをリセット" msgid "Danger zone!" -msgstr "" +msgstr "危険な操作!" msgid "Clicking this button will reset the entire database and delete all settings and metadata." msgstr "" +"このボタンをクリックすると、データベース全体がリセットされ、すべての設定と" +"メタデータが削除されます。" # ------End of Config_Global.html.tt2------ # ------Start of Config_Security.html.tt2------ msgid "Enable Password" -msgstr "" +msgstr "パスワードを有効化" msgid "If enabled, everything that isn't reading will require a password." -msgstr "" +msgstr "有効にすると、閲覧を除くすべての機能でパスワード認証が必要になります。" msgid "New Password" -msgstr "" +msgstr "新しいパスワード" msgid "New Password Confirmation" -msgstr "" +msgstr "新しいパスワード(確認)" msgid "Only edit these fields if you want to change your password." -msgstr "" +msgstr "パスワードを変更する場合のみ入力してください。" msgid "The one already stored will be used otherwise." -msgstr "" +msgstr "変更しない場合は、現在のパスワードがそのまま使用されます。" msgid "No-Fun Mode" -msgstr "" +msgstr "閲覧制限モード" msgid "Enabling No-Fun Mode will lock reading archives behind the password as well." -msgstr "" +msgstr "有効にすると、アーカイブの閲覧にもパスワードが必要になります。" msgid "Fully effective after restarting LANraragi." -msgstr "" +msgstr "LANraragi を再起動すると完全に反映されます。" msgid "API Key" -msgstr "" +msgstr "API キー" msgid "If you wish to use the Client API and have a password, you'll have to set a key here." msgstr "" +"パスワードを有効にした状態で Client API を使用する場合は、ここでキーを設定し" +"てください。" msgid "Empty keys will not work!" -msgstr "" +msgstr "キーが空欄の場合は 動作しません !" msgid "This key will need to be provided in every protected API call as the Authorization header." msgstr "" +"保護された API を呼び出す際は、このキーを Authorization ヘッダーに指定" +"する必要があります。" msgid "Enable CORS for the Client API" -msgstr "" +msgstr "Client API の CORS を有効化" msgid "Have API requests support Cross-Origin Resource Sharing, which allows web browsers to access it off other domains." msgstr "" +"API リクエストで CORS を有効にし、他ドメインからブラウザ経由でアクセスできる" +"ようにします。" msgid "Turn this on if you want to access this service through a web-based wrapper (e.g. a userscript) used/hosted on another domain." msgstr "" +"他ドメイン上の Web ベースツール(ユーザースクリプトなど)からアクセスする場合" +"は、有効にしてください。" # ------End of Config_Security.html.tt2------ # ------Start of Config_Shinobu.html.tt2------ msgid "Shinobu Status" -msgstr "" +msgstr "Shinobu ステータス" msgid "The Shinobu File Watcher is currently" -msgstr "" +msgstr "Shinobu File Watcher は現在" msgid "OK!" -msgstr "" +msgstr "動作中!" msgid "Kaput!" -msgstr "" +msgstr "停止中!" msgid "This File Watcher is responsible for monitoring your content directory and automatically handling new archives as they come." msgstr "" +"この File Watcher はコンテンツディレクトリを監視し、新しいアーカイブを自動的" +"に取り込みます。" msgid "Restart File Watcher" -msgstr "" +msgstr "File Watcher を再起動" msgid "If Shinobu is dead or unresponsive, you can reboot her by clicking this button." msgstr "" +"Shinobu が動作していない、または応答しない場合は、このボタンで再起動してくだ" +"さい。" msgid "Open Minion Console" -msgstr "" +msgstr "Minion コンソールを開く" msgid "The Minion Worker handles spare tasks that are too long to execute within the request/response lifecycle of web applications." msgstr "" +"Minion Worker は、Web アプリケーション上で即時処理できない長時間タスクを実行" +"します。" msgid "The console shows currently running and concluded tasks." -msgstr "" +msgstr "コンソールでは、現在実行中のタスクや完了済みのタスクを確認できます。" # ------End of Config_Shinobu.html.tt2------ # ------Start of Config_Tags.html.tt2------ msgid "Thumbnail Directory" -msgstr "" +msgstr "サムネイル保存先ディレクトリ" msgid "Directory where the archive thumbnails will be located. It will be created if it doesn't exist." msgstr "" +"アーカイブのサムネイルを保存するディレクトリです。存在しない場合は自動的に作" +"成されます。" msgid "Make sure the OS user running LANraragi has read/write access to this directory." -msgstr "" +msgstr "LANraragi 実行ユーザーに、このディレクトリへの読み書き権限が必要です。" msgid "Use high-quality thumbnails for pages" -msgstr "" +msgstr "ページサムネイルを高画質化" msgid "LANraragi generates lower-quality thumbnails for archive pages for performance reasons." msgstr "" +"LANraragi はパフォーマンス向上のため、アーカイブページのサムネイルを低画質で" +"生成します。" msgid "If this option is checked, it will instead generate page thumbnails at the same quality as cover thumbnails." msgstr "" +"この設定を有効にすると、ページサムネイルも表紙サムネイルと同じ画質で生成され" +"ます。" msgid "Use JPEG XL for thumbnails" -msgstr "" +msgstr "サムネイルに JPEG XL を使用" msgid "LANraragi generates JPEG thumbnails for compatibility and performance reasons." msgstr "" +"LANraragi は互換性とパフォーマンスを考慮し、JPEG 形式でサムネイルを生成します" +"。" msgid "If this option is checked, it will instead generate thumbnails using JPEG XL." -msgstr "" +msgstr "この設定を有効にすると、サムネイルは JPEG XL 形式で生成されます。" msgid "Generate Missing Thumbnails" -msgstr "" +msgstr "未生成のサムネイルを生成" msgid "Generate Thumbnails for all archives that don't have one yet." -msgstr "" +msgstr "サムネイル未生成のアーカイブにサムネイルを生成します。" msgid "Regenerate all Thumbnails" -msgstr "" +msgstr "すべてのサムネイルを再生成" msgid "Regenerate all thumbnails. This might take a while!" -msgstr "" +msgstr "すべてのサムネイルを再生成します。完了まで時間がかかる場合があります!" msgid "Add Timestamp Tag" -msgstr "" +msgstr "日時タグを追加" msgid "If enabled, LANraragi will add the UNIX timestamp of the current time as a tag under the \"date_added\" namespace to newly added archives." msgstr "" +"有効にすると、新しく追加されたアーカイブに現在時刻の UNIX タイムスタンプが " +"\"date_added\" タグとして追加されます。" msgid "Use \"Last modified\" Time" -msgstr "" +msgstr "最終更新日時を使用" msgid "Enabling this will use file modified time instead of current time when setting \"date_added\" timestamps." msgstr "" +"有効にすると、「date_added」には現在時刻ではなくファイルの最終更新日時が使用" +"されます。" msgid "Tag Rules" -msgstr "" +msgstr "タグルール" msgid "When tagging archives using Plugins, the rules specified here will be applied to the tags before saving them to the database." msgstr "" +"プラグインによるタグ付け時、ここで指定したルールがタグへ適用されてから" +"データベースに保存されます。" msgid "Split rules with linebreaks." -msgstr "" +msgstr "ルールは改行で区切ってください。" msgid "-tag | tag : removes the tag (like a blacklist)" -msgstr "" +msgstr "-tag | tag : 指定したタグを削除します(ブラックリストとして動作)" msgid "-namespace:* : removes all tags within this namespace" -msgstr "" +msgstr "-namespace:* : 指定した名前空間内のすべてのタグを削除します" msgid "namespace : strips the namespace from the tags" -msgstr "" +msgstr "~namespace : タグから名前空間を削除します" msgid "tag -> new-tag : replaces one tag" -msgstr "" +msgstr "tag -> new-tag : タグを別のタグへ置き換えます" msgid "tag => new-tag : replaces one tag, but use a hash table internally for faster performance. These rules will be executed once after all other rules." msgstr "" +"tag => new-tag : タグを別のタグへ置き換えます。内部的に" +"ハッシュテーブルを使用するため高速です。これらのルールは、他のすべてのルール" +"適用後に 一度だけ 実行されます。" msgid "namespace:* -> new-namespace:* : replaces the namespace with the new one" msgstr "" +"namespace:* -> new-namespace:* : 名前空間を新しい名前空間へ置き換えま" +"す" # ------End of Config_Tags.html.tt2------ # ------Start of Config_Theme.html.tt2------ msgid "The selected theme will apply to the entire application and be shown to all users." msgstr "" +"選択したテーマはアプリケーション全体に適用され、すべてのユーザーに表示されま" +"す。" msgid "If you're using a browser that supports \"theme-color\", the theme's primary color will also be applied there." -msgstr "" +msgstr "「theme-color」に対応したブラウザでは、テーマのメインカラーも適用されます。" msgid "Click on a theme to preview it before saving!" -msgstr "" +msgstr "テーマをクリックすると、保存前にプレビューできます!" # ------End of Config_Theme.html.tt2------ # ------Start of Edit.html.tt2------ msgid "Editing %1 by %2" -msgstr "" +msgstr "%2 による %1 の編集" msgid "Editing %1" -msgstr "" +msgstr "%1 を編集" msgid "Current File Name:" -msgstr "" +msgstr "現在のファイル名 :" msgid "ID:" -msgstr "" +msgstr "ID :" msgid "Title:" -msgstr "" +msgstr "タイトル :" msgid "Summary:" -msgstr "" +msgstr "概要 :" msgid "Tags" msgstr "" msgid "(separated by hyphens, i.e : tag1, tag2)" -msgstr "" +msgstr "(ハイフン区切り 例 : tag1-tag2)" msgid "Import Tags from Plugin :" -msgstr "" +msgstr "プラグインからタグをインポート :" msgid "Help" msgstr "" msgid "Go!" -msgstr "" +msgstr "実行!" msgid "Using a Plugin will save any modifications to archive metadata you might have made !" -msgstr "" +msgstr "プラグインを使用すると、アーカイブメタデータの変更が保存されます!" msgid "Save Metadata" -msgstr "" +msgstr "メタデータを保存" msgid "Delete Archive" msgstr "" @@ -624,76 +701,78 @@ msgstr "" # ------End of Edit.html.tt2------ # ------Start of Index.html.tt2------ msgid "Add Archives" -msgstr "" +msgstr "アーカイブを追加" msgid "Batch Operations" -msgstr "バッチ操作" +msgstr "バッチ処理" msgid "Settings" -msgstr "" +msgstr "設定" msgid "Modify Categories" -msgstr "" +msgstr "カテゴリを編集" msgid "Statistics" -msgstr "" +msgstr "統計情報" msgid "Logs" -msgstr "" +msgstr "ログ" msgid "Admin Login" -msgstr "" +msgstr "管理者ログイン" msgid "Search Title, Artist, Series, Language or Tags" -msgstr "" +msgstr "タイトル・作者・シリーズ・言語・タグで検索" msgid "Apply Filter" -msgstr "" +msgstr "フィルターを適用" msgid "Clear Filter" -msgstr "" +msgstr "フィルターをクリア" msgid "Click to show archives from the current search with the specified filter" -msgstr "" +msgstr "指定したフィルターで現在の検索結果のアーカイブを表示します" msgid "Refresh Selection" -msgstr "" +msgstr "選択を更新" msgid "Carousel Options" -msgstr "" +msgstr "スライド表示設定" msgid "No results here." -msgstr "" +msgstr "結果はありません。" msgid "Sort by:" -msgstr "" +msgstr "並び替え :" msgid "Title" msgstr "" msgid "Date" -msgstr "" +msgstr "日付" msgid "Sort Order" -msgstr "" +msgstr "並び順" msgid "Crop thumbnails" -msgstr "" +msgstr "サムネイルをトリミング" msgid "If enabled, thumbnails that don't fit a regular A4 page will be cropped to only show the left side." msgstr "" +"有効にすると、A4サイズに収まらないサムネイルは左側のみが表示されるように" +"トリミングされます。" msgid "Hide completed" -msgstr "" +msgstr "完了済を非表示" msgid "If enabled, Archives you've already completed will be hidden from the list." -msgstr "" +msgstr "有効にすると、すでに完了したアーカイブは一覧から非表示になります。" msgid "Go to Page:" -msgstr "" +msgstr "ページへ移動 :" msgid "Columns:" -msgstr "" +msgstr "列数 :" msgid "Switch to Compact Mode" msgstr "" @@ -705,10 +784,10 @@ msgid "Title" msgstr "" msgid "Artist" -msgstr "" +msgstr "作者" msgid "Series" -msgstr "" +msgstr "シリーズ" msgid "Tags" msgstr "" @@ -717,119 +796,128 @@ msgid "Edit this column" msgstr "" msgid "I don't know everything, but I sure as hell know this database's busted lads" -msgstr "" +msgstr "全部は分からないけど、このデータベースは間違いなく壊れてるぞ" msgid "The database cache is corrupt, and as such LANraragi is unable to display your archive list." -msgstr "" +msgstr "データベースキャッシュが破損しているため、アーカイブ一覧を表示できません。" msgid "Read" -msgstr "" +msgstr "読む" msgid "Download" -msgstr "" +msgstr "ダウンロード" msgid "Edit Metadata" -msgstr "" +msgstr "メタデータを編集" msgid "Delete" -msgstr "" +msgstr "削除" msgid "Add Rating" -msgstr "" +msgstr "評価を追加" msgid "Add to Category" -msgstr "" +msgstr "カテゴリに追加" msgid "New Version Release Notes" -msgstr "" +msgstr "新バージョンの更新内容" msgid "You\'re using the default password and that\'s super baka of you" msgstr "" +"デフォルトのパスワードが設定されています。安全のため変更することをおすすめし" +"ます" msgid "Login with password \"kamimamita\" and change that shit on the double. ...Or just disable it! Why not check the configuration options afterwards, while you\'re at it?" msgstr "" +"ログインしてパスワード「kamimamita」でアクセスし、すぐ" +"に設定を変更してください。
…あるいは無効にしてし" +"まうのもありです。
その後に設定オプションもチェックしてみてはいかが? " msgid "Select Archives" -msgstr "" +msgstr "アーカイブを選択" msgid "Run Batch Operations on selection" -msgstr "" +msgstr "選択したアーカイブに一括操作を実行" msgid "Clear selection" -msgstr "" +msgstr "選択を解除" msgid "Select All in Page" -msgstr "" +msgstr "このページをすべて選択" # ------End of Index.html.tt2------ # ------Start of Login.html.tt2------ msgid "This page requires you to log on." -msgstr "" +msgstr "このページを表示するにはログインしてください。" msgid "Admin Password:" -msgstr "" +msgstr "管理者パスワード :" msgid "Login" -msgstr "" +msgstr "ログイン" msgid "Wrong Password." -msgstr "" +msgstr "パスワードが正しくありません。" msgid "Login to toggle bookmark feature." -msgstr "" +msgstr "ログイン してブックマーク機能を有効化してください。" # ------End of Login.html.tt2------ # ------Start of Logs.html.tt2------ msgid "Application Logs" -msgstr "" +msgstr "アプリケーションログ" msgid "You can check LANraragi logs here for debugging purposes." -msgstr "" +msgstr "デバッグのため LANraragi のログをここから確認できます。" msgid "By default, this view only shows the last 100 lines of each logfile, newest lines last." msgstr "" +"デフォルトでは、各ログファイルの最新100行のみを表示し、最新の行が最後に表示さ" +"れます。" msgid "General Logs pertain to the main application." -msgstr "" +msgstr "一般ログには、メインアプリケーションのログが含まれます。" msgid "Shinobu Logs correspond to the Background Worker." -msgstr "" +msgstr "Shinobu ログはバックグラウンドワーカーの処理に関するログです。" msgid "Plugin Logs are reserved for metadata plugins only." -msgstr "" +msgstr "プラグインログはメタデータプラグイン専用です。" msgid "Mojolicious logs won't tell much unless you're running Debug Mode." msgstr "" +"Mojolicious ログは、デバッグモードを有効にしないと詳細な情報は表示されません" +"。" msgid "Redis logs won't be available from here if you're running from source!" -msgstr "" +msgstr "ソースから実行している場合、Redis のログはここでは表示できません!" msgid "Currently Viewing:" -msgstr "" +msgstr "現在表示中 : " msgid "general" -msgstr "" +msgstr "一般" msgid "Refresh" -msgstr "" +msgstr "更新" msgid "Lines:" -msgstr "" +msgstr "行数 : " msgid "View LANraragi Logs" -msgstr "" +msgstr "LANraragi のログを表示" msgid "View Shinobu Logs" -msgstr "" +msgstr "Shinobu のログを表示" msgid "View Plugin Logs" -msgstr "" +msgstr "プラグインのログを表示" msgid "View Mojolicious Logs" -msgstr "" +msgstr "Mojolicious のログを表示" msgid "View Redis Logs" -msgstr "" +msgstr "Redis のログを表示" msgid "Return to Library" msgstr "" @@ -837,284 +925,290 @@ msgstr "" # ------End of Logs.html.tt2------ # ------Start of Plugins.html.tt2------ msgid "Plugin Configuration" -msgstr "" +msgstr "プラグインの設定" msgid "Enable/Disable Auto-Plugin on metadata plugins by checking the toggles." -msgstr "" +msgstr "メタデータプラグインのトグルで、自動プラグインの有効/無効を切り替えられます。" msgid "Plugins will be automatically used on new archives if they're toggled here." -msgstr "" +msgstr "ここで有効化したプラグインは、新しいアーカイブに自動的に適用されます。" msgid "If they have configuration variables, you can set them here as well." -msgstr "" +msgstr "設定可能な変数がある場合は、ここで設定できます。" msgid "You can also trigger Scripts here. Triggering a script will save your Plugin settings beforehand." -msgstr "" +msgstr "ここからスクリプトを実行できます。実行前にプラグイン設定が保存されます。" msgid "Login Plugins" -msgstr "" +msgstr "ログインプラグイン" msgid "Downloaders" -msgstr "" +msgstr "ダウンローダー" msgid "Scripts" -msgstr "" +msgstr "スクリプト" msgid "Metadata Plugins" -msgstr "" +msgstr "メタデータプラグイン" msgid "Allow Plugins to replace archive titles:" -msgstr "" +msgstr "プラグインによるアーカイブタイトルの置き換えを許可 :" msgid "If enabled, metadata plugins will be able to change the title of your archives alongside adding tags to them." msgstr "" +"有効にすると、メタデータプラグインはタグの追加に加えてアーカイブのタイトルも" +"変更できるようになります。" msgid "A script is running..." -msgstr "" +msgstr "スクリプトを実行中..." msgid "Save Plugin Configuration" -msgstr "" +msgstr "プラグイン設定を保存" msgid "Upload Plugin" -msgstr "" +msgstr "プラグインをアップロード" msgid "Return to Library" msgstr "" msgid "This plugin will trigger on URLs matching this regex!" -msgstr "" +msgstr "このプラグインは、この正規表現に一致するURLで実行されます!" msgid "This plugin depends on the login plugin" -msgstr "" +msgstr "このプラグインはログインプラグインに依存しています" msgid "Trigger Script" -msgstr "" +msgstr "スクリプトを実行" msgid "Run Automatically" -msgstr "" +msgstr "自動的に実行" msgid "Plugin Settings" -msgstr "" +msgstr "プラグイン設定" # ------End of Plugins.html.tt2------ # ------Start of Reader.html.tt2------ msgid "Done reading? Go back to Archive Index" -msgstr "" +msgstr "読み終わりましたか?アーカイブ一覧に戻る" msgid "View full-size image" -msgstr "" +msgstr "フルサイズ画像を表示" msgid "Switch to another random archive" -msgstr "" +msgstr "別のランダムなアーカイブに切り替え" msgid "Archive Overview" msgstr "" msgid "Admin Options" -msgstr "" +msgstr "管理者オプション" msgid "Set this Page as Thumbnail" -msgstr "" +msgstr "このページをサムネイルに設定" msgid "Set the currently opened page as the thumbnail for this archive." -msgstr "" +msgstr "現在のページをこのアーカイブのサムネイルに設定します。" msgid "Clean Archive Cache" -msgstr "" +msgstr "アーカイブキャッシュをクリア" msgid "Edit Archive Metadata" -msgstr "" +msgstr "アーカイブメタデータを編集" msgid "Delete Archive" msgstr "アーカイブの削除" msgid "Categories" -msgstr "" +msgstr "カテゴリ" msgid "Add to : " -msgstr "" +msgstr "追加先 : " msgid " -- No Category -- " msgstr "" msgid "Add Archive to Category" -msgstr "" +msgstr "アーカイブをカテゴリに追加" msgid "Pages" -msgstr "" +msgstr "ページ" msgid "Working on it..." -msgstr "" +msgstr "処理中..." msgid "You can navigate between pages using:" -msgstr "" +msgstr "以下の方法でページ間を移動できます :" msgid "The arrow icons" -msgstr "" +msgstr "矢印アイコン" msgid "The a/d keys" -msgstr "" +msgstr "A/D キー" msgid "Your keyboard arrows (and the spacebar)" -msgstr "" +msgstr "キーボードの矢印キー(およびスペースキー)" msgid "Touching the left/right side of the image." -msgstr "" +msgstr "画像の左側/右側をタップ。" msgid "Other keyboard shortcuts:" -msgstr "" +msgstr "その他のキーボードショートカット :" msgid "M: toggle manga mode (right-to-left reading)" -msgstr "" +msgstr "M : マンガモードに切り替え(右→左読み)" msgid "O: show advanced reader options." -msgstr "" +msgstr "O : リーダーの詳細オプション表示。" msgid "P: toggle double page mode" -msgstr "" +msgstr "P : 見開き表示の切り替え" msgid "Q: bring up the thumbnail index and archive options." -msgstr "" +msgstr "Q : サムネイル一覧とアーカイブオプションを表示。" msgid "R: open a random archive." -msgstr "" +msgstr "R : ランダムなアーカイブを開く。" msgid "F: toggle fullscreen mode" -msgstr "" +msgstr "F : フルスクリーンモードの切り替え" msgid "B: toggle bookmark" -msgstr "" +msgstr "B : ブックマークの切り替え" msgid "To return to the archive index, touch the arrow pointing down or use Backspace." msgstr "" +"アーカイブ一覧に戻るには、下向きの矢印をタップするか Backspace キーを押してく" +"ださい。" msgid "Reader Options" -msgstr "" +msgstr "リーダー設定" msgid "Those options save automatically -- Click around and find out!" -msgstr "" +msgstr "これらの設定は自動保存されます。自由に操作してみてください!" msgid "Fit display to" -msgstr "" +msgstr "表示サイズの調整" msgid "Container" -msgstr "" +msgstr "表示領域" msgid "Width" -msgstr "" +msgstr "幅" msgid "Height" -msgstr "" +msgstr "高さ" msgid "Container Width (in pixels or percentage)" -msgstr "" +msgstr "表示領域の幅(ピクセルまたはパーセント)" msgid "The default value is 1200px, or 90% in Double Page Mode." -msgstr "" +msgstr "デフォルト値は1200pxです。見開き表示モードでは90%になります。" msgid "Apply" -msgstr "" +msgstr "適用" msgid "Page Rendering" -msgstr "" +msgstr "ページ描画" msgid "Single" -msgstr "" +msgstr "単ページ表示" msgid "Double" -msgstr "" +msgstr "見開き表示" msgid "Reading Direction" msgstr "" msgid "Left to Right" -msgstr "" +msgstr "左から右" msgid "Right to Left" -msgstr "" +msgstr "右から左" msgid "How many images to preload" -msgstr "" +msgstr "事前読み込みする画像数" msgid "The default is two images." -msgstr "" +msgstr "デフォルトは2枚です。" msgid "Header" msgstr "" msgid "Visible" -msgstr "" +msgstr "表示" msgid "Hidden" -msgstr "" +msgstr "非表示" msgid "Show Archive Overlay by default" -msgstr "" +msgstr "アーカイブオーバーレイをデフォルトで表示" msgid "This will show the overlay with thumbnails every time you open a new Reader page." -msgstr "" +msgstr "新しいリーダーページを開くたびに、サムネイル付きオーバーレイが表示されます。" msgid "Enabled" -msgstr "" +msgstr "有効" msgid "Disabled" -msgstr "" +msgstr "無効" msgid "Progression Tracking" -msgstr "" +msgstr "進行状況の記録" msgid "Disabling tracking will restart reading from page one every time you reopen the reader." msgstr "" +"トラッキングを無効にすると、リーダーを再度開くたびに最初のページから読み始め" +"ます。" msgid "Infinite Scrolling" -msgstr "" +msgstr "無限スクロール" msgid "Display all images in a vertical view in the same page." -msgstr "" +msgstr "すべての画像を1ページ内で縦表示します。" msgid "Help" -msgstr "" +msgstr "ヘルプ" msgid "Reading Direction" -msgstr "" +msgstr "ページ送り方向" msgid "Archive Overview" -msgstr "" +msgstr "アーカイブ概要" msgid "FullScreen" -msgstr "" +msgstr "全画面表示" msgid "Toggle Bookmark" -msgstr "" +msgstr "ブックマークの切り替え" # ------End of Reader.html.tt2------ # ------Start of Stats.html.tt2------ msgid "Library Statistics" -msgstr "" +msgstr "ライブラリ統計" msgid "Archives on record" -msgstr "" +msgstr "登録済みアーカイブ数" msgid "Different tags existing" -msgstr "" +msgstr "異なるタグ数" msgid "in content folder" -msgstr "" +msgstr "コンテンツフォルダ内" msgid "pages read" -msgstr "" +msgstr "読了ページ数" msgid "Tag Cloud" -msgstr "" +msgstr "タグクラウド" msgid "Asking the great powers that be for your tag statistics..." -msgstr "" +msgstr "タグ統計情報を取得しています…" msgid "Detailed Stats" -msgstr "" +msgstr "詳細統計" msgid "(These statistics only show tags that appear at least twice in your database.)" -msgstr "" +msgstr "(これらの統計には、データベース内で2回以上出現するタグのみが表示されます。)" msgid "Return to Library" msgstr "" @@ -1122,566 +1216,858 @@ msgstr "" # ------End of Stats.html.tt2------ # ------Start of Upload.html.tt2------ msgid "Upload Center" -msgstr "" +msgstr "アップロードセンター" msgid "Adding Archives to the Library" -msgstr "" +msgstr "アーカイブをライブラリに追加しています" msgid "Add files to your LANraragi instance from your computer, or the Internet directly." -msgstr "" +msgstr "PCやインターネットから、LANraragi にファイルを追加できます。" msgid "Add uploaded files to category:" -msgstr "" +msgstr "アップロードファイルの追加先カテゴリ :" msgid " -- No Category -- " -msgstr "" +msgstr " -- なし -- " msgid "From your computer" -msgstr "" +msgstr "お使いのコンピューターから" msgid "You can drag and drop files into this window, or click the upload button." msgstr "" +"このウィンドウにファイルをドラッグ&ドロップするか、アップロードボタンを" +"クリックしてください。" msgid "Add from your computer" -msgstr "" +msgstr "コンピューターから追加" msgid "From the Internet" -msgstr "" +msgstr "インターネットから" msgid "You can download files from remote URLs directly into LANraragi from here." -msgstr "" +msgstr "ここからURLを指定して、ファイルを直接 LANraragi にダウンロードできます。" msgid "Download jobs will keep going even if you close this window!" -msgstr "" +msgstr "このウィンドウを閉じても、ダウンロード処理は継続されます!" msgid "Type in your URLs (separated by a newline), and click the download button." -msgstr "" +msgstr "URLを改行で区切って入力し、ダウンロードボタンをクリックしてください。" msgid "If a Downloader plugin is compatible with the URL, it'll be automatically used." msgstr "" +"URLに対応する" +"ダウンローダープラグインがあれば、自動的に使用されます。" msgid "URL(s) to download:" -msgstr "" +msgstr "ダウンロードするURL :" msgid "Add from URL(s)" -msgstr "" +msgstr "URLから追加" msgid "Return to Library" -msgstr "ライブラリーに戻る" +msgstr "ライブラリに戻る" # ------End of Upload.html.tt2------ msgid "Rescan Archive Directory" -msgstr "" +msgstr "アーカイブディレクトリを再スキャン" msgid "Click this button to trigger a rescan of the Archive Directory in case you're missing files, or some data such as total page counts." msgstr "" +"ファイルや、総ページ数などのデータが欠落している場合は、このボタンをクリック" +"してアーカイブディレクトリの再スキャンを実行してください。
" # ------Stary of i18n.html.tt2------ msgid "Backup restored!" -msgstr "" +msgstr "バックアップを復元しました!" msgid "An error occured while restoring the backup.
Please check the server logs and that your JSON is correctly formatted." msgstr "" +"バックアップの復元中にエラーが発生しました。
サーバーログを確認し、JSON " +"の形式が正しいことを確認してください。" msgid "Backup generation in progress..." -msgstr "" +msgstr "バックアップを作成しています..." msgid "Uploading backup file..." -msgstr "" +msgstr "バックアップファイルをアップロードしています..." msgid "Backup complete! Download will start automatically." -msgstr "" +msgstr "バックアップが完了しました! ダウンロードを自動的に開始します。" msgid "Couldn't load the complete archive list! Please reload the page." -msgstr "" +msgstr "アーカイブ一覧を完全に読み込めませんでした! ページを再読み込みしてください。" msgid "Couldn't load the tag statistics! Please reload the page." -msgstr "" +msgstr "タグ統計を読み込めませんでした! ページを再読み込みしてください。" msgid "Error getting untagged archives!" -msgstr "" +msgstr "未タグのアーカイブ取得中にエラーが発生しました!" msgid "Are you sure you want to delete this archive?" -msgstr "" +msgstr "このアーカイブを削除してもよろしいですか?" msgid "Are you sure you want to delete the selected archives?" -msgstr "" +msgstr "選択したアーカイブを削除してもよろしいですか?" msgid "This action cannot be undone!" -msgstr "" +msgstr "この操作は取り消せません!" msgid "This action (truly) cannot be undone!" -msgstr "" +msgstr "この操作は絶対に取り消せません!" msgid "Yes, delete it!" -msgstr "" +msgstr "はい、削除します!" msgid "No, keep it!" -msgstr "" +msgstr "いいえ、そのまま残します!" msgid "Error while deleting cache! Check application logs." msgstr "" +"キャッシュの削除中にエラーが発生しました!アプリケーションログを確認してくだ" +"さい。" msgid "Error while processing request" -msgstr "" +msgstr "リクエストの処理中にエラーが発生しました" msgid "Error checking Minion job status" -msgstr "" +msgstr "Minion ジョブの状態確認中にエラーが発生しました" msgid "Saved!" -msgstr "" +msgstr "保存しました!" msgid "Error saving data" -msgstr "" +msgstr "データの保存中にエラーが発生しました" msgid "Started Batch Operation..." -msgstr "" +msgstr "一括処理を開始しました..." msgid "Batch Operation complete!" -msgstr "" +msgstr "一括処理が完了しました!" msgid "An error occured during batch tagging!" -msgstr "" +msgstr "一括タグ付け中にエラーが発生しました!" msgid "Please check application logs." -msgstr "" +msgstr "アプリケーションログを確認してください。" msgid "Error! Terminating session." -msgstr "" +msgstr "エラーが発生しました! セッションを終了します。" msgid "Sleeping for \${x} seconds." -msgstr "" +msgstr "${x} 秒待機します。" msgid "Error while processing ID \${id} (\${msg})" -msgstr "" +msgstr "ID ${id} の処理中にエラーが発生しました (${msg})" msgid "Processed ID \${id} with \"\${plug}\" (Added tags: \${tags})" -msgstr "" +msgstr "ID ${id} を \"${plug}\" で処理しました(追加タグ : ${tags})" msgid "Deleted ID \${id} (Filename: \${filename})" -msgstr "" +msgstr "ID ${id} を削除しました(ファイル名 : ${filename})" msgid "Replaced tags for ID \${id} (New tags: \${tags})" -msgstr "" +msgstr "ID ${id} のタグを置き換えました(新しいタグ : ${tags})" msgid "Added ID \${id} to category \${category}! (\${msg})" -msgstr "" +msgstr "ID ${id} をカテゴリ ${category} に追加しました!(${msg})" msgid "Cleared new flag for ID \${id}!" -msgstr "" +msgstr "ID ${id} の新着フラグを解除しました!" msgid "Unknown operation \${oper} (\${msg})" -msgstr "" +msgstr "不明な操作です ${oper} (${msg})" msgid "Changed title to: \${title}" -msgstr "" +msgstr "タイトルを変更しました : ${title}" msgid "Reloading page in 5 seconds to account for deleted archives..." -msgstr "" +msgstr "削除されたアーカイブを反映するため、5 秒後にページを再読み込みします..." msgid "Cancelling Batch Operation..." -msgstr "" +msgstr "一括処理をキャンセルしています..." msgid "Enter a name for the new category" -msgstr "" +msgstr "新しいカテゴリ名を入力してください" msgid "My Category" -msgstr "" +msgstr "マイカテゴリ" msgid "Please enter a category name." -msgstr "" +msgstr "カテゴリ名を入力してください。" msgid "No category" -msgstr "" +msgstr "カテゴリなし" msgid "Error getting categories from server" -msgstr "" +msgstr "サーバーからカテゴリを取得中にエラーが発生しました" msgid "Error modifying category" -msgstr "" +msgstr "カテゴリの変更中にエラーが発生しました" msgid "The category will be deleted permanently." -msgstr "" +msgstr "このカテゴリは完全に削除されます。" msgid "Category deleted!" -msgstr "" +msgstr "カテゴリを削除しました!" msgid "Error deleting category" -msgstr "" +msgstr "カテゴリの削除中にエラーが発生しました" msgid "Writing a Predicate" -msgstr "" +msgstr "条件式の作成" msgid "Predicates follow the same syntax as searches in the Archive Index. Check the Documentation for more information." msgstr "" +"条件式は、アーカイブ一覧の検索と同じ構文を使用します。詳細については、 " +"ドキュメント を参照してください。" msgid "Background Worker restarted!" -msgstr "" +msgstr "バックグラウンドワーカーを再起動しました!" msgid "Error restarting Worker:" -msgstr "" +msgstr "ワーカーの再起動中にエラーが発生しました :" msgid "Content folder rescan started!" -msgstr "" +msgstr "コンテンツフォルダの再スキャンを開始しました!" msgid "Error starting content folder rescan:" -msgstr "" +msgstr "コンテンツフォルダの再スキャン開始中にエラーが発生しました :" msgid "Error while querying Shinobu status:" -msgstr "" +msgstr "Shinobu の状態確認中にエラーが発生しました :" msgid "About Plugins" -msgstr "" +msgstr "プラグインについて" msgid "You can use plugins to automatically fetch metadata for this archive.
Just select a plugin from the dropdown and hit Go!
Some plugins might provide an optional argument for you to specify. If that's the case, a textbox will be available to input said argument." msgstr "" +"プラグインを使用すると、このアーカイブのメタデータを自動取得できます。
" +"ドロップダウンからプラグインを選択し、「実行!」を押してください
" +"プラグインによっては、任意の引数を指定できる場合があります。その場合は、引数" +"入力用のテキストボックスが表示されます。" msgid "Metadata saved!" -msgstr "" +msgstr "メタデータを保存しました!" msgid "Error saving archive metadata" -msgstr "" +msgstr "アーカイブのメタデータ保存中にエラーが発生しました" msgid "Error while fetching tags" -msgstr "" +msgstr "タグ取得中にエラーが発生しました" msgid "Archive title changed to" -msgstr "" +msgstr "アーカイブタイトルを変更しました" msgid "Archive summary updated!" -msgstr "" +msgstr "アーカイブ概要を更新しました!" msgid "Added the following tags" -msgstr "" +msgstr "以下のタグを追加しました" msgid "No new tags added!" -msgstr "" +msgstr "新しいタグは追加されませんでした!" # Do not translate _START_, _END_ or _TOTAL_. msgid "Showing _START_ to _END_ of _TOTAL_ ancient chinese lithographies." -msgstr "" +msgstr "_TOTAL_ 件中 _START_ ~ _END_ 件を表示しています。" msgid "No archives to show you! Try uploading some?" msgstr "" +"表示できるアーカイブがありません!アップロードしてみ" +"ますか?" msgid "Welcome to LANraragi \${version}!" -msgstr "" +msgstr "LANraragi ${version} へようこそ!" msgid "If you want to perform advanced operations on an archive, remember to just right-click its name. Happy reading!" msgstr "" +"アーカイブに対して高度な操作を行いたい場合は、名前を右クリックしてください。" +"快適な読書を!" msgid "Error getting basic server info." -msgstr "" +msgstr "サーバー基本情報の取得中にエラーが発生しました。" msgid "You're running in Debug Mode!" -msgstr "" +msgstr "デバッグモードで動作しています!" msgid "Advanced server statistics can be viewed here." -msgstr "" +msgstr "サーバーの詳細統計情報はこちらで確認できます。" msgid "Enter a tag namespace for this column" -msgstr "" +msgstr "この列のタグ名前空間を入力してください" msgid "Enter a full namespace without the colon, e.g \"artist\"." -msgstr "" +msgstr "コロンを含まない完全な名前空間(例 : \"artist\")を入力してください。" msgid "If you have multiple tags with the same namespace, only the last one will be shown in the column." -msgstr "" +msgstr "同じ名前空間のタグが複数ある場合、列には最後のタグのみ表示されます。" msgid "Tag namespace" -msgstr "" +msgstr "タグ名前空間" msgid "Please enter a tag namespace." -msgstr "" +msgstr "タグ名前空間を入力してください。" msgid "Randomly Picked" -msgstr "" +msgstr "ランダム選択" msgid "New Archives" -msgstr "" +msgstr "新着アーカイブ" msgid "Untagged Archives" -msgstr "" +msgstr "未タグのアーカイブ" msgid "On Deck" -msgstr "" +msgstr "続きから" msgid "Error getting carousel data!" -msgstr "" +msgstr "表示データの取得中にエラーが発生しました!" msgid "Edit this column" -msgstr "" +msgstr "この列を編集" msgid "Title" -msgstr "" +msgstr "タイトル" msgid "Tags" -msgstr "" +msgstr "タグ" msgid "Header" -msgstr "" +msgstr "ヘッダー" msgid "A new version of LANraragi (\${version}) is available!" -msgstr "" +msgstr "新しいバージョンの LANraragi (${version}) が利用可能です!" msgid "Click here to check it out." -msgstr "" +msgstr "こちらをクリックして確認してください。" msgid "Error getting changelog for new version" -msgstr "" +msgstr "新バージョンの更新履歴取得中にエラーが発生しました" msgid "Couldn't load data for \${id}!" -msgstr "" +msgstr "${id} のデータを読み込めませんでした!" msgid "Github API rate limit exceeded." -msgstr "" +msgstr "GitHub API のレート制限を超過しました。" msgid "Github API returned status: \${status}" -msgstr "" +msgstr "GitHub API がステータス : ${status} を返しました" msgid "This archive isn't in any category." -msgstr "" +msgstr "このアーカイブはどのカテゴリにも属していません。" msgid "No Categories yet..." -msgstr "" +msgstr "カテゴリはまだありません..." msgid "Remove rating" -msgstr "" +msgstr "評価を削除" msgid "Click here to display new archives only." -msgstr "" +msgstr "こちらをクリックすると、新着アーカイブのみ表示します。" msgid "Click here to display untagged archives only." -msgstr "" +msgstr "こちらをクリックすると、未タグのアーカイブのみ表示します。" msgid "Click here to display archives in this category only." -msgstr "" +msgstr "こちらをクリックすると、このカテゴリのアーカイブのみ表示します。" msgid "Your Reading Progression is now saved on the server!" -msgstr "" +msgstr "読書進捗をサーバーに保存しました!" msgid "You seem to have some local progression hanging around -- Please wait warmly while we migrate it to the server for you." msgstr "" +"ローカルに読書進捗データが残っているようです -- サーバーへ移行していますので" +"、しばらくお待ちください。" msgid "Error while migrating local progression to server" -msgstr "" +msgstr "ローカルの読書進捗をサーバーへ移行中にエラーが発生しました" msgid "Reading Progression has been fully migrated" -msgstr "" +msgstr "読書進捗の移行が完了しました" msgid "You'll have to reopen archives in the Reader to see the migrated progression values." -msgstr "" +msgstr "移行後の進捗を反映するには、リーダーでアーカイブを再度開いてください。" msgid "Plugin uploaded successfully!" -msgstr "" +msgstr "プラグインをアップロードしました!" msgid "The plugin \${name} has been successfully added. Refresh the page to see it." -msgstr "" +msgstr "プラグイン ${name} を追加しました。ページを再読み込みすると表示されます。" msgid "Error uploading plugin" -msgstr "" +msgstr "プラグインのアップロード中にエラーが発生しました" msgid "Successfully set page \${page} as the thumbnail!" -msgstr "" +msgstr "ページ ${page} をサムネイルに設定しました!" msgid "Error updating thumbnail" -msgstr "" +msgstr "サムネイル更新中にエラーが発生しました" msgid "Error clearing new flag" -msgstr "" +msgstr "新着フラグの解除中にエラーが発生しました" msgid "Error getting the archive's imagelist." -msgstr "" +msgstr "アーカイブの画像一覧取得中にエラーが発生しました。" msgid "This archive seems to be in RAR format!" -msgstr "" +msgstr "このアーカイブは RAR 形式のようです!" msgid "RAR archives might not work properly in LANraragi depending on how they were made. If you encounter errors while reading, consider converting your archive to zip." msgstr "" +"RAR アーカイブは、作成方法によっては LANraragi で正常に動作しない場合がありま" +"す。閲覧中にエラーが発生する場合は、ZIP 形式への変換を検討してください。" msgid "EPUB support in LANraragi is minimal" -msgstr "" +msgstr "LANraragi の EPUB サポートは限定的です" msgid "EPUB books will only show images in the Web Reader, and potentially out of order. If you want text support, consider pairing LANraragi with an OPDS reader." msgstr "" +"EPUB 書籍は Web リーダーでは画像のみ表示され、順序が正しくならない場合があり" +"ます。テキスト表示を利用したい場合は、LANraragi と OPDS リーダーを併用することを検討してください。" msgid "Navigation Help" -msgstr "" +msgstr "操作方法" msgid "Error updating reading progression" -msgstr "" +msgstr "読書進捗の更新中にエラーが発生しました" msgid "Page \${page}" -msgstr "" +msgstr "ページ ${page}" msgid "The page thumbnailing job didn't conclude properly. Your archive might be corrupted." msgstr "" +"ページサムネイル生成処理が正常に完了しませんでした。アーカイブが破損している" +"可能性があります。" msgid "A script is already running." -msgstr "" +msgstr "スクリプトは既に実行中です。" msgid "Please wait for it to finish before starting a new one." -msgstr "" +msgstr "新しいスクリプトを開始する前に、現在の処理が完了するまでお待ちください。" msgid "An error occured while running the script" -msgstr "" +msgstr "スクリプトの実行中にエラーが発生しました" msgid "Script result" -msgstr "" +msgstr "スクリプト結果" msgid "Script failed with error: \${error}" -msgstr "" +msgstr "スクリプトがエラーで失敗しました : ${error}" msgid "Temporary folder cleaned!" -msgstr "" +msgstr "一時フォルダをクリーンアップしました!" msgid "Error cleaning temporary folder" -msgstr "" +msgstr "一時フォルダのクリーンアップ中にエラーが発生しました" msgid "Threw away the cache!" -msgstr "" +msgstr "キャッシュを破棄しました!" msgid "Error clearing the cache" -msgstr "" +msgstr "キャッシュの削除中にエラーが発生しました" msgid "Threw away the search cache!" -msgstr "" +msgstr "検索キャッシュを破棄しました!" msgid "All archives are no longer new!" -msgstr "" +msgstr "すべてのアーカイブの新着フラグを解除しました!" msgid "Error during cleanup procedure" -msgstr "" +msgstr "クリーンアップ処理中にエラーが発生しました" msgid "Are you sure you want to wipe the database?" -msgstr "" +msgstr "データベースを初期化してもよろしいですか?" msgid "Sayonara! Redirecting you..." -msgstr "" +msgstr "さようなら!リダイレクトします..." msgid "Successfully cleaned the database and removed \${entries} entries." -msgstr "" +msgstr "データベースをクリーンアップし、 ${entries} 件のエントリを削除しました。" msgid "\${entries} other entries have been unlinked from the database and will be deleted on the next cleanup!" msgstr "" +"${entries} 件の他のエントリがデータベースからリンク解除され、次回の" +"クリーンアップで削除されます!" msgid "Do a backup now if some files disappeared from your archive index." msgstr "" +"アーカイブインデックスからファイルが消えている場合は、今すぐバックアップを作" +"成してください。" msgid "Queued up a job to regenerate thumbnails! Stay tuned for updates or check the Minion console." msgstr "" +"サムネイル再生成ジョブをキューに追加しました!進捗は更新を待つか、Minion " +"コンソールで確認してください。" msgid "All thumbnails generated! Errors will be listed below if there were any." -msgstr "" +msgstr "すべてのサムネイルが生成されました!エラーがある場合は以下に表示されます。" msgid "Added \${id} to category \${cat}!" -msgstr "" +msgstr "${id} をカテゴリ ${cat} に追加しました!" msgid "Removed \${id} from category \${cat}!" -msgstr "" +msgstr "${id} をカテゴリ ${cat} から削除しました!" msgid "Couldn't delete archive file.
(Maybe it has already been deleted beforehand?)" msgstr "" +"アーカイブファイルを削除できませんでした。
(すでに削除されている可能性が" +"あります)" msgid "Archive metadata has been deleted properly.
Please delete the file manually before returning to the archive index." msgstr "" +"アーカイブのメタデータは正常に削除されました。
アーカイブ一覧に戻る前に、" +"ファイルを手動で削除してください。" msgid "Archive successfully deleted. Redirecting you..." -msgstr "" +msgstr "アーカイブを削除しました。リダイレクトします..." msgid "Error while deleting archive" -msgstr "" +msgstr "アーカイブの削除中にエラーが発生しました" msgid "Processing your upload... (Job #\${jobid})" -msgstr "" +msgstr "アップロードを処理しています...(ジョブ : #${jobid})" msgid "Downloading file... (Job #\${jobid})" -msgstr "" +msgstr "ファイルをダウンロードしています...(ジョブ : #${jobid})" msgid "Processing" -msgstr "" +msgstr "処理中" msgid "Completed" -msgstr "" +msgstr "完了" msgid "Failed" -msgstr "" +msgstr "失敗" msgid "Total" -msgstr "" +msgstr "合計" msgid "Click here to edit metadata." -msgstr "" +msgstr "こちらをクリックしてメタデータを編集してください。" msgid "Error while processing file." -msgstr "" +msgstr "ファイルの処理中にエラーが発生しました。" msgid "Error while downloading file." -msgstr "" +msgstr "ファイルのダウンロード中にエラーが発生しました。" msgid "Error sending job to Minion" -msgstr "" +msgstr "Minion へのジョブ送信中にエラーが発生しました" # ------End of i18n.html.tt2------ # ------Start of Duplicates.html.tt2------ msgid "Duplicate Detection" -msgstr "" +msgstr "重複検出" msgid "This page allows you to search for potential duplicates in your library." -msgstr "" +msgstr "このページでは、ライブラリ内の重複候補を検索できます。" msgid "Clicking \"Start searching\" will start a background job to scan the entire library for dupes." msgstr "" +"「検索開始」をクリックすると、ライブラリ全体の重複をスキャンする" +"バックグラウンドジョブが開始されます。" msgid "After which, you can select which archives to delete, and then delete them in bulk." -msgstr "" +msgstr "その後、削除するアーカイブを選択し、一括で削除できます。" msgid "Found %1 duplicate group(s)." -msgstr "" +msgstr "%1 件の重複グループが見つかりました。" msgid "You can use the rules dropdown below to pre-select archives for deletion." msgstr "" +"下のルールのドロップダウンを使用して、削除対象のアーカイブを事前に選択できま" +"す。" msgid "For every duplicate group..." -msgstr "" +msgstr "各重複グループについて…" msgid "Keep all files" -msgstr "" +msgstr "すべてのファイルを保持する" msgid "Keep the file with the most tags" -msgstr "" +msgstr "最もタグが多いファイルを保持する" msgid "Keep the file with the most pages" -msgstr "" +msgstr "ページ数が最も多いファイルを保持する" msgid "Keep the largest file" -msgstr "" +msgstr "最もサイズの大きいファイルを保持する" msgid "Keep the youngest file" -msgstr "" +msgstr "最新のファイルを保持する" msgid "Keep the oldest file" -msgstr "" +msgstr "最も古いファイルを保持する" msgid "Start searching for duplicates" -msgstr "" +msgstr "重複の検索を開始する" msgid "Search for duplicates again" -msgstr "" +msgstr "重複を再検索する" msgid "Delete all selected items" -msgstr "" +msgstr "選択したすべての項目を削除する" msgid "Filename" -msgstr "" +msgstr "ファイル名" msgid "Filesize" -msgstr "" +msgstr "ファイルサイズ" msgid "Action" -msgstr "" +msgstr "操作" msgid "Searching for duplicates..." -msgstr "" +msgstr "重複を検索中..." # ------End of Duplicates.html.tt2------ +msgid "The maximum value allowed is 4GB." +msgstr "設定可能な最大値は 4GB です。" + +msgid "Language" +msgstr "言語" + +msgid "Automatic (browser default)" +msgstr "自動(ブラウザのデフォルト)" + +msgid "Select the language for the user interface. Set to Automatic to use your browser's language preference." +msgstr "" +"ユーザーインターフェースの言語を選択します。自動に設定すると、ブラウザの言語" +"設定を使用します。" + +msgid "Archives with no tags or from your last selection have been pre-checked." +msgstr "タグのないアーカイブと前回選択したアーカイブは事前にチェックされています。" + +msgid "Discard Selection" +msgstr "選択を解除" + +msgid "Displaying ${n} Archives from previous selection." +msgstr "前回選択した ${n} 件のアーカイブを表示しています。" + +msgid "Tankoubons" +msgstr "単行本" + +msgid "No Tankoubons in your library yet." +msgstr "ライブラリーにはまだ単行本がありません。" + +msgid "Archives" +msgstr "アーカイブ" + +msgid "No Archives in your library yet." +msgstr "ライブラリにはまだアーカイブがありません。" + +msgid "No duplicates found!" +msgstr "重複は見つかりませんでした!" + +msgid "Authenticated Progress Tracking" +msgstr "ログインユーザーごとの進行状況管理" + +msgid "If enabled, server-side progress will only be saved if you're logged in with a password, and will override clientside progress. This allows guests to browse without affecting the main user's progress." +msgstr "" +"有効にすると、サーバー側の閲覧状況はログイン中のみ保存され、クライアント側の" +"閲覧状況より優先されます。ゲストが閲覧しても、メインユーザーの閲覧状況には影" +"響しません。" + +msgid "Combine with clientside progress tracking to allow unauthenticated users to track progress locally." +msgstr "" +"クライアント側の閲覧状況管理と組み合わせることで、未ログインユーザーも" +"ローカルで閲覧状況を保存できます。" + +msgid "Disable OpenAPI Validation" +msgstr "OpenAPI 検証を無効化" + +msgid "Disables OpenAPI API schema validation. Turn this on if you are running into validation-related errors with old API clients." +msgstr "" +"OpenAPI の API スキーマ検証を無効化します。古い API クライアントで問題が発生" +"する場合に有効にしてください。" + +msgid "Excluded Namespaces" +msgstr "除外する名前空間" + +msgid "Comma-separated list of tag namespaces to exclude from search suggestions and tag statistics." +msgstr "検索候補およびタグ統計から除外するタグ名前空間を、カンマ区切りで指定します。" + +msgid "Clients will use this list to filter out noisy tags from autocomplete and tag clouds." +msgstr "" +"クライアントはこのリストを使用して、オートコンプリートやタグクラウドから不要" +"なタグを除外します。" + +msgid "Group Tankoubons" +msgstr "単行本をグループ化" + +msgid "If enabled, Archives belonging to a Tankoubon will show as a single item." +msgstr "" +"有効にすると、単行本に属するアーカイブはまとめて1つのアイテムとして表示されま" +"す。" + +msgid "Index Settings" +msgstr "インデックス設定" + +msgid "Display Mode" +msgstr "表示モード" + +msgid "Compact" +msgstr "コンパクト" + +msgid "Thumbnail" +msgstr "サムネイル" + +msgid "Copy link" +msgstr "リンクをコピー" + +msgid "Merge Archives into Tankoubon" +msgstr "アーカイブを単行本にまとめる" + +msgid "Rating" +msgstr "評価" + +msgid "Set Rating" +msgstr "評価を設定" + +msgid "Clear Rating" +msgstr "評価をクリア" + +msgid " -- Select Rating -- " +msgstr " -- 評価を選択 -- " + +msgid "N: toggle auto next page" +msgstr "N : 自動ページ送りの切り替え" + +msgid "G: go to page number" +msgstr "G : 指定ページへ移動" + +msgid "S: set a Stamp" +msgstr "S : スタンプを設定" + +msgid "Auto next page interval in seconds" +msgstr "自動ページ送り間隔(秒)" + +msgid "The default is 10 seconds." +msgstr "デフォルトは10秒です。" + +msgid "Auto Next Page" +msgstr "自動ページ送り" + +msgid "Toggle Stamps" +msgstr "スタンプの切り替え" + +msgid "Set Stamp" +msgstr "スタンプを設定" + +msgid "Filter stamped pages" +msgstr "スタンプ済みページをフィルタ" + +msgid "New archive" +msgstr "新着アーカイブ" + +msgid "Archive read" +msgstr "既読アーカイブ" + +msgid "Tankoubon (collection)" +msgstr "単行本(シリーズ)" + +msgid "Pages read / Total pages" +msgstr "読了ページ数 / 総ページ数" + +msgid "Pages read / Total pages / Archives in Tankoubon" +msgstr "読了ページ数 / 総ページ数 / 単行本内のアーカイブ数" + +msgid "Link copied successfully." +msgstr "リンクをコピーしました。" + +msgid "Copy failed." +msgstr "コピーに失敗しました。" + +msgid "Starting auto next page failed!" +msgstr "自動ページめくりの開始に失敗しました!" + +msgid "Please set the auto next page interval to a positive number." +msgstr "自動ページめくりの間隔には、0 より大きい数値を設定してください。" + +msgid "Chapters" +msgstr "区切り" + +msgid "Add Chapter at this Page" +msgstr "このページに区切りを追加" + +msgid "Edit Chapter name" +msgstr "区切り名を編集" + +msgid "Delete Chapter" +msgstr "区切りを削除" + +msgid "Are you sure you want to delete this chapter/section?" +msgstr "この区切りを削除してもよろしいですか?" + +msgid "Enter a title for this chapter/section:" +msgstr "この区切りのタイトルを入力してください :" + +msgid "Chapter added!" +msgstr "区切りを追加しました!" + +msgid "Error adding/removing chapter:" +msgstr "区切りの追加または削除中にエラーが発生しました :" + +msgid "Untitled Chapter" +msgstr "無題の区切り" + +msgid "Go to page:" +msgstr "ページへ移動 :" + +msgid "Selection" +msgstr "選択項目" + +msgid "\${n} selected" +msgstr "${n} 件選択中" + +msgid "Click Archives to add them to the selection. Your selection carries over across searches." +msgstr "" +"アーカイブをクリックして選択に追加できます。検索を変えても選択は保持されます" +"。" + +msgid "Exit selection mode? Your current selection will be lost. " +msgstr "選択モードを終了しますか?現在の選択は失われます。 " + +msgid "Add to selection" +msgstr "選択に追加する" + +msgid "Remove from selection" +msgstr "選択から削除する" + +msgid "Enter a name for the new Tankoubon." +msgstr "新しい単行本の名前を入力してください。" + +msgid "This will contain all the selected Archives as a single item in your Library." +msgstr "" +"これは、選択したすべてのアーカイブをライブラリ内の1つのアイテムとしてまとめる" +"ものです。" + +msgid "Please enter a name." +msgstr "名前を入力してください。" + +msgid "Added \${n} Archives to \${tank}!" +msgstr "${n} 件のアーカイブを ${tank} に追加しました!" + +msgid "Error creating Tankoubon" +msgstr "単行本の作成中にエラーが発生しました" + +msgid "Error adding archives to Tankoubon" +msgstr "単行本へのアーカイブ追加中にエラーが発生しました" + +msgid "Merge selection into a Tankoubon" +msgstr "選択した項目を単行本にまとめる" + +msgid "Hide completed Archives" +msgstr "完了済みのアーカイブを非表示にする" + +msgid "Enter Stamp name:" +msgstr "スタンプ名を入力してください :" + +msgid "Stamp name" +msgstr "スタンプ名" + +msgid "Error setting up the Stamp" +msgstr "スタンプの設定中にエラーが発生しました" diff --git a/locales/template/ko.po b/locales/template/ko.po index 5ba239640..49d6fc3e6 100644 --- a/locales/template/ko.po +++ b/locales/template/ko.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-06 17:24+0200\n" -"PO-Revision-Date: 2026-05-10 22:41+0000\n" +"PO-Revision-Date: 2026-05-22 04:34+0000\n" "Last-Translator: on9686 \n" "Language-Team: Korean \n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 2026.5-dev\n" +"X-Generator: Weblate 2026.6.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -1019,7 +1019,7 @@ msgid "Q: bring up the thumbnail index and archive options." msgstr "Q: 섬네일 인덱스 및 아카이브 옵션 표시" msgid "R: open a random archive." -msgstr "\"R: 랜덤 아카이브 열기." +msgstr "R: 랜덤 아카이브 열기." msgid "F: toggle fullscreen mode" msgstr "F: 전체 화면 모드 전환" @@ -1942,3 +1942,63 @@ msgstr "선택 항목에서 제거" msgid "Hide completed Archives" msgstr "완결된 아카이브 숨기기" + +msgid "Merge Archives into Tankoubon" +msgstr "단행본에 아카이브 병합" + +msgid "New archive" +msgstr "새 아카이브" + +msgid "Archive read" +msgstr "아카이브 읽기" + +msgid "Tankoubon (collection)" +msgstr "단행본(컬렉션)" + +msgid "Enter a name for the new Tankoubon." +msgstr "새 단행본의 이름을 입력하십시오." + +msgid "This will contain all the selected Archives as a single item in your Library." +msgstr "선택한 모든 아카이브가 라이브러의 단일 항목에 포함됩니다." + +msgid "Please enter a name." +msgstr "이름을 입력하십시오." + +msgid "Added \${n} Archives to \${tank}!" +msgstr "${n} 아카이브를 ${tank}에 추가했습니다!" + +msgid "Error creating Tankoubon" +msgstr "단행본 생성 오류" + +msgid "Error adding archives to Tankoubon" +msgstr "단행본에 아카이브 추가 중 오류" + +msgid "Merge selection into a Tankoubon" +msgstr "선택한 항목을 단행본에 병합" + +msgid "Pages read / Total pages" +msgstr "읽은 페이지 / 전체 페이지" + +msgid "Pages read / Total pages / Archives in Tankoubon" +msgstr "읽은 페이지 / 전체 페이지 / 단행본 내 아카이브" + +msgid "S: set a Stamp" +msgstr "S: 스탬프 설정" + +msgid "Toggle Stamps" +msgstr "스탬프 토글" + +msgid "Set Stamp" +msgstr "스탬프 설정" + +msgid "Filter stamped pages" +msgstr "스탬프된 페이지 필터" + +msgid "Enter Stamp name:" +msgstr "스탬프 이름 입력:" + +msgid "Stamp name" +msgstr "스탬프 이름" + +msgid "Error setting up the Stamp" +msgstr "스탬프 설정 오류" diff --git a/locales/template/pt.po b/locales/template/pt.po index 19a048908..7fa9fecdd 100644 --- a/locales/template/pt.po +++ b/locales/template/pt.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-05 03:27+0200\n" -"PO-Revision-Date: 2026-01-29 14:01+0000\n" -"Last-Translator: Petingoso \n" +"PO-Revision-Date: 2026-05-17 02:25+0000\n" +"Last-Translator: ssantos \n" "Language-Team: Portuguese \n" "Language: pt\n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 2026.6.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -26,8 +26,8 @@ msgstr "" msgid "Backuping allows you to download a JSON file containing all your categories and archive IDs, and their matching metadata." msgstr "" -"A cópia de segurança permite que você faça o download de um arquivo JSON que " -"contém todas as suas categorias e IDs de arquivo, além dos metadados " +"A cópia de segurança permite que descarregue um ficheiro JSON que contém " +"todas as suas categorias e IDs de ficheiro, além dos metadados " "correspondentes." msgid "Restoring from a backup will restore this metadata, for IDs which already exist in your database." @@ -350,9 +350,9 @@ msgstr "Substituir arquivos duplicados" msgid "If enabled, LANraragi will overwrite old archives when a newer one (with the same name) is uploaded through the Web Uploader or the Download System." msgstr "" -"Se ativado, o LANraragi substituirá arquivos antigos quando um mais novo (" -"com o mesmo nome) for carregado através do Web Uploader ou do Sistema de " -"Download." +"Se ativado, o LANraragi substituirá ficheiros antigos quando um mais novo " +"(com o mesmo nome) for carregado através do Web Uploader ou do Sistema de " +"Descargas." msgid "This will delete metadata for old files when they're replaced! Use with caution." msgstr "" @@ -836,7 +836,7 @@ msgid "Read" msgstr "Leitura" msgid "Download" -msgstr "Baixar" +msgstr "Descarregar" msgid "Edit Metadata" msgstr "Editar Metadata" @@ -983,7 +983,7 @@ msgid "Login Plugins" msgstr "Plugins de Acesso" msgid "Downloaders" -msgstr "Baixados" +msgstr "Descarregadores" msgid "Scripts" msgstr "Scripts" @@ -1290,24 +1290,24 @@ msgstr "Da Internet" msgid "You can download files from remote URLs directly into LANraragi from here." msgstr "" -"Você pode baixar os arquivos de URLs remotas diretamente para o LANraragi a " +"Pode descarregar os ficheiros de URLs remotas diretamente para o LANraragi a " "partir daqui." msgid "Download jobs will keep going even if you close this window!" -msgstr "Os serviços de download continuarão mesmo que você feche essa janela!" +msgstr "Os serviços de descargas continuarão mesmo que feche essa janela!" msgid "Type in your URLs (separated by a newline), and click the download button." msgstr "" -"Digite suas URLs (separadas por uma nova linha) e clique no botão de " -"download." +"Digite as suas URLs (separadas por uma nova linha) e clique no botão de " +"descarga." msgid "If a Downloader plugin is compatible with the URL, it'll be automatically used." msgstr "" -"Se um plugin baixados " -"forem compatíveis com a URL, ele será utilizado automaticamente." +"Se um plugin de descargas for compatível com a URL, será utilizado automaticamente." msgid "URL(s) to download:" -msgstr "URL(s) para download:" +msgstr "URL(s) para descarregar:" msgid "Add from URL(s)" msgstr "Adicionar a partir da URL(s)" @@ -1795,7 +1795,7 @@ msgid "Processing your upload... (Job #\${jobid})" msgstr "Processando seu upload... (Serviço de #${jobid})" msgid "Downloading file... (Job #\${jobid})" -msgstr "Baixando o arquivo... (Serviço de #${jobid})" +msgstr "A descarregar o ficheiro... (Serviço de #${jobid})" msgid "Processing" msgstr "Processando" @@ -1816,7 +1816,7 @@ msgid "Error while processing file." msgstr "Error enquanto está processando o arquivo." msgid "Error while downloading file." -msgstr "Error enquando faz o download do arquivo." +msgstr "Erro ao descarregar o ficheiro." msgid "Error sending job to Minion" msgstr "Error enviando serviço para o Minion" diff --git a/locales/template/zh.po b/locales/template/zh.po index ab6ff7f90..9bd2fbdc2 100644 --- a/locales/template/zh.po +++ b/locales/template/zh.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-05-04 20:25+0200\n" -"PO-Revision-Date: 2026-05-07 17:24+0000\n" +"PO-Revision-Date: 2026-05-15 02:11+0000\n" "Last-Translator: gustaavv \n" "Language-Team: Chinese (Simplified Han script) \n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 5.17.1\n" +"X-Generator: Weblate 2026.5.dev0\n" # Sample Data # ------Start of Backup.html.tt2------ @@ -747,16 +747,16 @@ msgstr "" ">为什么不顺便检查一下配置选项呢?" msgid "Select Archives" -msgstr "" +msgstr "选择档案" msgid "Run Batch Operations on selection" -msgstr "" +msgstr "对选中项运行批量操作" msgid "Clear selection" -msgstr "" +msgstr "清除选择" msgid "Select All in Page" -msgstr "" +msgstr "本页全选" # ------End of Index.html.tt2------ # ------Start of Login.html.tt2------ @@ -1822,3 +1822,102 @@ msgstr "G:跳转页码" msgid "Go to page:" msgstr "跳转页面:" + +msgid "Discard Selection" +msgstr "放弃选择" + +msgid "Displaying ${n} Archives from previous selection." +msgstr "正在显示先前选择中的 ${n} 个档案。" + +msgid "Tankoubons" +msgstr "单行本" + +msgid "No Tankoubons in your library yet." +msgstr "您的库中尚无单行本。" + +msgid "Archives" +msgstr "档案" + +msgid "No Archives in your library yet." +msgstr "您的库中尚无档案。" + +msgid "Group Tankoubons" +msgstr "归组单行本" + +msgid "If enabled, Archives belonging to a Tankoubon will show as a single item." +msgstr "如果启用,属于同一单行本的档案将显示为单个项目。" + +msgid "Index Settings" +msgstr "索引设置" + +msgid "Display Mode" +msgstr "显示模式" + +msgid "Compact" +msgstr "紧凑" + +msgid "Thumbnail" +msgstr "缩略图" + +msgid "Merge Archives into Tankoubon" +msgstr "将档案合并为单行本" + +msgid "Archives with no tags or from your last selection have been pre-checked." +msgstr "无标签或您上次选择的档案已预先勾选。" + +msgid "New archive" +msgstr "新建档案" + +msgid "Archive read" +msgstr "已读档案" + +msgid "Tankoubon (collection)" +msgstr "单行本(合集)" + +msgid "Selection" +msgstr "选择" + +msgid "\${n} selected" +msgstr "已选择 ${n} 项" + +msgid "Click Archives to add them to the selection. Your selection carries over across searches." +msgstr "点击档案将其加入选择。您的选择会在不同搜索间保留。" + +msgid "Exit selection mode? Your current selection will be lost. " +msgstr "退出选择模式?您当前的选择将会丢失。 " + +msgid "Add to selection" +msgstr "加入选择" + +msgid "Remove from selection" +msgstr "从选择中移除" + +msgid "Enter a name for the new Tankoubon." +msgstr "为新的单行本输入名称。" + +msgid "This will contain all the selected Archives as a single item in your Library." +msgstr "这会将所有选择的档案合并为库中的单个项目。" + +msgid "Please enter a name." +msgstr "请输入名称。" + +msgid "Added \${n} Archives to \${tank}!" +msgstr "已将 ${n} 个档案添加到 ${tank} !" + +msgid "Error creating Tankoubon" +msgstr "创建单行本出错" + +msgid "Error adding archives to Tankoubon" +msgstr "将档案添加到单行本时出错" + +msgid "Merge selection into a Tankoubon" +msgstr "将选择合并为单行本" + +msgid "Hide completed Archives" +msgstr "隐藏已读完的档案" + +msgid "Pages read / Total pages" +msgstr "已读页数 / 总页数" + +msgid "Pages read / Total pages / Archives in Tankoubon" +msgstr "已读页数 / 总页数 / 单行本档案数" diff --git a/package-lock.json b/package-lock.json index b21d2df60..c6da7f3ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "preact": "10.7.1", "raty-js": "^4.3.0", "react-toastify": "9.0.0-rc-2", + "sortablejs": "1.15.6", "sweetalert2": "11.22.4", "swiper": "12.1.2", "tippy.js": "6.3.7" @@ -1376,6 +1377,11 @@ "node": ">=8" } }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==" + }, "node_modules/sweetalert2": { "version": "11.22.4", "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.22.4.tgz", diff --git a/package.json b/package.json index c0907e214..b36ab4cc3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lanraragi", - "version": "0.9.71", - "version_name": "She'll Drive the Big Car", + "version": "0.9.80", + "version_name": "Where Are We Now?", "description": "I'm under Japanese influence and my honor's at stake!", "devEngines": { "runtime": { @@ -51,6 +51,7 @@ "preact": "10.7.1", "raty-js": "^4.3.0", "react-toastify": "9.0.0-rc-2", + "sortablejs": "1.15.6", "sweetalert2": "11.22.4", "swiper": "12.1.2", "tippy.js": "6.3.7" @@ -61,4 +62,4 @@ "eslint": "^10.1.0", "globals": "16.5.0" } -} +} \ No newline at end of file diff --git a/public/css/lrr.css b/public/css/lrr.css index 075d1054e..c70b92fc5 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -318,7 +318,7 @@ p#nb { user-select: none; align-self: center; cursor: pointer; - z-index: 22; + z-index: 19; } .marker { @@ -331,9 +331,7 @@ p#nb { background-repeat: no-repeat; background-position: center; filter: - drop-shadow(0 0 1px black) - drop-shadow(0 0 1px black) - drop-shadow(0 0 1px black); + drop-shadow(0 0 1px black) drop-shadow(0 0 1px black) drop-shadow(0 0 1px black); transform: translate(-50%, -50%); pointer-events: auto; @@ -768,6 +766,7 @@ body.swal2-shown>[aria-hidden="true"] { .overlay-bar #overlay-section { flex: 0 0 auto; margin: 0; + max-width: 50%; } .overlay-bar-left { @@ -776,6 +775,15 @@ body.swal2-shown>[aria-hidden="true"] { padding-left: 32px; } +.favtag-btn { + text-overflow: ellipsis; + overflow: hidden; +} + +#chapter-select { + width: 200px; +} + .chapter-selector { flex: 1; text-align: right; @@ -945,4 +953,48 @@ body.infinite-scroll .fullscreen-infinite img { position: absolute; top: 5px; left: 25% -} \ No newline at end of file +} + +/* Tankoubon sortable archive list */ +.sortable-ghost { + opacity: 0.4; +} + +.sortable-chosen { + box-shadow: 0 0 8px rgba(0, 123, 255, 0.5); +} + +.drag-handle { + cursor: grab; + opacity: 0.5; + margin-right: 8px; +} + +.drag-handle:hover { + opacity: 1; +} + +#tank-archive-list { + list-style: none; + padding: 0; + margin: 0; + text-align: left; +} + +#tank-archive-list li { + display: flex; + align-items: center; + padding: 6px 8px; + border-bottom: 1px solid #333; +} + +#tank-archive-list li:last-child { + border-bottom: none; +} + +#tank-archive-list li span.arc-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/public/js/batch.js b/public/js/batch.js index cfbdc4ab6..5dfdc1f93 100644 --- a/public/js/batch.js +++ b/public/js/batch.js @@ -76,44 +76,51 @@ Batch.showOverride = function () { /** * Load only the archives from the MSM selection, fetching each archive's metadata individually. + * Tankoubons are expanded to their constituent archives. * Shows the MSM selection banner and pre-checks all loaded archives. * @param {string[]} ids Array of archive IDs from msmSelection */ Batch.loadSelectionOnly = function (ids) { - const fetches = ids.map((id) => - Server.callAPI(`/api/archives/${id}/metadata`, "GET", null, null, (data) => data) - .catch(() => null), - ); + const tankIds = ids.filter((id) => id.startsWith("TANK_")); + const archiveIds = ids.filter((id) => !id.startsWith("TANK_")); - Promise.all(fetches).then((results) => { - let hasTanks = false; - let hasArchives = false; + // Acquire archive IDs from Tanks and merge with directly selected archive IDs, then fetch metadata for each archive ID + const tankFetches = tankIds.map((id) => + Server.callAPI(`/api/tankoubons/${id}?page=-1`, "GET", null, I18N.ArchiveListLoadFailure, (data) => { + archiveIds.push(...(data.result.archives || [])); + })); + + Promise.all(tankFetches).then(() => { - results.forEach((archive) => { - if (!archive) return; - const arcId = archive.arcid || archive.id; - const escapedTitle = LRR.encodeHTML(archive.title) + (archive.isnew === "true" ? " 🆕" : ""); - const html = `
  • `; + const archiveFetches = archiveIds.map((id) => + Server.callAPI(`/api/archives/${id}/metadata`, "GET", null, null, (data) => data) + .catch(() => null), + ); - if (arcId.startsWith("TANK_")) { - $("#tankoubonlist").append(html); - hasTanks = true; - } else { + Promise.all(archiveFetches).then((results) => { + + const addedIds = new Set(); + + results.forEach((archive) => { + if (!archive) return; + const arcId = archive.arcid; + if (!arcId || addedIds.has(arcId)) return; + addedIds.add(arcId); + const escapedTitle = LRR.encodeHTML(archive.title) + (archive.isnew === "true" ? " 🆕" : ""); + const html = `
  • `; $("#archivelist").append(html); - hasArchives = true; - } - }); + }); - if (hasTanks) $("#no-tankoubons-msg").hide(); - if (hasArchives) $("#no-archives-msg").hide(); + if (addedIds.size > 0) $("#no-archives-msg").hide(); - // Show the MSM selection banner - $("#msm-banner-count").text(I18N.BatchSelectionBanner(ids.length)); - $("#msm-banner").show(); - }).finally(() => { - $("#arclist-container").show(); - $("#loading-placeholder").hide(); + // Show the MSM selection banner + $("#msm-banner-count").text(I18N.BatchSelectionBanner(ids.length)); + $("#msm-banner").show(); + }).finally(() => { + $("#arclist-container").show(); + $("#loading-placeholder").hide(); + }); }); }; @@ -122,9 +129,7 @@ Batch.loadSelectionOnly = function (ids) { * Hides the selection banner (if present) and prechecks untagged archives. */ Batch.loadAllArchives = function () { - $("#tankoubonlist").empty(); $("#archivelist").empty(); - $("#no-tankoubons-msg").show(); $("#no-archives-msg").show(); $("#msm-banner").html(""); $("#arclist-container").hide(); @@ -133,7 +138,7 @@ Batch.loadAllArchives = function () { // Clear selection if present localStorage.removeItem("msmSelection"); - const archivePromise = Server.callAPI("/api/archives", "GET", null, I18N.ArchiveListLoadFailure, + Server.callAPI("/api/archives", "GET", null, I18N.ArchiveListLoadFailure, (data) => { data.forEach((archive) => { const escapedTitle = LRR.encodeHTML(archive.title) + (archive.isnew === "true" ? " 🆕" : ""); @@ -147,21 +152,7 @@ Batch.loadAllArchives = function () { (data) => { preCheckInternal(data); }, ); }, - ); - - const tankPromise = Server.callAPI("/api/tankoubons?page=-1", "GET", null, null, - (data) => { - data.result.forEach((tank) => { - const escapedTitle = LRR.encodeHTML(tank.name); - const html = `
  • `; - $("#tankoubonlist").append(html); - }); - - if (data.result.length > 0) $("#no-tankoubons-msg").hide(); - }, - ); - - Promise.all([archivePromise, tankPromise]).finally(() => { + ).finally(() => { $("#arclist-container").show(); $("#check-uncheck").show(); $("#loading-placeholder").hide(); diff --git a/public/js/edit.js b/public/js/edit.js index 240396595..307ca9dea 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -10,8 +10,11 @@ const Edit = {}; Edit.tagInput = null; Edit.suggestions = []; +Edit.isTank = false; Edit.initializeAll = function () { + Edit.isTank = $("body").data("is-tank") === 1; + // bind events to DOM $(document).on("change.plugin", "#plugin", Edit.updateOneShotArg); $(document).on("click.show-help", "#show-help", Edit.showHelp); @@ -24,7 +27,14 @@ Edit.initializeAll = function () { $(document).on("paste.tagger", ".tagger-new", Edit.handlePaste); $(document).on("keydown.run-plugin-enter", "#arg", Edit.runPluginByEnter); - Edit.updateOneShotArg(); + if (Edit.isTank) { + $(document).on("click.add-archive", "#add-archive-btn", Edit.addArchiveToTank); + $(document).on("click.remove-archive", ".remove-archive", Edit.removeArchiveFromTank); + $(document).on("click.tank-help", "#tank-help", Edit.showTankHelp); + Edit.initSortable(); + } else { + Edit.updateOneShotArg(); + } // Hide tag input while statistics load Edit.hideTags(); @@ -59,6 +69,52 @@ Edit.initializeAll = function () { }); }; +Edit.initSortable = function () { + const list = document.getElementById("tank-archive-list"); + if (!list || typeof Sortable === "undefined") return; + + Sortable.create(list, { + handle: ".drag-handle", + animation: 150, + ghostClass: "sortable-ghost", + chosenClass: "sortable-chosen", + //onEnd: Edit.saveArchiveOrder, + }); +}; + +Edit.addArchiveToTank = function () { + const tankId = $("#archiveID").val(); + const arcId = $("#add-archive-id").val().trim(); + if (!arcId) return; + + // Get the Archive metadata to feature the name, but don't actually save the Tank. + // That's handled by the Save button. + Server.callAPI(`/api/archives/${arcId}`, "GET", + null, + I18N.TankoubonAddArchiveError, + (data) => { + const li = $(`
  • + + ${data.title} + + + + +
  • `); + $("#tank-archive-list").append(li); + $("#add-archive-id").val(""); + }, + ); + +}; + +Edit.removeArchiveFromTank = function () { + $(this).closest("li").remove(); +}; + // this checks whether the rich-text tag editor is in use (initialized // on tagInput); if so, call its method to add the tag; if not, edit // the string directly @@ -147,6 +203,16 @@ Edit.showHelp = function () { }); }; +Edit.showTankHelp = function () { + LRR.toast({ + toastId: "tankHelp", + heading: I18N.TankoubonHelpTitle, + text: I18N.TankoubonHelp, + icon: "info", + hideAfter: 33000, + }); +}; + Edit.updateOneShotArg = function () { // show input $("#arg_label").show(); @@ -167,32 +233,40 @@ Edit.saveMetadata = function () { Edit.hideTags(); const id = $("#archiveID").val(); - const formData = new FormData(); - formData.append("tags", $("#tagText").val()); - formData.append("title", $("#title").val()); - formData.append("summary", $("#summary").val()); + let fetchPromise; + + if (Edit.isTank) { + const metadata = { + name: $("#title").val(), + summary: $("#summary").val(), + tags: $("#tagText").val(), + }; + const archives = $("#tank-archive-list li").map((_, el) => $(el).data("id")).get(); + Server.callAPIBody(`api/tankoubons/${id}`, "PUT", JSON.stringify({ metadata, archives }), + I18N.EditMetadataSaved, + I18N.TankoubonEditError, null) + .finally(() => { + Edit.showTags(); + }); - return fetch(new LRR.ApiURL(`/api/archives/${id}/metadata`), { method: "PUT", body: formData }) - .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) - .then((data) => { - if (data.success) { - LRR.toast({ - heading: I18N.EditMetadataSaved, - icon: "success", - }); - } else { - throw new Error(data.message); - } - }) - .catch((error) => LRR.showErrorToast(I18N.EditMetadataError, error)) - .finally(() => { - Edit.showTags(); - }); + } else { + const formData = new FormData(); + formData.append("tags", $("#tagText").val()); + formData.append("title", $("#title").val()); + formData.append("summary", $("#summary").val()); + Server.callAPIBody(`api/archives/${id}/metadata`, "PUT", formData, + I18N.EditMetadataSaved, + I18N.EditMetadataError, null) + .finally(() => { + Edit.showTags(); + }); + } }; Edit.deleteArchive = function () { + const confirmText = Edit.isTank ? I18N.ConfirmTankoubonDeletion : I18N.ConfirmArchiveDeletion; LRR.showPopUp({ - text: I18N.ConfirmArchiveDeletion, + text: confirmText, icon: "warning", showCancelButton: true, focusConfirm: false, @@ -201,7 +275,12 @@ Edit.deleteArchive = function () { confirmButtonColor: "#d33", }).then((result) => { if (result.isConfirmed) { - Server.deleteArchive($("#archiveID").val(), () => { document.location.href = "./"; }); + const id = $("#archiveID").val(); + if (Edit.isTank) { + Server.deleteTankoubon(id, () => { document.location.href = "./"; }); + } else { + Server.deleteArchive(id, () => { document.location.href = "./"; }); + } } }); }; diff --git a/public/js/mod/common.js b/public/js/mod/common.js index 0171dfa30..9550288c5 100644 --- a/public/js/mod/common.js +++ b/public/js/mod/common.js @@ -206,8 +206,8 @@ export function splitTagsByNamespace(tags) { } tags.split(/,\s?/).forEach((tag) => { - let nspce = null; - let val = null; + let nspce; + let val; // Split the tag from its namespace const arr = namespaceRegex.exec(tag); @@ -328,8 +328,8 @@ export function buildThumbnailDiv(data, tagTooltip = true) { const bookmarkIcon = buildBookmarkIconElement(id, "thumbnail-bookmark-icon"); const thumbSrc = id.startsWith("TANK_") - ? new ApiURL(`/api/tankoubons/${id}/thumbnail?no_fallback=true`) - : new ApiURL(`/api/archives/${id}/thumbnail?no_fallback=true`); + ? new ApiURL(`/api/tankoubons/${id}/thumbnail?no_fallback=true`) + : new ApiURL(`/api/archives/${id}/thumbnail?no_fallback=true`); // Don't enforce no_fallback=true here, we don't want those divs to trigger Minion jobs return `
    @@ -405,15 +405,40 @@ export function buildPageCountDiv(arcdata) { return ""; } -export function buildChapterObject(toc, totalpages) { +export function buildTankChapters(archiveData, pageOffset) { + const archiveChapter = { + id: archiveData.arcid, + chapters: [], + name: archiveData.title, + startPage: pageOffset + 1, + endPage: pageOffset + (archiveData.pagecount || 0) + }; + + // If there's a ToC, recursively build sub-chapters + if (archiveData.toc && archiveData.toc.length > 0) { + archiveChapter.chapters = buildArchiveChapters(archiveData.toc, archiveData.arcid, archiveData.pagecount); + // Adjust sub-chapter page numbers to tank coordinates + archiveChapter.chapters.forEach(ch => { + ch.startPage += pageOffset; + ch.endPage += pageOffset; + }); + } + + return [archiveChapter]; +} + +export function buildArchiveChapters(toc, id, totalpages) { const chapters = []; - if (toc.length === 0) { + + if (!toc || toc.length === 0) { return chapters; } if (toc[0].page > 1) { // Fill in gap before first chapter chapters.push({ + id: id, + chapters: null, name: I18N.UntitledChapter, startPage: 1, endPage: toc[0].page - 1, @@ -433,6 +458,8 @@ export function buildChapterObject(toc, totalpages) { } chapters.push({ + id: id, + chapters: null, name: entry.name, startPage: entry.page, endPage: null, // to be filled in later @@ -459,13 +486,9 @@ export function getProgress(arcdata) { const id = arcdata.arcid; const pagecount = parseInt(arcdata.pagecount || 0, 10); - let progress = -1; - - if (isProgressLocal && !(isProgressAuthenticated && isUserLogged())) { - progress = parseInt(localStorage.getItem(`${id}-reader`) || 0, 10); - } else { - progress = parseInt(arcdata.progress || 0, 10); - } + let progress = (isProgressLocal && !(isProgressAuthenticated && isUserLogged())) + ? parseInt(localStorage.getItem(`${id}-reader`) || 0, 10) + : parseInt(arcdata.progress || 0, 10); return { progress, pagecount }; } diff --git a/public/js/mod/index.js b/public/js/mod/index.js index 060bfecfe..03ec8ff89 100644 --- a/public/js/mod/index.js +++ b/public/js/mod/index.js @@ -662,10 +662,10 @@ export function addArchiveToSelection(data) { // Uses event delegation so it works even with virtual slides $(document).off(`click.msm-carousel-${id}`).on(`click.msm-carousel-${id}`, `#${id}.swiper-slide`, function (e) { - if (!isMultiSelectMode) return; - e.preventDefault(); - toggleArchiveSelection(id); - }); + if (!isMultiSelectMode) return; + e.preventDefault(); + toggleArchiveSelection(id); + }); } /** @@ -736,9 +736,9 @@ export function updateSelectionCount() { if (LRR.isUserLogged()) { $("#msm-batch-ops").show(); - // Don't show merge option if more than 2 tanks are in the selection + // Don't show merge option if more than 1 tank is in the selection const tankCount = [...selectedArchives].filter((id) => id.startsWith("TANK_")).length; - if (tankCount <= 2) + if (tankCount < 2) $("#msm-merge").show(); else $("#msm-merge").hide(); @@ -790,7 +790,18 @@ function mergeSelectionIntoTankoubon() { if (tankIds.length === 1) { // Fold non-tank archives into the existing tankoubon - addArchivesToTank(tankIds[0], archiveIds); + const tankId = tankIds[0]; + Server.callAPI(`/api/tankoubons/${tankId}`, "GET", null, I18N.MSMMergeError, (data) => { + const tankName = data.result.name; + LRR.showPopUp({ + text: I18N.MSMMergeExistingConfirmText(archiveIds.length, tankName), + showCancelButton: true, + reverseButtons: true, + }).then((result) => { + if (!result.isConfirmed) return; + addArchivesToTank(tankId, archiveIds); + }); + }); } else { // Prompt for a name and create a new tankoubon LRR.showPopUp({ @@ -954,8 +965,15 @@ export function migrateProgress() { const promises = []; localProgressKeys.forEach((id) => { const progress = localStorage.getItem(`${id}-reader`); + const metadataUrl = id.startsWith("TANK_") ? + new LRR.ApiURL(`api/tankoubons/${id}`) : + new LRR.ApiURL(`api/archives/${id}/metadata`); + + const progressUrl = id.startsWith("TANK_") ? + `api/tankoubons/${id}/progress/${progress}?force=1` : + `api/archives/${id}/progress/${progress}?force=1`; - promises.push(fetch(new LRR.ApiURL(`api/archives/${id}/metadata`), { method: "GET" }) + promises.push(fetch(metadataUrl), { method: "GET" }) .then((response) => response.json()) .then((data) => { // Don't migrate if the server progress is already further @@ -963,13 +981,13 @@ export function migrateProgress() { && data !== undefined && data !== null && progress > data.progress) { - Server.callAPI(`api/archives/${id}/progress/${progress}?force=1`, "PUT", null, I18N.LocalProgressionError, null); + Server.callAPI(progressUrl, "PUT", null, I18N.LocalProgressionError, null); } // Clear out localStorage'd progress localStorage.removeItem(`${id}-reader`); localStorage.removeItem(`${id}-totalPages`); - })); + }); }); Promise.all(promises).then(() => LRR.toast({ @@ -1002,9 +1020,10 @@ export function handleContextMenu(option, id) { case "edit-tank": LRR.openInNewTab(new LRR.ApiURL(`/tankoubon?arcid=${id}`)); break; - case "delete": + case "delete": { + const isTank = id.startsWith("TANK_"); LRR.showPopUp({ - text: I18N.ConfirmArchiveDeletion, + text: isTank ? I18N.ConfirmTankoubonDeletion : I18N.ConfirmArchiveDeletion, icon: "warning", showCancelButton: true, focusConfirm: false, @@ -1013,10 +1032,16 @@ export function handleContextMenu(option, id) { confirmButtonColor: "#d33", }).then((result) => { if (result.isConfirmed) { - Server.deleteArchive(id, () => { document.location.reload(true); }); + if (isTank) Server.deleteTankoubon(id, () => { + document.location.reload(true); + }); + else Server.deleteArchive(id, () => { + document.location.reload(true); + }); } }); break; + } case "read": LRR.openInNewTab(new LRR.ApiURL(`/reader?id=${id}`)); break; @@ -1076,7 +1101,7 @@ export function loadCategories() { type='button' id='NEW_ONLY' value='🆕 ${I18N.NewArchives}' onclick='window.Index.toggleCategory(this)' title='${I18N.NewArchiveDesc}'/>
    -
    `; diff --git a/public/js/mod/index_datatables.js b/public/js/mod/index_datatables.js index f5569a2e7..636b762d0 100644 --- a/public/js/mod/index_datatables.js +++ b/public/js/mod/index_datatables.js @@ -196,7 +196,7 @@ export function renderTitle(data, type) { return `${LRR.buildStatusDiv(data)}${LRR.buildPageCountDiv(data)}${bookmarkIcon} ${LRR.encodeHTML(data.title)} diff --git a/public/js/mod/server.js b/public/js/mod/server.js index 61e012556..a073efb78 100644 --- a/public/js/mod/server.js +++ b/public/js/mod/server.js @@ -19,7 +19,7 @@ let isScriptRunning = false; export function callAPI(endpoint, method, successMessage, errorMessage, successCallback) { let endpointUrl = new LRR.ApiURL(endpoint); return fetch(endpointUrl, { method }) - .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) + .then((response) => response.json()) .then((data) => { if (Object.prototype.hasOwnProperty.call(data, "success") && !data.success) { throw new Error(data.error); @@ -47,7 +47,7 @@ export function callAPI(endpoint, method, successMessage, errorMessage, successC export function callAPIBody(endpoint, method, body, successMessage, errorMessage, successCallback) { let endpointUrl = new LRR.ApiURL(endpoint); return fetch(endpointUrl, { method, body }) - .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) + .then((response) => response.json()) .then((data) => { if (Object.prototype.hasOwnProperty.call(data, "success") && !data.success) { throw new Error(data.error); @@ -84,7 +84,7 @@ export function callAPIBody(endpoint, method, body, successMessage, errorMessage export function checkJobStatus(jobId, useDetail, callback, failureCallback, progressCallback = null) { let endpoint = new LRR.ApiURL(useDetail ? `/api/minion/${jobId}/detail` : `/api/minion/${jobId}`); fetch(endpoint, { method: "GET" }) - .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) + .then((response) => response.json()) .then((data) => { if (data.error) throw new Error(data.error); @@ -130,7 +130,7 @@ export function saveFormData(formSelector) { const postData = new FormData($(formSelector)[0]); return fetch(window.location.href, { method: "POST", body: postData }) - .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) + .then((response) => response.json()) .then((data) => { if (data.success) { LRR.toast({ @@ -303,7 +303,7 @@ export function removeArchiveFromCategory(arcId, catId) { export function deleteArchive(arcId, callback) { let endpoint = new LRR.ApiURL(`/api/archives/${arcId}`); return fetch(endpoint, { method: "DELETE" }) - .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) + .then((response) => response.json()) .then((data) => { if (!data.success) { LRR.toast({ @@ -327,6 +327,17 @@ export function deleteArchive(arcId, callback) { .catch((error) => LRR.showErrorToast(I18N.ArchiveDeletedError, error)); } +/** + * Deletes a Tankoubon by ID. The archives remain in the library. + * @param {string} id Tankoubon ID + * @param {Function} callback Called after successful deletion + */ +export function deleteTankoubon(id, callback) { + return callAPI(`/api/tankoubons/${id}`, "DELETE", I18N.TankoubonDeleted, I18N.TankoubonDeleteError, + () => { setTimeout(callback, 1500); } + ); +} + /** * Sends a UPDATE request for the metadata of the archive ID * @param {string} arcId Archive ID @@ -356,7 +367,11 @@ export function loadBookmarkCategoryId() { * @param {number} currentPage Page the user navigated to */ export function updateServerSideProgress(id, currentPage) { - let endpointUrl = new LRR.ApiURL(`/api/archives/${id}/progress/${currentPage}`); + + let endpointUrl = id.startsWith("TANK_") ? + new LRR.ApiURL(`/api/tankoubons/${id}/progress/${currentPage}`) : + new LRR.ApiURL(`/api/archives/${id}/progress/${currentPage}`); + return fetch(endpointUrl, { method: "PUT" }) .then((response) => (response.ok ? {code: response.status, data: response.json()} : { code: response.status, data: {success: 0, error: I18N.GenericReponseError} })) .then((response) => { diff --git a/public/js/reader.js b/public/js/reader.js index aa803c71f..5433527d0 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -97,9 +97,10 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { }); $(document).on("click.edit-metadata", "#edit-archive", () => LRR.openInNewTab(new LRR.ApiURL(`/edit?id=${id}`))); $(document).on("click.delete-archive", "#delete-archive", () => { + const isTank = id.startsWith("TANK_"); LRR.closeOverlay(); LRR.showPopUp({ - text: I18N.ConfirmArchiveDeletion, + text: isTank ? I18N.ConfirmTankoubonDeletion : I18N.ConfirmArchiveDeletion, icon: "warning", showCancelButton: true, focusConfirm: false, @@ -108,7 +109,8 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { confirmButtonColor: "#d33", }).then((result) => { if (result.isConfirmed) { - Server.deleteArchive(id, () => { document.location.href = "./"; }); + if (isTank) Server.deleteTankoubon(id, () => { document.location.href = "./"; }); + else Server.deleteArchive(id, () => { document.location.href = "./"; }); } }); }); @@ -150,8 +152,14 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { $(document).on("click.set-thumbnail", ".set-thumbnail", (e) => { const pageNumber = +$(e.target).closest("div[page]").attr("page") + 1; - Server.callAPI(`/api/archives/${id}/thumbnail?page=${pageNumber}`, - "PUT", I18N.ReaderUpdateThumbnail(pageNumber), I18N.ReaderUpdateThumbnailError, null); + + if (id.startsWith("TANK_")) { + Server.callAPI(`/api/tankoubons/${id}/thumbnail?page=${pageNumber}`, + "PUT", I18N.ReaderUpdateThumbnail(pageNumber), I18N.ReaderUpdateThumbnailError, null); + } else { + Server.callAPI(`/api/archives/${id}/thumbnail?page=${pageNumber}`, + "PUT", I18N.ReaderUpdateThumbnail(pageNumber), I18N.ReaderUpdateThumbnailError, null); + } // Stop event propagation to avoid going to page e.stopPropagation(); @@ -167,6 +175,7 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { if (!markerMode) return; $(".reader-image").css("cursor", ""); + $(".reader-image").css("z-index", 19); // Compute marker position // This basically estimates the percentage of the width and legth of the image @@ -210,15 +219,16 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { }).then((result) => { $("#overlay-page").hide(); markerMode = false; - //toggleArchiveOverlay(); if (result.isConfirmed && result.value.trim() !== "") { - Server.callAPI(`/api/archives/${id}/stamps/${page}?position=${markerData.x},${markerData.y}&content=${result.value}`, "PUT", "Stamp added!", I18N.StampError, + const { arcId, localPage } = getArchiveForPage(page); + Server.callAPI(`/api/archives/${arcId}/stamps/${localPage}?position=${markerData.x},${markerData.y}&content=${result.value}`, "PUT", "Stamp added!", I18N.StampError, (data) => { markerData.id = data["stamp_id"]; markerData.name = result.value; markers.push(markerData); renderMarkers(); + checkStampedPages(); } ); } else { @@ -237,6 +247,7 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { renderMarkers(); pageNaviState = true; $(".reader-image").css("cursor", ""); + $(".reader-image").css("z-index", 19); } }); $(document).on("click.filter-stamped", "#filter-stamped", filterStampedOverlay); @@ -257,8 +268,9 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { force = params.get("force_reload") !== null; currentPage = (+params.get("p") || 1) - 1; - // Remove the "new" tag with an api call - Server.callAPI(`/api/archives/${id}/isnew`, "DELETE", null, I18N.ReaderErrorClearingNew, null); + // Remove the "new" tag with an api call (archives only; tanks don't have an isnew flag) + if (!id.startsWith("TANK_")) + Server.callAPI(`/api/archives/${id}/isnew`, "DELETE", null, I18N.ReaderErrorClearingNew, null); // Load metadata for the requested ID and populate the page loadContentData().then(() => { @@ -286,15 +298,15 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { $("#tagContainer").append(LRR.buildTagsDiv(content.tags)); - const ratyEl = document.querySelector('[data-raty]'); + const ratyEl = document.querySelector(`[data-raty]`); if (ratyEl) { const rating = LRR.splitTagsByNamespace(content.tags).rating?.at(0).length; new Raty(ratyEl, { - starType: 'i', + starType: `i`, cancelButton: true, - cancelClass: 'fas fa-trash raty-cancel', + cancelClass: `fas fa-trash raty-cancel`, cancelHint: I18N.ReaderClearRating, - cancelPlace: 'right', + cancelPlace: `right`, score: rating, click: function(score, element, evt) { @@ -333,7 +345,7 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { export function loadContentData() { // Initialize content object to hold metadata -- This is a recursive object that will be used to build the page overlay. - // (For tanks, content.chapters will hold an array of archive IDs, for archives it'll hold TOC data.) + // (For tanks, content.chapters will hold archive chapters that can themselves contain nested chapters from ToCs) content = { id: id, title: "", @@ -343,37 +355,81 @@ export function loadContentData() { summary: "" }; + const updateProgress = function(data, id) { + // Use localStorage progress value instead of the server one if needed + if (state.trackProgressLocally && !(state.authenticateProgress && LRR.isUserLogged())) { + progress = localStorage.getItem(`${id}-reader`) - 1 || 0; + } else { + progress = data.progress - 1; + } + } + // If the ID is a Tank ID (TANK_xxxx), use the Tankoubon API for metadata if (id.startsWith("TANK_")) { - // TODO + return fetch(new LRR.ApiURL(`/api/tankoubons/${id}?include_full_data=true&page=-1`)) + .then(r => r.ok ? r.json() : Promise.reject(new Error(I18N.ServerInfoError))) + .then(data => { + const tank = data.result; + content.title = tank.name; + content.tags = tank.tags || ""; + content.summary = tank.summary || ""; + + content.chapters = []; + + // full_data contains pre-fetched metadata for every archive in order + const fullData = tank.full_data || []; + // Cumulative offset as we iterate through the arclist + let pageOffset = 0; + + fullData.forEach(meta => { + if (!meta) return; + + // Create archive chapter (with nested ToC chapters if present) + const archiveChapters = LRR.buildTankChapters(meta, pageOffset); + content.chapters.push(...archiveChapters); + + pageOffset += meta.pagecount || 0; + }); + + content.pages = pageOffset; + updateProgress(tank, id); + }) + .catch(err => LRR.showErrorToast(I18N.ServerInfoError, err)); } - else return Server.callAPI(`/api/archives/${id}/metadata`, "GET", null, I18N.ServerInfoError, - (data) => { - let { title } = data; - content.title = title; + return Server.callAPI(`/api/archives/${id}/metadata`, "GET", null, I18N.ServerInfoError, + (data) => { + content.title = data.title; content.pages = data.pagecount; content.tags = data.tags; content.summary = data.summary; - // Use localStorage progress value instead of the server one if needed - if (state.trackProgressLocally && !(state.authenticateProgress && LRR.isUserLogged())) { - progress = localStorage.getItem(`${id}-reader`) - 1 || 0; - } else { - progress = data.progress - 1; - } + updateProgress(data, id); if (data.toc) - content.chapters = LRR.buildChapterObject(data.toc, data.pagecount); + content.chapters = LRR.buildArchiveChapters(data.toc, id, data.pagecount); // Check and display warnings for unsupported filetypes checkFiletypeSupport(data.extension); - } ); } +/** + * For Tank mode: map a page number to the archive it belongs to and said archive's local page number. + * @param {number} globalPage global page number + * @returns {{ arcId: string, localPage: number }} + */ +function getArchiveForPage(globalPage) { + if (id.startsWith("TANK_")) { + const arc = content.chapters.find(a => globalPage >= a.startPage && globalPage <= a.endPage); + if (arc) + return { arcId: arc.id, localPage: globalPage - arc.startPage + 1 }; + } + return { arcId: id, localPage: globalPage }; +}; + /** * Adds a removable category flag to the categories section within archive overview. */ @@ -407,7 +463,8 @@ export function addTocSection(page, currentTitle = null) { reverseButtons: true, }).then((result) => { if (result.isConfirmed && result.value.trim() !== "") { - Server.callAPI(`/api/archives/${id}/toc?page=${page}&title=${result.value}`, "PUT", "Chapter added!", I18N.ReaderTocError, + const { arcId, localPage } = getArchiveForPage(page); + Server.callAPI(`/api/archives/${arcId}/toc?page=${localPage}&title=${result.value}`, "PUT", "Chapter added!", I18N.ReaderTocError, () => loadContentData().then(() => { updateArchiveOverlay(true); toggleArchiveOverlay(); @@ -433,8 +490,8 @@ export function removeTocSection() { confirmButtonColor: "#d33", }).then((result) => { if (result.isConfirmed) { - let page = currentChapter.startPage; - Server.callAPI(`/api/archives/${id}/toc?page=${page}`, "DELETE", "Chapter removed!", I18N.ReaderTocError, + const { arcId, localPage } = getArchiveForPage(currentChapter.startPage); + Server.callAPI(`/api/archives/${arcId}/toc?page=${localPage}`, "DELETE", "Chapter removed!", I18N.ReaderTocError, () => loadContentData().then(() => { updateArchiveOverlay(true); toggleArchiveOverlay(); @@ -447,65 +504,84 @@ export function removeTocSection() { } export function loadImages() { - Server.callAPI(`/api/archives/${id}/files?force=${force}`, "GET", null, I18N.ReaderArchiveError, - (data) => { - pages = data.pages; - maxPage = pages.length - 1; - $(".max-page").html(pages.length); - - // Choices in order for page picking: - // * p is in parameters and is not the first page - // * progress is tracked and is not the last page - // * first page - // This allows for bookmarks to trump progress - // when there's no parameter, null is coerced to 0 so it becomes -1 - currentPage = currentPage || ( - !ignoreProgress && progress < maxPage - ? progress - : 0 - ); - if (infiniteScroll) { - initInfiniteScrollView(); - if (content.tags?.includes("webtoon")) { - $("head").append(` - - `); - } - } else { - $("#img").on("load", updateMetadata); - - // 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") && pageNaviState) { - // is click X position is left on screen or right - if (event.pageX < $(window).width() / 2) { - changePage(-1, true); - } else { - changePage(1, true); + const onLoad = (data) => { + pages = data; + maxPage = pages.length - 1; + $(".max-page").html(pages.length); + + // Choices in order for page picking: + // * p is in parameters and is not the first page + // * progress is tracked and is not the last page + // * first page + // This allows for bookmarks to trump progress + // when there's no parameter, null is coerced to 0 so it becomes -1 + currentPage = currentPage || ( + !ignoreProgress && progress < maxPage + ? progress + : 0 + ); + + if (infiniteScroll) { + initInfiniteScrollView(); + if (content.tags?.includes("webtoon")) { + $("head").append(` + + `); + } + } else { + $("#img").on("load", updateMetadata); + + // 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") && pageNaviState) { + // is click X position is left on screen or right + if (event.pageX < $(window).width() / 2) { + changePage(-1, true); + } else { + changePage(1, true); } - }); + } + }); - $(".current-page").each((_i, el) => $(el).html(currentPage + 1)); - goToPage(currentPage); - } + $(".current-page").each((_i, el) => $(el).html(currentPage + 1)); + goToPage(currentPage); + } - if (showOverlayByDefault) { toggleArchiveOverlay(); } - }, - ).finally(() => { + if (showOverlayByDefault) { toggleArchiveOverlay(); } + }; + + const onFinally = () => { if (pages === undefined) { $("#img").attr("src", new LRR.ApiURL("/img/flubbed.gif").toString()); $("#display").append(`

    ${I18N.ReaderArchiveError}

    `); } generateThumbnails(); - }); + }; + + if (id.startsWith("TANK_")) { + // For tanks: fetch pages for each archive and concatenate them + Promise.all( + content.chapters.map(arc => + fetch(new LRR.ApiURL(`/api/archives/${arc.id}/files?force=${force}`)) + .then(r => r.ok ? r.json() : Promise.reject()) + ) + ).then(results => { + onLoad(results.flatMap(r => r.pages)); + }).catch(() => LRR.showErrorToast(I18N.ReaderArchiveError)) + .finally(onFinally); + } + else { + Server.callAPI(`/api/archives/${id}/files?force=${force}`, "GET", null, I18N.ReaderArchiveError, + (data) => onLoad(data.pages), + ).finally(onFinally); + } } export function initializeSettings() { @@ -701,7 +777,9 @@ function handleShortcuts(e) { document.location.href = new LRR.ApiURL("/random"); break; case 83: // s - addStamp(); + if (!infiniteScroll) { + addStamp(); + } break; default: break; @@ -849,18 +927,20 @@ function toggleHelp() { } function addStamp() { + if (infiniteScroll) return; markerMode = true; clearMarkers(); $(".reader-image").css("cursor", "cell"); + $(".reader-image").css("z-index", 22); $("#overlay-page").show(); } function createMarkerElement(markerData, index) { - if (markerData.left) { - const img = document.getElementById("img"); - } else { - const img = document.getElementById("img_doublepage"); - } + if (infiniteScroll) return; + const img = markerData.left + ? document.getElementById("img") + : document.getElementById("img_doublepage"); + const display = document.getElementById("display"); const container = document.getElementById("i1"); @@ -960,6 +1040,7 @@ function createMarkerElement(markerData, index) { } function renderMarkers() { + if (infiniteScroll) return; // Clean markers const existing = document.querySelectorAll(".marker"); existing.forEach(el => el.remove()); @@ -984,14 +1065,14 @@ function toggleStamps() { } function loadStamps(currentPage) { + if (infiniteScroll) return; markers = []; + const { arcId: id1, localPage: p1 } = getArchiveForPage(currentPage); // Call for the first page - Server.callAPI(`/api/archives/${id}/stamps/${currentPage}`, "GET", null, I18N.ServerInfoError, + Server.callAPI(`/api/archives/${id1}/stamps/${p1}`, "GET", null, I18N.ServerInfoError, (data) => { - let markerData = {}; - for (var i = data.result.length - 1; i >= 0; i--) { - markerData = {}; + let markerData = {}; let x = data.result[i].position.split(",")[0]; let y = data.result[i].position.split(",")[1]; markerData.x = x; @@ -1005,13 +1086,12 @@ function loadStamps(currentPage) { if (doublePageMode && currentPage > 0 && currentPage < maxPage) { - // Call for the second page - Server.callAPI(`/api/archives/${id}/stamps/${currentPage+1}`, "GET", null, I18N.ServerInfoError, + const { arcId: id2, localPage: p2 } = getArchiveForPage(currentPage + 1); + // Call for the second page (may be in a different archive for tanks) + Server.callAPI(`/api/archives/${id2}/stamps/${p2}`, "GET", null, I18N.ServerInfoError, (data) => { - let markerData = {}; - for (var i = data.result.length - 1; i >= 0; i--) { - markerData = {}; + let markerData = {}; let x = data.result[i].position.split(",")[0]; let y = data.result[i].position.split(",")[1]; markerData.x = x; @@ -1035,17 +1115,18 @@ function loadStamps(currentPage) { } function handleMarkerContextMenu(option, index) { + if (infiniteScroll) return; let i = parseInt(index); switch (option) { - case "editmarker": + case "editmarker": { let emarker = markers[i]; let inputValue = emarker.name; LRR.showPopUp({ title: I18N.StampName, input: "text", - inputPlaceholder: I18N.StampPlaceholder, + inputPlaceholder: I18N.StampPlaceholder, inputAttributes: { autocapitalize: "off", }, @@ -1067,15 +1148,20 @@ function handleMarkerContextMenu(option, index) { } }); break; - case "deletemarker": + } + case "deletemarker": { let dmarker = markers[i]; Server.callAPI(`/api/stamps/${dmarker.id}`, "DELETE", "Stamp deleted!", I18N.StampError, () => { markers.splice(i, 1); renderMarkers(); + if (markers.length == 0) { + checkStampedPages(); + } } ); break; + } default: break; } @@ -1270,18 +1356,21 @@ function updateProgress() { // Clear markers markers = []; renderMarkers(); + + let page = currentPage + 1; // progress is 1-indexed + // Send an API request to update progress on the server if (state.authenticateProgress && LRR.isUserLogged()) { - Server.updateServerSideProgress(id, currentPage + 1); + Server.updateServerSideProgress(id, page); } else if (state.trackProgressLocally) { - localStorage.setItem(`${id}-reader`, currentPage + 1); + localStorage.setItem(`${id}-reader`, page); } else if (!state.authenticateProgress) { - Server.updateServerSideProgress(id, currentPage + 1); + Server.updateServerSideProgress(id, page); } // Load stamps if (!infiniteScroll) { - const stamps = loadStamps(currentPage + 1); + const stamps = loadStamps(page); } } @@ -1421,6 +1510,7 @@ function toggleProgressTracking() { } function toggleInfiniteScroll() { + clearMarkers(); infiniteScroll = localStorage.infiniteScroll = !infiniteScroll; $("#toggle-infinite-scroll input").toggleClass("toggled"); window.location.reload(); @@ -1534,17 +1624,24 @@ function handleFullScreen(enableFullscreen = false) { } function getCurrentChapter() { - let currentChapter = null; + return findChapterForPage(currentPage + 1, content.chapters); +} + +// Find the current chapter (or nested sub-chapter) for the given page. +function findChapterForPage(page, chapters) { + if (!chapters) return null; - if (content.chapters) { - content.chapters.forEach((chapter) => { - if (currentPage + 1 >= chapter.startPage && - currentPage + 1 <= chapter.endPage) { - currentChapter = chapter; + for (const chapter of chapters) { + if (page >= chapter.startPage && page <= chapter.endPage) { + // Check if there's a more specific nested chapter + if (chapter.chapters && chapter.chapters.length > 0) { + const nested = findChapterForPage(page, chapter.chapters); + if (nested) return nested; } - }); + return chapter; + } } - return currentChapter; + return null; } function updateArchiveOverlay(forceUpdate = false) { @@ -1561,6 +1658,12 @@ function updateArchiveOverlay(forceUpdate = false) { } } + // Reset stamp filter state when the overlay is rebuilt for a new chapter + if (overlayFiltered) { + overlayFiltered = false; + $("#filter-stamped").removeClass("toggled"); + } + // Otherwise, update chapter and overlay -- If there are no chapters defined, just show all pages currentChapter = getCurrentChapter(); let firstPage = currentChapter ? currentChapter.startPage : 1; @@ -1572,14 +1675,21 @@ function updateArchiveOverlay(forceUpdate = false) { // Create `; if (content.chapters) { - content.chapters.forEach((chapter, index) => { + content.chapters.forEach((chapter) => { const selected = (currentChapter && chapter.startPage === currentChapter.startPage) ? "selected" : ""; chapterOptions += ``; + + if (chapter.chapters && chapter.chapters.length > 0) { + chapter.chapters.forEach((subChapter) => { + const subSelected = (currentChapter && subChapter.startPage === currentChapter.startPage) ? "selected" : ""; + chapterOptions += ``; + }); + } }); } chapterOptions += ``; - if (LRR.isUserLogged() ) + if (LRR.isUserLogged() && currentChapter.chapters === null ) // Only show edit/delete options for leaf chapters chapterOptions += ` `; @@ -1598,7 +1708,8 @@ function updateArchiveOverlay(forceUpdate = false) { const index = page - 1; const thumbCss = (localStorage.cropthumbs === "true") ? "id3" : "id3 nocrop"; - const thumbnailUrl = new LRR.ApiURL(`/api/archives/${id}/thumbnail?page=${page}`); + const { arcId, localPage } = getArchiveForPage(page); + const thumbnailUrl = new LRR.ApiURL(`/api/archives/${arcId}/thumbnail?page=${localPage}`); let thumbnail = `
    @@ -1629,15 +1740,19 @@ function updateArchiveOverlay(forceUpdate = false) { } function checkStampedPages() { - Server.callAPI(`/api/archives/${id}/stamps/`, "GET", null, I18N.ServerInfoError, + const { arcId, localPage } = getArchiveForPage(currentPage + 1); + Server.callAPI(`/api/archives/${arcId}/stamps/`, "GET", null, I18N.ServerInfoError, (data) => { $("#extract-spinner").hide(); + cleanStampedPages(); let pages = data.result.sort(); let elements = $("div.id3.quick-thumbnail"); for (let element of elements) { let page = parseInt(element.getAttribute("page")); - if (pages.includes((page+1).toString())) { + const { _, localPage } = getArchiveForPage(page+1); + + if (pages.includes((localPage).toString())) { element.dataset.stamped = true; } } @@ -1645,19 +1760,29 @@ function checkStampedPages() { ); } +function cleanStampedPages() { + let elements = $("div.id3.quick-thumbnail[data-stamped=true]"); + + for (let element of elements) { + delete element.dataset.stamped; + } +} + function filterStampedOverlay() { let elements = $("div.id3.quick-thumbnail"); if (overlayFiltered) { overlayFiltered = false; + $("#filter-stamped").removeClass("toggled"); for (let element of elements) { - element.style.display = 'inline-block'; + element.style.display = `inline-block`; } } else { overlayFiltered = true; + $("#filter-stamped").addClass("toggled"); for (let element of elements) { if (!element.dataset.stamped) { - element.style.display = 'none'; + element.style.display = `none`; } } } @@ -1665,20 +1790,25 @@ function filterStampedOverlay() { function generateThumbnails() { - // Queue a single minion job for thumbnails and check on its progress regularly + // Function to evaluate Minion job progress and update thumbnails as they are generated const thumbProgress = function (notes) { - if (notes.total_pages === undefined) { return; } + if (notes.total_pages === undefined || notes.id === undefined) { return; } // Look at all the numbered keys in notes, aka notes.1, notes.2.. for (let i = 1; i <= notes.total_pages; i++) { if (Object.hasOwn(notes, i) && notes[i] === "processed") { - const index = i - 1; + + const startPage = id.startsWith("TANK_") ? + content.chapters.find(ch => ch.arcId === notes.id).startPage : + 1; + + const index = startPage + i - 2; // 0-based global pageThumbnails.push(index); // Live-update the page thumbnail in the overlay if it's visible if ($(`#${index}_spinner`).attr("loaded") !== "true") { // Set image source to the thumbnail - const thumbnailUrl = new LRR.ApiURL(`/api/archives/${id}/thumbnail?page=${i}&cachebust=${Date.now()}`); + const thumbnailUrl = new LRR.ApiURL(`/api/archives/${notes.id}/thumbnail?page=${i}&cachebust=${Date.now()}`); $(`#${index}_thumb`).attr("src", thumbnailUrl); $(`#${index}_spinner`).attr("loaded", true); $(`#${index}_spinner`).hide(); @@ -1687,25 +1817,38 @@ function generateThumbnails() { } }; - fetch(new LRR.ApiURL(`/api/archives/${id}/files/thumbnails`), { method: "POST" }) - .then((response) => { - if (response.status === 200) { - // Thumbnails are already generated, there's nothing to do. Very nice! - pageThumbnails = [...Array(pages.length).keys()]; - $(".ttspinner").hide(); - return; - } - if (response.status === 202) { - // Check status and update progress - response.json().then((data) => Server.checkJobStatus( - data.job, - false, - (data) => thumbProgress(data.notes), // call progress callback one last time to ensure all thumbs are loaded - () => LRR.showErrorToast(I18N.ThumbJobError), - thumbProgress, - )); - } - }); + const fetchThumbsForArc = function(arc) { + fetch(new LRR.ApiURL(`/api/archives/${arc.id}/files/thumbnails`), { method: "POST" }) + .then(response => { + if (response.status === 200) { + // Thumbnails are already generated, there's nothing to do. Very nice! + for (let idx = arc.startPage - 1; idx < arc.endPage; idx++) { + pageThumbnails.push(idx); + } + $(".ttspinner").hide(); + return; + } + if (response.status === 202) { + // Check status and update progress + response.json().then((data) => Server.checkJobStatus( + data.job, + false, + (data) => thumbProgress(data.notes), // call progress callback one last time to ensure all thumbs are loaded + () => LRR.showErrorToast(I18N.ThumbJobError), + thumbProgress, + )); + } + }); + }; + + if (id.startsWith("TANK_")) + content.chapters.forEach(arc => fetchThumbsForArc(arc)); // Generate thumbnails per archive + else + fetchThumbsForArc({ + id: id, + startPage: 1, + endPage: content.pages, + }); // Queue a single minion job for thumbnails } /** @@ -1793,7 +1936,7 @@ window.addEventListener("resize", () => { jQuery(() => { $.contextMenu({ - selector: '.marker-context-menu', + selector: `.marker-context-menu`, build: ($trigger, e) => { e.preventDefault(); e.stopPropagation(); @@ -1808,4 +1951,4 @@ jQuery(() => { } } }); -}); \ No newline at end of file +}); diff --git a/public/js/upload.js b/public/js/upload.js index 9524fc22a..f900082cd 100644 --- a/public/js/upload.js +++ b/public/js/upload.js @@ -101,6 +101,7 @@ function handleCompletedUpload(jobID, d) { if (d.result.id) { $(`#${jobID}-name`).attr("href", new LRR.ApiURL(`/reader?id=${d.result.id}`)); $(`#${jobID}-link`).attr("href", new LRR.ApiURL(`/edit?id=${d.result.id}`)); + $(`#${jobID}-link`).attr("target", "_blank"); } if (d.result.success) { diff --git a/templates/batch.html.tt2 b/templates/batch.html.tt2 index 611987fcd..b8c0be000 100644 --- a/templates/batch.html.tt2 +++ b/templates/batch.html.tt2 @@ -199,13 +199,7 @@