diff --git a/classes/helper.php b/classes/helper.php index 9ce6fcc..19e0b65 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -735,8 +735,9 @@ public static function read_last_line(string $filename) { /** * Takes csv data and replaces all matching strings within the DB * @param string $data CSV data to be read. + * @param string $type type of replace db || files. */ - public static function handle_replace_csv(string $data) { + public static function handle_replace_csv(string $data, string $type = 'db') { // Load the CSV content. $iid = csv_import_reader::get_new_iid('tool_advancedreplace'); $csvimport = new csv_import_reader($iid, 'tool_advancedreplace'); @@ -761,7 +762,30 @@ public static function handle_replace_csv(string $data) { } // Check if all required columns are present, and show which ones are missing. - $requiredcolumns = ['table', 'column', 'id', 'match', 'replace']; + if ($type == 'db') { + $requiredcolumns = ['table', 'column', 'id', 'match', 'replace']; + // Column indexes. + $tableindex = array_search('table', $header); + $columnindex = array_search('column', $header); + $idindex = array_search('id', $header); + $matchindex = array_search('match', $header); + $replaceindex = array_search('replace', $header); + } else if ($type == 'files') { + $requiredcolumns = ['contextid', 'component', 'filearea', 'itemid', 'filepath', + 'filename', 'replace', 'match', 'mimetype', 'internal']; + // Column indexes. + $contextidindex = array_search('contextid', $header); + $componentindex = array_search('component', $header); + $fileareaindex = array_search('filearea', $header); + $itemidindex = array_search('itemid', $header); + $filepathindex = array_search('filepath', $header); + $filenameindex = array_search('filename', $header); + $matchindex = array_search('match', $header); + $replaceindex = array_search('replace', $header); + $mimeindex = array_search('mimetype', $header); + $internalindex = array_search('internal', $header); + } + $missingcolumns = array_diff($requiredcolumns, $header); if (!empty($missingcolumns)) { @@ -777,13 +801,6 @@ public static function handle_replace_csv(string $data) { $progress = new progress_bar(); $progress->create(); - // Column indexes. - $tableindex = array_search('table', $header); - $columnindex = array_search('column', $header); - $idindex = array_search('id', $header); - $matchindex = array_search('match', $header); - $replaceindex = array_search('replace', $header); - // Read the data and replace the strings. $csvimport->init(); $rowcount = 0; @@ -792,10 +809,25 @@ public static function handle_replace_csv(string $data) { if (empty($record[$replaceindex])) { // Skip if 'replace' is empty. $rowskip++; - } else { + } else if ($type == 'db') { // Replace the string. self::replace_text_in_a_record($record[$tableindex], $record[$columnindex], $record[$matchindex], $record[$replaceindex], $record[$idindex]); + } else if ($type == 'files') { + $filerecord = [ + 'contextid' => $record[$contextidindex], + 'component' => $record[$componentindex], + 'filearea' => $record[$fileareaindex], + 'itemid' => $record[$itemidindex], + 'filepath' => $record[$filepathindex], + 'filename' => $record[$filenameindex], + 'mimetype' => $record[$mimeindex], + ]; + + if (!self::replace_text_in_file($filerecord, $record[$matchindex], $record[$replaceindex], + $record[$internalindex])) { + $rowskip++; + } } // Update the progress bar. @@ -827,4 +859,101 @@ public static function get_replace_csv_content(int $draftid): string { $file = reset($files); return $file->get_content(); } + + /** + * Replace a string in a file stored in Moodle's file storage. Supports both normal files and files inside zip archives. + * + * @param array $filerecord File record + * @param string $match The string to search for in the file's contents. + * @param string $replace The string to replace the matched string with. + * @param string $internal The name of the internal file to modify (only used for zip files). + * + * @return bool Returns true if the string was replaced and the file updated successfully, false otherwise. + */ + public static function replace_text_in_file(array $filerecord, string $match, string $replace, string $internal): bool { + $fs = get_file_storage(); + $file = $fs->get_file( + $filerecord['contextid'], + $filerecord['component'], + $filerecord['filearea'], + $filerecord['itemid'], + $filerecord['filepath'], + $filerecord['filename'] + ); + + if (!$file) { + mtrace(get_string('errorreplacingfilenotfound', 'tool_advancedreplace', + ['filename' => $filerecord['filename']])); + return false; + } + + // Specify tmp filename to avoid unique constraint conflict. + $filerecord['filename'] = time(); + + if ($filerecord['mimetype'] == 'application/zip' || $filerecord['mimetype'] == 'application/zip.h5p') { + $newzip = self::replace_text_in_zip($file, $match, $replace, $internal); + $newfile = $fs->create_file_from_pathname($filerecord, $newzip); + unlink($newzip); + } else { + + $content = $file->get_content(); + $newcontent = str_replace($match, $replace, $content); + $newfile = $fs->create_file_from_string($filerecord, $newcontent); + } + if ($newfile) { + $file->replace_file_with($newfile); + $newfile->delete(); + return true; + } else { + mtrace(get_string('errorreplacingfile', 'tool_advancedreplace', + ['replace' => $match, 'filename' => $filerecord['filepath']])); + return false; + } + } + + /** + * Extracts a file by name from a zip archive, replaces a string, and updates the zip file. + * + * @param \stored_file $zipfile Name of the file to extract and modify inside the zip. + * @param string $searchstring The string to search for in the file's contents. + * @param string $replacestring The string to replace the search string with. + * @param string $internalfilename The file name of the internal file to be modified. + */ + public static function replace_text_in_zip(\stored_file $zipfile, string $searchstring, + string $replacestring, string $internalfilename) { + + // Create a temporary file path for working with the ZIP file. + $tempzip = make_request_directory() . '/' . $zipfile->get_filename(); + $zipfile->copy_content_to($tempzip); + + // Open and modify the ZIP file. + $zip = new \ZipArchive(); + if ($zip->open($tempzip) !== true) { + return false; + } + + // Check if the target file exists in the zip. + $fileindex = $zip->locateName($internalfilename); + if ($fileindex === false) { + $zip->close(); + return false; + } + + // Extract the target file's content. + $filecontent = $zip->getFromIndex($fileindex); + if ($filecontent === false) { + $zip->close(); + return false; + } + + // Replace the string in the file's contents. + $modifiedcontents = str_replace($searchstring, $replacestring, $filecontent); + + // Delete the old file and add the modified file back to the ZIP. + $zip->deleteName($internalfilename); + $zip->addFromString($internalfilename, $modifiedcontents); + $zip->close(); + + return $tempzip; + } } diff --git a/cli/replace.php b/cli/replace.php index a780e47..5cd1a4a 100644 --- a/cli/replace.php +++ b/cli/replace.php @@ -35,6 +35,7 @@ Options: --input=FILE Required. Input CSV file produced by find.php in detail mode. +--type=db Type of replace database or in files. Default = 'db' -h, --help Print out this help. Example: @@ -44,6 +45,7 @@ list($options, $unrecognized) = cli_get_params( [ 'input' => null, + 'type' => 'db', 'help' => false, ], [ @@ -63,6 +65,8 @@ exit(0); } +$type = $options['type'] ?? 'db'; + try { $file = validate_param($options['input'], PARAM_PATH); } catch (invalid_parameter_exception $e) { @@ -77,5 +81,5 @@ $fp = fopen($file, 'r'); $data = fread($fp, filesize($file)); fclose($fp); -helper::handle_replace_csv($data); +helper::handle_replace_csv($data, $type); exit(0); diff --git a/file_replace.php b/file_replace.php new file mode 100644 index 0000000..19c88e5 --- /dev/null +++ b/file_replace.php @@ -0,0 +1,71 @@ +. + +/** + * Advanced search and replace strings throughout all texts in the whole database + * + * @package tool_advancedreplace + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define('NO_OUTPUT_BUFFERING', true); // Progress bar is used here. + +use tool_advancedreplace\helper; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->dirroot . '/lib/csvlib.class.php'); + +global $CFG; +$replace = optional_param('delete', 0, PARAM_INT); +$confirm = optional_param('confirm', '', PARAM_BOOL); +$draftid = optional_param('draftid', '', PARAM_TEXT); + +$url = new moodle_url('/admin/tool/advancedreplace/file_replace.php'); +$PAGE->set_url($url); + +admin_externalpage_setup('tool_advancedreplace_search'); + +$redirect = new moodle_url('/admin/tool/advancedreplace/files.php'); + +$customdata = [ + 'userid' => $USER->id, +]; +$form = new \tool_advancedreplace\form\replace($url->out(false), $customdata); +echo $OUTPUT->header(); +if ($form->is_cancelled()) { + redirect($redirect); +} else if (!(get_config('tool_advancedreplace', 'allowuireplace'))) { + echo $OUTPUT->heading(get_string('replacefilespageheader', 'tool_advancedreplace')); + echo html_writer::div(get_string('replace_warning', 'tool_advancedreplace', + '$CFG->forced_plugin_settings[\'tool_advancedreplace\'][\'allowuireplace\'] = 1;'), 'alert alert-warning'); +} else if ($data = $form->get_data()) { + $returnurl = new moodle_url('/admin/tool/advancedreplace/file_replace.php'); + $optionsyes = array('replace' => $replace, 'confirm' => 1, 'sesskey' => sesskey(), 'draftid' => $data->csvfile); + $deleteurl = new moodle_url($url, $optionsyes); + $deletebutton = new single_button($deleteurl, get_string('replace', 'tool_advancedreplace'), 'post'); + echo $OUTPUT->confirm(get_string('replacecheck', 'tool_advancedreplace'), $deletebutton, $returnurl); +} else if ($confirm && !empty($draftid)) { + require_sesskey(); + $contents = helper::get_replace_csv_content($draftid); + helper::handle_replace_csv($contents, 'files'); +} else { + // Display form. + echo $OUTPUT->heading(get_string('replacefilespageheader', 'tool_advancedreplace')); + $form->display(); +} + +echo $OUTPUT->footer(); diff --git a/files.php b/files.php index 1fcd2c6..38dd502 100644 --- a/files.php +++ b/files.php @@ -96,7 +96,12 @@ $url->param('id', 0); $newurl = new \moodle_url($url, ['id' => 0]); $newbutton = new \single_button($newurl, get_string('newsearch', 'tool_advancedreplace'), 'GET'); + + $replaceurl = new moodle_url('/admin/tool/advancedreplace/file_replace.php'); + $replacebutton = new \single_button($replaceurl, get_string('newreplace', 'tool_advancedreplace'), 'GET'); + echo $OUTPUT->render($newbutton); + echo $OUTPUT->render($replacebutton); } echo $OUTPUT->footer(); diff --git a/lang/en/tool_advancedreplace.php b/lang/en/tool_advancedreplace.php index a837a37..fbe43aa 100644 --- a/lang/en/tool_advancedreplace.php +++ b/lang/en/tool_advancedreplace.php @@ -32,6 +32,8 @@ $string['errorregexnotsupported'] = 'Regular expression searches are not supported by this database.'; $string['errorreplacetextnotsupported'] = 'Replace all text is not supported by this database.'; $string['errorsearchmethod'] = 'Please choose one of the search methods: plain text or regular expression.'; +$string['errorreplacingfile'] = 'Error replacing string: [{$a->replace}] in file: [{$a->filename}]'; +$string['errorreplacingfilenotfound'] = 'Error replacing string, file: [{$a->filename}] no found'; $string['eta'] = 'ETA: {$a}'; $string['excludedtables'] = 'Several tables that don\'t support replacements are not searched. These include configuration, log, events, and session tables.'; $string['field_actions'] = 'Actions'; @@ -84,12 +86,14 @@ $string['field_zipfilenames_help'] = 'Regular expression to match subfiles within a zip.'; $string['field_skipzipfilenames_help'] = 'Regular expression to reject subfiles within a zip.'; -$string['filespagename'] = 'Search and Replace in Files'; +$string['filespagename'] = 'Search in Files'; +$string['replacefilespagename'] = 'Replace strings in Files'; $string['filespageheader'] = 'Search for text in Moodle files'; $string['lastupdated'] = 'Last updated {$a} ago'; $string['newsearch'] = 'New search'; $string['newreplace'] = 'New replace'; $string['replacepageheader'] = 'Replace text stored in the DB'; +$string['replacefilespageheader'] = 'Replace text stored in files'; $string['replacepagename'] = 'Replace strings in the Database'; $string['replace'] = 'Replace'; $string['replacecheck'] = 'Are you sure you want to replace strings in the Database using the uploaded csv file?'; diff --git a/settings.php b/settings.php index 84dd0c8..75e66b7 100644 --- a/settings.php +++ b/settings.php @@ -58,6 +58,15 @@ ) ); + $ADMIN->add( + 'advancereplacefolder', + new admin_externalpage( + 'tool_advancedreplace_file_replace', + get_string('replacefilespagename', 'tool_advancedreplace'), + new moodle_url('/admin/tool/advancedreplace/file_replace.php'), + ) + ); + $settings->add(new admin_setting_configtextarea('tool_advancedreplace/excludetables', get_string('settings:excludetables', 'tool_advancedreplace'), get_string('settings:excludetables_help', 'tool_advancedreplace'), '', PARAM_TEXT));