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/file_search.php b/classes/file_search.php index be47d86..e4d895d 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_FILETIMEMODIFIED = 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; @@ -181,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)) { @@ -212,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', 'offset', 'match', + 'mimetype', 'strategy', 'internal', 'replace', 'filetimemodified', 'offset', 'match', ]; fputcsv($stream, $columnheaders, ',', '"', '\\'); } @@ -226,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.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 @@ -491,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, @@ -505,6 +521,7 @@ public static function search_file(object $filerecord, object $criteria, $stream self::CSV_STRATEGY => 'plain', self::CSV_INTERNAL => '', self::CSV_REPLACE => '', + 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/classes/form/results_filter_form.php b/classes/form/results_filter_form.php new file mode 100644 index 0000000..2e2d692 --- /dev/null +++ b/classes/form/results_filter_form.php @@ -0,0 +1,136 @@ +. + +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 results_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')); + 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); + } + + /** + * 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 + */ + public function validation($data, $files) { + $errors = parent::validation($data, $files); + if ( + !empty($data['reportfilterfrom']) && + !empty($data['reportfilterto']) && + $data['reportfilterfrom'] > $data['reportfilterto'] + ) { + $errors['reportfilterto'] = get_string('reportfilterinvalidrange', 'tool_advancedreplace'); + } + return $errors; + } +} diff --git a/classes/form/search.php b/classes/form/search.php index e2a1e7c..3e8cdd8 100644 --- a/classes/form/search.php +++ b/classes/form/search.php @@ -97,6 +97,40 @@ 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->addHelpButton('timemodifiedfrom', 'field_timemodified', 'tool_advancedreplace'); + $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 70d3e51..869248d 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'], @@ -327,26 +328,51 @@ 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)) { + // Include course info based on a course ID reference. $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"; } else if (isset($supportedtablemappings[$table])) { + // Inclcde course info based on specially supplied SQL. $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 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 { + // Time modified will be blank for all other tables. $sql = "SELECT id, $columnname FROM {" . $table . "} $tablealias WHERE $wheresql"; } @@ -454,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, @@ -464,6 +499,7 @@ public static function search_column(db_search $search, string $table, database_ $record->{$column->name}, '', $linkstring, + $dtstr, ], ',', '"', '\\'); $count++; } else { @@ -487,6 +523,7 @@ public static function search_column(db_search $search, string $table, database_ $match, '', $linkstring, + $dtstr, ], ',', '"', '\\'); $count++; } @@ -562,7 +599,13 @@ 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'], ',', '"', '\\'); } @@ -575,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/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/install.xml b/db/install.xml index 7e6b765..3616ac7 100644 --- a/db/install.xml +++ b/db/install.xml @@ -16,6 +16,8 @@ + + @@ -49,6 +51,8 @@ + + diff --git a/db/upgrade.php b/db/upgrade.php index d680a57..260a84e 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -173,5 +173,49 @@ 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'); + } + + 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/db_search_report.php b/db_search_report.php index 43543c8..10fab40 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\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/db_search_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(); @@ -190,30 +219,78 @@ 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; + } + $row[$timemodifiedindex] = userdate($tmtimestamp, get_string('strftimedatetimemonthshort', 'tool_advancedreplace')); + } + 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 +299,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 +344,12 @@ $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('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') . + $OUTPUT->help_icon('field_timemodified', 'tool_advancedreplace'), ]; // Build table header cells — the match column absorbs link. 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/lang/en/tool_advancedreplace.php b/lang/en/tool_advancedreplace.php index b1b51ea..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,13 +95,12 @@ $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'] = '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'; - $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 +121,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.'; @@ -132,9 +135,9 @@ $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.'; $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.'; 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 abf2577..f4c4aa5 100644 --- a/version.php +++ b/version.php @@ -25,9 +25,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025080705; // The current plugin version (Date: YYYYMMDDXX). -$plugin->release = 2025080705; +$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];