From d2851cbc90ed3997c38bb702145f72ca4bbf8918 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 20:35:06 +0100 Subject: [PATCH 1/6] feat: reprocess existing .elpx attachments (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a safe way to (re)process .elpx attachments that already live in the Media Library but were never extracted — e.g. uploaded before the plugin was active, or stored through a flow such as Formidable Forms that bypasses the upload handler. Previously such attachments had no extraction directory or eXeLearning metadata, so the [exelearning] shortcode fell back to a download link instead of rendering the preview iframe. - Add ExeLearning_Reprocessor: a single home for the validate -> extract -> commit -> cleanup primitives, with reprocess(), needs_reprocessing() and candidate-query helpers. Extracts to a fresh directory before touching any metadata and removes the previous extraction only on success, so it is idempotent and leaves existing content intact on failure. - Refactor the REST API save/create flows to delegate to the reprocessor instead of duplicating the extraction logic. - Add a "Reprocess eXeLearning file" bulk action to the Media Library plus an admin notice summarising the result. - Add a WP-CLI command: wp exelearning reprocess --id= | --all | --force. - Expose the security .htaccess writer so reprocessed extraction roots are guarded even on sites that installed the plugin after files were uploaded. Tested via new ReprocessorTest and MediaLibraryReprocessTest covering every acceptance criterion (extraction + metadata, no-preview files, invalid files, idempotency, failure preserving the prior extraction, the shortcode rendering the iframe after reprocessing, and the candidate query). Closes #42 --- exelearning.php | 13 +- includes/class-cli-command.php | 106 +++++ includes/class-elp-reprocessor.php | 309 +++++++++++++++ includes/class-elp-upload-handler.php | 12 + includes/class-exelearning-rest-api.php | 75 +--- includes/integrations/class-media-library.php | 123 ++++++ tests/unit/MediaLibraryReprocessTest.php | 168 ++++++++ tests/unit/ReprocessorTest.php | 368 ++++++++++++++++++ 8 files changed, 1111 insertions(+), 63 deletions(-) create mode 100644 includes/class-cli-command.php create mode 100644 includes/class-elp-reprocessor.php create mode 100644 tests/unit/MediaLibraryReprocessTest.php create mode 100644 tests/unit/ReprocessorTest.php diff --git a/exelearning.php b/exelearning.php index 2afe03e..6b21d5d 100644 --- a/exelearning.php +++ b/exelearning.php @@ -64,6 +64,12 @@ // Integration classes. require_once EXELEARNING_PLUGIN_DIR . 'includes/integrations/class-media-library.php'; +// ELP File Service (validates, parses and extracts .elp files). +require_once EXELEARNING_PLUGIN_DIR . 'includes/class-elp-file-service.php'; + +// Reprocessor for existing attachments (reused by the REST API and entry points). +require_once EXELEARNING_PLUGIN_DIR . 'includes/class-elp-reprocessor.php'; + // Editor classes. require_once EXELEARNING_PLUGIN_DIR . 'includes/class-exelearning-editor.php'; require_once EXELEARNING_PLUGIN_DIR . 'includes/class-export-bootstrap.php'; @@ -71,9 +77,12 @@ require_once EXELEARNING_PLUGIN_DIR . 'includes/class-content-proxy.php'; require_once EXELEARNING_PLUGIN_DIR . 'includes/class-static-editor-installer.php'; -// ELP File Service (validates, parses and extracts .elp files). -require_once EXELEARNING_PLUGIN_DIR . 'includes/class-elp-file-service.php'; +// WP-CLI commands (batch reprocessing for large sites). +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once EXELEARNING_PLUGIN_DIR . 'includes/class-cli-command.php'; + WP_CLI::add_command( 'exelearning', 'ExeLearning_CLI_Command' ); +} // Register activation and deactivation hooks. register_activation_hook( __FILE__, array( 'ExeLearning_Activator', 'activate' ) ); diff --git a/includes/class-cli-command.php b/includes/class-cli-command.php new file mode 100644 index 0000000..5cf2449 --- /dev/null +++ b/includes/class-cli-command.php @@ -0,0 +1,106 @@ +` for batch maintenance on large sites. + * Registered only when WP-CLI is loaded (see exelearning.php). + */ +class ExeLearning_CLI_Command { + + /** + * (Re)process existing .elpx attachments so they become previewable. + * + * Useful when the plugin is installed after .elpx files were already + * uploaded, or when files were stored through a flow (e.g. Formidable Forms) + * that bypassed the upload handler. + * + * ## OPTIONS + * + * [--id=] + * : Reprocess a single attachment by ID. + * + * [--all] + * : Reprocess every .elpx attachment that still needs it (no extraction yet, + * or a missing extraction directory). + * + * [--force] + * : With --all, reprocess every .elpx attachment even if it already has a + * valid extraction. + * + * ## EXAMPLES + * + * wp exelearning reprocess --id=123 + * wp exelearning reprocess --all + * wp exelearning reprocess --all --force + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Associative arguments. + */ + public function reprocess( $args, $assoc_args ) { + unset( $args ); + + $reprocessor = new ExeLearning_Reprocessor(); + + $id = isset( $assoc_args['id'] ) ? absint( $assoc_args['id'] ) : 0; + $all = isset( $assoc_args['all'] ); + $force = isset( $assoc_args['force'] ); + + if ( ! $id && ! $all ) { + WP_CLI::error( 'Specify --id= or --all.' ); + } + + if ( $id ) { + $ids = array( $id ); + } elseif ( $force ) { + $ids = $reprocessor->get_elpx_attachment_ids(); + } else { + $ids = $reprocessor->get_reprocessable_attachment_ids(); + } + + if ( empty( $ids ) ) { + WP_CLI::success( 'No eXeLearning files needed reprocessing.' ); + return; + } + + $ok = 0; + $failed = 0; + $skipped = 0; + + foreach ( $ids as $one ) { + if ( ! $reprocessor->is_elpx_attachment( $one ) ) { + WP_CLI::warning( sprintf( 'Skipped #%d: not an eXeLearning (.elpx) attachment.', $one ) ); + ++$skipped; + continue; + } + + $result = $reprocessor->reprocess( $one ); + + if ( is_wp_error( $result ) ) { + WP_CLI::warning( sprintf( 'Failed #%d: %s', $one, $result->get_error_message() ) ); + ++$failed; + continue; + } + + WP_CLI::log( + sprintf( + 'Reprocessed #%d (%s).', + $one, + $result['has_preview'] ? 'previewable' : 'no preview' + ) + ); + ++$ok; + } + + WP_CLI::success( sprintf( '%d reprocessed, %d failed, %d skipped.', $ok, $failed, $skipped ) ); + } +} diff --git a/includes/class-elp-reprocessor.php b/includes/class-elp-reprocessor.php new file mode 100644 index 0000000..25032a6 --- /dev/null +++ b/includes/class-elp-reprocessor.php @@ -0,0 +1,309 @@ +post_type ) { + return new WP_Error( + 'invalid_attachment', + __( 'Invalid attachment ID.', 'exelearning' ), + array( 'status' => 404 ) + ); + } + + $file_path = get_attached_file( $attachment_id ); + if ( ! $file_path || ! file_exists( $file_path ) ) { + return new WP_Error( + 'file_not_found', + __( 'The eXeLearning file could not be found on disk.', 'exelearning' ), + array( 'status' => 404 ) + ); + } + + $ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); + if ( 'elpx' !== $ext ) { + return new WP_Error( + 'invalid_file_type', + __( 'This is not an eXeLearning file (.elpx).', 'exelearning' ), + array( 'status' => 400 ) + ); + } + + // Remember the current extraction so we can clean it up only on success. + $old_hash = get_post_meta( $attachment_id, '_exelearning_extracted', true ); + + $extraction = $this->extract_to_new_dir( $file_path ); + if ( is_wp_error( $extraction ) ) { + return $extraction; + } + + $this->apply_metadata( $attachment_id, $extraction['service'], $extraction['hash'], $extraction['has_preview'] ); + + // Drop the previous extraction only after the new one is committed. + if ( $old_hash && $old_hash !== $extraction['hash'] ) { + $this->cleanup_by_hash( $old_hash ); + } + + return array( + 'attachment_id' => (int) $attachment_id, + 'hash' => $extraction['hash'], + 'has_preview' => (bool) $extraction['has_preview'], + ); + } + + /** + * Whether an attachment is an unprocessed (or stale) .elpx that should be reprocessed. + * + * Returns true for a .elpx attachment that has no extraction recorded, or + * whose recorded extraction directory no longer exists on disk. + * + * @param int $attachment_id Attachment ID. + * @return bool + */ + public function needs_reprocessing( $attachment_id ) { + if ( ! $this->is_elpx_attachment( $attachment_id ) ) { + return false; + } + + $hash = get_post_meta( $attachment_id, '_exelearning_extracted', true ); + if ( empty( $hash ) ) { + return true; + } + + $upload_dir = wp_upload_dir(); + $dir = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; + + return ! is_dir( $dir ); + } + + /** + * Whether the attachment points at a .elpx file. + * + * @param int $attachment_id Attachment ID. + * @return bool + */ + public function is_elpx_attachment( $attachment_id ) { + $attachment = get_post( $attachment_id ); + if ( ! $attachment || 'attachment' !== $attachment->post_type ) { + return false; + } + + $file = get_attached_file( $attachment_id ); + if ( ! $file ) { + return false; + } + + return 'elpx' === strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); + } + + /** + * Collect the IDs of every .elpx attachment that still needs processing. + * + * Used by the WP-CLI `--all` flow and the bulk/admin scan. The cheap meta + * LIKE query narrows the set to attachments whose file name contains + * `.elpx`; needs_reprocessing() then confirms the extension and extraction + * state for each candidate. + * + * @return int[] Attachment IDs. + */ + public function get_reprocessable_attachment_ids() { + $ids = array(); + foreach ( $this->get_elpx_attachment_ids() as $id ) { + if ( $this->needs_reprocessing( $id ) ) { + $ids[] = (int) $id; + } + } + + return $ids; + } + + /** + * Collect the IDs of every .elpx attachment, regardless of extraction state. + * + * Used by the WP-CLI `--all --force` flow. The cheap meta LIKE query narrows + * the candidates; is_elpx_attachment() then confirms the real extension. + * + * @return int[] Attachment IDs. + */ + public function get_elpx_attachment_ids() { + $query = new WP_Query( + array( + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-off admin/CLI maintenance scan. + array( + 'key' => '_wp_attached_file', + 'value' => '.elpx', + 'compare' => 'LIKE', + ), + ), + ) + ); + + $ids = array(); + foreach ( $query->posts as $id ) { + if ( $this->is_elpx_attachment( $id ) ) { + $ids[] = (int) $id; + } + } + + return $ids; + } + + /** + * Validate an ELP file and extract it to a fresh, unique directory. + * + * Does NOT touch any attachment metadata; on failure it cleans up the + * partially-created directory so callers can treat it as atomic. Also writes + * the security .htaccess guard so freshly-created extraction roots (e.g. on a + * site that installed the plugin after files were uploaded) are protected. + * + * @param string $file_path Path to the .elpx file to process. + * @return array|WP_Error { service: ExeLearning_Elp_File_Service, hash: string, has_preview: bool } or WP_Error. + */ + public function extract_to_new_dir( $file_path ) { + $elp_service = new ExeLearning_Elp_File_Service(); + $result = $elp_service->validate_elp_file( $file_path ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Unique hash: microtime()+wp_rand() avoids collisions for two runs of + // the same path within one second. Kept as sha1 (40 hex) for the + // content-proxy route regex. + $upload_dir = wp_upload_dir(); + $unique_hash = sha1( $file_path . microtime( true ) . wp_rand() ); + $destination = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $unique_hash . '/'; + + if ( ! wp_mkdir_p( $destination ) ) { + return new WP_Error( + 'mkdir_failed', + __( 'Failed to create directory for extracted files.', 'exelearning' ), + array( 'status' => 500 ) + ); + } + + // Ensure the parent directory is guarded even when this attachment was + // uploaded before the plugin (and its upload filter) ever ran. + ExeLearning_Elp_Upload_Handler::write_security_htaccess(); + + $extract_result = $elp_service->extract( $file_path, $destination ); + if ( is_wp_error( $extract_result ) ) { + $this->cleanup_by_hash( $unique_hash ); + return $extract_result; + } + + return array( + 'service' => $elp_service, + 'hash' => $unique_hash, + 'has_preview' => file_exists( $destination . 'index.html' ), + ); + } + + /** + * Persist ELP metadata for an attachment after a successful extraction. + * + * @param int $attachment_id Attachment ID. + * @param ExeLearning_Elp_File_Service $elp_service Parsed ELP service. + * @param string $hash Extraction hash. + * @param bool $has_preview Whether index.html exists. + */ + public function apply_metadata( $attachment_id, $elp_service, $hash, $has_preview ) { + update_post_meta( $attachment_id, '_exelearning_title', $elp_service->get_title() ); + update_post_meta( $attachment_id, '_exelearning_description', $elp_service->get_description() ); + update_post_meta( $attachment_id, '_exelearning_license', $elp_service->get_license() ); + update_post_meta( $attachment_id, '_exelearning_language', $elp_service->get_language() ); + update_post_meta( $attachment_id, '_exelearning_resource_type', $elp_service->get_learning_resource_type() ); + update_post_meta( $attachment_id, '_exelearning_extracted', $hash ); + update_post_meta( $attachment_id, '_exelearning_version', $elp_service->get_version() ); + update_post_meta( $attachment_id, '_exelearning_has_preview', $has_preview ? '1' : '0' ); + + // Update attachment title/caption. + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_excerpt' => $elp_service->get_title(), + 'post_content' => $elp_service->get_description(), + ) + ); + } + + /** + * Delete an extraction directory by hash. + * + * @param string $hash Extraction hash to clean up. + */ + public function cleanup_by_hash( $hash ) { + if ( empty( $hash ) ) { + return; + } + + $upload_dir = wp_upload_dir(); + $folder = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; + + if ( is_dir( $folder ) ) { + $this->recursive_delete( $folder ); + } + } + + /** + * Recursively delete a directory. + * + * @param string $dir Directory path. + */ + private function recursive_delete( $dir ) { + if ( ! file_exists( $dir ) ) { + return; + } + + if ( is_file( $dir ) || is_link( $dir ) ) { + wp_delete_file( $dir ); + } else { + $files = array_diff( scandir( $dir ), array( '.', '..' ) ); + foreach ( $files as $file ) { + $this->recursive_delete( $dir . DIRECTORY_SEPARATOR . $file ); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Direct filesystem access needed for cleanup. + rmdir( $dir ); + } + } +} diff --git a/includes/class-elp-upload-handler.php b/includes/class-elp-upload-handler.php index 99ee983..7ff59ee 100644 --- a/includes/class-elp-upload-handler.php +++ b/includes/class-elp-upload-handler.php @@ -240,6 +240,18 @@ public function exelearning_delete_extracted_folder( $post_id ) { * extensions. */ private function create_security_htaccess() { + self::write_security_htaccess(); + } + + /** + * Writes the security .htaccess for the exelearning uploads directory. + * + * Exposed as a static helper so other entry points (e.g. the reprocessor for + * existing attachments) can guarantee the guard file exists without + * duplicating its contents. See create_security_htaccess() for the rationale + * behind the allow-list. + */ + public static function write_security_htaccess() { $upload_dir = wp_upload_dir(); $htaccess_path = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/.htaccess'; diff --git a/includes/class-exelearning-rest-api.php b/includes/class-exelearning-rest-api.php index 9adb64c..10e9e0f 100644 --- a/includes/class-exelearning-rest-api.php +++ b/includes/class-exelearning-rest-api.php @@ -16,10 +16,21 @@ */ class ExeLearning_REST_API { + /** + * Shared extraction/metadata service. + * + * Owns the validate -> extract -> commit -> cleanup primitives so the save, + * create and reprocess flows all behave identically. + * + * @var ExeLearning_Reprocessor + */ + private $reprocessor; + /** * Constructor. */ public function __construct() { + $this->reprocessor = new ExeLearning_Reprocessor(); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } @@ -574,16 +585,7 @@ private function cleanup_old_extraction( $attachment_id ) { * @param string $hash Extraction hash to clean up. */ private function cleanup_extraction_by_hash( $hash ) { - if ( empty( $hash ) ) { - return; - } - - $upload_dir = wp_upload_dir(); - $folder = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; - - if ( is_dir( $folder ) ) { - $this->recursive_delete( $folder ); - } + $this->reprocessor->cleanup_by_hash( $hash ); } /** @@ -700,40 +702,7 @@ private function reprocess_elp_file( $attachment_id, $file_path ) { * @return array|WP_Error { service: ExeLearning_Elp_File_Service, hash: string, has_preview: bool } or WP_Error. */ private function extract_elp_to_new_dir( $file_path ) { - $elp_service = new ExeLearning_Elp_File_Service(); - $result = $elp_service->validate_elp_file( $file_path ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - // Unique hash: microtime()+wp_rand() avoids collisions for two saves of - // the same path within one second (which previously could let cleanup - // delete a freshly-created extraction). Kept as sha1 (40 hex) for the - // content-proxy route regex. - $upload_dir = wp_upload_dir(); - $unique_hash = sha1( $file_path . microtime( true ) . wp_rand() ); - $destination = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $unique_hash . '/'; - - if ( ! wp_mkdir_p( $destination ) ) { - return new WP_Error( - 'mkdir_failed', - __( 'Failed to create directory for extracted files.', 'exelearning' ), - array( 'status' => 500 ) - ); - } - - $extract_result = $elp_service->extract( $file_path, $destination ); - if ( is_wp_error( $extract_result ) ) { - $this->cleanup_extraction_by_hash( $unique_hash ); - return $extract_result; - } - - return array( - 'service' => $elp_service, - 'hash' => $unique_hash, - 'has_preview' => file_exists( $destination . 'index.html' ), - ); + return $this->reprocessor->extract_to_new_dir( $file_path ); } /** @@ -745,23 +714,7 @@ private function extract_elp_to_new_dir( $file_path ) { * @param bool $has_preview Whether index.html exists. */ private function apply_elp_metadata( $attachment_id, $elp_service, $hash, $has_preview ) { - update_post_meta( $attachment_id, '_exelearning_title', $elp_service->get_title() ); - update_post_meta( $attachment_id, '_exelearning_description', $elp_service->get_description() ); - update_post_meta( $attachment_id, '_exelearning_license', $elp_service->get_license() ); - update_post_meta( $attachment_id, '_exelearning_language', $elp_service->get_language() ); - update_post_meta( $attachment_id, '_exelearning_resource_type', $elp_service->get_learning_resource_type() ); - update_post_meta( $attachment_id, '_exelearning_extracted', $hash ); - update_post_meta( $attachment_id, '_exelearning_version', $elp_service->get_version() ); - update_post_meta( $attachment_id, '_exelearning_has_preview', $has_preview ? '1' : '0' ); - - // Update attachment title/caption. - wp_update_post( - array( - 'ID' => $attachment_id, - 'post_excerpt' => $elp_service->get_title(), - 'post_content' => $elp_service->get_description(), - ) - ); + $this->reprocessor->apply_metadata( $attachment_id, $elp_service, $hash, $has_preview ); } /** diff --git a/includes/integrations/class-media-library.php b/includes/integrations/class-media-library.php index a7e0fd6..4839a51 100644 --- a/includes/integrations/class-media-library.php +++ b/includes/integrations/class-media-library.php @@ -30,6 +30,129 @@ public function __construct() { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_media_modal_scripts' ) ); add_filter( 'wp_prepare_attachment_for_js', array( $this, 'add_elp_metadata_to_js' ), 10, 3 ); + + // Bulk action to (re)process existing .elpx attachments that were never + // extracted (e.g. uploaded before the plugin was active, or via a flow + // such as Formidable Forms that bypasses the upload handler). + add_filter( 'bulk_actions-upload', array( $this, 'register_bulk_reprocess_action' ) ); + add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_reprocess' ), 10, 3 ); + add_action( 'admin_notices', array( $this, 'render_reprocess_admin_notice' ) ); + } + + /** + * Add the "Reprocess eXeLearning file" bulk action to the media list table. + * + * @param array $actions Existing bulk actions. + * @return array Modified bulk actions. + */ + public function register_bulk_reprocess_action( $actions ) { + $actions['exelearning_reprocess'] = __( 'Reprocess eXeLearning file', 'exelearning' ); + return $actions; + } + + /** + * Handle the "Reprocess eXeLearning file" bulk action. + * + * Non-.elpx selections are skipped; each .elpx the user may edit is run + * through the shared reprocessor. Counts are passed back via the redirect + * URL so render_reprocess_admin_notice() can report the outcome. + * + * @param string $redirect_to Redirect URL. + * @param string $doaction The selected bulk action. + * @param array $post_ids Selected attachment IDs. + * @return string Redirect URL, augmented with result counts for our action. + */ + public function handle_bulk_reprocess( $redirect_to, $doaction, $post_ids ) { + if ( 'exelearning_reprocess' !== $doaction ) { + return $redirect_to; + } + + $reprocessor = new ExeLearning_Reprocessor(); + $reprocessed = 0; + $skipped = 0; + $failed = 0; + + foreach ( $post_ids as $post_id ) { + $post_id = (int) $post_id; + + if ( ! $reprocessor->is_elpx_attachment( $post_id ) ) { + ++$skipped; + continue; + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + ++$failed; + continue; + } + + $result = $reprocessor->reprocess( $post_id ); + if ( is_wp_error( $result ) ) { + ++$failed; + } else { + ++$reprocessed; + } + } + + return add_query_arg( + array( + 'exe_reprocessed' => $reprocessed, + 'exe_skipped' => $skipped, + 'exe_failed' => $failed, + ), + $redirect_to + ); + } + + /** + * Render the admin notice summarising a bulk reprocess run. + */ + public function render_reprocess_admin_notice() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display of counts after our own bulk redirect; no state change. + if ( ! isset( $_REQUEST['exe_reprocessed'], $_REQUEST['exe_skipped'], $_REQUEST['exe_failed'] ) ) { + return; + } + + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $reprocessed = absint( $_REQUEST['exe_reprocessed'] ); + $skipped = absint( $_REQUEST['exe_skipped'] ); + $failed = absint( $_REQUEST['exe_failed'] ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $messages = array(); + + if ( $reprocessed > 0 ) { + $messages[] = sprintf( + /* translators: %d: number of files reprocessed. */ + _n( '%d eXeLearning file reprocessed.', '%d eXeLearning files reprocessed.', $reprocessed, 'exelearning' ), + $reprocessed + ); + } + + if ( $skipped > 0 ) { + $messages[] = sprintf( + /* translators: %d: number of items skipped. */ + _n( '%d item skipped (not an eXeLearning file).', '%d items skipped (not eXeLearning files).', $skipped, 'exelearning' ), + $skipped + ); + } + + if ( $failed > 0 ) { + $messages[] = sprintf( + /* translators: %d: number of files that failed. */ + _n( '%d file could not be reprocessed.', '%d files could not be reprocessed.', $failed, 'exelearning' ), + $failed + ); + } + + if ( empty( $messages ) ) { + return; + } + + printf( + '

%2$s

', + esc_attr( $failed > 0 ? 'notice-warning' : 'notice-success' ), + esc_html( implode( ' ', $messages ) ) + ); } /** diff --git a/tests/unit/MediaLibraryReprocessTest.php b/tests/unit/MediaLibraryReprocessTest.php new file mode 100644 index 0000000..55b60bf --- /dev/null +++ b/tests/unit/MediaLibraryReprocessTest.php @@ -0,0 +1,168 @@ +media_library = new ExeLearning_Media_Library(); + $this->cleanup_paths = array(); + } + + /** + * Tear down test fixtures. + */ + public function tear_down() { + foreach ( $this->cleanup_paths as $path ) { + $this->recursive_delete( $path ); + } + parent::tear_down(); + } + + /** + * Recursively delete a path. + * + * @param string $dir Path. + */ + private function recursive_delete( $dir ) { + if ( ! file_exists( $dir ) ) { + return; + } + if ( is_file( $dir ) || is_link( $dir ) ) { + unlink( $dir ); // phpcs:ignore + return; + } + $files = array_diff( scandir( $dir ), array( '.', '..' ) ); + foreach ( $files as $file ) { + $this->recursive_delete( $dir . DIRECTORY_SEPARATOR . $file ); + } + rmdir( $dir ); // phpcs:ignore + } + + /** + * Create an attachment backed by a valid previewable .elpx on disk. + * + * @return int Attachment ID. + */ + private function make_elpx_attachment() { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $attachment_id = $this->factory->attachment->create( + array( + 'post_mime_type' => 'application/zip', + 'post_author' => $user_id, + ) + ); + wp_set_current_user( $user_id ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/bulk-' . $attachment_id . '.elpx'; + + $zip = new ZipArchive(); + $zip->open( $file_path, ZipArchive::CREATE ); + $zip->addFromString( 'content.xml', '' ); + $zip->addFromString( 'index.html', '' ); + $zip->close(); + + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + return $attachment_id; + } + + /** + * The reprocess bulk action is added to the media list table. + */ + public function test_bulk_action_is_registered() { + $actions = $this->media_library->register_bulk_reprocess_action( array() ); + + $this->assertArrayHasKey( 'exelearning_reprocess', $actions ); + } + + /** + * The handler ignores actions other than ours, returning the URL untouched. + */ + public function test_handle_bulk_ignores_other_actions() { + $url = 'http://example.org/wp-admin/upload.php'; + $result = $this->media_library->handle_bulk_reprocess( $url, 'trash', array( 1, 2 ) ); + + $this->assertSame( $url, $result ); + } + + /** + * The handler reprocesses selected .elpx attachments and reports the count. + */ + public function test_handle_bulk_reprocesses_selected_elpx() { + $id = $this->make_elpx_attachment(); + + $this->assertEmpty( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $redirect = $this->media_library->handle_bulk_reprocess( + 'http://example.org/wp-admin/upload.php', + 'exelearning_reprocess', + array( $id ) + ); + + // Attachment is now extracted and previewable. + $hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->assertNotEmpty( $hash ); + $this->assertEquals( '1', get_post_meta( $id, '_exelearning_has_preview', true ) ); + + $upload_dir = wp_upload_dir(); + $this->cleanup_paths[] = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; + + // Redirect carries a processed count for the admin notice. + $query = wp_parse_url( $redirect, PHP_URL_QUERY ); + parse_str( (string) $query, $args ); + $this->assertEquals( 1, (int) $args['exe_reprocessed'] ); + } + + /** + * Non-elpx selections are skipped, not errored. + */ + public function test_handle_bulk_skips_non_elpx() { + $image = $this->factory->attachment->create(); + $upload_dir = wp_upload_dir(); + $img_path = $upload_dir['basedir'] . '/skip-' . $image . '.jpg'; + file_put_contents( $img_path, 'x' ); // phpcs:ignore + $this->cleanup_paths[] = $img_path; + update_attached_file( $image, $img_path ); + + $redirect = $this->media_library->handle_bulk_reprocess( + 'http://example.org/wp-admin/upload.php', + 'exelearning_reprocess', + array( $image ) + ); + + $query = wp_parse_url( $redirect, PHP_URL_QUERY ); + parse_str( (string) $query, $args ); + + $this->assertEquals( 0, (int) $args['exe_reprocessed'] ); + $this->assertEquals( 1, (int) $args['exe_skipped'] ); + } +} diff --git a/tests/unit/ReprocessorTest.php b/tests/unit/ReprocessorTest.php new file mode 100644 index 0000000..d961994 --- /dev/null +++ b/tests/unit/ReprocessorTest.php @@ -0,0 +1,368 @@ +reprocessor = new ExeLearning_Reprocessor(); + $this->cleanup_paths = array(); + } + + /** + * Tear down test fixtures. + */ + public function tear_down() { + foreach ( $this->cleanup_paths as $path ) { + $this->recursive_delete( $path ); + } + parent::tear_down(); + } + + /** + * Create an attachment backed by a .elpx file on disk. + * + * @param bool $with_index Whether the archive includes index.html (previewable). + * @param bool $valid_zip Whether the file is a real ZIP (false writes garbage bytes). + * @return array { id: int, path: string } + */ + private function make_elpx_attachment( $with_index = true, $valid_zip = true ) { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $attachment_id = $this->factory->attachment->create( + array( + 'post_mime_type' => 'application/zip', + 'post_author' => $user_id, + 'post_title' => 'Existing ELP', + ) + ); + wp_set_current_user( $user_id ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/reprocess-' . $attachment_id . '.elpx'; + + if ( $valid_zip ) { + $zip = new ZipArchive(); + $zip->open( $file_path, ZipArchive::CREATE ); + $zip->addFromString( 'content.xml', '' ); + if ( $with_index ) { + $zip->addFromString( 'index.html', 'Preview' ); + } + $zip->close(); + } else { + file_put_contents( $file_path, 'this is not a zip file' ); // phpcs:ignore + } + + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + return array( + 'id' => $attachment_id, + 'path' => $file_path, + ); + } + + /** + * Absolute path to the extraction directory for a hash. + * + * @param string $hash Extraction hash. + * @return string + */ + private function extraction_dir( $hash ) { + $upload_dir = wp_upload_dir(); + return trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; + } + + /** + * Recursively delete a path. + * + * @param string $dir Path. + */ + private function recursive_delete( $dir ) { + if ( ! file_exists( $dir ) ) { + return; + } + if ( is_file( $dir ) || is_link( $dir ) ) { + unlink( $dir ); // phpcs:ignore + return; + } + $files = array_diff( scandir( $dir ), array( '.', '..' ) ); + foreach ( $files as $file ) { + $this->recursive_delete( $dir . DIRECTORY_SEPARATOR . $file ); + } + rmdir( $dir ); // phpcs:ignore + } + + /** + * Invalid attachment id is rejected. + */ + public function test_reprocess_invalid_attachment() { + $result = $this->reprocessor->reprocess( 999999 ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_attachment', $result->get_error_code() ); + } + + /** + * A non-attachment post is rejected. + */ + public function test_reprocess_non_attachment() { + $post_id = $this->factory->post->create( array( 'post_type' => 'post' ) ); + + $result = $this->reprocessor->reprocess( $post_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_attachment', $result->get_error_code() ); + } + + /** + * A non-elpx attachment is rejected. + */ + public function test_reprocess_non_elpx_file() { + $attachment_id = $this->factory->attachment->create(); + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/not-elp-' . $attachment_id . '.jpg'; + file_put_contents( $file_path, 'fake image' ); // phpcs:ignore + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + $result = $this->reprocessor->reprocess( $attachment_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_file_type', $result->get_error_code() ); + } + + /** + * Missing underlying file is reported. + */ + public function test_reprocess_missing_file() { + $attachment_id = $this->factory->attachment->create(); + update_attached_file( $attachment_id, '/nonexistent/path/file.elpx' ); + + $result = $this->reprocessor->reprocess( $attachment_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'file_not_found', $result->get_error_code() ); + } + + /** + * Reprocessing an existing previewable .elpx extracts and sets metadata. + */ + public function test_reprocess_creates_extraction_and_metadata() { + $fixture = $this->make_elpx_attachment( true ); + $id = $fixture['id']; + + // Precondition: not extracted yet. + $this->assertEmpty( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $result = $this->reprocessor->reprocess( $id ); + + $this->assertIsArray( $result ); + + $hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->assertNotEmpty( $hash ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{40}$/', $hash ); + $this->assertEquals( '1', get_post_meta( $id, '_exelearning_has_preview', true ) ); + + $dir = $this->extraction_dir( $hash ); + $this->cleanup_paths[] = $dir; + $this->assertDirectoryExists( $dir ); + $this->assertFileExists( $dir . 'index.html' ); + } + + /** + * A .elpx without index.html extracts but is marked as not previewable. + */ + public function test_reprocess_without_preview() { + $fixture = $this->make_elpx_attachment( false ); + $id = $fixture['id']; + + $result = $this->reprocessor->reprocess( $id ); + + $this->assertIsArray( $result ); + + $hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->assertNotEmpty( $hash ); + $this->assertEquals( '0', get_post_meta( $id, '_exelearning_has_preview', true ) ); + + $this->cleanup_paths[] = $this->extraction_dir( $hash ); + } + + /** + * An invalid .elpx returns a clear error and writes no extraction metadata. + */ + public function test_reprocess_invalid_file_returns_error() { + $fixture = $this->make_elpx_attachment( true, false ); + $id = $fixture['id']; + + $result = $this->reprocessor->reprocess( $id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEmpty( get_post_meta( $id, '_exelearning_extracted', true ) ); + } + + /** + * Reprocessing is idempotent: running twice leaves a single extraction. + */ + public function test_reprocess_is_idempotent() { + $fixture = $this->make_elpx_attachment( true ); + $id = $fixture['id']; + + $this->reprocessor->reprocess( $id ); + $first_hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->cleanup_paths[] = $this->extraction_dir( $first_hash ); + + $this->reprocessor->reprocess( $id ); + $second_hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->cleanup_paths[] = $this->extraction_dir( $second_hash ); + + // A fresh directory is used each run... + $this->assertNotEquals( $first_hash, $second_hash ); + // ...and the previous one is cleaned up (no orphans). + $this->assertDirectoryDoesNotExist( $this->extraction_dir( $first_hash ) ); + $this->assertDirectoryExists( $this->extraction_dir( $second_hash ) ); + } + + /** + * On failure the previously-good extraction and metadata are preserved. + */ + public function test_reprocess_failure_preserves_existing_extraction() { + // First a successful extraction. + $fixture = $this->make_elpx_attachment( true ); + $id = $fixture['id']; + $this->reprocessor->reprocess( $id ); + $good_hash = get_post_meta( $id, '_exelearning_extracted', true ); + $good_dir = $this->extraction_dir( $good_hash ); + $this->cleanup_paths[] = $good_dir; + $this->assertDirectoryExists( $good_dir ); + + // Now corrupt the underlying file and reprocess again. + file_put_contents( $fixture['path'], 'corrupted, not a zip' ); // phpcs:ignore + + $result = $this->reprocessor->reprocess( $id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + // Old extraction + metadata untouched. + $this->assertEquals( $good_hash, get_post_meta( $id, '_exelearning_extracted', true ) ); + $this->assertDirectoryExists( $good_dir ); + } + + /** + * After reprocessing, the [exelearning] shortcode renders the preview iframe. + */ + public function test_shortcode_renders_preview_after_reprocess() { + $fixture = $this->make_elpx_attachment( true ); + $id = $fixture['id']; + + // Before: shortcode falls back to the no-preview/download view. + $shortcodes = new ExeLearning_Shortcodes(); + $before = $shortcodes->display_exelearning( array( 'id' => $id ) ); + $this->assertStringNotContainsString( 'exelearning-iframe', $before ); + + // Reprocess, then the shortcode renders the iframe. + $this->reprocessor->reprocess( $id ); + $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $after = $shortcodes->display_exelearning( array( 'id' => $id ) ); + $this->assertStringContainsString( 'exelearning-iframe', $after ); + } + + /** + * needs_reprocessing() is true for an unprocessed .elpx attachment. + */ + public function test_needs_reprocessing_true_for_unprocessed_elpx() { + $fixture = $this->make_elpx_attachment( true ); + + $this->assertTrue( $this->reprocessor->needs_reprocessing( $fixture['id'] ) ); + } + + /** + * needs_reprocessing() is false once an attachment has a valid extraction. + */ + public function test_needs_reprocessing_false_after_reprocess() { + $fixture = $this->make_elpx_attachment( true ); + $id = $fixture['id']; + + $this->reprocessor->reprocess( $id ); + $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $this->assertFalse( $this->reprocessor->needs_reprocessing( $id ) ); + } + + /** + * needs_reprocessing() is false for a non-elpx attachment. + */ + public function test_needs_reprocessing_false_for_non_elpx() { + $attachment_id = $this->factory->attachment->create(); + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/img-' . $attachment_id . '.png'; + file_put_contents( $file_path, 'x' ); // phpcs:ignore + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + $this->assertFalse( $this->reprocessor->needs_reprocessing( $attachment_id ) ); + } + + /** + * needs_reprocessing() is true when the extraction directory is gone. + */ + public function test_needs_reprocessing_true_when_extraction_dir_missing() { + $fixture = $this->make_elpx_attachment( true ); + $id = $fixture['id']; + + // Metadata points at a hash whose directory does not exist. + update_post_meta( $id, '_exelearning_extracted', str_repeat( 'a', 40 ) ); + + $this->assertTrue( $this->reprocessor->needs_reprocessing( $id ) ); + } + + /** + * get_reprocessable_attachment_ids() finds only unprocessed .elpx files. + */ + public function test_get_reprocessable_attachment_ids() { + $unprocessed = $this->make_elpx_attachment( true ); + + $processed = $this->make_elpx_attachment( true ); + $this->reprocessor->reprocess( $processed['id'] ); + $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $processed['id'], '_exelearning_extracted', true ) ); + + // A non-elpx attachment that must never be returned. + $image = $this->factory->attachment->create(); + $upload_dir = wp_upload_dir(); + $img_path = $upload_dir['basedir'] . '/photo-' . $image . '.jpg'; + file_put_contents( $img_path, 'x' ); // phpcs:ignore + $this->cleanup_paths[] = $img_path; + update_attached_file( $image, $img_path ); + + $ids = $this->reprocessor->get_reprocessable_attachment_ids(); + + $this->assertContains( $unprocessed['id'], $ids ); + $this->assertNotContains( $processed['id'], $ids ); + $this->assertNotContains( $image, $ids ); + } +} From 3e007bf4c617b8ccdf138d953935aa69067b3518 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 21:27:25 +0100 Subject: [PATCH 2/6] i18n: translate new reprocess strings; cover admin notice; exclude CLI from coverage - Regenerate exelearning.pot with the new reprocess feature strings. - Translate them into es_ES (fixes the CI "Check untranslated strings" gate) and into all other shipped locales: ca, ca_valencia, de_DE, eo, eu, gl_ES, it_IT, pt_PT and ro_RO (with its 3 plural forms). Regenerate every .mo. - Add MediaLibraryReprocessTest cases for render_reprocess_admin_notice (success, warning, and the two silent paths). - Exclude includes/class-cli-command.php from coverage: it only loads under WP-CLI, which is unavailable to PHPUnit (same rationale as the existing exclusions). Overall line coverage stays at 79.36%. --- languages/exelearning-ca.mo | Bin 10190 -> 10893 bytes languages/exelearning-ca.po | 27 ++++++ languages/exelearning-ca_valencia.mo | Bin 10231 -> 10934 bytes languages/exelearning-ca_valencia.po | 27 ++++++ languages/exelearning-de_DE.mo | Bin 10442 -> 11196 bytes languages/exelearning-de_DE.po | 27 ++++++ languages/exelearning-eo.mo | Bin 10005 -> 10731 bytes languages/exelearning-eo.po | 27 ++++++ languages/exelearning-es_ES.mo | Bin 18815 -> 18568 bytes languages/exelearning-es_ES.po | 103 ++++++++++++++--------- languages/exelearning-eu.mo | Bin 10113 -> 10862 bytes languages/exelearning-eu.po | 27 ++++++ languages/exelearning-gl_ES.mo | Bin 10164 -> 10885 bytes languages/exelearning-gl_ES.po | 27 ++++++ languages/exelearning-it_IT.mo | Bin 10156 -> 10844 bytes languages/exelearning-it_IT.po | 27 ++++++ languages/exelearning-pt_PT.mo | Bin 10312 -> 11057 bytes languages/exelearning-pt_PT.po | 27 ++++++ languages/exelearning-ro_RO.mo | Bin 10444 -> 11294 bytes languages/exelearning-ro_RO.po | 30 +++++++ languages/exelearning.pot | 97 +++++++++++++-------- phpunit.xml.dist | 2 + tests/unit/MediaLibraryReprocessTest.php | 67 +++++++++++++++ 23 files changed, 442 insertions(+), 73 deletions(-) diff --git a/languages/exelearning-ca.mo b/languages/exelearning-ca.mo index 7256688295e3677d5cb68e3b29cbddec2633e6c0..68cca096c206df78d1251f5c70226ce303fef41d 100644 GIT binary patch delta 2546 zcmaLXe@vBC9LMov2ttM=n!g}$YV~*9K{>1}j_CYP!|Rv>&!LCaclv{oy{O2*!ZdInO=ke81;= z&U5=~_trKRi#<*Jo5*V2CwpT+g~B~HVFWMh_K1tww;%P@jj z*yVnH26O2DjZ1MsiZPRoiJAr)Nj&i3J-8L;;!#Y+4{;{;;r;kMGSMu&*_hifA7|il zOv5@q36bN&bPA(d5BW70`KJIw zsIyIBH6`dl?Whdz#VVYQdr*lVK^^fi)U|pCwchbG>R&?RV;($?L%100Z#AYCccT&< zz$a1Bwd0*wipOyc{)~5EIa%*PKQ`cR*oM`dS3aIZ9n}R?;=j(I{(5kQ2iZ7`D$RWA zM|I3ys2!|Cax*npf(@vE`%sCTaQm0Bf_^49QzhSkk6*Nn7>@p zsADnx9Mpt$s02c&fE}oXJJGtyn0N6Z`d^T>Dv`krYs6+$!k?fL`UbVpnVe@G&O^N& z(N-EdvjdompW!MTLcM0o$wFtg0;_QiT6hSR_y@?^rXTe^aS;`$kQ+_Ylp;knYf%Y# zQJ-%|jwEV!&{)BPBe(>AKviJw?eW)gHEIVDRG_!r=Vy={%^-68CT)JapN$%~3|Hd@ zRKUZ?(&i|tf<37FXZlAP#+*m0X)e1aa>W^DqxfaWig03%fC@z~9nPi7uhu)8A3I^a|>Br?CCUa4G5%9>R@y8uh)A z#ZB6cRd^qMj(6j9%C||+u?%0wefTv7u{l@gPh)_aSA;dBsRW)to$1qV{{>W_mr*-A zjXJ6yQE$gEs&a|kDBYbb)b~aWYF>xie+`vbAL_`GJk($BXF8)*;;pD#eGs+aTc~fc zQ>fc~4)4T2k$Y!~SUnl5P?cGST6mYDF7jLUM!VYfhJ%4%v(*%6v8}M(77qDr$Fcpz z8W6pHgfni)$UI-Dy~S?@LlLWS{306RSpPdD5V2b=XIr4H&GuXQTE`nyb`@jN^Ni%A zvCi1ReuVuGP m5FxfcD>kX9#@$#X9BTANCLU>dTU1=%^9|Wqv7GETll}#oc%?M} delta 1841 zcmYM!duY~G7zgm9t!W>Ec+|ESdG%U)>geLv^?F6TVwdCqxvtUbKC@>63? zXYseuXPD137o}Iq`P%;-pm{-m7W>teaxoWkCYN(0w=(x%;#hve`TU19yr#aC3^|AM zxPU`Tsgx}m{atvTmvIjl@jd3nvmDHGyp)6blroUxm_TMT6V7J>Xy-I`vYB6SBLC!M zUeVWwOF7K^rB&lf7anE??yNqrmznSY=kN$KVV_HS0v$+Wma)tNrdGXa<1o9 z+{$G5MKN$r$v^Zs}G!JB0bNfY;eDx{>>bue=n1&ZXxSkWeR7oT|G*aU|%)tAZnjT~V`-NG_87^RN4-erYCg2vPws%mu%RTg8 zI((%R6}ECeSF^w95>V?v`F})Xn+sWCO@ocXg}jOPa|%D<(>%jAZW3h&k21AgY85HX z1}5MKtNlkgO8*Ha6MJb5hEKo|DFlpY(*mz{L2hH#a3_XZ89dX+vGVD-^Z-*HLhV7 zv+FM~B^hmb6@I)?>Lt=2J>pUp1Iyaw<_gH4P`8^^js)!GY=l(3Z7yD zn&#}}_{{c|Os!!8YOA`D*`$wE?c_N9w>XAJXu@)qDbb+e$&C7s*2sX9m>-%sY~m^= zfDO!Pc%KREc(wl*ZRpZ4qGwZ$WbRL4CR)zvypvh+EViTKvWf!kxGSk8wzcywQ-oX6cmXFV4%nOZhZ{u?zwl-CcFKU zV>Mch)#?wI|Af$A6^=QRSkPua#vlFA)>>@Y?5En+G@PT^`@=nfNX0Jq^*rZ1=Xt)* z_xYYXxqYZHHj$qAs^QP&Ka2k(3)M5G?z&m4X+A{%9();h;7MGD#mUBO#!6g-4whpC zbFkmL|1sv#{|l=ycd;>v#>7lBjU+C#;#PbLZ^XAS6-RIdj^mwp8hOyHOEG387UFWe z9n-J@6-Wze!M&&ed$0^gu?YXbLe@7~ON=SuLJel()2K|mfH&b`)WoCS{Vz}p{)QEJ z1+`$s(s-cTkQmLws0}>f89;698C-?0;A+-4{WP@TN0^S^qf&bTHPJ$5A!{ZTb$=Bq z&>~dEs=a;}-a!8~RL1&|j~V8R7|my>jeL!I{wItn61#&a0LI?k&QhfzoMBP#H-%gMhk{KrS$Vq4?KbjAc$J96E$%kmLwSS9^OZPf~b{=0xGNp!>E8y;;ncFbp+X*X9MP= z-i}x&4V~FREWmH^UQA$hy=Jwj0P3+GccO)TNX+I0@`(8k^*eD1wNMomt)i~xOXGH< z0*azC{ycIdG4mpgySZ=#H{&H#1~#sVzm~hOl72Tbw;A=WPoNe~$&MFgKJuv9f^638 zKxM{3EjZ-eKaR@aH>mnEXQms*Ttu>I7E#(V3-hrY*WqKR1-npZ_d2SY2T&8fi`wZh zs+PXQTD*vS%pF!du-&Nsv$!lq;|&@rwtmzCL!O_bitQ|Jz$|8sVl6755#*Nn0TobU zZoHOOB9}}tDzG|Sk4@;q=e+x;F{XtRnN2B5N1a_Js!DTE)x8OKp&wO*A7T^!j{4oW zpGrD_F5ZQgQAM|n^r`xzsCmY*2d|)mJq6^ylg3m*e4;2}Du6@Cn`jPu{r6D|jUuOH z&Y@DhlnPRD=AzEL1QlQn>UYCMW$vKY{}i{PyP>b~A6IgKws~3yQDZ{hScnf>FnB1s%&DaQ6)^NqamwpMo5kwE|e delta 1837 zcmYM!ZA{H!7{~D|Jvd3H#G#aDJxS#uP0|#t<}os>7c#AE4DDFjnrdf_l95?k88&0a z3yc@1dcm@pSuYq?*w{2SMzWn(c%kt9Isc96eD3@I|DXH1?(4q(r+?SaGT%sCShw-7 zj=v=S&V*`aHXiX`H^T|$IXHE)SvVHqN-V{B*o3-&2^ZrF%*XE-hRa=MS~3^cU;!qW z`K*yaG#9#X242BJ>_dI=2YT=qPRD7HW-*wIN@Nu(U_L5=y_k*Nn2wJz4Fi~g@l*K2 zVoV~wRWX>&g=46NyPXHFqXIs_TpU6LjEo8<8iTB9i%}cMa$Jk-%r@anti?oZLRI)Y z#$hkIiEo1pw9sp0llI1_M z*E{nTT)_M|suI_cJ=gx!wMWloox~4 zz*nsb$Q!7S#tP|v?bCGf+?Kmq=s7S5#AGbRxXmDoP&yc!!&CBB2@*oW$R z7u(X2WMT>)#1ia6y*+PGfkto}e#ZHjONZ*M@>MWU_aDaf*ntZ41l6rWs0Rb6gu;1P zrS_mopNJc<7;~@#wbKF2z;Bp`@oZZ=FXuzo>yTSMyU2iJv_4e8K_r%a#8Ui?3RuET z-QU88Ds=#rP=jL|s!6*YZ(}m^N4N+_k$@IGCwN3@I9cz19s?~{gxcv=RMXVpMr=d9 z4bM@D1)O>0++dfdqnc_pDnN;2HL9nYaVg%$27H4`sEkEpiEs4`IDR{U3VZ>T(0xqB zL9E7rbAQMDV8D7*Wm-{3b`sTuXHm_26?fw>R^l=i*ZY48+p!mYMGW#62Aiw}E137A z7I2ehCl+EIKEp;Vp!2j)KPvDCh%tYzW9%A%UF235&J$<$xn-_8a7 zAoQY2HR8;pQi8t=t56m3q7rPz419^IoXVsHJ;;k;*+|=1DXzd8QLpI(Dba|0==l@`o)P2=NCN4MzM0HLJOR diff --git a/languages/exelearning-ca_valencia.po b/languages/exelearning-ca_valencia.po index 4e27add..3f5870e 100644 --- a/languages/exelearning-ca_valencia.po +++ b/languages/exelearning-ca_valencia.po @@ -548,3 +548,30 @@ msgstr "No s'ha pogut fer una còpia de seguretat de la instal·lació actual de #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Error en copiar els fitxers de l'editor al directori del connector." + +msgid "The eXeLearning file could not be found on disk." +msgstr "No s'ha trobat el fitxer eXeLearning al disc." + +msgid "Reprocess eXeLearning file" +msgstr "Reprocessa el fitxer eXeLearning" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d fitxer eXeLearning reprocessat." +msgstr[1] "%d fitxers eXeLearning reprocessats." + +#. translators: %d: number of items skipped. +#, php-format +msgid "%d item skipped (not an eXeLearning file)." +msgid_plural "%d items skipped (not eXeLearning files)." +msgstr[0] "%d element omès (no és un fitxer eXeLearning)." +msgstr[1] "%d elements omesos (no són fitxers eXeLearning)." + +#. translators: %d: number of files that failed. +#, php-format +msgid "%d file could not be reprocessed." +msgid_plural "%d files could not be reprocessed." +msgstr[0] "%d fitxer no s'ha pogut reprocessar." +msgstr[1] "%d fitxers no s'han pogut reprocessar." diff --git a/languages/exelearning-de_DE.mo b/languages/exelearning-de_DE.mo index 9dc3cf0d8e1ee4998417fdaee85817ea8ff8fdd5..5f9833e02780783694f111eb3c6462731c842397 100644 GIT binary patch delta 2569 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 delta 1841 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 diff --git a/languages/exelearning-es_ES.po b/languages/exelearning-es_ES.po index 22aaba1..e6753dd 100644 --- a/languages/exelearning-es_ES.po +++ b/languages/exelearning-es_ES.po @@ -8,7 +8,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "POT-Creation-Date: 2025-11-29T00:24:31+00:00\n" -"PO-Revision-Date: 2026-05-28T15:27:28+00:00\n" +"PO-Revision-Date: 2026-06-02T20:18:41+00:00\n" "Language: es_ES\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: exelearning\n" @@ -275,15 +275,27 @@ msgstr "Estado" msgid "Edit" msgstr "Editar" +msgid "Invalid attachment ID." +msgstr "ID de adjunto no válido." + +msgid "The eXeLearning file could not be found on disk." +msgstr "No se encontró el archivo eXeLearning en el disco." + +msgid "This is not an eXeLearning file (.elpx)." +msgstr "Este no es un archivo eXeLearning (.elpx)." + +msgid "Failed to create directory for extracted files." +msgstr "Error al crear el directorio para los archivos extraídos." + msgid "Error: eXeLearning content not found" msgstr "Error: contenido de eXeLearning no encontrado" -msgid "This eXeLearning content is a source file and cannot be previewed directly." -msgstr "Este contenido de eXeLearning es un archivo fuente y no puede previsualizarse directamente." - msgid "Download file" msgstr "Descargar archivo" +msgid "This eXeLearning content is a source file and cannot be previewed directly." +msgstr "Este contenido de eXeLearning es un archivo fuente y no puede previsualizarse directamente." + msgid "eXeLearning Editor" msgstr "Editor de eXeLearning" @@ -323,33 +335,21 @@ msgstr "No tienes permiso para leer este archivo." msgid "File created successfully." msgstr "Archivo creado correctamente." +msgid "Another save is already in progress for this file. Please retry shortly." +msgstr "Ya hay un guardado en curso para este archivo. Vuelve a intentarlo en unos instantes." + msgid "Failed to save the file." msgstr "Error al guardar el archivo." msgid "File copy appears truncated." msgstr "La copia del archivo parece estar truncada." -msgid "Invalid attachment ID." -msgstr "ID de adjunto no válido." - msgid "Original file not found." msgstr "Archivo original no encontrado." -msgid "This is not an eXeLearning file (.elpx)." -msgstr "Este no es un archivo eXeLearning (.elpx)." - msgid "File saved successfully." msgstr "Archivo guardado correctamente." -msgid "Failed to create directory for extracted files." -msgstr "Error al crear el directorio para los archivos extraídos." - -msgid "Another save is already in progress for this file. Please retry shortly." -msgstr "Ya hay un guardado en curso para este archivo. Vuelve a intentarlo en unos instantes." - -msgid "The downloaded editor package failed its integrity (SHA-256) check and was discarded." -msgstr "El paquete del editor descargado no superó la comprobación de integridad (SHA-256) y se descartó." - msgid "Attachment not found." msgstr "Adjunto no encontrado." @@ -406,6 +406,9 @@ msgstr "Formato de etiqueta de versión inesperado: %s" msgid "Failed to download the editor package: %s" msgstr "Error al descargar el paquete del editor: %s" +msgid "The downloaded editor package failed its integrity (SHA-256) check and was discarded." +msgstr "El paquete del editor descargado no superó la comprobación de integridad (SHA-256) y se descartó." + msgid "The downloaded file is not a valid ZIP archive." msgstr "El archivo descargado no es un archivo ZIP válido." @@ -435,14 +438,11 @@ msgstr "No se pudo hacer copia de seguridad de la instalación actual del editor msgid "Failed to copy editor files to the plugin directory." msgstr "Error al copiar los archivos del editor al directorio del plugin." -msgid "Style not found." -msgstr "Estilo no encontrado." - -msgid "Failed to create style directory." -msgstr "No se pudo crear el directorio del estilo." +msgid "The uploaded file is not a readable ZIP archive." +msgstr "El archivo subido no es un archivo ZIP legible." -msgid "The uploaded style does not contain any stylesheet." -msgstr "El estilo subido no contiene ninguna hoja de estilos." +msgid "config.xml could not be read from the archive." +msgstr "No se pudo leer config.xml del archivo." msgid "Uploaded file is missing or unreadable." msgstr "El archivo subido no existe o no se puede leer." @@ -458,9 +458,6 @@ msgstr "El estilo subido supera el tamaño máximo permitido de %s." msgid "The ZipArchive PHP extension is not available." msgstr "La extensión ZipArchive de PHP no está disponible." -msgid "The uploaded file is not a readable ZIP archive." -msgstr "El archivo subido no es un archivo ZIP legible." - msgid "The ZIP archive contains unreadable entries." msgstr "El archivo ZIP contiene entradas ilegibles." @@ -483,15 +480,6 @@ msgstr "El archivo debe contener una única carpeta raíz o tener todos los fich msgid "File type not allowed in style package: %s" msgstr "Tipo de archivo no permitido en el paquete de estilo: %s" -msgid "config.xml could not be read from the archive." -msgstr "No se pudo leer config.xml del archivo." - -msgid "config.xml is not valid XML." -msgstr "config.xml no es XML válido." - -msgid "config.xml must declare a element." -msgstr "config.xml debe declarar un elemento ." - msgid "Failed to reopen ZIP archive." msgstr "No se pudo reabrir el archivo ZIP." @@ -510,6 +498,45 @@ msgstr "No se pudo leer un archivo del archivo comprimido." msgid "Failed to write an extracted file." msgstr "No se pudo escribir un archivo extraído." +msgid "config.xml is not valid XML." +msgstr "config.xml no es XML válido." + +msgid "config.xml must declare a element." +msgstr "config.xml debe declarar un elemento ." + +msgid "Style not found." +msgstr "Estilo no encontrado." + +msgid "Failed to create style directory." +msgstr "No se pudo crear el directorio del estilo." + +msgid "The uploaded style does not contain any stylesheet." +msgstr "El estilo subido no contiene ninguna hoja de estilos." + +msgid "Reprocess eXeLearning file" +msgstr "Reprocesar archivo eXeLearning" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d archivo eXeLearning reprocesado." +msgstr[1] "%d archivos eXeLearning reprocesados." + +#. translators: %d: number of items skipped. +#, php-format +msgid "%d item skipped (not an eXeLearning file)." +msgid_plural "%d items skipped (not eXeLearning files)." +msgstr[0] "%d elemento omitido (no es un archivo eXeLearning)." +msgstr[1] "%d elementos omitidos (no son archivos eXeLearning)." + +#. translators: %d: number of files that failed. +#, php-format +msgid "%d file could not be reprocessed." +msgid_plural "%d files could not be reprocessed." +msgstr[0] "%d archivo no se pudo reprocesar." +msgstr[1] "%d archivos no se pudieron reprocesar." + msgid "eXeLearning Info" msgstr "Información de eXeLearning" diff --git a/languages/exelearning-eu.mo b/languages/exelearning-eu.mo index ee60764140d0f0e92997b525a7058313dcde6fb9..d8ded5939935a5d1eb8a9aecc5601520bf011726 100644 GIT binary patch delta 2459 zcmaLXe@xVM9LMpGdGaGgOaoL%zAD%O1~rU8)EPo6M9qS7HJM+yFZ%89j_!_hx9HYC zjc8Pk>Yv(bIhFn^kIf&&+E^>MMn81f)?Ce&7#Xga=2nzFU)-evTkN>U=kxu3zMuE| z^ZtCk_xzTNjj`|NdtNvEh566pe_*D1#x$hfu!iO)`nC8fHsUup7mL%3S%pu&7#%1Zoc#MgeW*RA6XvNj|6fVL8n1LVT?Kq6<@kiuAv;0EW>70z?V^pe3a~8#47rEoJ^JcF?<|bFc*)bDtj7PlNpWC;2HC0 z(oE`DO1}X0z@w-Hf~bIdQ4=4;{9BAUj7{`UlJ!>fa>7CwMFkkcRrn9;ttn?eYq1gw zG1f&x@8f>d>+%DxLl4pQzy{QwJ&5)AFsf2-A~~BuT!N?Y0lb77U&V>m_4`rdcA!@N z4C?-i$kxWpJ{lXi@FAAtHPlMW??~*ujTQ7?LQOb?C3qHKoGC-~A4=-S)%1H& zTYLgpoB0A&!BL#2_x}nFoyHW7w6-D(b$W|&8P=dG1usMqoWY7etno*y@&4(SnGj$fe; z=Ver49UgR0m zhuZ67s6%xMwSZC7*8Pr3D2FZ5Z?HmAX{LjgCi(`s|u9V_H?go3S3 zIPCaJH6S*1M0nbeiRW5_o$Wp=5R6zY()>Qqj>7-C#BdH>>~;b~W=z;_k0g??yiT;QwP0~!qDI#TT_2%Y8N+I5kZqR7_EV*8Ip{TOv_f;1Y0L&%>n@_5l@+{} z>p6kFOord)SRQ7h^~*7hO!Nb7viwpXaGHtq5|grVBm4VnI7a_*CS$!!0B_RJ@-DNH z5198qVGED*7GCCh&hSz-k?hb&jrTDVA7X0yITP4PW+!LZ&VdGB$0ba_4=}Y|N98V$ z(QoPTC#9&ei-)+HqePd0x+?PjqQ*84vctin9TawOC7{rN}Cz&>V& zr|RFIXKHwv%Q)Q?n!^puhW0Ywf6d&UznFDeZGR?LQ6SawyoNc-p8f~rNZlW~!1Iet zZQI-(bCd;4rtW7lw2sbId4`f$wo+2cZr;R$OlH1i-oHS{ri{6A*f#q8uHvv4}GLdT&@Vivrexo&Hj628n0{Ftgxnk-VWep#fEi0)=; z(@j^jyu{Ra2k&K#-Yr9>)c^4?*RGW**<5CxcHY6&e1zNC&2zk!_gG{Ldz1f8jf)!d zxmdndaWhlPFZl-l=EHoosg$R9mZ@!*Z4T!)CIdT}Qte{`Kfsi>k2yoXGvhBYrD|xF z|3oyw=%nf{PUj}(knG_?9%E`g=;r=wSK$=>N12JXGo^WlDd7=bIke0?r)Y}OXz~$k uV-9J@biLGUqX)H?VFwy&e>CC;%RQH}O}ks` zhilm?s%7hk%QEP%CjLR1v?(oH`G;(3YmHTF8e%_IEt}Eo{ecUDY`M#MJ6H|MfdH0$ZF#~1NlJc(ChQHn9EupDP&2um@F zx!4_lehl;J|BJWbl2l`ojER|M8p+)7;~IPfugCW>9Y4iu@i^X&KOh55-c`n2hhEIU z3iMzjDv=gcz%8f*cVP)0!9x5Uy~H=!X~x{djcUxo4pb$c!yE7e)Wk>P&j(QfN3aYp zp#ql89Zz&ElB2l?wSWiX22l%p3^Va1Tu6M=O+x_(a6W#ID(#=BiDoeiRWs?R=b5NP z3sDuTiuZTmHS}LYRjeENnZsPj(R_(o$Z6F0vlvq;6s-t~Fn~>{KnGD3IF9_xPh1q> z0%~tlnN0~6qE=Lj>+mjIfICr%??G*G59(Nbgqp9!Au@EKHs zr*Jzex>mdcS70CBjpuL~mXY;N3}7?uAB;(eH(Xv}_81x}N7J?3%3+OPu^USjLB1c?@-$Uq)5v4b)cdMSiA_ zixT)6^DxI6Pq+$Mqw!&SjK&i*_|(nws8a4mVw?S_!*&97CK5QI+i*EDr|Cf*+Ap}M zv?Iv*HKVA#oySFIr4+T0N^C+KEj)xVt>9Z4+Or=~{Snmn`3J7TMAClb-i@A?(IoIE3xkwv_ruX?(Mk6NwGfSqW@MCGZr^#x7Lg z-KfeOLv6uX)LvdhZCw&4O7mo+601cXnP}WMaSin{Kh$z&O{o!kxi@bNNxkqNJ@s{}wwKVd#{X><9xU z=biQ*vuUT=>~=d8bu6bn7}cZ{DP$e(u{uMVapl;QQ78GQpMMVmcbRkHR2(tYnx@d%&JwNt9%Q@dO^PTUT_nmoX_Sv;ZR#i?n z^y@1Aw)&3oec_7qO1aqoe}`&b(x1t}14_A)i#db0^9FV@zwco)zvNt=V?W+nS4xJQ z%~sB5V=0x=p)tgb-MohTxrpyDFP`TJUf|UnKCqN}PGl09#RQzoB(Raw*u@rp!pZ!N zH*?e=UoPVq@k^V=Xg8i<2JWgo(9Hxq%Gvyi2{`bo-bCwZ%+kzEU~0{IG_!IiM{*Od zV<)r1ud;zhI8^-dsYV9+h9+5ltUYj+$@C(#WFrRm_V3|v{msmZbutOONRO7i%tQ_{ z@4wF$p5jfs#7&&+rAji{rI8x%X9hmZ)bt}Ju`|p}dN`kbJ$x+}F$u3@YI{GGyF5sr z((aN{RM^JDT*V=>OG0h+)_=Rkb8cjY{f5~nT*#Gtgj4tdcd~~YxJ{PrJjK*@nOUSX zTbP6&t@WSec>QOXmFT89l%q`ICmO83*yU?CrtxPc;0T3JB1@V6W4w(Am?i#!_wp=L z+r{eO)zU^emUgBzJ8HhdsrrYQ=f7tXIA767fXmFlQ*8C-zJf6WZLrR>_%O4?-MoWu zastnB9!HtpM6Tshb~2~uLnhG2T*H&Rp5yG$BwSgb;a*wIg?xw!bdbsPZRYr$WD@#` z8Tc+EC`HZwJzOB=Z?XKK2J7Ncxr0(CGe@-maid(6zwFf+f*0XhE-?q-QbQx!@RlgJz< zfpu)+Udp%}qj$?0Ch)&ZB7^;ym2705o9>c5wVJ8@4tDS@X8abHVd9q+8un3H&(w4) zvlm`sX7C!<@;Jw_(Iv;Ml_}MdT7Lzdu(Fynxsms=i|hFZv-Ed65>K;(l_eS%H0E=G z=y`BEv+2I&E)FsME!@Lx9O(b;Eqsbe{(iYp diff --git a/languages/exelearning-gl_ES.po b/languages/exelearning-gl_ES.po index 73f9605..e45d559 100644 --- a/languages/exelearning-gl_ES.po +++ b/languages/exelearning-gl_ES.po @@ -548,3 +548,30 @@ msgstr "Non se puido facer copia de seguridade da instalación actual do editor. #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Erro ao copiar os ficheiros do editor ao directorio do complemento." + +msgid "The eXeLearning file could not be found on disk." +msgstr "Non se atopou o ficheiro eXeLearning no disco." + +msgid "Reprocess eXeLearning file" +msgstr "Reprocesar o ficheiro eXeLearning" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d ficheiro eXeLearning reprocesado." +msgstr[1] "%d ficheiros eXeLearning reprocesados." + +#. translators: %d: number of items skipped. +#, php-format +msgid "%d item skipped (not an eXeLearning file)." +msgid_plural "%d items skipped (not eXeLearning files)." +msgstr[0] "%d elemento omitido (non é un ficheiro eXeLearning)." +msgstr[1] "%d elementos omitidos (non son ficheiros eXeLearning)." + +#. translators: %d: number of files that failed. +#, php-format +msgid "%d file could not be reprocessed." +msgid_plural "%d files could not be reprocessed." +msgstr[0] "Non se puido reprocesar %d ficheiro." +msgstr[1] "Non se puideron reprocesar %d ficheiros." diff --git a/languages/exelearning-it_IT.mo b/languages/exelearning-it_IT.mo index 09742c4d8d2a436b346a76cdc54b04a3abf890af..5714db4e0092dd8bd30c37a3a7198c1d827ee8ab 100644 GIT binary patch delta 2498 zcmaLXe@sEJQ##0_p_{G#p7HE6SEiQuwP`GJ$)rBbR&Vo=dSM-OV}Y z+G@I#`lp3+88q8mw=HHd%WAn9tJR`K&X#{z_QOA#%a*o!f9}03@ou>5eLd&g=RD8n z`99C#c=NeUk@LA}hYkNc{ulB8-Beu})0FYQjWi$O`awK^oA4XV#)_H7tiU?F1%p_F zVJya>#P4UYjO%}J6_#WglV(iBw9-iDMhD)DyKp|ffmwJ8Z^tpb4=*4On$la1xdVMT z7aK4KTTnsTQ4>Ce3iu*c<7q6%KhVegrf8NimE2f^`S>)d5-(r@zKI(6bmI4~P!s-& zwfHY;!rIyKp!G;b^B8IYPb73u3wsvx@E{g4zZs&T2|vMH{1H{!E2x1|8HK8uEY$CL zsG#MjiZv##`|&oe2T&CoLOy1cFEX05sD*rsdj2Pjs1%y50xHmtn^6;;L{(r6`Iw*i z(gasgdz;B<3RsR>Q4OxfbvO_Aqk<2jws;tItWKcD8_A*m)igfi#&*1l%W(4?W14Xv zD&RQoM$N7j_v3OL!S(nH-i5WK-j9B4#ow_9o7gWOj-s~eA}aWAbE&^>{K<_)cpX)m zh18Ggn0rtwSc7CU8?YK%Q4_v|3i3hX`ZCsWUBt;$$+zHEY{x=8iK^^4vLH@ge*g)3AUP+OuMO6qllc_3_2|G>4Gv<_K!b-a`e>;6yW*nS(kD z6{rYk&150o;q~cLsOiHPl2T=X1mQeraX&he?{{vJ>y%k^!sQU9$pE^>oKmn;RdB$?6>U4?Iyb|6m)_+txhLkTOqqA zDpETRx4`7F4>r zDe=lOR2bd^;05ty1hZa|SaZ=5dV*nzfoH`Eq(W9g>p;8dBl-94`7Iqk7pPd*v4t;ERFcPQkk*c^7i?zV$r z*K&3SV>4U6m#vb574iBcesv~)byU!x<+(vODL!?2a^8iEf|kiFNj;*giw~zPzcF_> ZC2u+7tuZyN@qS~pw1&((N=gHb52d|~SxBT|jZ|cfcIW~HT?olU z3Q=MVEHj#nXwWDak|+qR9;9M?NKzldhs0>Cet*t^hO^Iat$p@h>s#yl*8F_W2d$M~ zM-SXn{5|TL=z4WPdZqLY`rmrZtNOQc_~25m;Yu##8s5YX=J&Tbjo))I|Kvd4GNhCY z*~~k*l;caOlpPv%ZtUiDJirw^#=Ll$4g8y9IBIAqBRHK2R1{xSdTr&X;+S8~KDN+j*MF?P{w? zYPK-}KVIwa;$;07n4RdPHIx%f;NOn6|K=_~x-p->F%vdOd;(d;^q=G+zRPU!FWkWM zOm0`ogI7x%#aP;z)I3-7EzZ+_pLzZa6Tsz)Mke@|8MsNQPwj6qCa^a9JeS*;E#Akq zJit`_c`jkSEzgNY9pcxP;xz4qah#H>R<=GmT72W-#M4@n){zCT`~jo?>cgj!AZLJu9m; ze$ZIJaZ^g!$`GnU7E^;HsV=EKkzM4HuF diff --git a/languages/exelearning-it_IT.po b/languages/exelearning-it_IT.po index dbfe392..06f8744 100644 --- a/languages/exelearning-it_IT.po +++ b/languages/exelearning-it_IT.po @@ -548,3 +548,30 @@ msgstr "Impossibile effettuare il backup dell'installazione attuale dell'editor. #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Errore durante la copia dei file dell'editor nella directory del plugin." + +msgid "The eXeLearning file could not be found on disk." +msgstr "Impossibile trovare il file eXeLearning sul disco." + +msgid "Reprocess eXeLearning file" +msgstr "Rielabora file eXeLearning" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d file eXeLearning rielaborato." +msgstr[1] "%d file eXeLearning rielaborati." + +#. translators: %d: number of items skipped. +#, php-format +msgid "%d item skipped (not an eXeLearning file)." +msgid_plural "%d items skipped (not eXeLearning files)." +msgstr[0] "%d elemento ignorato (non è un file eXeLearning)." +msgstr[1] "%d elementi ignorati (non sono file eXeLearning)." + +#. translators: %d: number of files that failed. +#, php-format +msgid "%d file could not be reprocessed." +msgid_plural "%d files could not be reprocessed." +msgstr[0] "Impossibile rielaborare %d file." +msgstr[1] "Impossibile rielaborare %d file." diff --git a/languages/exelearning-pt_PT.mo b/languages/exelearning-pt_PT.mo index 01ee21e3759f6a2c0c05b22e1397c7f116fbf91b..9ec39d9de78de6b21739f558d10162a35fb7fc89 100644 GIT binary patch delta 2499 zcmaLXe@vBC9LMp4B0nOaG!X*f5d`jq1RDmT($t_eMJyF^I%#n4gWQ1o;C(>Lska|n zshh3Fw9U1})*@&}v0cgnq0?Ei{O#z**#De&I_C zjH327k=YcW7nM;NR^bYqg}YIK_oBA=UDUBUf|~DWD)ld=@i`YZ;V90>`g@F7jW3}B zJdZn2t1IK3_#hs|l{kX)u$-uOqaPdbckIMk_RE9EP+RpAD)5UlsJ|{;;ldoehAK@q z^`knb0+m4x5}T>RQfx#m_zEhJkE8uRu#$cjCsQSFz$dX8GjR}A+4D$F=GQO{o-uz# zO{I<{^yi`;Sc?k4K`r7{b+`x% zFuaF`-tQw=jDO-|n8WIN&00|bbYMMhMeY3=RNxnpM@$SSco}A)7HULp85_yXJdeuw zb=185$a-OOh{hva7{Y}(dv+v{1}voi0?xa-m>(8PR zJAm5aA*5L50&4HCV6xtSrK7!0L1mJK8d!n(SQoVmdBz+>W%3c~G=G7b@Ej`BZ&86> z!WvAmBJ-|`>Y#p@_F_ty#$g(~>gE&F;X94F_yazG@tn*hxBwm8g1SG97RJ(1K>4UG zDMxOZm8f|;P>F0uosnK7Hggrj8jz6}*|P#vM)OdI=OL`XYV5!rScN0F0X?LlGxH+u z!NXXMm8`ZHci|=+#BxmM#O=q`*o{9HP=A-kMmAcf`%6>+*HIZw^F;P84Rt>YEnI)?9Pzmvt8HrmuNuv z))DTcA!E<^obGnN6?D3+=E;+2glpY#NTAE^u-vvlXQ%DAJesE|sOTyugy$WLNn_oK zgC|AlEg85L*B6%_&1O=f18*j~F-5m#KOw{xr#tAkoS@|oxNQSTsr3m{^8G!-88gE- zOVH$Z#zJZd_*(5i$Z;nubfcIIb4yI!iMJf9#R*uQj_aN{wAF6^-$WscYL8EI=dpQB z69zLgkB${EusP_2Sj_VD^ycO)yiu;8YmMJ>sjlL1$AYR(D9_4giU?1L5S4yUw z%6mAIBTA{17c>SN*v{eH!`b|ldGI0|`7f{GumPnEWebzYolL;#OajZ<#%_-1VYc!- zC-C~KeK?P!#4jBhBMoe1Cho4?u$KvVh*S9k6L7%5otYgki*7BunT70Q zp8uTVd7QWMGFP(ILzQImhDK_x)c(Jy@v?!euur{%!dZNXPjey<@)e%rV|-SYojlIecAizF zG;5fI*Vp=+c(eW%W+(R28p=Oh#86c_U^rnGaMP$5g@agCh*=h@EfOrRf`OiwVc-`~v2 z2D>>Ek7fdn_CMwKbFyQQ6DxQw@P6K8NA*YVHV^(E?1FMipmky>nHPU{|K z!Y`Q>e9xu4K>uYyQ_uCaOsTq<*KRYD&}+PlySRqOxQw$*o~?g}ukveF=4mXF?sRsu zgU2|PZDWn`32xveuIDqBpF{fx6L`2Rb2dh?FDEkBC$otwn73mya~Qjs1@CUL|9S1I z1}5tvv}2 wZ1tO-Et_VbI==4xe$}%>I(t>O)i0>4o*gl=uG%u@aIYO(nwzTgo6q$74Lk3w2)R`iE4SJ4lxEBdtiT)4 zkL4J~eC$iyKZ1+t|Alwr!gOPjjft8j8Yx`x;A(sXbMP(9z>hH-k6|T#i#%u+O*7_Z zEW}y37BjIHl}HOJ;KQf{cVQ_WMi*YdLgJge>Bbaup&IAnqo_(ei?`r@)WnAq_lHpd z&)`bDf(p2DMm*8GkQ~is)B+k3`cMn&z}fgB<`UoZ(NMr4oQbDVrM-xnXezT%HIsq5 zKO2>(3steIM86ZW=)Z)jSRe8+gM5*rIf`1y3DomHVpOG2v?3@%FK$Hz8bDRx81gYc z@udKlQG1)tY)a6DT2VQ!!TWFycB2x11GU9(qmI>ksQC_NQvXsKpK_rQFJl>Qy~&tO zxC52o2tJ95t`&FUay*C|@E2T$E6KVWz1W23Fo>1K5JOIDo3`2(l*gbCd?p zm_HI`QpXbdi%}2Uk4hkb3iudm;$GY`#h4E;KtGj})qx%OGM-13xSRdc>$Vp)&ll*% zFL5D8T|~Kqh8uO^X{^FN)N6JIwP(L!CH{^YSHX#;Smr@wE)&G{_#CRV$5ChG6q1X% zf~sK7y!csgVVU0lG8*f+(To+?8^2+`K~*Aaetgd>Q4>Ca+RIl_TQ!7izxfIk=sYUH zbOvw4rKl}_998M3P?gw&GxYxNqoE1jK}|S_RMm{&B0PtDOqLa2>2g$Q-KfemAipKX zgG#`UdafI5@Lgmr=1)|mayV$}ufSPuU#q^DT2vIm3%E~Tn%3jVF;PZ3}aL)xJW}K8b!UQQ;4pF(y<(KQ4elP z^bcSI{qL|8?=Fb{{}I4@bsbk>@e*U|(Sx_+A$$`rpbqQaTdDs}8kuaU_WCtcB1bU; zKgVP|g$ndNYGv83_*Rvp4x1ZQxjIzB4r=^R38 z$taG$pSPh(S&ce0ji}QeLIv1?+QR`%!;`oSPovI8rlJ;jExX=!+sz@r&%e!T^|jen z$PR`A9@}wjZ;1v(CysC?4T(MH2}IhwmOl`-S|(4T5sr1;A-=HPZaLe1!JzH63N=r& zU)fcROV1U{Nn@SygC}L_D(N3h=}noNSk0t`_P?ItBo$3u{kRlc0};R13ivIr&)MET zJ+mtY$A6|FfO%P%p+{+BU!Y>?L;o9g=G#^U)SR&&$}M#7OW5B{?W z6GLiPeC-RH0)5zBUZ>I!mv@jXb~wPUyK*nKC$=xeP#{sasD ze-HKVnzJD-tu}EWdj_n${*(D1B_-X{^NE!`&rzC{wahZjOVczj)4Zi7YOSPo7o$O}vV=4!7HT(*Cy7k(QVQA& zNfv1@Dw3QM6$%v*gfAjVvWsYDU=mT{UX)g!FVCRyJo}vU-~XKRJHOvK+u3sHp31R- zwcCoHCwzzcZoDYHQqK4I-+r1G^rx{;&r-T`0jF{yhjTUa_Z@8DSDeFhtmTxtQZnTX z&gNWRQA(veqtVxct-O@&oX`Etz)lY0-@JqadX-Yo(M%xInFZ%C0X)P;ZsRz9&awQR z<9YeTzHH)9>z7uIAs#%(OuVh@h26}82RVaZGYj_WT@AFJ<}3}&1}1fR1MRFd^D-{w zm0Zna_%#mXKK8SI`9dQT9idH@<6SSDW+FY$q-;>1YX5Ey&|l7EY&8?WtMqEw$!z3p zX8cDS$HTmq7r2yT4XPxPO&Y0jJ2UYfrly}Tft_G>a)xu+&C7#1p9#2ysqGRfcUeZC z(&kr6QDH0h@GkZhT>@&Ym;W^yFME(3*7kQ$IFGmSNlxSeuICv(z?GtG<6)+@O?Hve zJkA8XqO1QrN9w=GWMVh%p&VoaKQvJOtzEwHppmDT1qUg70=bFlui`Ae$)xy4-pA8S zZ5OD6p{12#ENx6_)^@p_ll0$V-v5pXptGWp1^#6wp6IAA?q)H{R@p4ixkj%u1CH`` z{=`viw5{v8i0NEwL=!@M`o2^D*(Jgkw^{}gZK7AA$?GiTrweM)yP zB~!zhS~qesXRwKnau(n1dj1QuFCV-X9 zz|Fjqd+AeZuBuKjoaxVI#ksvcdiAZ~gMEhH{qE z^iHW6QBCy-CXmT|kc*kqypO5r5hgRon3DX=1agK`d4U->#i0ASg*oKEaRrBoqOvf5 z(3r`iY~cW_PUmCX#dbc;n{8_&4=}ZD9$gLKS@zYhu$G&cg|;#q`jjcn3Fd75#bl~> zjQl6kkz=X@?&SpimzV|iGKca6b34Xg<1TY1#ZW$GuHA7agJ+pD(%T8k=^nw%KaDBb zGS+b&bL(CitAIHS2R!H)SNB$rj$iw=)pTs=zoxEZ{gCFmj?PgZ)^wa~nA7t=^uN83 diff --git a/languages/exelearning-ro_RO.po b/languages/exelearning-ro_RO.po index 0634bc8..8bf8d38 100644 --- a/languages/exelearning-ro_RO.po +++ b/languages/exelearning-ro_RO.po @@ -549,3 +549,33 @@ msgstr "Nu s-a putut face o copie de siguranță a instalării curente a editoru #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Eroare la copierea fișierelor editorului în directorul pluginului." + +msgid "The eXeLearning file could not be found on disk." +msgstr "Fișierul eXeLearning nu a putut fi găsit pe disc." + +msgid "Reprocess eXeLearning file" +msgstr "Reprocesează fișierul eXeLearning" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d fișier eXeLearning reprocesat." +msgstr[1] "%d fișiere eXeLearning reprocesate." +msgstr[2] "%d de fișiere eXeLearning reprocesate." + +#. translators: %d: number of items skipped. +#, php-format +msgid "%d item skipped (not an eXeLearning file)." +msgid_plural "%d items skipped (not eXeLearning files)." +msgstr[0] "%d element omis (nu este un fișier eXeLearning)." +msgstr[1] "%d elemente omise (nu sunt fișiere eXeLearning)." +msgstr[2] "%d de elemente omise (nu sunt fișiere eXeLearning)." + +#. translators: %d: number of files that failed. +#, php-format +msgid "%d file could not be reprocessed." +msgid_plural "%d files could not be reprocessed." +msgstr[0] "%d fișier nu a putut fi reprocesat." +msgstr[1] "%d fișiere nu au putut fi reprocesate." +msgstr[2] "%d de fișiere nu au putut fi reprocesate." diff --git a/languages/exelearning.pot b/languages/exelearning.pot index 686504e..a4d1110 100644 --- a/languages/exelearning.pot +++ b/languages/exelearning.pot @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: eXeLearning 0.0.0\n" -"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-exelearning\n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/exelearning\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" @@ -274,15 +274,27 @@ msgstr "" msgid "Edit" msgstr "" -msgid "Error: eXeLearning content not found" +msgid "Invalid attachment ID." msgstr "" -msgid "This eXeLearning content is a source file and cannot be previewed directly." +msgid "The eXeLearning file could not be found on disk." +msgstr "" + +msgid "This is not an eXeLearning file (.elpx)." +msgstr "" + +msgid "Failed to create directory for extracted files." +msgstr "" + +msgid "Error: eXeLearning content not found" msgstr "" msgid "Download file" msgstr "" +msgid "This eXeLearning content is a source file and cannot be previewed directly." +msgstr "" + msgid "eXeLearning Editor" msgstr "" @@ -322,27 +334,21 @@ msgstr "" msgid "File created successfully." msgstr "" -msgid "Failed to save the file." +msgid "Another save is already in progress for this file. Please retry shortly." msgstr "" -msgid "File copy appears truncated." +msgid "Failed to save the file." msgstr "" -msgid "Invalid attachment ID." +msgid "File copy appears truncated." msgstr "" msgid "Original file not found." msgstr "" -msgid "This is not an eXeLearning file (.elpx)." -msgstr "" - msgid "File saved successfully." msgstr "" -msgid "Failed to create directory for extracted files." -msgstr "" - msgid "Attachment not found." msgstr "" @@ -399,6 +405,9 @@ msgstr "" msgid "Failed to download the editor package: %s" msgstr "" +msgid "The downloaded editor package failed its integrity (SHA-256) check and was discarded." +msgstr "" + msgid "The downloaded file is not a valid ZIP archive." msgstr "" @@ -428,13 +437,10 @@ msgstr "" msgid "Failed to copy editor files to the plugin directory." msgstr "" -msgid "Style not found." -msgstr "" - -msgid "Failed to create style directory." +msgid "The uploaded file is not a readable ZIP archive." msgstr "" -msgid "The uploaded style does not contain any stylesheet." +msgid "config.xml could not be read from the archive." msgstr "" msgid "Uploaded file is missing or unreadable." @@ -451,9 +457,6 @@ msgstr "" msgid "The ZipArchive PHP extension is not available." msgstr "" -msgid "The uploaded file is not a readable ZIP archive." -msgstr "" - msgid "The ZIP archive contains unreadable entries." msgstr "" @@ -476,15 +479,6 @@ msgstr "" msgid "File type not allowed in style package: %s" msgstr "" -msgid "config.xml could not be read from the archive." -msgstr "" - -msgid "config.xml is not valid XML." -msgstr "" - -msgid "config.xml must declare a element." -msgstr "" - msgid "Failed to reopen ZIP archive." msgstr "" @@ -503,6 +497,45 @@ msgstr "" msgid "Failed to write an extracted file." msgstr "" +msgid "config.xml is not valid XML." +msgstr "" + +msgid "config.xml must declare a element." +msgstr "" + +msgid "Style not found." +msgstr "" + +msgid "Failed to create style directory." +msgstr "" + +msgid "The uploaded style does not contain any stylesheet." +msgstr "" + +msgid "Reprocess eXeLearning file" +msgstr "" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "" +msgstr[1] "" + +#. translators: %d: number of items skipped. +#, php-format +msgid "%d item skipped (not an eXeLearning file)." +msgid_plural "%d items skipped (not eXeLearning files)." +msgstr[0] "" +msgstr[1] "" + +#. translators: %d: number of files that failed. +#, php-format +msgid "%d file could not be reprocessed." +msgid_plural "%d files could not be reprocessed." +msgstr[0] "" +msgstr[1] "" + msgid "eXeLearning Info" msgstr "" @@ -598,9 +631,3 @@ msgstr "" msgid "This is an eXeLearning v2 source file. The content will be displayed on the frontend if exported HTML is available." msgstr "" - -msgid "Another save is already in progress for this file. Please retry shortly." -msgstr "" - -msgid "The downloaded editor package failed its integrity (SHA-256) check and was discarded." -msgstr "" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c06089f..67e2eca 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,6 +19,8 @@ admin/class-admin-upload.php public/views/elp-list.php + + includes/class-cli-command.php diff --git a/tests/unit/MediaLibraryReprocessTest.php b/tests/unit/MediaLibraryReprocessTest.php index 55b60bf..f9d1925 100644 --- a/tests/unit/MediaLibraryReprocessTest.php +++ b/tests/unit/MediaLibraryReprocessTest.php @@ -165,4 +165,71 @@ public function test_handle_bulk_skips_non_elpx() { $this->assertEquals( 0, (int) $args['exe_reprocessed'] ); $this->assertEquals( 1, (int) $args['exe_skipped'] ); } + + /** + * The admin notice reports reprocessed/skipped counts as a success notice. + */ + public function test_admin_notice_renders_success_counts() { + $_REQUEST['exe_reprocessed'] = '2'; + $_REQUEST['exe_skipped'] = '1'; + $_REQUEST['exe_failed'] = '0'; + + ob_start(); + $this->media_library->render_reprocess_admin_notice(); + $output = ob_get_clean(); + + unset( $_REQUEST['exe_reprocessed'], $_REQUEST['exe_skipped'], $_REQUEST['exe_failed'] ); + + $this->assertStringContainsString( 'notice-success', $output ); + $this->assertStringContainsString( '2 eXeLearning files reprocessed.', $output ); + $this->assertStringContainsString( 'skipped', $output ); + } + + /** + * Failures turn the notice into a warning. + */ + public function test_admin_notice_warns_on_failures() { + $_REQUEST['exe_reprocessed'] = '0'; + $_REQUEST['exe_skipped'] = '0'; + $_REQUEST['exe_failed'] = '1'; + + ob_start(); + $this->media_library->render_reprocess_admin_notice(); + $output = ob_get_clean(); + + unset( $_REQUEST['exe_reprocessed'], $_REQUEST['exe_skipped'], $_REQUEST['exe_failed'] ); + + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringContainsString( 'could not be reprocessed', $output ); + } + + /** + * The notice stays silent when our query args are absent. + */ + public function test_admin_notice_silent_without_params() { + unset( $_REQUEST['exe_reprocessed'], $_REQUEST['exe_skipped'], $_REQUEST['exe_failed'] ); + + ob_start(); + $this->media_library->render_reprocess_admin_notice(); + $output = ob_get_clean(); + + $this->assertEmpty( $output ); + } + + /** + * The notice stays silent when every count is zero (nothing to report). + */ + public function test_admin_notice_silent_when_all_zero() { + $_REQUEST['exe_reprocessed'] = '0'; + $_REQUEST['exe_skipped'] = '0'; + $_REQUEST['exe_failed'] = '0'; + + ob_start(); + $this->media_library->render_reprocess_admin_notice(); + $output = ob_get_clean(); + + unset( $_REQUEST['exe_reprocessed'], $_REQUEST['exe_skipped'], $_REQUEST['exe_failed'] ); + + $this->assertEmpty( $output ); + } } From ae932ceb9bffece42db6a8f050f3d461ed8ccd35 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 21:49:43 +0100 Subject: [PATCH 3/6] feat: accept content-validated .zip attachments in the reprocessor eXeLearning source projects are ZIP archives whose only reliable signature is an inner content.xml. A genuine project can land in the Media Library with a .zip extension (renamed, or stored by a flow such as Formidable Forms). The reprocessor previously rejected those purely by extension. Broaden only the reprocessor (bulk action, WP-CLI, single --id) to also accept .zip attachments, gated by content validation. The upload path, MIME registration and UI stay .elpx-only, so backup zips and the plugin's own web/SCORM/IMS exports (which have no content.xml) are never treated as eXeLearning. - Add ACCEPTED_EXTENSIONS (elpx, zip) and split detection into is_exelearning_candidate() (cheap extension check) and is_eligible() (content-validates .zip via ExeLearning_Elp_File_Service::validate_elp_file). - reprocess() accepts .elpx or .zip; extraction still validates content and returns a clear error for a non-eXeLearning archive without writing metadata. - needs_reprocessing()/scan queries match .elpx OR .zip and filter by is_eligible(), so plain archives are never auto-flagged. - Bulk handler skips ineligible items (incl. plain zips); CLI uses the renamed candidate query and eligibility guard. No new user-facing strings (reuse existing translated messages), so the i18n gate is unaffected. New ReprocessorTest/MediaLibraryReprocessTest cases cover valid vs plain .zip across reprocess, needs_reprocessing, the candidate query and the bulk action. Line coverage 79.26%. --- includes/class-cli-command.php | 6 +- includes/class-elp-reprocessor.php | 151 ++++++++++++++---- includes/integrations/class-media-library.php | 5 +- tests/unit/MediaLibraryReprocessTest.php | 80 ++++++++++ tests/unit/ReprocessorTest.php | 119 ++++++++++++++ 5 files changed, 324 insertions(+), 37 deletions(-) diff --git a/includes/class-cli-command.php b/includes/class-cli-command.php index 5cf2449..a8f4f10 100644 --- a/includes/class-cli-command.php +++ b/includes/class-cli-command.php @@ -62,7 +62,7 @@ public function reprocess( $args, $assoc_args ) { if ( $id ) { $ids = array( $id ); } elseif ( $force ) { - $ids = $reprocessor->get_elpx_attachment_ids(); + $ids = $reprocessor->get_candidate_attachment_ids(); } else { $ids = $reprocessor->get_reprocessable_attachment_ids(); } @@ -77,8 +77,8 @@ public function reprocess( $args, $assoc_args ) { $skipped = 0; foreach ( $ids as $one ) { - if ( ! $reprocessor->is_elpx_attachment( $one ) ) { - WP_CLI::warning( sprintf( 'Skipped #%d: not an eXeLearning (.elpx) attachment.', $one ) ); + if ( ! $reprocessor->is_eligible( $one ) ) { + WP_CLI::warning( sprintf( 'Skipped #%d: not eXeLearning content (.elpx or a .zip containing content.xml).', $one ) ); ++$skipped; continue; } diff --git a/includes/class-elp-reprocessor.php b/includes/class-elp-reprocessor.php index 25032a6..e4bd6fc 100644 --- a/includes/class-elp-reprocessor.php +++ b/includes/class-elp-reprocessor.php @@ -19,10 +19,24 @@ /** * Class ExeLearning_Reprocessor. * - * Validates, (re)extracts and updates metadata for existing .elpx attachments. + * Validates, (re)extracts and updates metadata for existing .elpx attachments + * (and .zip attachments whose contents validate as eXeLearning). */ class ExeLearning_Reprocessor { + /** + * File extensions the reprocessor will consider as eXeLearning candidates. + * + * `.elpx` is the canonical extension; `.zip` is accepted too because a real + * eXeLearning source project can land in the Media Library renamed or stored + * by a flow (e.g. Formidable Forms) that kept a generic extension. A `.zip` + * is only ever acted upon once its contents validate as eXeLearning, so plain + * backup archives and the plugin's own web/SCORM/IMS exports are ignored. + * + * @var string[] + */ + const ACCEPTED_EXTENSIONS = array( 'elpx', 'zip' ); + /** * (Re)process an existing attachment so it becomes previewable. * @@ -55,7 +69,7 @@ public function reprocess( $attachment_id ) { } $ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); - if ( 'elpx' !== $ext ) { + if ( ! in_array( $ext, self::ACCEPTED_EXTENSIONS, true ) ) { return new WP_Error( 'invalid_file_type', __( 'This is not an eXeLearning file (.elpx).', 'exelearning' ), @@ -63,6 +77,10 @@ public function reprocess( $attachment_id ) { ); } + // For a .zip the extension alone proves nothing; extract_to_new_dir() + // below validates the contents (content.xml) and returns a clear error + // for a non-eXeLearning archive without writing any metadata. + // Remember the current extraction so we can clean it up only on success. $old_hash = get_post_meta( $attachment_id, '_exelearning_extracted', true ); @@ -86,16 +104,17 @@ public function reprocess( $attachment_id ) { } /** - * Whether an attachment is an unprocessed (or stale) .elpx that should be reprocessed. + * Whether an attachment is an unprocessed (or stale) eXeLearning file. * - * Returns true for a .elpx attachment that has no extraction recorded, or - * whose recorded extraction directory no longer exists on disk. + * Returns true for an eligible attachment (a .elpx, or a .zip whose contents + * validate as eXeLearning) that has no extraction recorded, or whose recorded + * extraction directory no longer exists on disk. * * @param int $attachment_id Attachment ID. * @return bool */ public function needs_reprocessing( $attachment_id ) { - if ( ! $this->is_elpx_attachment( $attachment_id ) ) { + if ( ! $this->is_eligible( $attachment_id ) ) { return false; } @@ -111,12 +130,16 @@ public function needs_reprocessing( $attachment_id ) { } /** - * Whether the attachment points at a .elpx file. + * Whether the attachment has an accepted extension (.elpx or .zip). + * + * Cheap, extension-only check with no file I/O. A positive result does NOT + * guarantee the file is really eXeLearning content; use is_eligible() (which + * also content-validates .zip files) before scanning or bulk processing. * * @param int $attachment_id Attachment ID. * @return bool */ - public function is_elpx_attachment( $attachment_id ) { + public function is_exelearning_candidate( $attachment_id ) { $attachment = get_post( $attachment_id ); if ( ! $attachment || 'attachment' !== $attachment->post_type ) { return false; @@ -127,22 +150,69 @@ public function is_elpx_attachment( $attachment_id ) { return false; } - return 'elpx' === strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); + $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); + + return in_array( $ext, self::ACCEPTED_EXTENSIONS, true ); + } + + /** + * Whether an attachment should be treated as eXeLearning content. + * + * `.elpx` candidates are eligible by extension; `.zip` candidates are only + * eligible when their contents validate as an eXeLearning project. This is + * the content-gate that keeps plain backup archives out of the scan and bulk + * flows. + * + * @param int $attachment_id Attachment ID. + * @return bool + */ + public function is_eligible( $attachment_id ) { + if ( ! $this->is_exelearning_candidate( $attachment_id ) ) { + return false; + } + + $file = get_attached_file( $attachment_id ); + $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); + + if ( 'elpx' === $ext ) { + return true; + } + + // .zip: only eligible if the archive actually contains eXeLearning content. + return $this->is_valid_elp_file( $file ); + } + + /** + * Whether a file on disk validates as an eXeLearning project (content sniff). + * + * Reuses the file service's parse-only validation (requires content.xml) + * without extracting anything. + * + * @param string $file_path Absolute path to the file. + * @return bool + */ + private function is_valid_elp_file( $file_path ) { + if ( ! $file_path || ! file_exists( $file_path ) ) { + return false; + } + + $service = new ExeLearning_Elp_File_Service(); + + return ! is_wp_error( $service->validate_elp_file( $file_path ) ); } /** - * Collect the IDs of every .elpx attachment that still needs processing. + * Collect the IDs of every eXeLearning attachment that still needs processing. * * Used by the WP-CLI `--all` flow and the bulk/admin scan. The cheap meta - * LIKE query narrows the set to attachments whose file name contains - * `.elpx`; needs_reprocessing() then confirms the extension and extraction - * state for each candidate. + * LIKE query narrows the set to .elpx/.zip attachments; needs_reprocessing() + * then content-validates each candidate and checks its extraction state. * * @return int[] Attachment IDs. */ public function get_reprocessable_attachment_ids() { $ids = array(); - foreach ( $this->get_elpx_attachment_ids() as $id ) { + foreach ( $this->query_candidate_ids() as $id ) { if ( $this->needs_reprocessing( $id ) ) { $ids[] = (int) $id; } @@ -152,14 +222,42 @@ public function get_reprocessable_attachment_ids() { } /** - * Collect the IDs of every .elpx attachment, regardless of extraction state. + * Collect the IDs of every eligible eXeLearning attachment, processed or not. * - * Used by the WP-CLI `--all --force` flow. The cheap meta LIKE query narrows - * the candidates; is_elpx_attachment() then confirms the real extension. + * Used by the WP-CLI `--all --force` flow. Plain .zip archives are filtered + * out by the is_eligible() content gate. * * @return int[] Attachment IDs. */ - public function get_elpx_attachment_ids() { + public function get_candidate_attachment_ids() { + $ids = array(); + foreach ( $this->query_candidate_ids() as $id ) { + if ( $this->is_eligible( $id ) ) { + $ids[] = (int) $id; + } + } + + return $ids; + } + + /** + * Query attachments whose file name ends in an accepted extension. + * + * The meta LIKE clauses are a cheap pre-filter; is_exelearning_candidate() + * (via the callers) confirms the exact extension afterwards. + * + * @return int[] Attachment IDs. + */ + private function query_candidate_ids() { + $meta_query = array( 'relation' => 'OR' ); + foreach ( self::ACCEPTED_EXTENSIONS as $ext ) { + $meta_query[] = array( + 'key' => '_wp_attached_file', + 'value' => '.' . $ext, + 'compare' => 'LIKE', + ); + } + $query = new WP_Query( array( 'post_type' => 'attachment', @@ -168,24 +266,11 @@ public function get_elpx_attachment_ids() { 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, - 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-off admin/CLI maintenance scan. - array( - 'key' => '_wp_attached_file', - 'value' => '.elpx', - 'compare' => 'LIKE', - ), - ), + 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-off admin/CLI maintenance scan. ) ); - $ids = array(); - foreach ( $query->posts as $id ) { - if ( $this->is_elpx_attachment( $id ) ) { - $ids[] = (int) $id; - } - } - - return $ids; + return array_map( 'intval', $query->posts ); } /** diff --git a/includes/integrations/class-media-library.php b/includes/integrations/class-media-library.php index 4839a51..73bcf57 100644 --- a/includes/integrations/class-media-library.php +++ b/includes/integrations/class-media-library.php @@ -75,7 +75,10 @@ public function handle_bulk_reprocess( $redirect_to, $doaction, $post_ids ) { foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; - if ( ! $reprocessor->is_elpx_attachment( $post_id ) ) { + // Skip anything that is not eXeLearning content: non-candidates and + // plain .zip archives (a broken .elpx stays eligible and is reported + // as a failure when reprocessing errors). + if ( ! $reprocessor->is_eligible( $post_id ) ) { ++$skipped; continue; } diff --git a/tests/unit/MediaLibraryReprocessTest.php b/tests/unit/MediaLibraryReprocessTest.php index f9d1925..806ec72 100644 --- a/tests/unit/MediaLibraryReprocessTest.php +++ b/tests/unit/MediaLibraryReprocessTest.php @@ -95,6 +95,42 @@ private function make_elpx_attachment() { return $attachment_id; } + /** + * Create an attachment backed by a ZIP file with an arbitrary extension. + * + * @param string $ext File extension (e.g. 'zip'). + * @param bool $with_content_xml Whether the archive is a real eXeLearning project. + * @return int Attachment ID. + */ + private function make_zip_attachment( $ext, $with_content_xml ) { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $attachment_id = $this->factory->attachment->create( + array( + 'post_mime_type' => 'application/zip', + 'post_author' => $user_id, + ) + ); + wp_set_current_user( $user_id ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/bulk-' . $attachment_id . '.' . $ext; + + $zip = new ZipArchive(); + $zip->open( $file_path, ZipArchive::CREATE ); + if ( $with_content_xml ) { + $zip->addFromString( 'content.xml', '' ); + $zip->addFromString( 'index.html', '' ); + } else { + $zip->addFromString( 'readme.txt', 'just a backup archive' ); + } + $zip->close(); + + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + return $attachment_id; + } + /** * The reprocess bulk action is added to the media list table. */ @@ -166,6 +202,50 @@ public function test_handle_bulk_skips_non_elpx() { $this->assertEquals( 1, (int) $args['exe_skipped'] ); } + /** + * A .zip whose contents are a valid eXeLearning project is reprocessed. + */ + public function test_handle_bulk_reprocesses_valid_zip() { + $id = $this->make_zip_attachment( 'zip', true ); + + $redirect = $this->media_library->handle_bulk_reprocess( + 'http://example.org/wp-admin/upload.php', + 'exelearning_reprocess', + array( $id ) + ); + + $hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->assertNotEmpty( $hash ); + + $upload_dir = wp_upload_dir(); + $this->cleanup_paths[] = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; + + $query = wp_parse_url( $redirect, PHP_URL_QUERY ); + parse_str( (string) $query, $args ); + $this->assertEquals( 1, (int) $args['exe_reprocessed'] ); + } + + /** + * A plain backup .zip (no content.xml) is skipped, not failed. + */ + public function test_handle_bulk_skips_plain_zip() { + $id = $this->make_zip_attachment( 'zip', false ); + + $redirect = $this->media_library->handle_bulk_reprocess( + 'http://example.org/wp-admin/upload.php', + 'exelearning_reprocess', + array( $id ) + ); + + $this->assertEmpty( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $query = wp_parse_url( $redirect, PHP_URL_QUERY ); + parse_str( (string) $query, $args ); + $this->assertEquals( 0, (int) $args['exe_reprocessed'] ); + $this->assertEquals( 1, (int) $args['exe_skipped'] ); + $this->assertEquals( 0, (int) $args['exe_failed'] ); + } + /** * The admin notice reports reprocessed/skipped counts as a success notice. */ diff --git a/tests/unit/ReprocessorTest.php b/tests/unit/ReprocessorTest.php index d961994..3f7f899 100644 --- a/tests/unit/ReprocessorTest.php +++ b/tests/unit/ReprocessorTest.php @@ -87,6 +87,49 @@ private function make_elpx_attachment( $with_index = true, $valid_zip = true ) { ); } + /** + * Create an attachment backed by a ZIP file with an arbitrary extension. + * + * @param string $ext File extension (e.g. 'zip'). + * @param bool $with_content_xml Whether the archive contains content.xml (i.e. is a real eXeLearning project). + * @param bool $with_index Whether the archive includes index.html (previewable). + * @return array { id: int, path: string } + */ + private function make_zip_attachment( $ext, $with_content_xml = true, $with_index = true ) { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $attachment_id = $this->factory->attachment->create( + array( + 'post_mime_type' => 'application/zip', + 'post_author' => $user_id, + 'post_title' => 'Existing ZIP', + ) + ); + wp_set_current_user( $user_id ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/reprocess-' . $attachment_id . '.' . $ext; + + $zip = new ZipArchive(); + $zip->open( $file_path, ZipArchive::CREATE ); + if ( $with_content_xml ) { + $zip->addFromString( 'content.xml', '' ); + } else { + $zip->addFromString( 'readme.txt', 'just a backup archive, not eXeLearning' ); + } + if ( $with_index ) { + $zip->addFromString( 'index.html', 'Preview' ); + } + $zip->close(); + + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + return array( + 'id' => $attachment_id, + 'path' => $file_path, + ); + } + /** * Absolute path to the extraction directory for a hash. * @@ -292,6 +335,75 @@ public function test_shortcode_renders_preview_after_reprocess() { $this->assertStringContainsString( 'exelearning-iframe', $after ); } + /** + * A .zip whose contents validate as eXeLearning is reprocessed. + */ + public function test_reprocess_accepts_valid_exelearning_zip() { + $fixture = $this->make_zip_attachment( 'zip', true, true ); + $id = $fixture['id']; + + $result = $this->reprocessor->reprocess( $id ); + + $this->assertIsArray( $result ); + + $hash = get_post_meta( $id, '_exelearning_extracted', true ); + $this->assertNotEmpty( $hash ); + $this->assertEquals( '1', get_post_meta( $id, '_exelearning_has_preview', true ) ); + + $this->cleanup_paths[] = $this->extraction_dir( $hash ); + } + + /** + * A plain .zip (no content.xml) is rejected and writes no metadata. + */ + public function test_reprocess_rejects_plain_zip() { + $fixture = $this->make_zip_attachment( 'zip', false, false ); + $id = $fixture['id']; + + $result = $this->reprocessor->reprocess( $id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEmpty( get_post_meta( $id, '_exelearning_extracted', true ) ); + } + + /** + * is_exelearning_candidate() accepts .elpx and .zip, rejects other types. + */ + public function test_is_exelearning_candidate() { + $elpx = $this->make_elpx_attachment( true ); + $zip = $this->make_zip_attachment( 'zip', true, true ); + + $this->assertTrue( $this->reprocessor->is_exelearning_candidate( $elpx['id'] ) ); + $this->assertTrue( $this->reprocessor->is_exelearning_candidate( $zip['id'] ) ); + + $attachment_id = $this->factory->attachment->create(); + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/cand-' . $attachment_id . '.jpg'; + file_put_contents( $file_path, 'x' ); // phpcs:ignore + $this->cleanup_paths[] = $file_path; + update_attached_file( $attachment_id, $file_path ); + + $this->assertFalse( $this->reprocessor->is_exelearning_candidate( $attachment_id ) ); + } + + /** + * needs_reprocessing() is true for a valid unprocessed eXeLearning .zip. + */ + public function test_needs_reprocessing_true_for_valid_zip() { + $fixture = $this->make_zip_attachment( 'zip', true, true ); + + $this->assertTrue( $this->reprocessor->needs_reprocessing( $fixture['id'] ) ); + } + + /** + * needs_reprocessing() is false for a plain .zip (content does not validate). + */ + public function test_needs_reprocessing_false_for_plain_zip() { + $fixture = $this->make_zip_attachment( 'zip', false, false ); + + $this->assertFalse( $this->reprocessor->needs_reprocessing( $fixture['id'] ) ); + } + /** * needs_reprocessing() is true for an unprocessed .elpx attachment. */ @@ -359,10 +471,17 @@ public function test_get_reprocessable_attachment_ids() { $this->cleanup_paths[] = $img_path; update_attached_file( $image, $img_path ); + // A .zip that IS a valid eXeLearning project must be picked up... + $valid_zip = $this->make_zip_attachment( 'zip', true, true ); + // ...while a plain backup .zip must be ignored. + $plain_zip = $this->make_zip_attachment( 'zip', false, false ); + $ids = $this->reprocessor->get_reprocessable_attachment_ids(); $this->assertContains( $unprocessed['id'], $ids ); + $this->assertContains( $valid_zip['id'], $ids ); $this->assertNotContains( $processed['id'], $ids ); + $this->assertNotContains( $plain_zip['id'], $ids ); $this->assertNotContains( $image, $ids ); } } From bbf85634c3a2caa8db1c69746568318b1c7e71d1 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 22:19:38 +0100 Subject: [PATCH 4/6] feat: "Process as eXeLearning" button in the Media Library grid modal (#42) Make the reprocessing capability discoverable where users actually click. In the default grid view, opening an unprocessed .elpx/.zip showed nothing eXeLearning and no way to act on it: the modal JS only ran when the prepared attachment data carried the `exelearning` blob, which exists only once a file is extracted. - REST: add POST /exelearning/v1/reprocess/{id} (permission check_edit_permission) that delegates to ExeLearning_Reprocessor::reprocess() and returns the save response (preview_url) or a clear WP_Error. Reuses the existing content gate, so a non-eXeLearning .zip is rejected without writing metadata. - Media library: expose `exelearningReprocessable` (unprocessed candidate the user may edit; cheap extension check, no per-item ZIP I/O while browsing) and localize a new `exelearningMediaSettings` object (REST root + wp_rest nonce) plus the new button strings. - Modal JS: render a "Process as eXeLearning" button + "not processed yet" hint for reprocessable attachments (both the single-column details and the two-column attachment-info actions), call the REST endpoint with X-WP-Nonce, and on success refresh the attachment so the preview renders. Errors are shown inline (no blocking dialogs). New strings translated in es_ES (CI gate) and all shipped locales; .pot/.mo regenerated. Tests cover the REST route/handler (success, invalid, non-candidate, permission) and the exelearningReprocessable flag. Verified end-to-end in the browser: clicking the button on a .zip eXeLearning project extracts it and the preview appears. Line coverage 79.51%. --- assets/js/exelearning-media-modal.js | 127 +++++++++++++++++- includes/class-exelearning-rest-api.php | 39 ++++++ includes/integrations/class-media-library.php | 23 ++++ languages/exelearning-ca.mo | Bin 10893 -> 11222 bytes languages/exelearning-ca.po | 12 ++ languages/exelearning-ca_valencia.mo | Bin 10934 -> 11263 bytes languages/exelearning-ca_valencia.po | 12 ++ languages/exelearning-de_DE.mo | Bin 11196 -> 11535 bytes languages/exelearning-de_DE.po | 12 ++ languages/exelearning-eo.mo | Bin 10731 -> 11067 bytes languages/exelearning-eo.po | 12 ++ languages/exelearning-es_ES.mo | Bin 18568 -> 18886 bytes languages/exelearning-es_ES.po | 14 +- languages/exelearning-eu.mo | Bin 10862 -> 11195 bytes languages/exelearning-eu.po | 12 ++ languages/exelearning-gl_ES.mo | Bin 10885 -> 11209 bytes languages/exelearning-gl_ES.po | 12 ++ languages/exelearning-it_IT.mo | Bin 10844 -> 11164 bytes languages/exelearning-it_IT.po | 12 ++ languages/exelearning-pt_PT.mo | Bin 11057 -> 11392 bytes languages/exelearning-pt_PT.po | 12 ++ languages/exelearning-ro_RO.mo | Bin 11294 -> 11619 bytes languages/exelearning-ro_RO.po | 12 ++ languages/exelearning.pot | 12 ++ tests/unit/MediaLibraryTest.php | 67 +++++++++ tests/unit/RestApiTest.php | 109 +++++++++++++++ 26 files changed, 493 insertions(+), 6 deletions(-) diff --git a/assets/js/exelearning-media-modal.js b/assets/js/exelearning-media-modal.js index 079129a..3e4d4af 100644 --- a/assets/js/exelearning-media-modal.js +++ b/assets/js/exelearning-media-modal.js @@ -1,9 +1,12 @@ -/* eXeLearning Media Modal - Updated 2026-02-02 22:50 */ +/* eXeLearning Media Modal - Updated 2026-06-02 (process-as-eXeLearning button) */ jQuery( document ).ready( function( $ ) { // Localized strings from PHP (via wp_localize_script) var strings = window.exelearningMediaStrings || {}; + // REST settings for the one-click "Process as eXeLearning" action. + var settings = window.exelearningMediaSettings || {}; + // Cache buster to avoid stale iframe content var cacheBuster = Date.now(); @@ -153,6 +156,11 @@ jQuery( document ).ready( function( $ ) { } if ( ! attachment || ! attachment.get( 'exelearning' ) ) { + // Unprocessed eXeLearning candidate (e.g. a .zip or a not-yet-processed + // .elpx): offer a one-click "Process as eXeLearning" button. + if ( attachment && attachment.get( 'exelearningReprocessable' ) ) { + addProcessButtonToDetails( attachment, $detailsThumbnail ); + } return; } @@ -307,6 +315,112 @@ jQuery( document ).ready( function( $ ) { $container.after( $editButton ); } + // Build a "Process as eXeLearning" button element. + function makeProcessButton( extraClass, extraStyle ) { + var $btn = $( '' ) + .addClass( 'button button-primary ' + extraClass ) + .attr( 'style', extraStyle ) + .text( strings.processAsExe || 'Process as eXeLearning' ); + $( '' ).prependTo( $btn ); + return $btn; + } + + // Show an inline error under an anchor element (never use blocking dialogs). + function showProcessError( $anchor, message ) { + $anchor.siblings( '.exelearning-process-error' ).remove(); + $( '
' ) + .text( message ) + .insertAfter( $anchor ); + } + + // Call the REST reprocess endpoint for an attachment, then refresh the modal. + function reprocessAttachment( attachmentId, $button ) { + if ( ! settings.restUrl || ! window.fetch ) { + return; + } + + var originalHtml = $button.html(); + $button.prop( 'disabled', true ).text( strings.processing || 'Processing…' ); + + fetch( settings.restUrl + '/reprocess/' + attachmentId, { + method: 'POST', + headers: { 'X-WP-Nonce': settings.nonce || '' }, + credentials: 'same-origin' + } ).then( function( resp ) { + return resp.json().then( function( body ) { + return { ok: resp.ok, body: body }; + } ); + } ).then( function( res ) { + var failed = ! res.ok || ( res.body && res.body.code && true !== res.body.success ); + if ( failed ) { + var msg = ( res.body && res.body.message ) ? res.body.message : ( strings.processFailed || 'This file could not be processed as eXeLearning.' ); + $button.prop( 'disabled', false ).html( originalHtml ); + showProcessError( $button, msg ); + return; + } + + // Success: refresh the attachment so its prepared data now carries the + // eXeLearning preview/edit info, then re-render the modal. + var attachment = wp.media.attachment( attachmentId ); + attachment.set( 'exelearningReprocessable', false ); + attachment.fetch().always( function() { + $( '.exelearning-process-button, .exelearning-process-button-actions, .exelearning-process-hint, .exelearning-process-error' ).remove(); + $( '.attachment-details .thumbnail' ).removeClass( 'exelearning-details-preview-added exelearning-details-no-preview' ); + $( '.attachment-preview.type-application .thumbnail' ).removeClass( 'exelearning-preview-added exelearning-no-preview' ); + runAllUpdates(); + } ); + } ).catch( function() { + $button.prop( 'disabled', false ).html( originalHtml ); + showProcessError( $button, strings.processFailed || 'This file could not be processed as eXeLearning.' ); + } ); + } + + // Add the process button + hint under the single-column details thumbnail. + function addProcessButtonToDetails( attachment, $container ) { + if ( $container.siblings( '.exelearning-process-button' ).length > 0 ) { + return; + } + + var attachmentId = attachment.get( 'id' ); + var $button = makeProcessButton( 'exelearning-process-button', 'margin-top: 10px; width: 100%;' ); + var $hint = $( '
' ) + .text( strings.notProcessed || 'eXeLearning file (not processed yet)' ); + + $button.on( 'click', function( e ) { + e.preventDefault(); + reprocessAttachment( attachmentId, $button ); + } ); + + $container.after( $button ); + $button.after( $hint ); + } + + // Add the process button into the two-column attachment-info actions row. + function insertProcessButtonInActions( attachment, $attachmentInfo ) { + if ( attachment.get( 'exelearning' ) || ! attachment.get( 'exelearningReprocessable' ) ) { + return; + } + + if ( $attachmentInfo.find( '.exelearning-process-button-actions' ).length > 0 ) { + return; + } + + var $actions = $attachmentInfo.find( '.actions' ); + if ( $actions.length === 0 ) { + return; + } + + var attachmentId = attachment.get( 'id' ); + var $button = makeProcessButton( 'exelearning-process-button-actions', 'display: inline-block; margin-bottom: 10px; padding: 6px 12px; font-size: 13px;' ); + + $button.on( 'click', function( e ) { + e.preventDefault(); + reprocessAttachment( attachmentId, $button ); + } ); + + $actions.prepend( $button ); + } + // Function to add "Edit in eXeLearning" button to the two-column attachment details view function addEditButtonToAttachmentInfo() { var $attachmentInfo = $( '.attachment-info' ); @@ -385,12 +499,15 @@ jQuery( document ).ready( function( $ ) { var attachment = wp.media.attachment( attachmentId ); // Wait for the attachment to be fetched if needed + var applyButtons = function() { + insertEditButtonInActions( attachment, $attachmentInfo ); + insertProcessButtonInActions( attachment, $attachmentInfo ); + }; + if ( ! attachment.get( 'id' ) ) { - attachment.fetch().done( function() { - insertEditButtonInActions( attachment, $attachmentInfo ); - }); + attachment.fetch().done( applyButtons ); } else { - insertEditButtonInActions( attachment, $attachmentInfo ); + applyButtons(); } } diff --git a/includes/class-exelearning-rest-api.php b/includes/class-exelearning-rest-api.php index 10e9e0f..d948967 100644 --- a/includes/class-exelearning-rest-api.php +++ b/includes/class-exelearning-rest-api.php @@ -111,6 +111,45 @@ public function register_routes() { 'permission_callback' => array( $this, 'check_upload_permission' ), ) ); + + // Reprocess an existing attachment (extract + set metadata) so a file + // already in the Media Library becomes previewable. No upload involved. + register_rest_route( + $namespace, + '/reprocess/(?P\d+)', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'reprocess_attachment' ), + 'permission_callback' => array( $this, 'check_edit_permission' ), + 'args' => array( + 'id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * Reprocess an existing attachment via the shared reprocessor. + * + * Accepts .elpx and content-validated .zip attachments; the reprocessor + * returns a clear WP_Error for anything else without writing metadata. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response or error. + */ + public function reprocess_attachment( $request ) { + $attachment_id = $request->get_param( 'id' ); + + $result = $this->reprocessor->reprocess( $attachment_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return rest_ensure_response( $this->build_save_response( $attachment_id ) ); } /** diff --git a/includes/integrations/class-media-library.php b/includes/integrations/class-media-library.php index 73bcf57..84dcb65 100644 --- a/includes/integrations/class-media-library.php +++ b/includes/integrations/class-media-library.php @@ -193,6 +193,20 @@ public function enqueue_media_modal_scripts( $hook ) { 'noPreviewDesc' => __( 'This is an eXeLearning v2 source file (.elp). To view the content, open it in eXeLearning and export it as HTML.', 'exelearning' ), 'previewNewTab' => __( 'Preview in new tab', 'exelearning' ), 'editInExe' => __( 'Edit in eXeLearning', 'exelearning' ), + 'processAsExe' => __( 'Process as eXeLearning', 'exelearning' ), + 'notProcessed' => __( 'eXeLearning file (not processed yet)', 'exelearning' ), + 'processing' => __( 'Processing…', 'exelearning' ), + 'processFailed' => __( 'This file could not be processed as eXeLearning.', 'exelearning' ), + ) + ); + + // REST settings for the one-click reprocess action from the modal. + wp_localize_script( + 'exelearning-media-modal', + 'exelearningMediaSettings', + array( + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'restUrl' => esc_url_raw( rest_url( 'exelearning/v1' ) ), ) ); @@ -244,6 +258,15 @@ public function add_elp_metadata_to_js( $response, $post, $meta ) { // phpcs:ign } } + // Flag unprocessed candidates so the media modal can offer a one-click + // "Process as eXeLearning" action. Cheap extension-only check here (no + // per-item ZIP I/O while browsing the grid); the REST reprocess call + // validates .zip content and reports a clear error if it is not eXeLearning. + $reprocessor = new ExeLearning_Reprocessor(); + $response['exelearningReprocessable'] = empty( $extracted_hash ) + && current_user_can( 'edit_post', $post->ID ) + && $reprocessor->is_exelearning_candidate( $post->ID ); + return $response; } diff --git a/languages/exelearning-ca.mo b/languages/exelearning-ca.mo index 68cca096c206df78d1251f5c70226ce303fef41d..bc864ee36291bb9f9d5da82442ebb09c89de9d64 100644 GIT binary patch delta 2199 zcmZwHe@s_%c8l{`A7XFdbYpc0cy7fn6vT7M^+1jFCWcWv`Rj2btF^>`8&;}z6K7A%Y(dHO=~uR6=(h9=CzDy+b**n%tYEN;M` zQ32Pn>HvDE49wu&m`Bu);s(^Xal8vJ-~pV+r*M$->%o~!@*kwJiG1k{2T(iM>-P^~ zKK;X}l$}9kVhXjBOUS0|J5=Dmp%yHpG8IrGs=p8K!_%mvn8POgGfE?u#zyi*aMp!v z(xS**_LA@GSV{jZs;YlNjY}u53Zx9RPz`DU7o7yNUHCBlVJc6VnZ{Nu;2afrbUzJ6 zHj2u?$JmIUqZSIXiq5bBi}6K#2v1`o{)IZizi~Y#QOOQ<#WISb*a3t-k+@C|xc11QLsFL#6OpREiGzzJg@kCVbCf5&bW4IbKCAnD4}oa3v};4XF9H zAg66E^09s{Yoj#AXfW2kK}B{G6{mhK>RVBVD!O&3=gp{v!l)`ALhbl{4C7@iz^z>L z-X7HWaddD3Rb!JFRpSy3vSD+m>Q7}mC76ZVupS*ef|}?9RK})IKi!|9iuX%Y)nCCD zTt)$@C6IBZ&62>z>i1@=9H3urL=$>Dyo&p$98hjz)`<{4i(U4)LAa) zVG~xNQvU*~+GD8sK0>~EHiJ~T&0{rY^RnK5gbVd+eK|A~$p|V{uVN}rmdDm54<;9G z40pGAk%;3)9Ix41@44YnXK1_mw`tDoc_Vfqy*IF;t-G(Q-3fK~I<2156CdWa|7&2S zZSLrdI8S$Wc_ZgDi(_8KwLoT2gKmu2?e&(%8nYv*L4Ot3Y3ttQxc?V`8|qa6ALjoO uD2|kLxK2;^_P*pfA4CZj5r>-0BbDE)n?iq#_@yTlhc+AY*V z9-{7lgI<+F(NZ7<=VJ*fP$Q}W&B({z@}&SRsJ#tgHYJ#hT2Tg$#C+_9Rj9;kQCoZx zb*#>y=4)_M|1=uc8JL4DI0#F+noYv3s097E0To><-iYbgfD`dO4!}&ZuEP0Pil1-= z7O-DQcnP&tuThDA>_+`H@PmO^Y(Nc>4Fr zT9t_8gq7n$RKhn=2|Y$Fv^)Db1tU;Thj%Fr?b&Yh;2q4v7Sv;wLl)YzF<5{TF$VXe z62E}VZMRYHiMOagshntVHYorO*C}acA$Q62o>l& z4#Inwimq6*m6(Qccm%m*S5XPQc5Fe`V8Q%Ir#ul8F&)crCdObRdKLHy4VCB}>N)*_ zI;B5Qr#pn@&&Dj&A>5BM@EYp95zR^3iupJS@8U2FrF=6rjv06itML&o!-XF0KMg-8 zuRj)%rV?0>+SAQWe-A3qLDb5wp|gi}jRW6VdrLz-_dT$h=?px>dkD?N5Mr~R9 zWR{`lGmKxW#7j`8dLL@SQ>ZuDRn%#Iii7bda_+1@vj<~7sxrl>i7Qil+k&rMSlU_VaNgi diff --git a/languages/exelearning-ca.po b/languages/exelearning-ca.po index 70094d8..73f8fc4 100644 --- a/languages/exelearning-ca.po +++ b/languages/exelearning-ca.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "%d fitxer no s'ha pogut reprocessar." msgstr[1] "%d fitxers no s'han pogut reprocessar." + +msgid "Process as eXeLearning" +msgstr "Processa com a eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "Fitxer eXeLearning (encara sense processar)" + +msgid "Processing…" +msgstr "Processant…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Aquest fitxer no s'ha pogut processar com a eXeLearning." diff --git a/languages/exelearning-ca_valencia.mo b/languages/exelearning-ca_valencia.mo index 1324ab4aa82f42005ff2d46c678d1ae1a3b4c3bb..1c7a7767d46c50c5ba45ab64e47afc55d059c8e7 100644 GIT binary patch delta 2237 zcmZwHe@xVM9LMp`xKmI96$An$zw-MyPy$w_IZzr}YC5E#japxhZ-U_++#QLHx`x&I zLmfgkor}4e{qT>%&edYE)t0rHu39Z?H8WQ8`nmOEu2_zquibaLRqVLO=kxu3KcDye z^ZtCk_xfG4ZLtek-b04J-TW`*|5J~8#_UVI=6ag@>EDJQVJqgO8Iy@Su^#u}9DD`q z@Dwh?gnK`2o-rl#*Ww!NM6WS1GfrbJ7oNw}cmnhBJIugYT#Om>jcLRpWS|LP4t8TU zK8g!)6qU$xsDLk_5+vM25@)agFQFz*xh{D>3l-3h zH(>)R;6tcHhmf4jLDT|{y1tBB*c+IOALA0@n*OAhTZ-YE~5V#Dv<>8F<1Cv4JMsQwU9j2_;QS?6pb`0aVK`+AygtWs7x~$rHM*V z6{$sTnJuV*9jLtyq7puU8h04i;;Wd4Kcf~hXJK;7GZ#{SowFP+Xu?8ViB)(nc3=%o z;#T|-mGBy(4xxjpz%<^7g=D=6x1z=!#cDi@PvH#ShkMz-KAg^`{uMN~Q!nk|5NZX( zZhs#xr$34+*(p>d&Y@QFC9){3 zA`;5{hI$PbaAG&;{rAyOz<$&NyHO>48daiWt`kVr&D*Z$v6TKVSd5FUWWZIZE!>Ve z++C>odQl4aA$S3=E)F)bF|vb?9EiDm;$^=q*Yn z^f2oAan$%X(Zcsp2_`V6L-rR9#+po)p-gMA9B)M%yHNMvLrs)GRqQfqFaJgzUJnOG zr#=@ua3faZacsv=P`@)(lPftS7rdgRYI(<3xS&mfVBsb#4^f3WRo9_Wvbd2csll zS{!${J@#R7ZroFJ#&h$!{sAWvB~*0K36qxe`k(=Q2MO5pu!0T b?67S`oM7bYYV5GjG>_fCENAS&%1!?O0of*$ delta 1919 zcmYM!ZA{H!7{~EzIz%WUc{qxrjyeZX7Rh6TNai6iWYP<~$ivZO)BZNo8q1h5W;SmS zuW+mxTGMP9>xHMxOq(nZdD_g=jOF`t{+o6>pZos*&;P!z`?{}lx=R|0o%VRoapNz< zznA}OQJR?*M*laD;S%Qa@CdHNCm4%?-e!|A2YX@#X5lVO#(MYuT})yA2XoQa$IN5q zSSf>ET-b!ua4QbObJ!o7a4@#wZ0tZDv@tPe37Ce1a3;p#a#SK^sDPVM3D#i-He)(| z!8GEVx35`{3;8${cc3b95QpOh)WXf~{fDT4?=c&@Q3132MG~Ek!K8(8bQ1+}r= zI0TR42;y5k0|mT;@%R!|+OMdEqF9BhS%1|1A*e*tQ5DN`=hZln`3Y3T>XDZ<@<*@RN@~7QGZ?d&V|wV8&#Sl>PK~K3Tg-WNN!ey8CZ%6co>z)EqDG2 zbC`SSOqF~UmS7o(^6%&y`B z=IvyyO8DupGTezu_z6zMPSg=3ah}UD74_|Ksu<|Z&Y&Nk<9zHvbbZYVPze-a5tiU6 zJdfmTEyyGG0`)ub6BQ_zj#g9W@utshKqXX(s`vrqNE|!FU@jM~;bi=Ys=)Y>k+0=C z%wb-OtZmKi^>$R?n50NkrXr8p6lAlu5>=TBRKN!J{w-7mpP~BCI{yvKz9H4Lp0u{~ zVk&0g7+ix2SdBWnlc;XKh+6OpYNw5;UV4ND_zij4tWlA~HlXHvFxFvkih-J~9u=U$ z^#Q8cKHxa?vT7w3pb~0AZrLkTLLOhFmlBXm7C4 zlabCglwmHP4&pJ?*Rct;;A2e0PpIbWMHZRpL;BB(u{VZLf%l>oz7z-_jNTs|&WsC1 ch1=p&JmKU-r*F8$*A*4+@}Kq`yAmw=3zwzFZvX%Q diff --git a/languages/exelearning-ca_valencia.po b/languages/exelearning-ca_valencia.po index 3f5870e..f7b4090 100644 --- a/languages/exelearning-ca_valencia.po +++ b/languages/exelearning-ca_valencia.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "%d fitxer no s'ha pogut reprocessar." msgstr[1] "%d fitxers no s'han pogut reprocessar." + +msgid "Process as eXeLearning" +msgstr "Processa com a eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "Fitxer eXeLearning (encara sense processar)" + +msgid "Processing…" +msgstr "Processant…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Aquest fitxer no s'ha pogut processar com a eXeLearning." diff --git a/languages/exelearning-de_DE.mo b/languages/exelearning-de_DE.mo index 5f9833e02780783694f111eb3c6462731c842397..b70606abfa9355efe81c2cf3159c5834df97e21f 100644 GIT binary patch delta 2210 zcmZ|Pe@vBC9LMqR;1Z->5)gl6=!KAL2~W&1SMzwz;KlvB<5}SYu`M^DkG9W%T}ZpN(pZUGD3g=eg&c@A;nZ z^PH`J@qx%hvS-Nndz}AU_#cl|&#XUw)=e}I(%+1q;SNkqFiXT1EWsX(!?&;)PvLT$ za_=YHXqHL86gOZaddwpBDvcYsFpTT*Brd`4(2Fy;0KId}$}s~OXaP*cW=z38ya@+T zfgD3EcpMerB`m<0xn{XoggLBl4K(t(upbxWyQoZjiZ|o;sEK3dMeir07R<-na3gBL zZd9PXNX&KwwSl9qCr}$ZjSF!Mm$JT1(a?e(ew~czsMMCACb|pBqE)*0ccKCfqB6GE z?T=y-{mZC8rjUl?$3M4GVB3K8Ouih-a`Ge?kSk zfmM6aL1kbP*I*h^--p$xaYu0#Uc~2c8Xv-aoL@UmrjY*}8g=ALXV{C{!CtrDkLmOW zP$@fw%ETqqPQF4mW#6I#zlvHglgd;;wW$8HxDMY#9mUUBkJlnJmeQypUj%2Z$R;g< z%w>mNU&qz-&!DRM2h_Mk@~S}cPzx2K7Vu+6jM<}DN&hgFr=3q=3zl+@D&oj-8XIYx zMV=7*11m6|*|TvgZpFv22tUAD{1uh43My9tY{7f523O$As0Bt*nH$4x_#e}IN|GU?u!LZvXy7d_h~RB@&wF<2pzeXBx}We)POJ$&ixXah=z)4Kk_cJlbD02F@WEoQnr~NdC`wLqBhjdx=|DM zp#nOHK|GITxQ2pI&9oyi**QFdS5}b!DjH8@Q4n|@9h|{C(V+tM93R6s@pJ6Qr-<%( zOrStiBZGX!;VA0qIfot`M`E`xQ9Hkm%FOb-=+UgrBmYWq85h=L4c>t-pmy>J7UQ3| z1&i{dwecj@(?5Vp@dWD3r%?fTS4J0FhC0d;)cc_dnacW50i9SvV-AfAsA|6K_C2dc z?v39UpIOt<7H~Q{eg00LbHB6A@ppuRp@+@AOmlM28zUDJ!?A^dwyxGDU#KnYYjk|= z(P2*0e+I6$9nHZ`-|k?mGx$+T)`*il6PuEwL9-)zoN(@N?ZSBl6|MhT)Yt8F_&XY% zVAu&+eXyhH9~Z(3V|&J0Pxd2ip-|ZIg@S?Ru+RU$GmNxnUGpqv;3|8!5_K+ delta 1923 zcmYM!ZD`e17y$5-yEUDzxoI=sbzAN&Te+A`&2proO{J+@FSHM8kp{h>m4tGsrO8rL z#`=(bh^Sl{ku5})CP-0GeleU;BTC5ZML$F;R8qf(d!cc6f9IUb|2)rm&U5d}_aEuV zeqWa9P5w6epXC3ef|#Y$TKK;$hRe+F;vTNzDPGIE;VIq9W?s$5*vL(s%D(*PU$Dyj z3g@z7L`s>IvZ>SH8VhSVhZ{JaZ?TxiIF=`PC(qD>sj?`g>sZ4voX--rF%ntB2>1vi z!Ch?Nan|w=)`*`bjZCTD!a`opCmEIK<^=9%Y?yH7J8CP|dk@)Xp)ISz3S(wg$7^Rt_eyWpZGfuFOa!bqEz)nWM z-Hb$z z+pHLt(uZ7TeoEGH;z}L1fzQ)f(s|y&tK6f8PYbw!t*mBtx4{yFL!8Bk+J;6(WwtXC z*vUogY(JSrqX3I3d=%Le$gH-F-Sf9zL;8I5arFMhp#)m0J zdWx!=dZ;MrT~6a?`Sr7mJG{cE%xE7*SC40O{dC4Y&0NV2#(oDFl{(I-$agHw8eBAp z1PZ6;N>t9+pq|ZK!X|d{ISw#RJg*|xv~7&%H!%WrF@E`8rJs;=j5Yj~xS0pH497P38!pNSM$z}a3Dl0Pm?^mD6^n85ds{a7d=f=wb diff --git a/languages/exelearning-de_DE.po b/languages/exelearning-de_DE.po index 78ce3b3..56a947d 100644 --- a/languages/exelearning-de_DE.po +++ b/languages/exelearning-de_DE.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "%d Datei konnte nicht neu verarbeitet werden." msgstr[1] "%d Dateien konnten nicht neu verarbeitet werden." + +msgid "Process as eXeLearning" +msgstr "Als eXeLearning verarbeiten" + +msgid "eXeLearning file (not processed yet)" +msgstr "eXeLearning-Datei (noch nicht verarbeitet)" + +msgid "Processing…" +msgstr "Wird verarbeitet…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Diese Datei konnte nicht als eXeLearning verarbeitet werden." diff --git a/languages/exelearning-eo.mo b/languages/exelearning-eo.mo index ebd77cf76b8e359af0586c94f0391a1fb4a708d1..221b5e0948fd85677497b767c1c4fe4761d7a93a 100644 GIT binary patch delta 2208 zcmZY9e@xVM9LMo@{1Ah<3`7*czR)Q@6GF(694yUE3q>Ss4cZR)8i&UnKfLhNoy_Fl*Q=z+I^RsvEv?}&wcLm`Mlrn z&*ytrcYN9!yOfnOX8b+Ne?I>|B&%n3Fzu!f(|m>gMm&ctn6uE#hdo$>gP4jR;2J!M zcjL4-zVLQ4KmGMshaH$=7PBKX7VyDQT!+VT8UBiyIFE}lGu^BKi;#)di8#w%4Xf}JZo{jn zfa}xC?dUCy-an z;=GfLokNb%uAlT!J?iOr59 zOWK!Mqv!uJ4Q;rTE380Ppzc5|W@0_^gjypilP6JkA%a?PKRWm}YW^8iX)mBMok1PZ zHE(2Rm#&||4*!-U%;=9ZtoV< z*uG5#8=62mO zakaH{2cyoeV8|UkoxLjVX3ZyOmuS+>8H4UXX}o#KTzXkke=rp2i1Y`Y-9a~WO8|+X zKzP7a2wMw3PcH6@>|t>?IuK+zZ|Xqcf0N_a{Szs9C4unnNPpnW`%YN#{`d7#8=Wo6 JADt-O{4ev&94-I= delta 1919 zcmYM#drXaS9LMqR6cI@m_vl{F$)yrmE}@otev;jk%1?*zyO- z{6U@mXyvlS#%9eh`NM|Tklbd-+}|JP*|a*Z-|u;Pe!uVK_f*^Bo`t?Ak-^7}uWEi` z_-PK(%&aivzj+Lcn0xUsF2aX67}NTjO~xGTiyJTtw_q$bxc6^k67yd;9TNta1)KS- zf*^VKvtqQT#X9wAf{tGdhioE z>~Apx&Bk#dA4lM3R3#4JC_Il^xZS;fAGKi*X5$~!hS`GxfzCi;v|>~O%Usu>65EEu z@Cc4(e`{c%4R2y3cA-l98MROlt57uyL){;S3e#AtU= zi9A9*{|bF7g?3AWG_1l>)JCnS3Unaf_L>iE(2MHr09I3g9#lqII1}gMaI8fIeg@Uz z3#emt8MR(ZIQ36waElA&*ozrhI>c-N?nedKiMvp{E8`l>#1@=~Z*T%;6Ll?CVFkX! zO;||39K4EZ)k{?1Z=e}H<;qKHDh8-WEFi(_#W zDuaEf1WuqAFQR(<5p}4)Au-xeCUnWhqXNlAUQeHS8RTa6B1L>MQM)tA~$fGupC?}(X+OQlINF}QGJ26b}|6T^XX?7HqNge78G@};m zz&PwiEfC7lS83x>nP#F|l!LmThjCbjD)mlOKuxH1?;=atYaHZb5E2*2AR6@x4w8h; zL7mzas20`Yc5Ff2&!ij-Z2>BQO~^5_8f0xdhI-BXs14guE$l)i<|I&mW#DC?5*4C) zU4nYB40U)`V>zC5=e@Xyxsw?9yHSI?m^Yv{NT#vMd>xkJS)793@ib;6o2|!=Wa_U9 zOiu~)ybTpVH~u?BsKCA<&)9DycuV2{DANMe?=43KT!rJX8db46cYYUjmVTfY9m=Y= zXuY#E@P6*#0>RroR13bi4)z580;b}>-l7&NLzQ?x@_)qYQHQn>weVAq|3Ju|5dWm` f>L7n-WKys{c6d*ye@eo&Ab)zw!Qf+W(u)5AAF9H% diff --git a/languages/exelearning-eo.po b/languages/exelearning-eo.po index 42c3b47..931483a 100644 --- a/languages/exelearning-eo.po +++ b/languages/exelearning-eo.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "%d dosiero ne povis esti reprilaborita." msgstr[1] "%d dosieroj ne povis esti reprilaboritaj." + +msgid "Process as eXeLearning" +msgstr "Prilabori kiel eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "eXeLearning-dosiero (ankoraŭ ne prilaborita)" + +msgid "Processing…" +msgstr "Prilaborante…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Ĉi tiu dosiero ne povis esti prilaborita kiel eXeLearning." diff --git a/languages/exelearning-es_ES.mo b/languages/exelearning-es_ES.mo index 6f1ca8917a0d86367fec2cdf3ce4bdd9139f1945..c906c8381161d9110f237ab4db8e16fe195366ba 100644 GIT binary patch delta 3464 zcmZwI3rv<(9LMnkf(UXEkV`50s$9h@s33|cf`K<|UP#UA~4}Yc}L_&civ+`Jey!pQjDAZYS#9 zTvviTwiy0v<6m$7Wpq`~n5DjtOs82)KLeX_7T!gFoa|?e7tX~I=)^4CiXnIv1Mx0C ziC#P`3228=@ z_!&OHC-86&YutC(pY_dk8U$y&36rJ?!=4z6<1rN#SQTo)YShj)VlcjkyxX**GI1O= z{tPPM^QaB{i9;|x$e5ux5BsscsiC2W_h2kuz<7L!%0N7;#NZT+z;X=7TI`RzP)GMA zD#bse0`g=R8W(~CaR~A!GZ#l;F}jrc9W-P!D)K|k}$X~UV=j{KMyw(pH&QGcIg zI}4TKJPgHE*bD1X0X89fGG|dobv1V=n4XMPJcFFMQ`mxc=bY2?Q&+~VF8Bn zM=iJ#6<{s0mf3-N;X%~AQ>Zh(gZ*#-X;G18qK;@PD&Qg)4ek78)Q;Am&b-ck-hx`- z5T;@~5{n7sEy`3J^2j8hb~qJPLr&C!HK?MjMJ?EXD)L5K*M1rrc-kIt9tY9ChC|Sg z1C(QN8ZJQ{#eUQd131_Pn1;jfUChC+P?_jM#je6EOu;rBj;2p%pxl2Nnm8X5aT89# zBldG&(lMBRDsI3f_#FO$%W$TH8-ZV-cAiYWRm4tIW{d3pOURE|!LQCtV7TsoBMqhg zDC$hxF&D3+29BdbN8&W(qi5Elj^ItywQNKOp2JbtfvTn00oM3rRBdFUj$$V2=;mVp z>ze`^U9bXu-Hdq!mD+WDJ+$*rP$~Pt?uSGfGn)QH)X}U)vTgQYXYrwm`VML%4^j6% zH`=<6OHdnq9bGDhZ8TIoyKRr4Ui>Yxrtx5#9Jh%>MV^TiwwaC!un4u_7L0K>=6%ej ze~M_bFqkW}2%kl**M@q}g@NRM1dXdaP*L@ax2pX`R6ws{EN(?b{5j6UZ%`>7Or>_k zRMdhQ=)lc57MoBT_ysdDkZ3(I7Zt#Q!Q@{rUdRJgXC=Xer~vOH7s|L(>H2;|pfZu@qTxd$12rKBS=-FU3Ah&7tZ739 z*nuR=45A>|l*vW)t5Jb9+5H=sKtG6I)T7Bn)yN`LjjThx*VROWD`_sFJ~V$}U+lqM zOvYiT$QR>etj6y6we2NT;5U$?H@~7jNVn06KAf*^%Tn~lYV^a+mM*j19JYheN<8CSw<5253(NFh(C5=fu z*k}(phswwu)XoFRt5P=zeKFg14u;XsM`dg!aysT6+p}0f{~y$XWu#MqZ9?^TqaW*= zHX7rx9W$}#XsZV1p{jHPYGN(+z-H8rkD_*b2Ki)|Yp4J{C=_Kb5a(eMD!@8a##&H+ zKY%Xn^coE|YaXDgb>tXp;8;}3Gi>Kz2>nI&->Wc){(4j~H6W*E+E7J#59eYOcd-Cn zNLiR4@dXS|CI5O*nQBG)5o%($aa1TKVmj_e-evwqRc~^d^@Yqsj@Q)Td_0NNuZbLQ znTxgbYp?`+bEPWr4P1eq=`L%*)vTh_G@=hSqbD9jeF=}EQhgg8=sD5)q(-AMl7b3g z1_t0BWH06r>PYTkU-Zhb0*^rzd9I6wc2WM@UE65rL$VLG@K2bF9jKjUXIYUxkLs_- zUbr2@aUX`_c~t6eVhr}mwyxVmRNxCyj!-N#i zds3cPzjD@GUQlYk^)ZuL)6y<>3yF0eKTzT*UtIFvz&JCZK4(N|{o$cL_0d^*{{q6U BtqA}C delta 3196 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 sWkJ5Chy;f(E_!p1P|wEFt)(SKTU7VV*mgE diff --git a/languages/exelearning-eu.po b/languages/exelearning-eu.po index 19e3e89..1579125 100644 --- a/languages/exelearning-eu.po +++ b/languages/exelearning-eu.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "ezin izan da fitxategi %d birprozesatu." msgstr[1] "ezin izan dira %d fitxategi birprozesatu." + +msgid "Process as eXeLearning" +msgstr "Prozesatu eXeLearning gisa" + +msgid "eXeLearning file (not processed yet)" +msgstr "eXeLearning fitxategia (oraindik prozesatu gabe)" + +msgid "Processing…" +msgstr "Prozesatzen…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Fitxategi hau ezin izan da eXeLearning gisa prozesatu." diff --git a/languages/exelearning-gl_ES.mo b/languages/exelearning-gl_ES.mo index 10013e21d5ffbe8ff4b1683e63f47b7c17327c83..e556980a1b00fee0bdc2abef473c72cdddb29e3b 100644 GIT binary patch delta 2225 zcmZwHe@xVM9LMo@{0NL7;spEwcF-X|1w?3xF`Y)aigXCk52Oz6TbzSC$Dzc=I)}2g z1(naO5pxCg7T9~1Ed+<>R> zE}V}&PhMu`;kphtVLK+6g>90-t=xDUYw;Ma#5qjGC0vfFx0!9hd}N|^U?z5=3tzxH za1@ouTd08Vq7poh6&ROdR*Y3xM0{&yP|A%VT!9~=D)A+*!YinS<8F^W&p-t%#d~lw zD&T%pq60|I_6lkP6EWXIZR}&r!mn{P@ok=g0w(b549r87wi>n2Hl&Ky5PSX@Dp5bG zVuP{kPw`H!FQ5{cM?Q9gFZN(5EUJxUqvjW4Sf$v)pal1z564l7ETS?^WtJB5pej;@ zJhBE4V7E?GZ}J5ci`a2=k*7jY5W@F3^67iV46zlgyu>ZLOrKx!!eIwIoDIDuKoozFO9k?krGs(4X6NK^u(EUqbf2==QZFZ?7}k6@$WdVF{t6j zG1O~#8MosV%*RTi+=sic01x9fJdJt{7f}f;VIwBf$+`FxYD4{~$_?XYJc5cdhfE3E zRR$Enl9{E9)}R)yz#^

BAI;+v=*_znv(VNJBuWk}A}hHB=1Vf^pn`s9y6-SV2+5{?41XYn4R3g`roGiy3-FYplxf@Uw zYDOJt8}d^NVSSjv7=yd!qthJU zzb2MjQ>Q=Ve%9aRj7_`NMx2bLcvq1o-JH?y^cF`Nv-(mBqfxv)?v7x0@PFca1HPc* zf01`NzV^{zz#Ss;KEE#*TaB4csMjG}N2lZO3I1oDa;xY0Sb9NjN delta 1919 zcmYM!duW$c7zgl^o2jYIw6tNUeh!$u@tmilDU*Ff;XtJi>PIw4Z50SR90kE zNOqafAL07LG>yziDgRJVBf>6(rgnjf3^VNW#X;k@_jAts?suN&Jm-10XZ90K&4;^J zzEu1y^;_-tgNn>bncm@l8w?xGC-XVZ;sKt|`i`ZH;#juleH_VU?8BC}=et>J{x7d( zzfPr8meO428l3CK0*>Q7yo7JFD?jFi+{^3u1AVddKd+RFSjQfm$STfc5}D5gyo*V2 zEr)U^2l02-iC?NamonImDZH3Vm`beSrF@rJcxT)5{Y=0U9L+OKz|mdKCOV#SEH^V7 zxV_E0nTUoev)+~}^&e{Rxf^%#B!{u7TPZX6B$MDlu4JNT$E!G;TX-Xna|lPv zdNmhvE`Q+zobJ5pxSctwBTV8y_fY@b_{)vUd73Frjryrh8N=*g3guRA;!w_I0zS(m z@=@FTSB^EW)|n~!t(?vI?9HuAWe?Jx%CTkx-zlfstWw9T%xjr1+`=SqKND~Tv+z5t zZCA=>rUHj#-N^nrY%!NG0S@zO{>B`^0OvWCSF#_Qmm7>Xc!dLafYunecYl}09^1gfdsjIoob7>6MYpMkcX|i`bXj*qj}F zV~{iZk(r-h-p@ZcitVI-2gfjpyvW=5DRUGx{?9(jX>8z^oWLqk^LLF*6SizKgnnab>Dj^HSBmZzAbtJG0hrJ&-xFe4#x%n diff --git a/languages/exelearning-gl_ES.po b/languages/exelearning-gl_ES.po index e45d559..2664121 100644 --- a/languages/exelearning-gl_ES.po +++ b/languages/exelearning-gl_ES.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "Non se puido reprocesar %d ficheiro." msgstr[1] "Non se puideron reprocesar %d ficheiros." + +msgid "Process as eXeLearning" +msgstr "Procesar como eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "Ficheiro eXeLearning (aínda sen procesar)" + +msgid "Processing…" +msgstr "Procesando…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Non se puido procesar este ficheiro como eXeLearning." diff --git a/languages/exelearning-it_IT.mo b/languages/exelearning-it_IT.mo index 5714db4e0092dd8bd30c37a3a7198c1d827ee8ab..00d8e55d3b8b78b999c3f9a6db839397f0cff4a7 100644 GIT binary patch delta 2215 zcmZY9e@xVM9LMo@aEL|_f%qdNzUV0jloB|zkkm+4mcf)kO>!RG*EoVZ;*OexT#1!y z%2cEuv*oJUvNf!5wcK>G+}u>Qey?Ta#$uAyKdnES8`<;aeK%LR!^h|Iz59IL@6Y@F zx%;8vwWh>RIlf`z-&6d}hf#@KMrE4GDsALPRipxW z%hsU+HlunSK_xthTK5W8;}M*VzoQP4mYr;QRyOt5JPHY~s;cq=}N%~+1dunvDk zC0s?+K6FtP7{}YOfUFop>2H;Q;;X!tq?{A7HS7dZ~wfs1t1W=DVONqQHdvd8I&=2 z5%o3v2=Bw=s6f+*vI5I+A+}*H4x$ggLACIEtjBTWWtH4$?zPn+XS8Ni3%gMXe~RRi zu(J$E(k4(RpF$;)Rgmm;9x9=gScN-rIi5n5bjo{v$1P?K^BC%m>_-CF3FLa%cgUIS zlJ`7~8+*6D|Ah?H`%OqyErk45*wd&>`8;Z)VN{|=JWpdW^9#t!(w*ezictYqqbjoj zb>fYv^&!-LeOQ@b@Dc+KZD&xWx`-Ugrcepwa|6}WQq(0}hAMF#s^^cResW*MD2}1l zSIaaa09%pelV36S{QoGhmHu#$_^gj-N|?F4SaOMdEK!=R0ur4PP`?RWuoqP2^XU#ot6nfVB6VJ&s< z$5W^uu9`qHkqD~fJ*Wh?q23=rRd@tdiLL{aN{Q7zhos^nqR{wGlf{1M67ve{fMT0WP-3;w89CNmWJKUj@xr;{KoGYoh0WG?=Vyhc38C^f?p^U(qj$li)JLt4VJKcYU z_ul9YMLi?ex5Uzvn#9^ZWk3-|usdEqFCI&=lo9Z2WED zKaT&)F3rqx!~W}MIGydkd8AhV7gV`_~iS2L=X5vPS$Li4c4=|be9~_N|;bv~L zfE6%k&xxft3Rhw;JcSW>1G`~8j>Wggjh58WtUG!!8gtNtb5Ma4p%z?@3UDuG;B8FB zFX&}`i|b^T#)*m86W5_Ku?J)E4C=wRL*G9^E%*_$@Gok?tjOn4^Le?w-^?eK~&{R~${GoX{c4c0H%2+k>u{sXK zXb({vd5*gO9R`#Nt>yzC7UMkBLRV24s7F5bo`V+nj;d`qPg8)Ys2yeE7@Ueda5pOO zN>qt!P;GS)^}JdS`Ojc*pA-4`9S7sQE@m@v7b?I8+=^OVJ1)l|Sc}u}0}jG0qTY?g zSb(2#E#^`$FV>+-)r<=KQ#AS4iEo_fhrdy&=}Z2|jtxicU?LKm&A<#SKrOfr70C6_ zycI_>kE1h{^7*&`i?BCdMP;@D*^{*d7;umM4Cx_{>CBT+H{_uLC`Bz;hI;U6OloU( z3CA&SAZpo@4pRvip#r{vY4{jbf?qfp+frA(9f4^KvKg$!{&)c=;Zt;DEK#Ut@i+?y zpaLu7K!4gHBz8NAD%llOz+rSWYgrdmFZfV9_M-yHLrNR4MGO?tP8^N(I0D1_IH~lb z&Tqv8tVQ+23)BLxzD`s2L1MNH)cL8HgT<&4S0Tx=8syctn;5D0{~-h2(1?okYsk*D z_yFd~$j5Ry=(^QNzu7KSW{#qET#35B26f#n%*2<-p3IZrWNIK1yZJFPz@U(UYFdhF zzAZ==?GUQw7f}yt!cvUt=R9CLYR8qR)Luh1*CXU(uQ@27HiSU?mScN5YCP^lUH1T2;xE)&vy28if)%Li z(^JU*HU{TYoFAYx@~r^oqf(oX3akY61GNg3$_i9>2T||w9aP5ZQ31E0-lABq6X2AP zn^3)V5|!~r??PuM&75EpmP2Q$n~PATC_|<20BV66)XwgqYWM@gF^1?=ssxO{8NT42 yupME+p`Hz{U_(^0I~d<1yHkfbD@s-`DJorma7j{h@I>M{S8!WOmHXh7w7h?xXU32K diff --git a/languages/exelearning-it_IT.po b/languages/exelearning-it_IT.po index 06f8744..091adf1 100644 --- a/languages/exelearning-it_IT.po +++ b/languages/exelearning-it_IT.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "Impossibile rielaborare %d file." msgstr[1] "Impossibile rielaborare %d file." + +msgid "Process as eXeLearning" +msgstr "Elabora come eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "File eXeLearning (non ancora elaborato)" + +msgid "Processing…" +msgstr "Elaborazione…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Impossibile elaborare questo file come eXeLearning." diff --git a/languages/exelearning-pt_PT.mo b/languages/exelearning-pt_PT.mo index 9ec39d9de78de6b21739f558d10162a35fb7fc89..f8018f3f6587febcd365a96ebb927c5b987ef9d2 100644 GIT binary patch delta 2235 zcmZY9drZ}39LMo*TmmFeIH4k;Ke>6h1Srf@q*N~JMjGB5){ci?aR@ldff^fim}Zwv zT6v(GEnCjYwrpHln^~>;C!4O?S~}bKgLT1Gwr*RqnY};qD_1$>^*q1dd7kg(c@86+ z2DU~gG82Z4fA{nM8vcJCubJ82q|2^hILN#nKgA80m28%d?O2U{n21NP3deB)PI>Q> zQ_KpO*J2HB#{{#ey};l~9vs9K_!?&8*O-PE@oG$)VYUkMkcAe+ENnv`K8Q1MA1aZT zPyt^>C3q4mFfP@s7?+@*_}0Rplm~loE{>o&@iETBZ&4e^T{Zna6BV!&m*NemfcK#i z?MHI9Cs76L_dJX$>@A#)pJ5L1ZHj>cCh+M@T!?CIHEN?eq>I+%z2AgNG=%EdE^q!0 zW-$K(mBS$b28F zW#gz$oJ3XfB~p}qg-ZNaRKNmmrV?sK%^$+$IEp%o?{PE!9%YcjU>*G;IqN`*v?#Kd zJ>hu>%b6cV-RiTbb?Nj~iIkuMRiOd|uqe)~6PuV1ar3s|B!;ktb5zHphZrccan#rF z9Nvb%VG-67Mc@B>u@sNsjd%w2HO%McDgi%k#Kow)@Cd4aSCFmj7_P=ss5sfVmkNlQ z!+<8(N>t^|sEvcDfNfZZL%0l2d(SiGbAy@JqR!eyaqe1SHh~mve;_}WMRs~# zjcfG%zny{3ehBHRJ%_pjBbbJ7dmcwsatigipRo{AoM|gi0XCuv=|NrYJ*fQ#QH4H@ zO7wMXh%z|NKpW5GrpXf288@KLs2%weVST7e_%P<-0W8B0a3%hTVJzdN-apST?e`vP zpEKy-SyX~E@;QHe5jEA*E5*UO{>8mZo>npiZ0+%T){yP<4)|wB)?e%A3)vupHPV` zD4wot5hgNUhI)TJI@p2wmJMPuzF18E)q%r2(D(OUEXQ-GOmq3OC~v|>?8Q_(hMVyt z)Dbv*NN3xGx?DljQ4OHZ`dQQ^eG~QhZ%`ffWiv}*upE_X9jcW(%f@a_+MQIet~(rb zBM~PMaojELt!|*ZE7Wz5d5;-R4!k^eBE2WRG8pddXmz^6J(lvfo>-l?hOAcfz^L62y~NyHM`#W z*cJJ6#|ra)iI1(%_LX}N*}#qTxP%S1xuNdx|F$c)2HwY_g(YK+MX`ize<0M=8gRNk WJQ)5bPHVW>8eV zZ02%2pt+3Mn3&BbYm711TpnOr?(fgef74Fq_51z*{eQpj<@ayF0R95Y;x}3!F1-oaU7;bn7PaX z=4a5I3rn#8SKj0BjS6H5YQq(%0QX}K-b62c zK@a|2Vfj6RB zdXSl-)gChkTB*os?GyDQ^uI1(@6G;GIV=p*XAxD5UH z3D@9s`sKl^s8+o}1^%%w_1A^(To{bMP^C$tepJWuP#KgWu~`}BpdYp20aPGYocU+W zXYS@?s^k?o50_vfHlr%risWSP0}Ob^emKTZ$KlM=Q4h>P1yGIJa1(0b6PVG}>;leU z-bU2(F_RNkiQ7>nZbt>wiE2SE{Vc-*%)r1t2715GVK#onDVWUedd*g#0;s}r+<@x+ zJyhTykw>fxCwKz7Q5*S@Tecj@&32$NK7v}e3E3}TXBbT4LK}|7L4!hpRA46aoj3wd zqZWMP-0wghv9zSnYgLSVY$0E|z8RI+VN{FTkYd?$RPVoIl-_@(qh3d&GI68+Fb{`d znd3U-89R!~5S8guRG?q56r)o@>&|toM*U-|!{`8mvkZ9E?HcOvwO}f~ z!l4+>$t=XtSdD8@_g`QNx|k@SA*h!4kXtqlwQdzEku9h*(ul-nofyy`3`h(0ECZF% zFx250gLznjRk#(4upJkohct9%cHureizS%PZsV{P7h^N}Fo6@-fHQGDzRsZjH4GNf zXr1l{r~rPWGV10D^)3!|-;F6a74;ge!X8+Q%6t#%^*n~d@g6F`pN^Scv&qcoU?d*( zQV+eKC%B+e|3URIn}3HoL=#Xg@uPaW5p@{rQ6;^F5!j9j@EfXfsoBB0@Ll1-Q86`P c!PeMxS1>ugF*5i*wVx|kka@#Z?-@G(A19i_fdBvi diff --git a/languages/exelearning-pt_PT.po b/languages/exelearning-pt_PT.po index f67c9b4..2dd74ef 100644 --- a/languages/exelearning-pt_PT.po +++ b/languages/exelearning-pt_PT.po @@ -575,3 +575,15 @@ msgid "%d file could not be reprocessed." msgid_plural "%d files could not be reprocessed." msgstr[0] "Não foi possível reprocessar %d ficheiro." msgstr[1] "Não foi possível reprocessar %d ficheiros." + +msgid "Process as eXeLearning" +msgstr "Processar como eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "Ficheiro eXeLearning (ainda não processado)" + +msgid "Processing…" +msgstr "A processar…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Não foi possível processar este ficheiro como eXeLearning." diff --git a/languages/exelearning-ro_RO.mo b/languages/exelearning-ro_RO.mo index 8bbbcf3b9e5bfb8bad1d6084252cdd046c3f1e65..b2fbb916c6066839f9fa1b1555f51de0345d8d60 100644 GIT binary patch delta 2192 zcmZA1e@xVM9LMpu{OB}2G%i3>>>xUR`U?;<=0b70QsRb8$*6W5Ulm3N^Nvi%Iw!UH z2O7$pOgH~XBcjb#$7-?lhs9roEjLzcHMfW+rRCcAqnVAKFTdAZvEv?}&-d>0dA~pJ z&*yU&nts|Fzn+@upKM$Fs9>WtiVxx z6lap3&tG6xKz|cfV+*F4#q9!(2Y7H1pTrSdhCibh=kQ_lK4?~h`N%*EV-_AjAHIf* z@iZ!tw@?9xQ3+nfHJGx{tQgC&i1^k_qm&14;8OemRf#X~5xjw#IAxLhc_u1gDOTZA zsDQ7a674~9wo|ADoKN~LYGEH^HeSQ!#J3q53Yf;XGcgZU+DgR&`-7xmH}_Mld9EZOhF zJo=|mB^yOm;wow--y@5%X;k8~sDK5WOeNHS>c5T~@k7*B+`>KhXPm}z8bRtsa@K(? z(&ETm)}QokTupxrb*g_sjmw~}N~8o8r~(xrgpDa?FJmYD&p3I9u$=uH#1T}*T-Evh0Ds59^v>JV1(rY+r#+OjxuJZumZ zX9ShtIM(U@-=LwrU+;HIUX7|m5V@K59BP7ns0m+1s&A+8F&svwunDB9b_-Qn4+lq; z$wq#AY$YmzV$`@=e3tmOp9YJxk5Cg#Ci}Nh1HGiH1ePLIwGz}B*p6Cx2X4f7@Hqa6 zysVuln(qW^{4gr;7;idb*D=odu-i1Ko_Uy6TM$5fu?kXsA+%%Zp_K>fGt10HNZU!hqO?!ZdCgzND;yo8I{Se@cg z?8CBR_Y8c3>GWsNi@#$U-bDqR$FG|fxDK_2L0o`sCDdOfjq*U5_M--jV;Rn(4q0BQ zdwPSY>vax$aR^zomGEU<-+ELS3%)chBbwb-|)EuULPLx(Mw%gf6Gyy@^X zu1Y-P>Gc!@4|ax~Xw)By`kf|crxQ9DX^ZT)=`jC-FdiEH_HQ?o<V!Z delta 1919 zcmYM#drZw?9LMo59Zpe>ibxTPI$acp$h9@iaLSz~W#kY1QOK5|9hEULRF%SD;C3CSgP9`aMjc{|9H_*a$O+ zna|2;^k<*~r{iXf#|s#Rw{QsF$9#N=+-S)I%!Xnb#^P*r;Zjs0>rnwWq7poU*?1e> z_!ZNLZ;1oVG8ibt;kXr5iGw%-FQF#B9lG9*3fP0W_!kv0H!_&$Oe9BJf?7an$W5q) z)nFVR#{}YA0}TbdgE9C5RoZW;iNctLs#z53dK@ZIH>zRrUc!n6?t$LF2G^vMPK~KDryCVNN!e)*;tMWcodaLOQ`<| z^XMmXGF9@GxC+-}0yd*6+lj2n-uY;7kNphkqK*^jr=V_FhDx9c6>tY?;)}Svuh|W( zqVMEn)nE;t#?Pn{``JG|Zgr@69-Hp8p&gbGfh%^YCKuf;~r7B6?JC z&+}0e?n3S58PrzYLAKwXq5^$JB^be%i!c+l#XC`z-h-;dNsQFTO5_O*+i}5P57W;*&R6GYw{mD4MM`I}sB~XS` z$*NGlT!(QwUPo2t1Nw11%hL)Q`B0#C)M4sHJ$6n8DS{=TNNjF3{cg<0Ipc!=KdNw^#&IfUj5jO63Y>y1cpkr^ z4r^U1^*>C*#fECH&!Q4(!zg@!4(vh&dWBlq5O;8^JgCFwMOAJkD&cC>_b*{4K0?j! z;8$)wdQeYMZ93`grE!1(7GqHv!RK=ls+5JOLsN!2?b}cR_M`T&8N=~0X5kCe*>GhB m4m$Tb1Cw0UVS&z=6h|OwSY>3OIr&#u;C*VJekWqH7XJgNWyZGv diff --git a/languages/exelearning-ro_RO.po b/languages/exelearning-ro_RO.po index 8bf8d38..626e2f7 100644 --- a/languages/exelearning-ro_RO.po +++ b/languages/exelearning-ro_RO.po @@ -579,3 +579,15 @@ msgid_plural "%d files could not be reprocessed." msgstr[0] "%d fișier nu a putut fi reprocesat." msgstr[1] "%d fișiere nu au putut fi reprocesate." msgstr[2] "%d de fișiere nu au putut fi reprocesate." + +msgid "Process as eXeLearning" +msgstr "Procesează ca eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "Fișier eXeLearning (încă neprocesat)" + +msgid "Processing…" +msgstr "Se procesează…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Acest fișier nu a putut fi procesat ca eXeLearning." diff --git a/languages/exelearning.pot b/languages/exelearning.pot index a4d1110..e3a7c2c 100644 --- a/languages/exelearning.pot +++ b/languages/exelearning.pot @@ -563,6 +563,18 @@ msgstr "" msgid "Preview in new tab" msgstr "" +msgid "Process as eXeLearning" +msgstr "" + +msgid "eXeLearning file (not processed yet)" +msgstr "" + +msgid "Processing…" +msgstr "" + +msgid "This file could not be processed as eXeLearning." +msgstr "" + msgid "eXeLearning Content Preview" msgstr "" diff --git a/tests/unit/MediaLibraryTest.php b/tests/unit/MediaLibraryTest.php index fd21ed9..23740fb 100644 --- a/tests/unit/MediaLibraryTest.php +++ b/tests/unit/MediaLibraryTest.php @@ -620,4 +620,71 @@ public function test_enqueue_media_modal_scripts_includes_all_strings() { $this->assertStringContainsString( '"' . $key . '"', $data ); } } + + /** + * Build an attachment with the given extension and return its post object. + * + * @param string $ext File extension. + * @return WP_Post + */ + private function make_attachment_post( $ext ) { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $attachment_id = $this->factory->attachment->create( + array( + 'post_mime_type' => 'application/zip', + 'post_author' => $user_id, + ) + ); + wp_set_current_user( $user_id ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/jsflag-' . $attachment_id . '.' . $ext; + update_attached_file( $attachment_id, $file_path ); + + return get_post( $attachment_id ); + } + + /** + * An unprocessed .elpx/.zip candidate is flagged reprocessable for the modal. + */ + public function test_metadata_to_js_marks_unprocessed_candidate_reprocessable() { + $post = $this->make_attachment_post( 'elpx' ); + $response = $this->media_library->add_elp_metadata_to_js( array(), $post, array() ); + + $this->assertArrayHasKey( 'exelearningReprocessable', $response ); + $this->assertTrue( $response['exelearningReprocessable'] ); + } + + /** + * A .zip candidate is also flagged reprocessable (content is checked on the server). + */ + public function test_metadata_to_js_marks_zip_candidate_reprocessable() { + $post = $this->make_attachment_post( 'zip' ); + $response = $this->media_library->add_elp_metadata_to_js( array(), $post, array() ); + + $this->assertTrue( ! empty( $response['exelearningReprocessable'] ) ); + } + + /** + * An already-processed attachment is not flagged reprocessable. + */ + public function test_metadata_to_js_processed_not_reprocessable() { + $post = $this->make_attachment_post( 'elpx' ); + update_post_meta( $post->ID, '_exelearning_extracted', str_repeat( 'a', 40 ) ); + + $response = $this->media_library->add_elp_metadata_to_js( array(), $post, array() ); + + $this->assertFalse( ! empty( $response['exelearningReprocessable'] ) ); + } + + /** + * A non-candidate attachment (e.g. an image) is not flagged reprocessable. + */ + public function test_metadata_to_js_non_candidate_not_reprocessable() { + $post = $this->make_attachment_post( 'jpg' ); + + $response = $this->media_library->add_elp_metadata_to_js( array(), $post, array() ); + + $this->assertFalse( ! empty( $response['exelearningReprocessable'] ) ); + } } diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index fd194ea..8d76b93 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -2081,4 +2081,113 @@ public function test_save_elp_file_rejects_concurrent_save() { } unset( $_FILES['file'] ); } + + /** + * The reprocess route is registered. + */ + public function test_reprocess_route_is_registered() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/exelearning/v1/reprocess/(?P\\d+)', $routes ); + } + + /** + * The reprocess route is a POST endpoint. + */ + public function test_reprocess_route_method() { + $routes = rest_get_server()->get_routes(); + $route = $routes['/exelearning/v1/reprocess/(?P\\d+)']; + $this->assertArrayHasKey( 'POST', $route[0]['methods'] ); + } + + /** + * reprocess_attachment() extracts an existing .elpx and sets metadata. + */ + public function test_reprocess_attachment_success() { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $attachment_id = $this->factory->attachment->create( + array( + 'post_mime_type' => 'application/zip', + 'post_author' => $user_id, + ) + ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/reprocess-rest-' . $attachment_id . '.elpx'; + $zip = new ZipArchive(); + $zip->open( $file_path, ZipArchive::CREATE ); + $zip->addFromString( 'content.xml', '' ); + $zip->addFromString( 'index.html', '' ); + $zip->close(); + update_attached_file( $attachment_id, $file_path ); + + $request = new WP_REST_Request( 'POST', '/exelearning/v1/reprocess/' . $attachment_id ); + $request->set_param( 'id', $attachment_id ); + + $result = $this->rest_api->reprocess_attachment( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $result ); + $hash = get_post_meta( $attachment_id, '_exelearning_extracted', true ); + $this->assertNotEmpty( $hash ); + $this->assertEquals( '1', get_post_meta( $attachment_id, '_exelearning_has_preview', true ) ); + + unlink( $file_path ); + $this->recursive_delete_test( $upload_dir['basedir'] . '/exelearning/' . $hash . '/' ); + } + + /** + * reprocess_attachment() rejects an invalid attachment ID. + */ + public function test_reprocess_attachment_invalid() { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'POST', '/exelearning/v1/reprocess/999999' ); + $request->set_param( 'id', 999999 ); + + $result = $this->rest_api->reprocess_attachment( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_attachment', $result->get_error_code() ); + } + + /** + * reprocess_attachment() rejects a non-candidate (non .elpx/.zip) file. + */ + public function test_reprocess_attachment_non_candidate() { + $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $attachment_id = $this->factory->attachment->create( array( 'post_author' => $user_id ) ); + wp_set_current_user( $user_id ); + + $upload_dir = wp_upload_dir(); + $file_path = $upload_dir['basedir'] . '/reprocess-rest-' . $attachment_id . '.jpg'; + file_put_contents( $file_path, 'fake image' ); + update_attached_file( $attachment_id, $file_path ); + + $request = new WP_REST_Request( 'POST', '/exelearning/v1/reprocess/' . $attachment_id ); + $request->set_param( 'id', $attachment_id ); + + $result = $this->rest_api->reprocess_attachment( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_file_type', $result->get_error_code() ); + + unlink( $file_path ); + } + + /** + * The reprocess route denies users who cannot edit the attachment. + */ + public function test_reprocess_route_denies_logged_out() { + $attachment_id = $this->factory->attachment->create(); + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', '/exelearning/v1/reprocess/' . $attachment_id ); + $request->set_param( 'id', $attachment_id ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } } From 7b384f89ade2adceb02c45ec6b815cc6a1409884 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 22:37:30 +0100 Subject: [PATCH 5/6] fix: rename a reprocessed .zip to .elpx so the editor and exporter accept it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprocessing a .zip made it previewable, but the editor and export-bootstrap paths are .elpx-only and aborted with "This file is not an eXeLearning file (.elpx)." once the now-processed file showed an Edit/Download affordance. Once extraction has confirmed the archive is a real eXeLearning project, rename the underlying .zip to the canonical .elpx (unique filename) and update the attachment via update_attached_file(). The file then behaves as a first-class eXeLearning source everywhere — preview, edit, export and save — with no changes to the existing .elpx extension guards. Embedding is by attachment ID, so shortcodes keep working; the move is non-fatal (the preview still works if it fails). Covered by ReprocessorTest::test_reprocess_renames_valid_zip_to_elpx and verified end-to-end via WP-CLI (a reprocessed .zip becomes .elpx and is editor-accepted). Line coverage 79.55%. --- includes/class-elp-reprocessor.php | 39 ++++++++++++++++++++++++ tests/unit/MediaLibraryReprocessTest.php | 1 + tests/unit/ReprocessorTest.php | 23 ++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/includes/class-elp-reprocessor.php b/includes/class-elp-reprocessor.php index e4bd6fc..9157320 100644 --- a/includes/class-elp-reprocessor.php +++ b/includes/class-elp-reprocessor.php @@ -89,6 +89,11 @@ public function reprocess( $attachment_id ) { return $extraction; } + // The archive validated as a real eXeLearning project. If it arrived with + // a generic .zip extension, give it the canonical .elpx name so the + // editor, exporter and save flow (all .elpx-only) accept it too. + $this->normalize_zip_to_elpx( $attachment_id, $file_path ); + $this->apply_metadata( $attachment_id, $extraction['service'], $extraction['hash'], $extraction['has_preview'] ); // Drop the previous extraction only after the new one is committed. @@ -370,6 +375,40 @@ public function cleanup_by_hash( $hash ) { } } + /** + * Rename a validated .zip attachment to the canonical .elpx extension. + * + * eXeLearning source projects sometimes arrive as a generic .zip (renamed, or + * stored by a flow that kept the extension). Once the contents are confirmed + * to be a real eXeLearning project, renaming to .elpx makes the file a + * first-class citizen everywhere — the editor, exporter and save flow all key + * off the .elpx extension. Embedding uses the attachment ID, so existing + * shortcodes keep working. Non-fatal: if the move fails the preview still + * works, the file just keeps its .zip name. + * + * @param int $attachment_id Attachment ID. + * @param string $file_path Current file path. + * @return string Resulting file path (.elpx on success, unchanged otherwise). + */ + private function normalize_zip_to_elpx( $attachment_id, $file_path ) { + if ( 'zip' !== strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ) ) { + return $file_path; + } + + $dir = dirname( $file_path ); + $new_name = wp_unique_filename( $dir, preg_replace( '/\.zip$/i', '.elpx', basename( $file_path ) ) ); + $new_path = trailingslashit( $dir ) . $new_name; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- In-place move on the same filesystem; a failure is non-fatal and handled below. + if ( ! @rename( $file_path, $new_path ) ) { + return $file_path; + } + + update_attached_file( $attachment_id, $new_path ); + + return $new_path; + } + /** * Recursively delete a directory. * diff --git a/tests/unit/MediaLibraryReprocessTest.php b/tests/unit/MediaLibraryReprocessTest.php index 806ec72..30018fd 100644 --- a/tests/unit/MediaLibraryReprocessTest.php +++ b/tests/unit/MediaLibraryReprocessTest.php @@ -218,6 +218,7 @@ public function test_handle_bulk_reprocesses_valid_zip() { $this->assertNotEmpty( $hash ); $upload_dir = wp_upload_dir(); + $this->cleanup_paths[] = get_attached_file( $id ); // Renamed to .elpx by reprocessing. $this->cleanup_paths[] = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/' . $hash . '/'; $query = wp_parse_url( $redirect, PHP_URL_QUERY ); diff --git a/tests/unit/ReprocessorTest.php b/tests/unit/ReprocessorTest.php index 3f7f899..4ea04e4 100644 --- a/tests/unit/ReprocessorTest.php +++ b/tests/unit/ReprocessorTest.php @@ -350,9 +350,32 @@ public function test_reprocess_accepts_valid_exelearning_zip() { $this->assertNotEmpty( $hash ); $this->assertEquals( '1', get_post_meta( $id, '_exelearning_has_preview', true ) ); + $this->cleanup_paths[] = get_attached_file( $id ); $this->cleanup_paths[] = $this->extraction_dir( $hash ); } + /** + * A reprocessed .zip is renamed to the canonical .elpx so the editor/exporter accept it. + */ + public function test_reprocess_renames_valid_zip_to_elpx() { + $fixture = $this->make_zip_attachment( 'zip', true, true ); + $id = $fixture['id']; + + $result = $this->reprocessor->reprocess( $id ); + $this->assertIsArray( $result ); + + $new_path = get_attached_file( $id ); + $this->cleanup_paths[] = $new_path; + $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $this->assertStringEndsWith( '.elpx', $new_path ); + $this->assertFileExists( $new_path ); + // The original .zip was moved, not left behind. + $this->assertFileDoesNotExist( $fixture['path'] ); + // It is now a first-class .elpx everywhere. + $this->assertTrue( $this->reprocessor->is_exelearning_candidate( $id ) ); + } + /** * A plain .zip (no content.xml) is rejected and writes no metadata. */ From 3ef2f87fe47d3f3f943171d75158cc35626666e2 Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 22:40:44 +0100 Subject: [PATCH 6/6] style: satisfy PHPCS on the zip->elpx rename helper CI's WPCS flagged two issues the local container's phpcs missed: - Generic.Commenting.DocComment.LongNotCapital: the doc long description started with the lowercase brand 'eXeLearning'; reworded to start with a capital. - Use the correct ignore code WordPress.WP.AlternativeFunctions.rename_rename (not file_system_operations_rename) for the in-place rename(). --- includes/class-elp-reprocessor.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/includes/class-elp-reprocessor.php b/includes/class-elp-reprocessor.php index 9157320..ef01362 100644 --- a/includes/class-elp-reprocessor.php +++ b/includes/class-elp-reprocessor.php @@ -378,13 +378,12 @@ public function cleanup_by_hash( $hash ) { /** * Rename a validated .zip attachment to the canonical .elpx extension. * - * eXeLearning source projects sometimes arrive as a generic .zip (renamed, or - * stored by a flow that kept the extension). Once the contents are confirmed - * to be a real eXeLearning project, renaming to .elpx makes the file a - * first-class citizen everywhere — the editor, exporter and save flow all key - * off the .elpx extension. Embedding uses the attachment ID, so existing - * shortcodes keep working. Non-fatal: if the move fails the preview still - * works, the file just keeps its .zip name. + * Once extraction has confirmed the archive is a real eXeLearning project, + * renaming a generic .zip to .elpx makes the file a first-class citizen + * everywhere — the editor, exporter and save flow all key off the .elpx + * extension. Embedding uses the attachment ID, so existing shortcodes keep + * working. Non-fatal: if the move fails the preview still works, the file + * just keeps its .zip name. * * @param int $attachment_id Attachment ID. * @param string $file_path Current file path. @@ -399,7 +398,7 @@ private function normalize_zip_to_elpx( $attachment_id, $file_path ) { $new_name = wp_unique_filename( $dir, preg_replace( '/\.zip$/i', '.elpx', basename( $file_path ) ) ); $new_path = trailingslashit( $dir ) . $new_name; - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- In-place move on the same filesystem; a failure is non-fatal and handled below. + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- In-place move on the same filesystem; a failure is non-fatal and handled below. if ( ! @rename( $file_path, $new_path ) ) { return $file_path; }