diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ecd6546 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +# .github/workflows/ci.yml +name: ci + +on: [push, pull_request] + +jobs: + ci: + uses: catalyst/catalyst-moodle-workflows/.github/workflows/ci.yml@main diff --git a/README.md b/README.md index 677ca96..b5da5ce 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ -# moodle-tool_advancedreplace \ No newline at end of file +# moodle-tool_advancedreplace + +This is a Moodle plugin that allows administrators to search and replace strings in the Moodle database. + +Administrators can search and replace strings in tables and columns of the Moodle database. +They can use simple text search or regular expressions. + +## GDPR +The plugin does not store any personal data. + +## Examples +- Find all occurrences of "http://example.com/" followed by any number of digits on tables: + + `php admin/tool/advancedreplace/cli/find.php --regex-match="http://example.com/\d+"` +- Find all occurrences of "http://example.com/" on a table: + + `php admin/tool/advancedreplace/cli/find.php --regex-match="http://example.com/" --tables=page` + +- Find all occurrences of "http://example.com/" on multiple tables: + + `php admin/tool/advancedreplace/cli/find.php --regex-match="http://example.com/" --tables=page,forum` + +- Replace all occurrences of "http://example.com/" on different tables and columns: + + `php admin/tool/advancedreplace/cli/find.php --regex-match="http://example.com/" --tables=page:content,forum:message` \ No newline at end of file diff --git a/classes/helper.php b/classes/helper.php new file mode 100644 index 0000000..8d66969 --- /dev/null +++ b/classes/helper.php @@ -0,0 +1,193 @@ +. + +namespace tool_advancedreplace; + +use core\exception\moodle_exception; + +/** + * Helper class to search and replace text throughout 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 + */ +class helper { + + /** @var string ALL_COLUMNS Flag to indicate we search all columns in a table **/ + const ALL_COLUMNS = 'all columns'; + + /** + * Perform a plain text search on a table and column. + * + * @param string $search The text to search for. + * @param string $table The table to search. + * @param string $column The column to search. + * @param int $limit The maximum number of results to return. + * @return array The results of the search. + */ + private static function plain_text_search(string $search, string $table, + string $column = self::ALL_COLUMNS, $limit = 0): array { + global $DB; + + $results = []; + + $columns = $DB->get_columns($table); + + if ($column !== self::ALL_COLUMNS) { + // Only search the specified column. + $columns = array_filter($columns, function($col) use ($column) { + return $col->name == $column; + }); + } + + foreach ($columns as $column) { + $columnname = $DB->get_manager()->generator->getEncQuoted($column->name); + + $searchsql = $DB->sql_like($columnname, '?', false); + $searchparam = '%'.$DB->sql_like_escape($search).'%'; + + $sql = "SELECT id, $columnname + FROM {".$table."} + WHERE $searchsql"; + + if ($column->meta_type === 'X' || $column->meta_type === 'C') { + $records = $DB->get_records_sql($sql, [$searchparam], 0, $limit); + if ($records) { + $results[$table][$column->name] = $records; + } + } + } + + return $results; + } + + /** + * Perform a regular expression search on a table and column. + * This function is only called if the database supports regular expression searches. + * + * @param string $search The regular expression to search for. + * @param string $table The table to search. + * @param string $column The column to search. + * @param $limit The maximum number of results to return. + * @return array + */ + private static function regular_expression_search(string $search, string $table, + string $column = self::ALL_COLUMNS, $limit = 0): array { + global $DB; + + // Check if the database supports regular expression searches. + if (!$DB->sql_regex_supported()) { + throw new moodle_exception(get_string('errorregexnotsupported', 'tool_advancedreplace')); + } + + $results = []; + + $columns = $DB->get_columns($table); + + if ($column !== self::ALL_COLUMNS) { + // Only search the specified column. + $columns = array_filter($columns, function($col) use ($column) { + return $col->name == $column; + }); + } + + foreach ($columns as $column) { + $columnname = $DB->get_manager()->generator->getEncQuoted($column->name); + + $select = $columnname . ' ' . $DB->sql_regex() . ' :pattern '; + $params = ['pattern' => $search]; + + if ($column->meta_type === 'X' || $column->meta_type === 'C') { + $records = $DB->get_records_select($table, $select, $params, '', '*', 0, $limit); + + if ($records) { + $results[$table][$column->name] = $records; + } + } + } + + return $results; + } + + /** + * Perform a search on a table and column. + * + * @param string $search The text to search for. + * @param bool $regex Whether to use regular expression search. + * @param string $tables A comma separated list of tables and columns to search. + * @param int $limit The maximum number of results to return. + * @return array + */ + public static function search(string $search, bool $regex = false, string $tables = '', int $limit = 0): array { + global $DB; + + // Build a list of tables and columns to search. + $tablelist = explode(',', $tables); + $searchlist = []; + foreach ($tablelist as $table) { + $tableandcols = explode(':', $table); + $tablename = $tableandcols[0]; + $columnname = $tableandcols[1] ?? ''; + + // Check if the table already exists in the list. + if (array_key_exists($tablename, $searchlist)) { + // Skip if the table has already been flagged to search all columns. + if (in_array(self::ALL_COLUMNS, $searchlist[$tablename])) { + continue; + } + + // Skip if the column already exists in the list for that table. + if (!in_array($columnname, $searchlist[$tablename])) { + continue; + } + } + + // Add the table to the list. + if ($columnname == '') { + // If the column is not specified, search all columns in the table. + $searchlist[$tablename][] = self::ALL_COLUMNS; + } else { + // Add the column to the list. + $searchlist[$tablename][] = $columnname; + } + } + + // If no tables are specified, search all tables and columns. + if (empty($tables)) { + $tables = $DB->get_tables(); + // Mark all columns in each table to be searched. + foreach ($tables as $table) { + $searchlist[$table] = [self::ALL_COLUMNS]; + } + } + + // Perform the search for each table and column. + $results = []; + foreach ($searchlist as $table => $columns) { + foreach ($columns as $column) { + // Perform the search on this column. + if ($regex) { + $results = array_merge($results, self::regular_expression_search($search, $table, $column, $limit)); + } else { + $results = array_merge($results, self::plain_text_search($search, $table, $column, $limit)); + } + } + } + + return $results; + } +} diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php new file mode 100644 index 0000000..7248d12 --- /dev/null +++ b/classes/privacy/provider.php @@ -0,0 +1,37 @@ +. + +namespace tool_advancedreplace\privacy; + +/** + * Privacy Subsystem implementation for tool_advancedreplace. + * + * @package tool_advancedreplace + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/cli/find.php b/cli/find.php new file mode 100644 index 0000000..37e46a0 --- /dev/null +++ b/cli/find.php @@ -0,0 +1,150 @@ +. + +/** + * Search 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 + */ + +use tool_advancedreplace\helper; + +define('CLI_SCRIPT', true); + +require(__DIR__.'/../../../../config.php'); +require_once($CFG->libdir.'/clilib.php'); +require_once($CFG->libdir.'/adminlib.php'); + +$help = + "Search text throughout the whole database. + +Options: +--search=STRING String to search for. +--regex-match=STRING Use regular expression to match the search string. +--tables=tablename:columnname Tables and columns to search. Separate multiple tables/columns with a comma. + If not specified, search all tables and columns. + If specify table only, search all columns in the table. + Example: + --tables=user:username,user:email + --tables=user,assign_submission:submission + --tables=user,assign_submission +--summary Summary mode, only shows column/table where the text is found. + If not specified, run in detail mode, which shows the full text where the search string is found. +-h, --help Print out this help. + +Example: +\$ sudo -u www-data /usr/bin/php admin/tool/advancedreplace/cli/find.php --search=thelostsoul --summary +\$ sudo -u www-data /usr/bin/php admin/tool/advancedreplace/cli/find.php --regex-match=thelostsoul\\d+ --summary +"; + +list($options, $unrecognized) = cli_get_params( + [ + 'search' => null, + 'regex-match' => null, + 'tables' => '', + 'summary' => false, + 'help' => false, + ], + [ + 'h' => 'help', + ] +); + +if ($unrecognized) { + $unrecognized = implode("\n ", $unrecognized); + cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); +} + +// Ensure that we have required parameters. +if ($options['help'] || (!is_string($options['search']) && empty($options['regex-match']))) { + echo $help; + exit(0); +} + +// Ensure we only have one search method. +if (!empty($options['regex-match']) && !empty($options['search'])) { + cli_error(get_string('errorsearchmethod', 'tool_advancedreplace')); +} + +try { + if (!empty($options['search'])) { + $search = validate_param($options['search'], PARAM_RAW); + } else { + $search = validate_param($options['regex-match'], PARAM_RAW); + } + $tables = validate_param($options['tables'], PARAM_RAW); +} catch (invalid_parameter_exception $e) { + cli_error(get_string('invalidcharacter', 'tool_advancedreplace')); +} + +// Perform the search. +$result = helper::search($search, !empty($options['regex-match']), $tables, $options['regex-match'] ? 1 : 0); + +// Notifying the user if no results were found. +if (empty($result)) { + echo "No results found.\n"; + exit(0); +} + +// Start output. +$fp = fopen('php://stdout', 'w'); + +// Show header. +if (!$options['summary']) { + fputcsv($fp, ['Table', 'Column', 'ID', 'Match']); +} else { + fputcsv($fp, ['Table', 'Column']); +} + +// Output the result. +foreach ($result as $table => $columns) { + foreach ($columns as $column => $rows) { + if ($options['summary']) { + echo "$table, $column\n"; + } else { + foreach ($rows as $row) { + // Fields to show. + $id = $row->id; + $data = $row->$column; + + if (!empty($options['regex-match'])) { + // If the search string is a regular expression, show each matching instance. + + // Replace "/" with "\/", as it is used as delimiters. + $search = str_replace('/', '\\/', $search); + + // Perform the regular expression search. + preg_match_all( "/" . $search . "/", $data, $matches); + + if (!empty($matches[0])) { + // Show the result foreach match. + foreach ($matches[0] as $match) { + fputcsv($fp, [$table, $column, $id, $match]); + } + } + } else { + // Show the result for simple plain text search. + fputcsv($fp, [$table, $column, $id, $data]); + } + } + } + } +} + +fclose($fp); +exit(0); diff --git a/lang/en/tool_advancedreplace.php b/lang/en/tool_advancedreplace.php new file mode 100644 index 0000000..b40cbcb --- /dev/null +++ b/lang/en/tool_advancedreplace.php @@ -0,0 +1,28 @@ +. + +/** + * Strings for component 'tool_advancedreplace', language 'en', branch 'MOODLE_22_STABLE' + * + * @package tool_advancedreplace + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['errorregexnotsupported'] = 'Regular expression searches are not supported by this database.'; +$string['errorsearchmethod'] = 'Please choose one of the search methods: plain text or regular expression.'; +$string['pluginname'] = 'Advanced DB search and replace'; +$string['privacy:metadata'] = 'The plugin does not store any personal data.'; diff --git a/version.php b/version.php new file mode 100644 index 0000000..514bee3 --- /dev/null +++ b/version.php @@ -0,0 +1,32 @@ +. + +/** + * Version details. + * + * @package tool_advancedreplace + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2024091700; // The current plugin version (Date: YYYYMMDDXX). +$plugin->release = 2024091700; +$plugin->requires = 2020110900; // Requires this Moodle version. +$plugin->component = 'tool_advancedreplace'; +$plugin->maturity = MATURITY_STABLE; +$plugin->supported = [401, 405];