Skip to content

Commit

Permalink
Issue #95: Expose file replace in cli and UI
Browse files Browse the repository at this point in the history
  • Loading branch information
petersistrom authored and brendanheywood committed Dec 9, 2024
1 parent bbf9ee6 commit ce6c555
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 12 deletions.
149 changes: 139 additions & 10 deletions classes/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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)) {
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
}
6 changes: 5 additions & 1 deletion cli/replace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -44,6 +45,7 @@
list($options, $unrecognized) = cli_get_params(
[
'input' => null,
'type' => 'db',
'help' => false,
],
[
Expand All @@ -63,6 +65,8 @@
exit(0);
}

$type = $options['type'] ?? 'db';

try {
$file = validate_param($options['input'], PARAM_PATH);
} catch (invalid_parameter_exception $e) {
Expand All @@ -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);
71 changes: 71 additions & 0 deletions file_replace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

/**
* 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();
5 changes: 5 additions & 0 deletions files.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
6 changes: 5 additions & 1 deletion lang/en/tool_advancedreplace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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?';
Expand Down
9 changes: 9 additions & 0 deletions settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down

0 comments on commit ce6c555

Please sign in to comment.