From 5331023174468d5d43d497d89f4ddca64ef8ddda Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Thu, 24 Sep 2026 09:38:02 +1000 Subject: [PATCH 1/7] Issue #153 Add time modified to search report. --- classes/form/date_time_filter_form.php | 99 +++++++++++++++++++++++ classes/helper.php | 33 +++++++- db_search_report.php | 105 ++++++++++++++++++++++++- lang/en/tool_advancedreplace.php | 9 ++- 4 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 classes/form/date_time_filter_form.php diff --git a/classes/form/date_time_filter_form.php b/classes/form/date_time_filter_form.php new file mode 100644 index 0000000..f114c82 --- /dev/null +++ b/classes/form/date_time_filter_form.php @@ -0,0 +1,99 @@ +. + +namespace tool_advancedreplace\form; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/formslib.php'); + +/** + * Form for filtering by date and time. + * + * @package tool_advancedreplace\form + * @copyright 2026 Catalyst IT + * @author Jason den Dulk + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class date_time_filter_form extends \moodleform { + /** + * Define the form. + */ + protected function definition() { + $mform = $this->_form; + + $mform->addElement('hidden', 'id'); + $mform->setType('id', PARAM_INT); + $mform->setConstant('id', $this->_customdata['id']); + + if (isset($this->_customdata['page'])) { + $mform->addElement('hidden', 'page'); + $mform->setType('page', PARAM_INT); + $mform->setConstant('page', $this->_customdata['page']); + } + + if (isset($this->_customdata['perpage'])) { + $mform->addElement('hidden', 'perpage'); + $mform->setType('perpage', PARAM_INT); + $mform->setConstant('perpage', $this->_customdata['perpage']); + } + + $mform->addElement( + 'date_time_selector', + 'reportfilterfrom', + get_string('reportfilterfrom', 'tool_advancedreplace'), + ['optional' => true] + ); + if (isset($this->_customdata['timefrom'])) { + $mform->setDefault('reportfilterfrom', $this->_customdata['timefrom']); + } + $mform->addElement( + 'date_time_selector', + 'reportfilterto', + get_string('reportfilterto', 'tool_advancedreplace'), + ['optional' => true] + ); + if (isset($this->_customdata['timeto'])) { + $mform->setDefault('reportfilterto', $this->_customdata['timeto']); + } + + $buttons = []; + $buttons[] = &$mform->createElement('submit', 'submitbutton', get_string('filter')); + $buttons[] = &$mform->createElement('submit', 'clearfilter', get_string('clear')); + $mform->addGroup($buttons, 'filterbuttons', '', ' ', false); + } + + /** + * Custom validate the form data. + * + * @param $data + * @param $files + * @return array + * @throws \coding_exception + */ + public function validation($data, $files) { + $errors = parent::validation($data, $files); + if ( + empty($data['clearfilter']) && + !empty($data['reportfilterfrom']) && + !empty($data['reportfilterto']) && + $data['reportfilterfrom'] > $data['reportfilterto'] + ) { + $errors['reportfilterto'] = get_string('reportfilterinvalidrange', 'tool_advancedreplace'); + } + return $errors; + } +} diff --git a/classes/helper.php b/classes/helper.php index 70d3e51..f40cedd 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -333,7 +333,8 @@ private static function build_search_query( $sql = "SELECT $tablealias.id, $tablealias.$columnname, $tablealias.$coursefield as courseid, - c.shortname as courseshortname + c.shortname as courseshortname, + c.timemodified as timemodified FROM {" . $table . "} $tablealias LEFT JOIN {course} c ON c.id = $tablealias.$coursefield WHERE $wheresql"; @@ -341,18 +342,36 @@ private static function build_search_query( $sql = "SELECT {$supportedtablemappings[$table][0]} $tablealias.$columnname, c.id as courseid, - c.shortname as courseshortname + c.shortname as courseshortname, + c.timemodified as timemodified FROM {" . $table . "} $tablealias {$supportedtablemappings[$table][1]} LEFT JOIN {course} c ON c.id = t2.course WHERE $wheresql"; } else { - $sql = "SELECT id, $columnname FROM {" . $table . "} $tablealias WHERE $wheresql"; + if (self::has_timemodifed_column($table)) { + $sql = "SELECT id, $columnname, timemodified FROM {" . $table . "} $tablealias WHERE $wheresql"; + } else { + $sql = "SELECT id, $columnname FROM {" . $table . "} $tablealias WHERE $wheresql"; + } } return [$sql, $params]; } + /** + * Returns true if the table has a timemodified column. + * + * @param string $table The table to check. + * @return bool + */ + private static function has_timemodifed_column(string $table): bool { + global $DB; + + $dbman = $DB->get_manager(); + return $dbman->field_exists($table, 'timemodified'); + } + /** * Get the highest id value in a table, used to bound batched id-range searches. * @@ -381,6 +400,7 @@ public static function search_column(db_search $search, string $table, database_ $results = []; $regex = $search->get('regex'); $summary = $search->get('summary'); + $dtformat = get_string('strftimedatetimemonthshort', 'tool_advancedreplace'); // If using a regex search make sure the database supports them. if ($regex && !$DB->sql_regex_supported()) { @@ -464,6 +484,7 @@ public static function search_column(db_search $search, string $table, database_ $record->{$column->name}, '', $linkstring, + !empty($record->timemodified) ? userdate($record->timemodified, $dtformat) : '-', ], ',', '"', '\\'); $count++; } else { @@ -487,6 +508,7 @@ public static function search_column(db_search $search, string $table, database_ $match, '', $linkstring, + !empty($record->timemodified) ? userdate($record->timemodified, $dtformat) : '-', ], ',', '"', '\\'); $count++; } @@ -562,7 +584,10 @@ public static function search_db(db_search $search, string $output = ''): void { $fp = fopen($output, 'w'); // Show header. if (!$search->get('summary')) { - fputcsv($fp, ['table', 'column', 'courseid', 'shortname', 'id', 'match', 'replace', 'link'], ',', '"', '\\'); + fputcsv( + $fp, + ['table', 'column', 'courseid', 'shortname', 'id', 'match', 'replace', 'link', 'timemodified'], + ); } else { fputcsv($fp, ['table', 'column'], ',', '"', '\\'); } diff --git a/db_search_report.php b/db_search_report.php index 43543c8..c91ae00 100644 --- a/db_search_report.php +++ b/db_search_report.php @@ -22,6 +22,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use tool_advancedreplace\form\date_time_filter_form; + require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir . '/adminlib.php'); require_once($CFG->libdir . '/tablelib.php'); @@ -29,6 +31,8 @@ $id = required_param('id', PARAM_INT); $page = optional_param('page', 0, PARAM_INT); $perpage = optional_param('perpage', 50, PARAM_INT); +$timefrom = optional_param('timefrom', 0, PARAM_INT); +$timeto = optional_param('timeto', 0, PARAM_INT); $url = new moodle_url('/admin/tool/advancedreplace/db_search_report.php', ['id' => $id]); $PAGE->set_url($url); @@ -74,6 +78,43 @@ } } +$customdata = ['id' => $id]; +if ($timefrom !== 0) { + $customdata['timefrom'] = $timefrom; +} +if ($timeto !== 0) { + $customdata['timeto'] = $timeto; +} +if ($page !== 0) { + $customdata['page'] = $page; +} +if ($perpage !== 50) { + $customdata['perpage'] = $perpage; +} + +$form = new date_time_filter_form($url, $customdata); + +if ($formdata = $form->get_data()) { + if (!isset($formdata->clearfilter)) { + $timefrom = $formdata->reportfilterfrom; + $timeto = $formdata->reportfilterto; + + $url->param('timefrom', $timefrom); + $url->param('timeto', $timeto); + } else { + $url->remove_params(['timefrom', 'timeto']); + } + + if (isset($formdata->page)) { + $url->param('page', $formdata->page); + } + if (isset($formdata->perpage)) { + $url->param('perpage', $formdata->perpage); + } + + redirect($url); +} + // Output the page header before any early-exit notifications. $title = get_string('searchreporttitle', 'tool_advancedreplace', $searchname); echo $OUTPUT->header(); @@ -190,30 +231,77 @@ throw new \moodle_exception('errorinvalidfile', 'tool_advancedreplace'); } +// Columns to hide entirely (rendered elsewhere or always empty). +$hiddencols = ['replace', 'link']; + +// Determine which column indexes to skip or merge. +$hiddenindexes = []; +$linkindex = null; +$matchindex = null; +$courseidindex = null; +$shortnameindex = null; +$timemodifiedindex = null; +foreach ($headers as $i => $h) { + if (in_array($h, $hiddencols)) { + $hiddenindexes[] = $i; + } + if ($h === 'link') { + $linkindex = $i; + } + if ($h === 'match') { + $matchindex = $i; + } + if ($h === 'courseid') { + $courseidindex = $i; + } + if ($h === 'shortname') { + $shortnameindex = $i; + } + if ($h === 'timemodified') { + $timemodifiedindex = $i; + } +} + // Count total data rows and collect the page's rows efficiently. // Use the stored match count to avoid scanning the entire file just for pagination. -$totalrows = (int)$search->get('matches'); +$maxrows = (int)$search->get('matches'); $pagerows = []; $start = $page * $perpage; $end = $start + $perpage; $rownum = 0; while (($row = fgetcsv($fp, 0, ',', '"', '\\')) !== false) { + $timemodified = $row[$timemodifiedindex]; + if ($timemodified != '-') { + $tmtimestamp = strtotime($timemodified); + + // Filter out any record that lies outside the time range. + if ( + ($timefrom != 0 && $tmtimestamp < $timefrom) || + ($timeto != 0 && $tmtimestamp > $timeto) + ) { + continue; + } + } + if ($rownum >= $start && $rownum < $end) { $pagerows[] = $row; } $rownum++; // Stop reading once we have collected the page rows and passed the end offset. - if ($rownum >= $end && $rownum <= $totalrows) { + if ($rownum >= $end) { break; } } fclose($fp); +$totalrows = $rownum; if (!empty($cleanup) && file_exists($csvpath)) { @unlink($csvpath); } +$form->display(); + // Output. if ($totalrows === 0) { @@ -222,8 +310,16 @@ exit; } +$url = clone $url; +if ($timefrom !== 0) { + $url->param('timefrom', $timefrom); +} +if ($timeto !== 0) { + $url->param('timeto', $timeto); +} + // Pager. -echo $OUTPUT->paging_bar($totalrows, $page, $perpage, $url); +echo $OUTPUT->paging_bar($maxrows, $page, $perpage, $url); // Search term to highlight — use prematch for regex searches. $highlightneedle = $search->get('regex') ? $search->get('prematch') : $search->get('search'); @@ -259,10 +355,11 @@ $columnsmap = [ 'table' => get_string('field_table', 'tool_advancedreplace'), 'column' => get_string('field_column', 'tool_advancedreplace'), - 'courseid' => get_string('field_id', 'tool_advancedreplace') . ' (course)', + 'courseid' => get_string('field_course_id', 'tool_advancedreplace'), 'shortname' => get_string('shortnamecourse'), 'id' => get_string('field_id', 'tool_advancedreplace'), 'match' => get_string('field_search', 'tool_advancedreplace'), + 'timemodified' => get_string('field_timemodified', 'tool_advancedreplace'), ]; // Build table header cells — the match column absorbs link. diff --git a/lang/en/tool_advancedreplace.php b/lang/en/tool_advancedreplace.php index b1b51ea..0054985 100644 --- a/lang/en/tool_advancedreplace.php +++ b/lang/en/tool_advancedreplace.php @@ -49,6 +49,7 @@ $string['field_component'] = 'Component'; $string['field_components'] = 'Components'; $string['field_components_help'] = 'Restrict the search to these components. Separate components with commas. eg core_h5p,mod_hvp,mod_assign'; +$string['field_course_id'] = 'ID (course)'; $string['field_duration'] = 'Duration'; $string['field_error'] = 'Error'; $string['field_filearea'] = 'Filearea'; @@ -94,13 +95,11 @@ $string['field_tables'] = 'Tables'; $string['field_tables_help'] = 'Tables and columns to search. Separate multiple tables/columns with a comma. If not specified, search all tables and columns. Example format: user,assign_submission:submission'; $string['field_timeend'] = 'End'; -$string['field_timemodified'] = 'Last modified'; +$string['field_timemodified'] = 'Modified'; $string['field_timestart'] = 'Start'; $string['field_userid'] = 'User'; $string['field_zipfilenames'] = 'Zip filenames'; - $string['field_zipfilenames_help'] = 'Regular expression to match subfiles within a zip.'; - $string['filespageheader'] = 'Search for text in Moodle files'; $string['filespagename'] = 'Search in Files'; $string['lastupdated'] = 'Last updated {$a} ago'; @@ -121,6 +120,9 @@ $string['replacefilespagename'] = 'Replace strings in Files'; $string['replacepageheader'] = 'Replace text stored in the DB'; $string['replacepagename'] = 'Replace strings in the Database'; +$string['reportfilterfrom'] = 'Modified from'; +$string['reportfilterinvalidrange'] = 'The start of the modified date range must be before the end.'; +$string['reportfilterto'] = 'Modified to'; $string['requeueoptions'] = 'Requeue search'; $string['searchcopy'] = 'Search options have been copied from a previous search. This will be treated as a new search.'; $string['searchdeleted'] = 'The selected search was deleted.'; @@ -135,6 +137,7 @@ $string['searchrequeued'] = 'The search has been reset and requeued.'; $string['searchreporttitle'] = 'Search results: {$a}'; $string['searchreportview'] = 'View report'; +$string['searchrequeued'] = 'The search has been reset and requeued.'; $string['selectfile'] = 'Select file'; $string['settings:excludetables'] = 'Exclude tables'; $string['settings:excludetables_help'] = 'Custom list of tables that will always be excluded from searches. Each table should be on a new line.'; From de35739b861229034653c58473b5b9c79a9b9cbf Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Thu, 1 Oct 2026 17:20:55 +1000 Subject: [PATCH 2/7] Issue #153: Add time modified to file search. --- classes/file_search.php | 26 ++++++++++++-------------- classes/helper.php | 20 ++++++-------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/classes/file_search.php b/classes/file_search.php index be47d86..2c9931a 100644 --- a/classes/file_search.php +++ b/classes/file_search.php @@ -109,19 +109,16 @@ class file_search { */ const CSV_REPLACE = 12; - /** - * 10th column of csv output - the offset of the match within the file. - * - * @var int - */ - const CSV_OFFSET = 13; - /** - * 11th column of csv output - the text that was matched. - * - * @var int - */ - const CSV_MATCH = 14; + /** @var int Time modified of the course the file is associated with */ + const CSV_COURSETIMEMODIFIED = 13; + + /** @var int The offset of the match within the file. */ + const CSV_OFFSET = 14; + + /** @var int The text that was matched. */ + const CSV_MATCH = 15; + /** @var int Chunk size for splitting. 10MB to make border cases rare. */ const CHUNK_SIZE = 10 * 1024 * 1024; @@ -212,7 +209,7 @@ public static function files( $stream = fopen($output, 'w'); $columnheaders = [ 'fileid', 'courseid', 'shortname', 'contextid', 'component', 'filearea', 'itemid', 'filepath', 'filename', - 'mimetype', 'strategy', 'internal', 'replace', 'offset', 'match', + 'mimetype', 'strategy', 'internal', 'replace', 'coursetimemodified', 'offset', 'match', ]; fputcsv($stream, $columnheaders, ',', '"', '\\'); } @@ -226,7 +223,7 @@ public static function files( $sql = " SELECT f.id, f.component, f.filearea, f.contextid, f.itemid, f.filename, f.filepath, f.mimetype, - c.id AS courseid, c.shortname + c.id AS courseid, c.shortname, c.timemodified FROM {files} f JOIN {context} ctx ON ctx.id = f.contextid LEFT JOIN {course_modules} cm ON cm.id = ctx.instanceid AND ctx.contextlevel = 70 @@ -505,6 +502,7 @@ public static function search_file(object $filerecord, object $criteria, $stream self::CSV_STRATEGY => 'plain', self::CSV_INTERNAL => '', self::CSV_REPLACE => '', + self::CSV_COURSETIMEMODIFIED => $filerecord->timemodified, ]; switch ($filerecord->mimetype) { case 'application/zip.h5p': diff --git a/classes/helper.php b/classes/helper.php index f40cedd..5d2087f 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -296,6 +296,7 @@ private static function build_search_query( $wheresql = []; $params = []; + // Special SQL fragments for specific tables. static $supportedtablemappings = [ 'course_sections' => ['t.id as id, section', ''], 'book_chapters' => ['t.bookid as moduleid, t.id as id,', 'LEFT JOIN {book} t2 ON t.bookid = t2.id'], @@ -330,6 +331,7 @@ private static function build_search_query( // Build query. $wheresql = implode(' AND ', $wheresql); if (!empty($coursefield)) { + // Include course info based on a course ID reference. $sql = "SELECT $tablealias.id, $tablealias.$columnname, $tablealias.$coursefield as courseid, @@ -339,6 +341,7 @@ private static function build_search_query( LEFT JOIN {course} c ON c.id = $tablealias.$coursefield WHERE $wheresql"; } else if (isset($supportedtablemappings[$table])) { + // Inclcde course info based on specially supplied SQL. $sql = "SELECT {$supportedtablemappings[$table][0]} $tablealias.$columnname, c.id as courseid, @@ -349,9 +352,11 @@ private static function build_search_query( LEFT JOIN {course} c ON c.id = t2.course WHERE $wheresql"; } else { - if (self::has_timemodifed_column($table)) { + if ($table == 'course') { + // Get time modified for course. $sql = "SELECT id, $columnname, timemodified FROM {" . $table . "} $tablealias WHERE $wheresql"; } else { + // Time modified will be blank for all other tables. $sql = "SELECT id, $columnname FROM {" . $table . "} $tablealias WHERE $wheresql"; } } @@ -359,19 +364,6 @@ private static function build_search_query( return [$sql, $params]; } - /** - * Returns true if the table has a timemodified column. - * - * @param string $table The table to check. - * @return bool - */ - private static function has_timemodifed_column(string $table): bool { - global $DB; - - $dbman = $DB->get_manager(); - return $dbman->field_exists($table, 'timemodified'); - } - /** * Get the highest id value in a table, used to bound batched id-range searches. * From 70228d01ae5a41010e9ee918a34bf8cc4a9d3f00 Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Wed, 14 Oct 2026 10:27:28 +1100 Subject: [PATCH 3/7] Issue #153 Add time modified to DB search form. # Conflicts: # lang/en/tool_advancedreplace.php --- classes/db_search.php | 10 ++++++++++ classes/form/search.php | 33 +++++++++++++++++++++++++++++++++ classes/helper.php | 15 +++++++++++++++ db/install.xml | 2 ++ db/upgrade.php | 22 ++++++++++++++++++++++ version.php | 4 ++-- 6 files changed, 84 insertions(+), 2 deletions(-) diff --git a/classes/db_search.php b/classes/db_search.php index 507ed9c..dce1966 100644 --- a/classes/db_search.php +++ b/classes/db_search.php @@ -37,6 +37,8 @@ class db_search extends search { 'skiptables', 'skipcolumns', 'summary', + 'timemodifiedfrom', + 'timemodifiedto', ]; /** How many seconds to wait before marking a search as stale. */ @@ -96,6 +98,14 @@ protected static function define_properties() { 'type' => PARAM_BOOL, 'default' => 0, ], + 'timemodifiedfrom' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'timemodifiedto' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], 'origin' => [ 'type' => PARAM_TEXT, ], diff --git a/classes/form/search.php b/classes/form/search.php index e2a1e7c..f964eb2 100644 --- a/classes/form/search.php +++ b/classes/form/search.php @@ -97,6 +97,39 @@ public function definition(): void { $mform->addElement('checkbox', 'summary', get_string('field_summary', 'tool_advancedreplace')); $mform->addHelpButton('summary', 'field_summary', 'tool_advancedreplace'); + $mform->addElement( + 'date_time_selector', + 'timemodifiedfrom', + get_string('reportfilterfrom', 'tool_advancedreplace'), + ['optional' => true] + ); + $mform->addElement( + 'date_time_selector', + 'timemodifiedto', + get_string('reportfilterto', 'tool_advancedreplace'), + ['optional' => true] + ); + $this->add_action_buttons(true, get_string('search')); } + + /** + * Extra validation of the time filter. + * + * @param \stdClass $data Data to validate. + * @param array $files Array of files. + * @param array $errors Currently reported errors. + * @return array of additional errors. + */ + protected function extra_validation($data, $files, array &$errors) { + $newerrors = []; + if ( + !empty($data->timemodifiedfrom) && + !empty($data->timemodifiedto) && + $data->timemodifiedfrom > $data->timemodifiedto + ) { + $newerrors['timemodifiedto'] = get_string('reportfilterinvalidrange', 'tool_advancedreplace'); + } + return $newerrors; + } } diff --git a/classes/helper.php b/classes/helper.php index 5d2087f..622be55 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -328,6 +328,21 @@ private static function build_search_query( $params['maxid'] = $maxid; } + // Restrict to the time modified range, where the course time modified is available. + // As with the report filter, rows without a time modified are not filtered out. + if (!empty($coursefield) || isset($supportedtablemappings[$table]) || $table == 'course') { + $timemodifiedfrom = $search->get('timemodifiedfrom'); + $timemodifiedto = $search->get('timemodifiedto'); + if (!empty($timemodifiedfrom)) { + $wheresql[] = "(COALESCE(c.timemodified, 0) = 0 OR c.timemodified >= :timemodifiedfrom)"; + $params['timemodifiedfrom'] = $timemodifiedfrom; + } + if (!empty($timemodifiedto)) { + $wheresql[] = "(COALESCE(c.timemodified, 0) = 0 OR c.timemodified <= :timemodifiedto)"; + $params['timemodifiedto'] = $timemodifiedto; + } + } + // Build query. $wheresql = implode(' AND ', $wheresql); if (!empty($coursefield)) { diff --git a/db/install.xml b/db/install.xml index 7e6b765..9e00444 100644 --- a/db/install.xml +++ b/db/install.xml @@ -16,6 +16,8 @@ + + diff --git a/db/upgrade.php b/db/upgrade.php index d680a57..6a7b45b 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -173,5 +173,27 @@ function xmldb_tool_advancedreplace_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024112100, 'tool', 'advancedreplace'); } + if ($oldversion < 2025080706) { + // Define fields timemodifiedfrom and timemodifiedto to be added to tool_advancedreplace_search. + $table = new xmldb_table('tool_advancedreplace_search'); + + $field = new xmldb_field('timemodifiedfrom', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'summary'); + + // Conditionally launch add field timemodifiedfrom. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $field = new xmldb_field('timemodifiedto', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'timemodifiedfrom'); + + // Conditionally launch add field timemodifiedto. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Advancedreplace savepoint reached. + upgrade_plugin_savepoint(true, 2025080706, 'tool', 'advancedreplace'); + } + return true; } diff --git a/version.php b/version.php index abf2577..bbec005 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025080705; // The current plugin version (Date: YYYYMMDDXX). -$plugin->release = 2025080705; +$plugin->version = 2025080706; // The current plugin version (Date: YYYYMMDDXX). +$plugin->release = 2025080706; $plugin->requires = 2020110900; // Requires this Moodle version. $plugin->component = 'tool_advancedreplace'; $plugin->maturity = MATURITY_STABLE; From 6e0b63453176a597e0f372de341b4e11e56beff3 Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Wed, 14 Oct 2026 10:36:21 +1100 Subject: [PATCH 4/7] Issue #153: Refactor results page to use GET for form. Also rename filter form. --- ...ilter_form.php => results_filter_form.php} | 12 ++++++-- db_search_report.php | 28 ++++++------------- 2 files changed, 17 insertions(+), 23 deletions(-) rename classes/form/{date_time_filter_form.php => results_filter_form.php} (89%) diff --git a/classes/form/date_time_filter_form.php b/classes/form/results_filter_form.php similarity index 89% rename from classes/form/date_time_filter_form.php rename to classes/form/results_filter_form.php index f114c82..ef23db3 100644 --- a/classes/form/date_time_filter_form.php +++ b/classes/form/results_filter_form.php @@ -28,7 +28,7 @@ * @author Jason den Dulk * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class date_time_filter_form extends \moodleform { +class results_filter_form extends \moodleform { /** * Define the form. */ @@ -72,7 +72,14 @@ protected function definition() { $buttons = []; $buttons[] = &$mform->createElement('submit', 'submitbutton', get_string('filter')); - $buttons[] = &$mform->createElement('submit', 'clearfilter', get_string('clear')); + if (isset($this->_customdata['clearurl'])) { + $buttons[] = &$mform->createElement( + 'static', + 'clearfilter', + '', + \html_writer::link($this->_customdata['clearurl'], get_string('clear'), ['class' => 'btn btn-secondary']) + ); + } $mform->addGroup($buttons, 'filterbuttons', '', ' ', false); } @@ -87,7 +94,6 @@ protected function definition() { public function validation($data, $files) { $errors = parent::validation($data, $files); if ( - empty($data['clearfilter']) && !empty($data['reportfilterfrom']) && !empty($data['reportfilterto']) && $data['reportfilterfrom'] > $data['reportfilterto'] diff --git a/db_search_report.php b/db_search_report.php index c91ae00..713a3fb 100644 --- a/db_search_report.php +++ b/db_search_report.php @@ -22,7 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use tool_advancedreplace\form\date_time_filter_form; +use tool_advancedreplace\form\results_filter_form; require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir . '/adminlib.php'); @@ -90,29 +90,17 @@ } if ($perpage !== 50) { $customdata['perpage'] = $perpage; + $url->param('perpage', $perpage); } +// Clearing the filter is a plain link back to the unfiltered report. +$customdata['clearurl'] = $url; -$form = new date_time_filter_form($url, $customdata); +$form = new results_filter_form($url, $customdata, 'get'); +// The form submits via GET, so apply the submitted filter directly to this request. if ($formdata = $form->get_data()) { - if (!isset($formdata->clearfilter)) { - $timefrom = $formdata->reportfilterfrom; - $timeto = $formdata->reportfilterto; - - $url->param('timefrom', $timefrom); - $url->param('timeto', $timeto); - } else { - $url->remove_params(['timefrom', 'timeto']); - } - - if (isset($formdata->page)) { - $url->param('page', $formdata->page); - } - if (isset($formdata->perpage)) { - $url->param('perpage', $formdata->perpage); - } - - redirect($url); + $timefrom = $formdata->reportfilterfrom; + $timeto = $formdata->reportfilterto; } // Output the page header before any early-exit notifications. From 85c21696a254df048ec47ae3c6cc04fbb46ca974 Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Wed, 14 Oct 2026 11:28:13 +1100 Subject: [PATCH 5/7] Issue #153: Change CSV date to use ISO. --- classes/helper.php | 20 ++++++++++++++------ classes/search_table.php | 6 +++++- db_search_report.php | 5 +++-- lang/en/tool_advancedreplace.php | 3 --- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/classes/helper.php b/classes/helper.php index 622be55..b155507 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -407,7 +407,6 @@ public static function search_column(db_search $search, string $table, database_ $results = []; $regex = $search->get('regex'); $summary = $search->get('summary'); - $dtformat = get_string('strftimedatetimemonthshort', 'tool_advancedreplace'); // If using a regex search make sure the database supports them. if ($regex && !$DB->sql_regex_supported()) { @@ -481,6 +480,15 @@ public static function search_column(db_search $search, string $table, database_ $linkstring = $linkfunction($record); } + // Create an ISO 8601 date string for recording is the CSV. + if (!empty($record->timemodified)) { + $dt = new \DateTime('@' . $record->timemodified); // Unix timestamp, created in UTC. + $dt->setTimezone(\core_date::get_user_timezone_object()); // Switch to the user's timezone. + $dtstr = $dt->format(DATE_ATOM); // ISO 8601 (e.g. 2026-10-14T13:45:07+11:00). + } else { + $dtstr = '-'; + } + if (!$regex) { fputcsv($stream, [ $table, @@ -491,7 +499,7 @@ public static function search_column(db_search $search, string $table, database_ $record->{$column->name}, '', $linkstring, - !empty($record->timemodified) ? userdate($record->timemodified, $dtformat) : '-', + $dtstr, ], ',', '"', '\\'); $count++; } else { @@ -515,7 +523,7 @@ public static function search_column(db_search $search, string $table, database_ $match, '', $linkstring, - !empty($record->timemodified) ? userdate($record->timemodified, $dtformat) : '-', + $dtstr, ], ',', '"', '\\'); $count++; } @@ -594,6 +602,9 @@ public static function search_db(db_search $search, string $output = ''): void { fputcsv( $fp, ['table', 'column', 'courseid', 'shortname', 'id', 'match', 'replace', 'link', 'timemodified'], + ',', + '"', + '\\' ); } else { fputcsv($fp, ['table', 'column'], ',', '"', '\\'); @@ -607,9 +618,6 @@ public static function search_db(db_search $search, string $output = ''): void { // Output the result for each table. $rowcount = 0; $matches = 0; - $update = new \stdClass(); - $update->time = time(); - $update->percent = 0; $tablec = 0; $columnc = 0; diff --git a/classes/search_table.php b/classes/search_table.php index 93a87f5..158cfca 100644 --- a/classes/search_table.php +++ b/classes/search_table.php @@ -80,7 +80,11 @@ public function make_columns(): void { $headers = []; $columns = $this->get_columns(); foreach ($columns as $column) { - $headers[] = get_string('field_' . $column, 'tool_advancedreplace'); + if ($column == 'timemodified') { + $headers[] = get_string('timemodified', 'reportbuilder'); + } else { + $headers[] = get_string('field_' . $column, 'tool_advancedreplace'); + } } foreach (static::NOSORT_COLUMNS as $column) { diff --git a/db_search_report.php b/db_search_report.php index 713a3fb..08a9d24 100644 --- a/db_search_report.php +++ b/db_search_report.php @@ -270,6 +270,7 @@ ) { continue; } + $row[$timemodifiedindex] = userdate($tmtimestamp, get_string('strftimedatetimemonthshort', 'tool_advancedreplace')); } if ($rownum >= $start && $rownum < $end) { @@ -343,11 +344,11 @@ $columnsmap = [ 'table' => get_string('field_table', 'tool_advancedreplace'), 'column' => get_string('field_column', 'tool_advancedreplace'), - 'courseid' => get_string('field_course_id', 'tool_advancedreplace'), + 'courseid' => get_string('idnumbercourse'), 'shortname' => get_string('shortnamecourse'), 'id' => get_string('field_id', 'tool_advancedreplace'), 'match' => get_string('field_search', 'tool_advancedreplace'), - 'timemodified' => get_string('field_timemodified', 'tool_advancedreplace'), + 'timemodified' => get_string('timemodified', 'reportbuilder'), ]; // Build table header cells — the match column absorbs link. diff --git a/lang/en/tool_advancedreplace.php b/lang/en/tool_advancedreplace.php index 0054985..073a001 100644 --- a/lang/en/tool_advancedreplace.php +++ b/lang/en/tool_advancedreplace.php @@ -49,7 +49,6 @@ $string['field_component'] = 'Component'; $string['field_components'] = 'Components'; $string['field_components_help'] = 'Restrict the search to these components. Separate components with commas. eg core_h5p,mod_hvp,mod_assign'; -$string['field_course_id'] = 'ID (course)'; $string['field_duration'] = 'Duration'; $string['field_error'] = 'Error'; $string['field_filearea'] = 'Filearea'; @@ -95,7 +94,6 @@ $string['field_tables'] = 'Tables'; $string['field_tables_help'] = 'Tables and columns to search. Separate multiple tables/columns with a comma. If not specified, search all tables and columns. Example format: user,assign_submission:submission'; $string['field_timeend'] = 'End'; -$string['field_timemodified'] = 'Modified'; $string['field_timestart'] = 'Start'; $string['field_userid'] = 'User'; $string['field_zipfilenames'] = 'Zip filenames'; @@ -134,7 +132,6 @@ $string['searchreportinprogress'] = 'This search is still in progress, no results are available yet.'; $string['searchreportpending'] = 'This search has not started yet, no results are available.'; $string['searchreportplaintext'] = 'Plain text'; -$string['searchrequeued'] = 'The search has been reset and requeued.'; $string['searchreporttitle'] = 'Search results: {$a}'; $string['searchreportview'] = 'View report'; $string['searchrequeued'] = 'The search has been reset and requeued.'; From dda2dac66fd42b20db9ee7ff55bcc37c7e118864 Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Tue, 20 Oct 2026 15:42:46 +1100 Subject: [PATCH 6/7] Issue #153: Add time modified filter to file search. --- classes/file_search.php | 27 ++++++-- classes/files.php | 10 +++ classes/form/files.php | 33 ++++++++++ db/install.xml | 2 + db/upgrade.php | 22 +++++++ files_report.php | 140 ++++++++++++++++++++++++++++++---------- version.php | 2 +- 7 files changed, 196 insertions(+), 40 deletions(-) diff --git a/classes/file_search.php b/classes/file_search.php index 2c9931a..e4d895d 100644 --- a/classes/file_search.php +++ b/classes/file_search.php @@ -111,7 +111,7 @@ class file_search { /** @var int Time modified of the course the file is associated with */ - const CSV_COURSETIMEMODIFIED = 13; + const CSV_FILETIMEMODIFIED = 13; /** @var int The offset of the match within the file. */ const CSV_OFFSET = 14; @@ -178,6 +178,17 @@ public static function files( } [$whereclause, $params] = self::make_where_clause($criteria); + // Restrict to files modified within the time range, if set. + $timemodifiedfrom = $record->get('timemodifiedfrom'); + $timemodifiedto = $record->get('timemodifiedto'); + if (!empty($timemodifiedfrom)) { + $whereclause .= ' AND f.timemodified >= :timemodifiedfrom'; + $params['timemodifiedfrom'] = $timemodifiedfrom; + } + if (!empty($timemodifiedto)) { + $whereclause .= ' AND f.timemodified <= :timemodifiedto'; + $params['timemodifiedto'] = $timemodifiedto; + } // If we are running a shard, then restrict the range of id. if (!empty($startid) || !empty($endid)) { if (empty($finalshard)) { @@ -209,7 +220,7 @@ public static function files( $stream = fopen($output, 'w'); $columnheaders = [ 'fileid', 'courseid', 'shortname', 'contextid', 'component', 'filearea', 'itemid', 'filepath', 'filename', - 'mimetype', 'strategy', 'internal', 'replace', 'coursetimemodified', 'offset', 'match', + 'mimetype', 'strategy', 'internal', 'replace', 'filetimemodified', 'offset', 'match', ]; fputcsv($stream, $columnheaders, ',', '"', '\\'); } @@ -223,7 +234,7 @@ public static function files( $sql = " SELECT f.id, f.component, f.filearea, f.contextid, f.itemid, f.filename, f.filepath, f.mimetype, - c.id AS courseid, c.shortname, c.timemodified + c.id AS courseid, c.shortname, f.timemodified FROM {files} f JOIN {context} ctx ON ctx.id = f.contextid LEFT JOIN {course_modules} cm ON cm.id = ctx.instanceid AND ctx.contextlevel = 70 @@ -488,6 +499,14 @@ public static function search_file(object $filerecord, object $criteria, $stream } } + // Create an ISO 8601 date string for recording is the CSV. + if (!empty($filerecord->timemodified)) { + $dt = new \DateTime('@' . $filerecord->timemodified); // Unix timestamp, created in UTC. + $dt->setTimezone(\core_date::get_user_timezone_object()); // Switch to the user's timezone. + $dtstr = $dt->format(DATE_ATOM); // ISO 8601 (e.g. 2026-10-14T13:45:07+11:00). + } else { + $dtstr = '-'; + } $csv = [ self::CSV_FILEID => $filerecord->id, self::CSV_COURSEID => $filerecord->courseid, @@ -502,7 +521,7 @@ public static function search_file(object $filerecord, object $criteria, $stream self::CSV_STRATEGY => 'plain', self::CSV_INTERNAL => '', self::CSV_REPLACE => '', - self::CSV_COURSETIMEMODIFIED => $filerecord->timemodified, + self::CSV_FILETIMEMODIFIED => $dtstr, ]; switch ($filerecord->mimetype) { case 'application/zip.h5p': diff --git a/classes/files.php b/classes/files.php index 2b5d8e8..5e38eaf 100644 --- a/classes/files.php +++ b/classes/files.php @@ -42,6 +42,8 @@ class files extends search { 'zipfilenames', 'skipzipfilenames', 'shards', + 'timemodifiedfrom', + 'timemodifiedto', ]; /** How many seconds to wait before marking a search as stale. */ @@ -120,6 +122,14 @@ protected static function define_properties() { 'default' => null, 'null' => NULL_ALLOWED, ], + 'timemodifiedfrom' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'timemodifiedto' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], 'origin' => [ 'type' => PARAM_TEXT, ], diff --git a/classes/form/files.php b/classes/form/files.php index 03ac186..834a294 100644 --- a/classes/form/files.php +++ b/classes/form/files.php @@ -121,6 +121,39 @@ public function definition(): void { $mform->setDefault('shards', 1); $mform->addElement('static', 'shards_help', '', get_string('field_shards_help', self::PLUGIN)); + $mform->addElement( + 'date_time_selector', + 'timemodifiedfrom', + get_string('reportfilterfrom', self::PLUGIN), + ['optional' => true] + ); + $mform->addElement( + 'date_time_selector', + 'timemodifiedto', + get_string('reportfilterto', self::PLUGIN), + ['optional' => true] + ); + $this->add_action_buttons(true, get_string('search')); } + + /** + * Extra validation of the time filter. + * + * @param \stdClass $data Data to validate. + * @param array $files Array of files. + * @param array $errors Currently reported errors. + * @return array of additional errors. + */ + protected function extra_validation($data, $files, array &$errors) { + $newerrors = []; + if ( + !empty($data->timemodifiedfrom) && + !empty($data->timemodifiedto) && + $data->timemodifiedfrom > $data->timemodifiedto + ) { + $newerrors['timemodifiedto'] = get_string('reportfilterinvalidrange', self::PLUGIN); + } + return $newerrors; + } } diff --git a/db/install.xml b/db/install.xml index 9e00444..3616ac7 100644 --- a/db/install.xml +++ b/db/install.xml @@ -51,6 +51,8 @@ + + diff --git a/db/upgrade.php b/db/upgrade.php index 6a7b45b..260a84e 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -195,5 +195,27 @@ function xmldb_tool_advancedreplace_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025080706, 'tool', 'advancedreplace'); } + if ($oldversion < 2025080707) { + // Define fields timemodifiedfrom and timemodifiedto to be added to tool_advancedreplace_files. + $table = new xmldb_table('tool_advancedreplace_files'); + + $field = new xmldb_field('timemodifiedfrom', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'shardnum'); + + // Conditionally launch add field timemodifiedfrom. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $field = new xmldb_field('timemodifiedto', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'timemodifiedfrom'); + + // Conditionally launch add field timemodifiedto. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Advancedreplace savepoint reached. + upgrade_plugin_savepoint(true, 2025080707, 'tool', 'advancedreplace'); + } + return true; } diff --git a/files_report.php b/files_report.php index 54d6925..c23b448 100644 --- a/files_report.php +++ b/files_report.php @@ -22,6 +22,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use tool_advancedreplace\form\results_filter_form; + require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir . '/adminlib.php'); require_once($CFG->libdir . '/tablelib.php'); @@ -29,6 +31,8 @@ $id = required_param('id', PARAM_INT); $page = optional_param('page', 0, PARAM_INT); $perpage = optional_param('perpage', 50, PARAM_INT); +$timefrom = optional_param('timefrom', 0, PARAM_INT); +$timeto = optional_param('timeto', 0, PARAM_INT); $url = new moodle_url('/admin/tool/advancedreplace/files_report.php', ['id' => $id]); $PAGE->set_url($url); @@ -74,6 +78,31 @@ } } +$customdata = ['id' => $id]; +if ($timefrom !== 0) { + $customdata['timefrom'] = $timefrom; +} +if ($timeto !== 0) { + $customdata['timeto'] = $timeto; +} +if ($page !== 0) { + $customdata['page'] = $page; +} +if ($perpage !== 50) { + $customdata['perpage'] = $perpage; + $url->param('perpage', $perpage); +} +// Clearing the filter is a plain link back to the unfiltered report. +$customdata['clearurl'] = $url; + +$form = new results_filter_form($url, $customdata, 'get'); + +// The form submits via GET, so apply the submitted filter directly to this request. +if ($formdata = $form->get_data()) { + $timefrom = $formdata->reportfilterfrom; + $timeto = $formdata->reportfilterto; +} + // Output the page header before any early-exit notifications. $title = get_string('searchreporttitle', 'tool_advancedreplace', $searchname); echo $OUTPUT->header(); @@ -187,41 +216,6 @@ throw new \moodle_exception('errorinvalidfile', 'tool_advancedreplace'); } -// Count total data rows and collect the page's rows efficiently. -// Use the stored match count to avoid scanning the entire file just for pagination. -$totalrows = (int)$search->get('matches'); -$pagerows = []; -$start = $page * $perpage; -$end = $start + $perpage; -$rownum = 0; - -while (($row = fgetcsv($fp, 0, ',', '"', '\\')) !== false) { - if ($rownum >= $start && $rownum < $end) { - $pagerows[] = $row; - } - $rownum++; - // Stop reading once we have collected the page rows and passed the end offset. - if ($rownum >= $end && $rownum <= $totalrows) { - break; - } -} -fclose($fp); - -if (!empty($cleanup) && file_exists($csvpath)) { - @unlink($csvpath); -} - -// Output. - -if ($totalrows === 0) { - echo $OUTPUT->notification(get_string('searchreportempty', 'tool_advancedreplace'), 'info'); - echo $OUTPUT->footer(); - exit; -} - -// Pager. -echo $OUTPUT->paging_bar($totalrows, $page, $perpage, $url); - // Columns to hide entirely (rendered elsewhere or always empty). $hiddencols = ['replace']; @@ -236,6 +230,7 @@ $itemidindex = null; $filepathindex = null; $filenameindex = null; +$filetimemodifiedindex = null; foreach ($headers as $i => $h) { if (in_array($h, $hiddencols)) { $hiddenindexes[] = $i; @@ -267,8 +262,76 @@ if ($h === 'filename') { $filenameindex = $i; } + if ($h === 'filetimemodified') { + $filetimemodifiedindex = $i; + } } +// Count total data rows and collect the page's rows efficiently. +// Without a filter, use the stored match count to avoid scanning the entire file just for pagination. +// With a filter, the whole file must be scanned to count the matching rows. +$filtered = ($timefrom != 0 || $timeto != 0) && $filetimemodifiedindex !== null; +$totalrows = (int)$search->get('matches'); +$pagerows = []; +$start = $page * $perpage; +$end = $start + $perpage; +$rownum = 0; + +while (($row = fgetcsv($fp, 0, ',', '"', '\\')) !== false) { + if ($filtered) { + $filetimemodified = $row[$filetimemodifiedindex] ?? '-'; + if ($filetimemodified !== '-') { + $tmtimestamp = strtotime($filetimemodified); + + // Filter out any record that lies outside the time range. + if ( + ($timefrom != 0 && $tmtimestamp < $timefrom) || + ($timeto != 0 && $tmtimestamp > $timeto) + ) { + continue; + } + } + } + + if ($rownum >= $start && $rownum < $end) { + $pagerows[] = $row; + } + $rownum++; + // Stop reading once we have collected the page rows and passed the end offset. + if (!$filtered && $rownum >= $end && $rownum <= $totalrows) { + break; + } +} +fclose($fp); +if ($filtered) { + $totalrows = $rownum; +} + +if (!empty($cleanup) && file_exists($csvpath)) { + @unlink($csvpath); +} + +$form->display(); + +// Output. + +if ($totalrows === 0) { + echo $OUTPUT->notification(get_string('searchreportempty', 'tool_advancedreplace'), 'info'); + echo $OUTPUT->footer(); + exit; +} + +$url = clone $url; +if ($timefrom !== 0) { + $url->param('timefrom', $timefrom); +} +if ($timeto !== 0) { + $url->param('timeto', $timeto); +} + +// Pager. +echo $OUTPUT->paging_bar($totalrows, $page, $perpage, $url); + // Column label map. $columnsmap = [ 'fileid' => get_string('field_id', 'tool_advancedreplace'), @@ -278,6 +341,7 @@ 'filearea' => get_string('field_filearea', 'tool_advancedreplace'), 'filename' => get_string('field_filename', 'tool_advancedreplace'), 'match' => get_string('field_search', 'tool_advancedreplace'), + 'filetimemodified' => get_string('timemodified', 'reportbuilder'), ]; // Build table header cells. @@ -330,6 +394,12 @@ $cell = \html_writer::link($courseurl, htmlspecialchars($cell, ENT_QUOTES, 'UTF-8')); } else if ($header === 'filename' && !empty($cell) && !empty($linkedurl)) { $cell = \html_writer::link($linkedurl, htmlspecialchars($cell, ENT_QUOTES, 'UTF-8'), ['target' => '_blank']); + } else if ($header === 'filetimemodified') { + if ($cell !== '-') { + $timestamp = strtotime($cell); + $cell = userdate($timestamp, get_string('strftimedatetimemonthshort', 'tool_advancedreplace')); + } + $cell = htmlspecialchars($cell, ENT_QUOTES, 'UTF-8'); } else { $cell = htmlspecialchars($cell, ENT_QUOTES, 'UTF-8'); } diff --git a/version.php b/version.php index bbec005..f29963a 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025080706; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025080707; // The current plugin version (Date: YYYYMMDDXX). $plugin->release = 2025080706; $plugin->requires = 2020110900; // Requires this Moodle version. $plugin->component = 'tool_advancedreplace'; From 2364c44f9c8f326a45a8f07198be51600438cb1c Mon Sep 17 00:00:00 2001 From: jasondendulk Date: Thu, 29 Oct 2026 11:40:01 +1100 Subject: [PATCH 7/7] Issue #153: Expand timemodified to any table that has the concept Read table definitions and form acache to store the names of the fields that store last modified timestamps. Use this cache during filtering. Add help icon to explain time modified. Add unit test. --- classes/form/results_filter_form.php | 35 +++++++- classes/form/search.php | 1 + classes/helper.php | 14 +-- classes/timemodified_columns.php | 128 +++++++++++++++++++++++++++ db/caches.php | 35 ++++++++ db_search_report.php | 3 +- lang/en/tool_advancedreplace.php | 3 + tests/timemodified_columns_test.php | 109 +++++++++++++++++++++++ version.php | 4 +- 9 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 classes/timemodified_columns.php create mode 100644 db/caches.php create mode 100644 tests/timemodified_columns_test.php diff --git a/classes/form/results_filter_form.php b/classes/form/results_filter_form.php index ef23db3..2e2d692 100644 --- a/classes/form/results_filter_form.php +++ b/classes/form/results_filter_form.php @@ -84,12 +84,43 @@ protected function definition() { } /** - * Custom validate the form data. + * After definition hook. + */ + protected function after_definition() { + parent::after_definition(); + + // The parent constructor adds this after definition(). + $this->_form->removeElement('sesskey'); + $this->_form->disable_form_change_checker(); + } + + /** + * Alters submitted data to be suitable for quickforms processing. + * + * @param $method + * @throws \coding_exception + */ + public function _process_submission($method) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore + if ($method !== 'get') { + throw new \coding_exception('This search form must use GET.'); + } + + // Deliberately accept only GET data, with no files or sesskey check. + $submission = $_GET; + $marker = '_qf__' . $this->_formname; + if (($submission[$marker] ?? null) != 1) { + $submission = []; + } + + $this->_form->updateSubmission($submission, []); + } + + /** + * Extra validation. * * @param $data * @param $files * @return array - * @throws \coding_exception */ public function validation($data, $files) { $errors = parent::validation($data, $files); diff --git a/classes/form/search.php b/classes/form/search.php index f964eb2..3e8cdd8 100644 --- a/classes/form/search.php +++ b/classes/form/search.php @@ -103,6 +103,7 @@ public function definition(): void { get_string('reportfilterfrom', 'tool_advancedreplace'), ['optional' => true] ); + $mform->addHelpButton('timemodifiedfrom', 'field_timemodified', 'tool_advancedreplace'); $mform->addElement( 'date_time_selector', 'timemodifiedto', diff --git a/classes/helper.php b/classes/helper.php index b155507..869248d 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -366,14 +366,14 @@ private static function build_search_query( {$supportedtablemappings[$table][1]} LEFT JOIN {course} c ON c.id = t2.course WHERE $wheresql"; + } else if ($timemodifiedcolumn = timemodified_columns::get_column($table)) { + // Get time modified for the row itself. + $sql = "SELECT id, $columnname, $timemodifiedcolumn AS timemodified + FROM {" . $table . "} $tablealias + WHERE $wheresql"; } else { - if ($table == 'course') { - // Get time modified for course. - $sql = "SELECT id, $columnname, timemodified FROM {" . $table . "} $tablealias WHERE $wheresql"; - } else { - // Time modified will be blank for all other tables. - $sql = "SELECT id, $columnname FROM {" . $table . "} $tablealias WHERE $wheresql"; - } + // Time modified will be blank for all other tables. + $sql = "SELECT id, $columnname FROM {" . $table . "} $tablealias WHERE $wheresql"; } return [$sql, $params]; diff --git a/classes/timemodified_columns.php b/classes/timemodified_columns.php new file mode 100644 index 0000000..ce4aaa4 --- /dev/null +++ b/classes/timemodified_columns.php @@ -0,0 +1,128 @@ +. + +namespace tool_advancedreplace; + +use xmldb_field; + +/** + * Finds the column used by each table to store the time a row was last modified. + * + * The map of table => column is generated from the install.xml table definitions and cached. + * All caches are purged on install and upgrade, so the map is rebuilt whenever the schema changes. + * + * The map is stored as a single value in the cache under the key CACHE_KEY. This is because the map is essentially a + * constant value, and only needs to be updated during a upgrade. + * + * This cache is not guaranteed to be perfect, but is good enough. + * + * @package tool_advancedreplace + * @copyright 2026 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class timemodified_columns { + /** @var string Key the map is stored under in the cache. **/ + const CACHE_KEY = 'map'; + + /** + * @var array Column names known to hold a last modified timestamp. + * + * In order of preference, for tables that have more than one of these columns. + **/ + const COLUMN_NAMES = [ + 'timemodified', + 'modified', + 'lastmodified', + 'timeupdated', + 'updated', + 'lastupdated', + 'lastupdate', + 'dateupdated', + ]; + + /** @var array Known table => columns that match COLUMN_NAMES but are not modification times. **/ + const EXCLUDED_COLUMNS = [ + // Flag for whether the string has been modified. Technically, this is redundant, but is put here as a example. + 'tool_customlang' => ['modified'], + ]; + + /** @var int Minimum integer length (in digits) able to hold a unix timestamp. **/ + const MIN_LENGTH = 10; + + /** + * Returns the name of the column holding the time a row was last modified. + * + * @param string $table The table to check. + * @return string|null The column name, or null if the table has no such column. + */ + public static function get_column(string $table): ?string { + return self::get_map()[$table] ?? null; + } + + /** + * Returns the map of table => last modified column, generating it if it does not exist. + * + * @return array + */ + public static function get_map(): array { + $cache = \cache::make('tool_advancedreplace', 'timemodifiedcolumns'); + $map = $cache->get(self::CACHE_KEY); + if ($map === false) { + $map = self::generate_map(); + $cache->set(self::CACHE_KEY, $map); + } + return $map; + } + + /** + * Generates the map of table => last modified column from the table definitions. + * + * @return array + */ + public static function generate_map(): array { + global $DB; + + $map = []; + $schema = $DB->get_manager()->get_install_xml_schema(); + foreach ($schema->getTables() as $table) { + $tablename = $table->getName(); + $excluded = self::EXCLUDED_COLUMNS[$tablename] ?? []; + foreach (self::COLUMN_NAMES as $columnname) { + // Do not include blacklisted columns. + if (in_array($columnname, $excluded)) { + continue; + } + $field = $table->getField($columnname); + // If the field exists and is compatible with unix timestamps, then add it. + if ($field && self::is_timestamp_field($field)) { + $map[$tablename] = $columnname; + break; + } + } + } + return $map; + } + + /** + * Checks if the field is compatible with unix timestamps. + * + * @param xmldb_field $field + * @return bool Returns true if the field is an integer type big enough to hold a unix timestamp. + */ + private static function is_timestamp_field(xmldb_field $field): bool { + return $field->getType() == XMLDB_TYPE_INTEGER && $field->getLength() >= self::MIN_LENGTH; + } +} diff --git a/db/caches.php b/db/caches.php new file mode 100644 index 0000000..5815596 --- /dev/null +++ b/db/caches.php @@ -0,0 +1,35 @@ +. + +/** + * Cache definitions. + * + * @package tool_advancedreplace + * @copyright 2026 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$definitions = [ + // Map of table => column, to identify columns that hold a 'last modified' value. + 'timemodifiedcolumns' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => true, + ], +]; diff --git a/db_search_report.php b/db_search_report.php index 08a9d24..10fab40 100644 --- a/db_search_report.php +++ b/db_search_report.php @@ -348,7 +348,8 @@ 'shortname' => get_string('shortnamecourse'), 'id' => get_string('field_id', 'tool_advancedreplace'), 'match' => get_string('field_search', 'tool_advancedreplace'), - 'timemodified' => get_string('timemodified', 'reportbuilder'), + 'timemodified' => get_string('field_timemodified', 'tool_advancedreplace') . + $OUTPUT->help_icon('field_timemodified', 'tool_advancedreplace'), ]; // Build table header cells — the match column absorbs link. diff --git a/lang/en/tool_advancedreplace.php b/lang/en/tool_advancedreplace.php index 073a001..f9e3c58 100644 --- a/lang/en/tool_advancedreplace.php +++ b/lang/en/tool_advancedreplace.php @@ -21,6 +21,7 @@ * @copyright 2024 Catalyst IT Australia Pty Ltd * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['cachedef_timemodifiedcolumns'] = 'Map of tables to the column holding the time a row was last modified'; $string['cleancache'] = 'Some replacements may not take effect until after a cache purge, would you like to do that now?'; $string['cleancachebutton'] = 'Purge all caches'; $string['confirm_delete'] = 'Are you sure you want to delete the search? This will also delete the output.'; @@ -94,6 +95,8 @@ $string['field_tables'] = 'Tables'; $string['field_tables_help'] = 'Tables and columns to search. Separate multiple tables/columns with a comma. If not specified, search all tables and columns. Example format: user,assign_submission:submission'; $string['field_timeend'] = 'End'; +$string['field_timemodified'] = 'Time modified'; +$string['field_timemodified_help'] = 'Time modified is the date and time something was last changed. When a match belongs to a course, this is the last time the course itself was changed, not the exact piece of content that matched. When a match does not belong to a course, it is the last time that particular item was changed, if Moodle keeps a record of it. Some items have no such record, so their time modified is left blank, and items with a blank time are never excluded when you filter by date.'; $string['field_timestart'] = 'Start'; $string['field_userid'] = 'User'; $string['field_zipfilenames'] = 'Zip filenames'; diff --git a/tests/timemodified_columns_test.php b/tests/timemodified_columns_test.php new file mode 100644 index 0000000..adcc056 --- /dev/null +++ b/tests/timemodified_columns_test.php @@ -0,0 +1,109 @@ +. + +namespace tool_advancedreplace; + +/** + * Time modified columns test. + * + * Note: This class deliberately contains both old annotations and new attributes. PHPUnit will use one and ignore the + * other. + * + * @package tool_advancedreplace + * @copyright 2024 Catalyst IT Australia Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversMethod(timemodified_columns::class, 'generate_map')] +#[\PHPUnit\Framework\Attributes\CoversMethod(timemodified_columns::class, 'get_map')] +#[\PHPUnit\Framework\Attributes\CoversMethod(timemodified_columns::class, 'get_column')] +final class timemodified_columns_test extends \advanced_testcase { + /** @var array|null The generated map, shared by all tests as it is the same every time. */ + private static ?array $map = null; + + /** + * Generate the shared map. + */ + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + self::$map = timemodified_columns::generate_map(); + } + + /** + * Release the shared map. + */ + public static function tearDownAfterClass(): void { + self::$map = null; + parent::tearDownAfterClass(); + } + + /** + * Data provider for test_generate_map. + * + * @return array + */ + public static function generate_map_provider(): array { + return [ + // Standard column name. + ['course', 'timemodified'], + // Preferred over timemodifiedoffline. + ['quiz_attempts', 'timemodified'], + // Non-standard column names. + ['forum_posts', 'modified'], + ['post', 'lastmodified'], + ['enrol_paypal', 'timeupdated'], + ['enrol_lti_lti2_consumer', 'updated'], + ['event_subscriptions', 'lastupdated'], + ['lti_submission', 'dateupdated'], + // No time modified column at all. + ['config', null], + ]; + } + + /** + * Test the map is generated from the table definitions. + * + * @dataProvider generate_map_provider + * @covers \tool_advancedreplace\timemodified_columns::generate_map + * @param string $table The table to check. + * @param string|null $expected The expected column, or null if the table should not be in the map. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('generate_map_provider')] + public function test_generate_map(string $table, ?string $expected): void { + $this->assertSame($expected, self::$map[$table] ?? null); + } + + /** + * Test the map is cached, and generated when the cache is empty. + * + * @covers \tool_advancedreplace\timemodified_columns::get_map + * @covers \tool_advancedreplace\timemodified_columns::get_column + */ + public function test_get_map_uses_cache(): void { + $this->resetAfterTest(); + + $cache = \cache::make('tool_advancedreplace', 'timemodifiedcolumns'); + $cache->purge(); + + // An empty cache is populated with the generated map. + $this->assertSame('modified', timemodified_columns::get_column('forum_posts')); + $this->assertSame(self::$map, $cache->get(timemodified_columns::CACHE_KEY)); + + // A populated cache is used as is. + $cache->set(timemodified_columns::CACHE_KEY, ['forum_posts' => 'cachedcolumn']); + $this->assertSame('cachedcolumn', timemodified_columns::get_column('forum_posts')); + $this->assertNull(timemodified_columns::get_column('course')); + } +} diff --git a/version.php b/version.php index f29963a..f4c4aa5 100644 --- a/version.php +++ b/version.php @@ -25,9 +25,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025080707; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025080708; // The current plugin version (Date: YYYYMMDDXX). $plugin->release = 2025080706; $plugin->requires = 2020110900; // Requires this Moodle version. $plugin->component = 'tool_advancedreplace'; $plugin->maturity = MATURITY_STABLE; -$plugin->supported = [401, 405]; +$plugin->supported = [401, 503];