Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #95: Expose file replace in cli and UI #100

Merged
merged 1 commit into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading