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/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..a8f4f10 --- /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_candidate_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_eligible( $one ) ) { + WP_CLI::warning( sprintf( 'Skipped #%d: not eXeLearning content (.elpx or a .zip containing content.xml).', $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..ef01362 --- /dev/null +++ b/includes/class-elp-reprocessor.php @@ -0,0 +1,432 @@ +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 ( ! in_array( $ext, self::ACCEPTED_EXTENSIONS, true ) ) { + return new WP_Error( + 'invalid_file_type', + __( 'This is not an eXeLearning file (.elpx).', 'exelearning' ), + array( 'status' => 400 ) + ); + } + + // 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 ); + + $extraction = $this->extract_to_new_dir( $file_path ); + if ( is_wp_error( $extraction ) ) { + 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. + 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) eXeLearning file. + * + * 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_eligible( $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 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_exelearning_candidate( $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; + } + + $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 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 .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->query_candidate_ids() as $id ) { + if ( $this->needs_reprocessing( $id ) ) { + $ids[] = (int) $id; + } + } + + return $ids; + } + + /** + * Collect the IDs of every eligible eXeLearning attachment, processed or not. + * + * 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_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', + 'post_status' => 'inherit', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-off admin/CLI maintenance scan. + ) + ); + + return array_map( 'intval', $query->posts ); + } + + /** + * 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 ); + } + } + + /** + * Rename a validated .zip attachment to the canonical .elpx extension. + * + * 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. + * @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.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; + } + + update_attached_file( $attachment_id, $new_path ); + + return $new_path; + } + + /** + * 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..d948967 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' ) ); } @@ -100,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 ) ); } /** @@ -574,16 +624,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 +741,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 +753,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..84dcb65 100644 --- a/includes/integrations/class-media-library.php +++ b/includes/integrations/class-media-library.php @@ -30,6 +30,132 @@ 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; + + // 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; + } + + 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 ) ) + ); } /** @@ -67,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' ) ), ) ); @@ -118,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 7256688..bc864ee 100644 Binary files a/languages/exelearning-ca.mo and b/languages/exelearning-ca.mo differ diff --git a/languages/exelearning-ca.po b/languages/exelearning-ca.po index b08c2cb..73f8fc4 100644 --- a/languages/exelearning-ca.po +++ b/languages/exelearning-ca.po @@ -548,3 +548,42 @@ 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." + +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 7e706cf..1c7a776 100644 Binary files a/languages/exelearning-ca_valencia.mo and b/languages/exelearning-ca_valencia.mo differ diff --git a/languages/exelearning-ca_valencia.po b/languages/exelearning-ca_valencia.po index 4e27add..f7b4090 100644 --- a/languages/exelearning-ca_valencia.po +++ b/languages/exelearning-ca_valencia.po @@ -548,3 +548,42 @@ 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." + +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 9dc3cf0..b70606a 100644 Binary files a/languages/exelearning-de_DE.mo and b/languages/exelearning-de_DE.mo differ diff --git a/languages/exelearning-de_DE.po b/languages/exelearning-de_DE.po index ab6f455..56a947d 100644 --- a/languages/exelearning-de_DE.po +++ b/languages/exelearning-de_DE.po @@ -548,3 +548,42 @@ msgstr "Die bestehende Editor-Installation konnte nicht gesichert werden." #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Fehler beim Kopieren der Editor-Dateien in das Plugin-Verzeichnis." + +msgid "The eXeLearning file could not be found on disk." +msgstr "Die eXeLearning-Datei wurde auf der Festplatte nicht gefunden." + +msgid "Reprocess eXeLearning file" +msgstr "eXeLearning-Datei neu verarbeiten" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d eXeLearning-Datei neu verarbeitet." +msgstr[1] "%d eXeLearning-Dateien neu verarbeitet." + +#. 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 übersprungen (keine eXeLearning-Datei)." +msgstr[1] "%d Elemente übersprungen (keine eXeLearning-Dateien)." + +#. 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 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 bdab3a8..221b5e0 100644 Binary files a/languages/exelearning-eo.mo and b/languages/exelearning-eo.mo differ diff --git a/languages/exelearning-eo.po b/languages/exelearning-eo.po index 85aac9c..931483a 100644 --- a/languages/exelearning-eo.po +++ b/languages/exelearning-eo.po @@ -548,3 +548,42 @@ msgstr "Ne povis krei savkopion de la nuna redaktilo-instalado." #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Eraro dum kopiado de redaktilo-dosieroj al la kromprogramo-dosierujo." + +msgid "The eXeLearning file could not be found on disk." +msgstr "La eXeLearning-dosiero ne troviĝis en la disko." + +msgid "Reprocess eXeLearning file" +msgstr "Reprilabori eXeLearning-dosieron" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "%d eXeLearning-dosiero reprilaborita." +msgstr[1] "%d eXeLearning-dosieroj reprilaboritaj." + +#. 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 ero preterlasita (ne estas eXeLearning-dosiero)." +msgstr[1] "%d eroj preterlasitaj (ne estas eXeLearning-dosieroj)." + +#. 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 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 8e57ae7..c906c83 100644 Binary files a/languages/exelearning-es_ES.mo and b/languages/exelearning-es_ES.mo differ diff --git a/languages/exelearning-es_ES.po b/languages/exelearning-es_ES.po index 22aaba1..4096d4d 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-02T21:16:23+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" @@ -537,6 +564,18 @@ msgstr "Este es un archivo fuente de eXeLearning v2 (.elp). Para ver el contenid msgid "Preview in new tab" msgstr "Vista previa en nueva pestaña" +msgid "Process as eXeLearning" +msgstr "Procesar como eXeLearning" + +msgid "eXeLearning file (not processed yet)" +msgstr "Archivo eXeLearning (aún sin procesar)" + +msgid "Processing…" +msgstr "Procesando…" + +msgid "This file could not be processed as eXeLearning." +msgstr "Este archivo no se pudo procesar como eXeLearning." + msgid "eXeLearning Content Preview" msgstr "Vista previa del contenido eXeLearning" diff --git a/languages/exelearning-eu.mo b/languages/exelearning-eu.mo index ee60764..56900e2 100644 Binary files a/languages/exelearning-eu.mo and b/languages/exelearning-eu.mo differ diff --git a/languages/exelearning-eu.po b/languages/exelearning-eu.po index 9024691..1579125 100644 --- a/languages/exelearning-eu.po +++ b/languages/exelearning-eu.po @@ -548,3 +548,42 @@ msgstr "Ezin izan da editorearen uneko instalazioaren segurtasun-kopia egin." #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Errorea editorearen fitxategiak pluginaren direktoriora kopiatzean." + +msgid "The eXeLearning file could not be found on disk." +msgstr "Ezin izan da eXeLearning fitxategia diskoan aurkitu." + +msgid "Reprocess eXeLearning file" +msgstr "Birprozesatu eXeLearning fitxategia" + +#. translators: %d: number of files reprocessed. +#, php-format +msgid "%d eXeLearning file reprocessed." +msgid_plural "%d eXeLearning files reprocessed." +msgstr[0] "eXeLearning fitxategi %d birprozesatu da." +msgstr[1] "%d eXeLearning fitxategi birprozesatu dira." + +#. 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] "elementu %d saltatu da (ez da eXeLearning fitxategia)." +msgstr[1] "%d elementu saltatu dira (ez dira eXeLearning fitxategiak)." + +#. 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] "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 e55be2f..e556980 100644 Binary files a/languages/exelearning-gl_ES.mo and b/languages/exelearning-gl_ES.mo differ diff --git a/languages/exelearning-gl_ES.po b/languages/exelearning-gl_ES.po index 73f9605..2664121 100644 --- a/languages/exelearning-gl_ES.po +++ b/languages/exelearning-gl_ES.po @@ -548,3 +548,42 @@ 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." + +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 09742c4..00d8e55 100644 Binary files a/languages/exelearning-it_IT.mo and b/languages/exelearning-it_IT.mo differ diff --git a/languages/exelearning-it_IT.po b/languages/exelearning-it_IT.po index dbfe392..091adf1 100644 --- a/languages/exelearning-it_IT.po +++ b/languages/exelearning-it_IT.po @@ -548,3 +548,42 @@ 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." + +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 01ee21e..f8018f3 100644 Binary files a/languages/exelearning-pt_PT.mo and b/languages/exelearning-pt_PT.mo differ diff --git a/languages/exelearning-pt_PT.po b/languages/exelearning-pt_PT.po index bf953bd..2dd74ef 100644 --- a/languages/exelearning-pt_PT.po +++ b/languages/exelearning-pt_PT.po @@ -548,3 +548,42 @@ msgstr "Não foi possível fazer cópia de segurança da instalação atual do e #: includes/class-static-editor-installer.php:491 msgid "Failed to copy editor files to the plugin directory." msgstr "Falha ao copiar os ficheiros do editor para o diretório do plugin." + +msgid "The eXeLearning file could not be found on disk." +msgstr "Não foi possível encontrar o ficheiro eXeLearning no disco." + +msgid "Reprocess eXeLearning file" +msgstr "Reprocessar 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 reprocessado." +msgstr[1] "%d ficheiros eXeLearning reprocessados." + +#. 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 item ignorado (não é um ficheiro eXeLearning)." +msgstr[1] "%d itens ignorados (não são 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] "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 336786e..b2fbb91 100644 Binary files a/languages/exelearning-ro_RO.mo and b/languages/exelearning-ro_RO.mo differ diff --git a/languages/exelearning-ro_RO.po b/languages/exelearning-ro_RO.po index 0634bc8..626e2f7 100644 --- a/languages/exelearning-ro_RO.po +++ b/languages/exelearning-ro_RO.po @@ -549,3 +549,45 @@ 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." + +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 686504e..e3a7c2c 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 "" @@ -530,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 "" @@ -598,9 +643,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 new file mode 100644 index 0000000..30018fd --- /dev/null +++ b/tests/unit/MediaLibraryReprocessTest.php @@ -0,0 +1,316 @@ +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; + } + + /** + * 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. + */ + 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'] ); + } + + /** + * 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[] = 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 ); + 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. + */ + 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 ); + } +} 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/ReprocessorTest.php b/tests/unit/ReprocessorTest.php new file mode 100644 index 0000000..4ea04e4 --- /dev/null +++ b/tests/unit/ReprocessorTest.php @@ -0,0 +1,510 @@ +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, + ); + } + + /** + * 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. + * + * @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 ); + } + + /** + * 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[] = 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. + */ + 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. + */ + 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 ); + + // 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 ); + } +} 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() ); + } }