├── .gitattributes
├── CHANGELOG.md
├── client
└── css
│ └── datachange-tracker.css
├── _config
└── datachange-tracker.yml
├── phpunit.xml
├── .editorconfig
├── src
├── Admin
│ └── DataChangeAdmin.php
├── Job
│ ├── DataChangeConvertJsonTask.php
│ ├── CleanupDataChangeHistoryTask.php
│ └── PruneChangesBeforeJob.php
├── Service
│ └── DataChangeTrackService.php
├── Extension
│ ├── SiteTreeChangeRecordable.php
│ ├── ChangeRecordable.php
│ └── SignificantChangeRecordable.php
└── Model
│ ├── TrackedManyManyList.php
│ └── DataChangeRecord.php
├── .upgrade.yml
└── composer.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | /tests export-ignore
2 | /docs export-ignore
3 | /.travis.yml export-ignore
4 | /.scrutinizer.yml export-ignore
5 | README.md export-ignore
6 | LICENSE.md export-ignore
7 | CONTRIBUTING.md export-ignore
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | For more information on changes and releases, please visit [Releases](https://github.com/symbiote/silverstripe-datachange-tracker/releases).
4 |
5 | This project adheres to [Semantic Versioning](http://semver.org/)
6 |
--------------------------------------------------------------------------------
/client/css/datachange-tracker.css:
--------------------------------------------------------------------------------
1 | .datachange-field ins {
2 | background-color: #DFD;
3 | padding: 2px;
4 | text-decoration: none;
5 | }
6 |
7 | .datachange-field del {
8 | background-color: #FDD;
9 | padding: 2px;
10 | color: #ff4444;
11 | }
12 |
13 | .datachange-field.ss-toggle .ui-accordion-content .field label{
14 | float: left;
15 | }
--------------------------------------------------------------------------------
/_config/datachange-tracker.yml:
--------------------------------------------------------------------------------
1 | ---
2 | Name: datachange_injector_config
3 | ---
4 | SilverStripe\Core\Injector\Injector:
5 | DataChangeTrackService:
6 | class: \Symbiote\DataChange\Service\DataChangeTrackService
7 | Symbiote\DataChange\Extension\ChangeRecordable:
8 | properties:
9 | dataChangeTrackService: "%$DataChangeTrackService"
10 | Symbiote\DataChange\Extension\SiteTreeChangeRecordable:
11 | properties:
12 | dataChangeTrackService: "%$DataChangeTrackService"
13 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | tests/
4 |
5 |
6 |
7 |
8 | src/
9 |
10 | tests/
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # For more information about the properties used in this file,
2 | # please see the EditorConfig documentation:
3 | # http://editorconfig.org
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | indent_size = 4
9 | indent_style = space
10 | insert_final_newline = true
11 | trim_trailing_whitespace = true
12 |
13 | [{*.yml,package.json}]
14 | indent_size = 2
15 |
16 | # The indent size used in the package.json file cannot be changed:
17 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516
18 |
--------------------------------------------------------------------------------
/src/Admin/DataChangeAdmin.php:
--------------------------------------------------------------------------------
1 | getVar('run')) {
17 | // load all items and convert 'before' and 'after' to json if their serialize returns a value
18 | $records = DataChangeRecord::get();
19 | foreach ($records as $record) {
20 | $before = @unserialize($record->Before);
21 | $after = @unserialize($record->After);
22 |
23 | if ($before || $after) {
24 | $record->Before = json_encode($before);
25 | $record->After = json_encode($after);
26 | $record->write();
27 | echo "Updated $record->Title (#$record->ID)
\n";
28 | }
29 | }
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Service/DataChangeTrackService.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class DataChangeTrackService implements \Stringable
13 | {
14 |
15 | protected $dcr_cache = [];
16 |
17 | public $disabled = false;
18 |
19 | public function track(DataObject $object, $type = 'Change')
20 | {
21 |
22 | if ($this->disabled) {
23 | return;
24 | }
25 |
26 | if (!isset($this->dcr_cache["{$object->ID}-{$object->Classname}-$type"])) {
27 | $this->dcr_cache["{$object->ID}-{$object->Classname}"] = DataChangeRecord::create();
28 | }
29 |
30 | $this->dcr_cache["{$object->ID}-{$object->Classname}"]->track($object, $type);
31 | }
32 |
33 | public function resetChangeCache()
34 | {
35 | $this->dcr_cache = [];
36 | }
37 |
38 | public function __toString(): string
39 | {
40 | return '';
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "symbiote/silverstripe-datachange-tracker",
3 | "description": "Record and track changes to any dataobjects. View chages/diffs in model admin.",
4 | "type": "silverstripe-vendormodule",
5 | "keywords": ["silverstripe", "data", "tracker"],
6 | "homepage": "http://github.com/symbiote/silverstripe-datachange-tracker",
7 | "authors": [
8 | {
9 | "name": "Marcus Nyeholt",
10 | "email": "marcus@symbiote.com.au"
11 | }
12 | ],
13 |
14 | "require": {
15 | "silverstripe/framework": "^4.12 || ^5"
16 | },
17 | "require-dev": {
18 | "phpunit/phpunit": "^9.6"
19 | },
20 | "extra": {
21 | "expose": [
22 | "client"
23 | ],
24 | "branch-alias": {
25 | "dev-master": "5.0.x-dev"
26 | }
27 | },
28 | "replace": {
29 | "sheadawson/silverstripe-datachange-tracker": "self.version",
30 | "silverstripe-australia/datachange-tracker": "self.version"
31 | },
32 | "autoload": {
33 | "psr-4": {
34 | "Symbiote\\DataChange\\": "src/",
35 | "Symbiote\\DataChange\\Tests\\": "tests/"
36 | }
37 | },
38 | "prefer-stable": true,
39 | "minimum-stability": "dev"
40 | }
41 |
--------------------------------------------------------------------------------
/src/Job/CleanupDataChangeHistoryTask.php:
--------------------------------------------------------------------------------
1 |
14 | * @license BSD License http://www.silverstripe.org/bsd-license
15 | */
16 | class CleanupDataChangeHistoryTask extends BuildTask
17 | {
18 | public function run($request)
19 | {
20 | $confirm = $request->getVar('run') ? true : false;
21 | $force = $request->getVar('force') ? true : false;
22 | $since = $request->getVar('older');
23 |
24 | if (!$since) {
25 | echo "Please specify an 'older' param with a date older than which to prune (in strtotime friendly format)
\n";
26 | return;
27 | }
28 |
29 | $since = strtotime((string) $since);
30 | if (!$since) {
31 | echo "Please specify an 'older' param with a date older than which to prune (in strtotime friendly format)
\n";
32 | return;
33 | }
34 |
35 | if ($since > strtotime('-3 months') && !$force) {
36 | echo "To cleanup data more recent than 3 months, please supply the 'force' parameter as well as the run parameter, swapping to dry run
\n";
37 | $confirm = false;
38 | }
39 |
40 | $since = date('Y-m-d H:i:s', $since);
41 |
42 | $items = DataChangeRecord::get()->filter('Created:LessThan', $since);
43 | $max = $items->max('ID');
44 | echo "Pruning records older than $since (ID $max)
\n";
45 |
46 | if ($confirm && $max) {
47 | $query = new SQLDelete('DataChangeRecord', '"ID" < \'' . $max . '\'');
48 | $query->execute();
49 | } else {
50 | echo "Dry run performed, please supply the run=1 parameter to actually execute the deletion!
\n";
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Job/PruneChangesBeforeJob.php:
--------------------------------------------------------------------------------
1 | priorTo = $priorTo;
33 | $this->pruneBefore = date('Y-m-d 00:00:00', $ts);
34 | // NOTE(Jake): 2018-05-08
35 | //
36 | // Change steps to 1 as it's technically doing
37 | // this in 1 step now, this is to avoid an issue where
38 | // totalSteps=0 can occur and the job won't requeue itself.
39 | // (When using ->count() off the DataList)
40 | //
41 | $this->totalSteps = 1;
42 | }
43 |
44 | public function getSignature()
45 | {
46 | return md5($this->pruneBefore);
47 | }
48 |
49 | public function getTitle()
50 | {
51 | return "Prune data change track entries before " . $this->pruneBefore;
52 | }
53 |
54 | public function process()
55 | {
56 | $items = DataChangeRecord::get()->filter('Created:LessThan', $this->pruneBefore);
57 | $max = $items->max('ID');
58 |
59 | $query = new SQLDelete('DataChangeRecord', '"ID" < \'' . $max . '\'');
60 | $query->execute();
61 |
62 | $job = new PruneChangesBeforeJob($this->priorTo);
63 |
64 | $next = date('Y-m-d 03:00:00', strtotime('tomorrow'));
65 |
66 | $this->currentStep = 1;
67 | $this->isComplete = true;
68 |
69 | Injector::inst()->get(QueuedJobService::class)->queueJob($job, $next);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/Extension/SiteTreeChangeRecordable.php:
--------------------------------------------------------------------------------
1 | dataChangeTrackService->track($this->owner, 'Publish');
23 | }
24 |
25 | public function onAfterUnpublish()
26 | {
27 | $this->dataChangeTrackService->track($this->owner, 'Unpublish');
28 | }
29 |
30 | public function updateCMSFields(FieldList $fields)
31 | {
32 | if (Permission::check('CMS_ACCESS_DataChangeAdmin')) {
33 | //Get all data changes relating to this page filter them by publish/unpublish
34 | $dataChanges = DataChangeRecord::get()->filter([
35 | 'ChangeRecordID' => $this->owner->ID,
36 | 'ChangeRecordClass' => $this->owner->ClassName
37 | ])->exclude('ChangeType', 'Change');
38 |
39 | //create a gridfield out of them
40 | $gridFieldConfig = GridFieldConfig_RecordViewer::create();
41 | $publishedGrid = new GridField('PublishStates', 'Published States', $dataChanges, $gridFieldConfig);
42 | $dataColumns = $publishedGrid->getConfig()->getComponentByType(\SilverStripe\Forms\GridField\GridFieldDataColumns::class);
43 | $dataColumns->setDisplayFields([
44 | 'ChangeType' => 'Change Type',
45 | 'ObjectTitle' => 'Page Title',
46 | 'ChangedBy.Title' => 'User',
47 | 'Created' => 'Modification Date'
48 | ]);
49 |
50 | //linking through to the datachanges modeladmin
51 |
52 | $fields->addFieldsToTab('Root.PublishedState', $publishedGrid);
53 | return $fields;
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Extension/ChangeRecordable.php:
--------------------------------------------------------------------------------
1 | owner->isInDB()) {
40 | $this->dataChangeTrackService->track($this->owner, $this->changeType);
41 | } else {
42 | $this->isNewObject = true;
43 | $this->changeType = 'New';
44 | }
45 | }
46 |
47 | public function onAfterWrite()
48 | {
49 | parent::onAfterWrite();
50 | if ($this->isNewObject) {
51 | $this->dataChangeTrackService->track($this->owner, $this->changeType);
52 | $this->isNewObject = false;
53 | }
54 | }
55 |
56 | public function onBeforeDelete()
57 | {
58 | parent::onBeforeDelete();
59 | $this->dataChangeTrackService->track($this->owner, 'Delete');
60 | }
61 |
62 | public function getIgnoredFields()
63 | {
64 | $ignored = Config::inst()->get(ChangeRecordable::class, 'ignored_fields');
65 | $class = $this->owner->ClassName;
66 | if (isset($ignored[$class])) {
67 | return array_combine($ignored[$class], $ignored[$class]);
68 | }
69 | }
70 |
71 | public function onBeforeVersionedPublish($from, $to)
72 | {
73 | if ($this->owner->isInDB()) {
74 | $this->dataChangeTrackService->track($this->owner, 'Publish ' . $from . ' to ' . $to);
75 | }
76 | }
77 |
78 | /**
79 | * Get the list of data changes for this item
80 | *
81 | * @return \SilverStripe\ORM\DataList
82 | */
83 | public function getDataChangesList()
84 | {
85 | return DataChangeRecord::get()->filter([
86 | 'ChangeRecordID' => $this->owner->ID,
87 | 'ChangeRecordClass' => $this->owner->ClassName
88 | ]);
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/Model/TrackedManyManyList.php:
--------------------------------------------------------------------------------
1 | recordManyManyChange(__FUNCTION__, $item);
20 | $result = parent::add($item, $extraFields);
21 | return $result;
22 | }
23 |
24 | public function remove($item)
25 | {
26 | $this->recordManyManyChange(__FUNCTION__, $item);
27 | $result = parent::remove($item);
28 | return $result;
29 | }
30 |
31 | protected function recordManyManyChange($type, $item)
32 | {
33 | $joinName = $this->getJoinTable();
34 | if (!in_array($joinName, $this->trackedRelationships)) {
35 | return;
36 | }
37 | $parts = explode('_', $joinName);
38 | if (isset($parts[0]) && count($parts) > 1) {
39 | // table name could be sometihng like Symbiote_DataChange_Tests_TestObject_Kids
40 | // which is ClassName_RelName, with
41 | $tableName = $parts;
42 | $relationName = array_pop($tableName);
43 | $tableName = implode('_', $tableName);
44 |
45 | $addingToClass = $this->tableClass($tableName);
46 | if (!$addingToClass) {
47 | return;
48 | }
49 | if (!class_exists($addingToClass)) {
50 | return;
51 | }
52 | $onItem = $addingToClass::get()->byID($this->getForeignID());
53 | if (!$onItem) {
54 | return;
55 | }
56 | if ($item && !($item instanceof DataObject)) {
57 | $class = $this->dataClass();
58 | $item = $class::get()->byID($item);
59 | }
60 | $join = $type === 'add' ? ' to ' : ' from ';
61 | $type = ucfirst((string) $type) . ' "' . $item->Title . '"' . $join . $relationName;
62 | $onItem->RelatedItem = $item->ClassName . ' #' . $item->ID;
63 | singleton('DataChangeTrackService')->track($onItem, $type);
64 | }
65 | }
66 |
67 | /**
68 | * Find the class for the given table.
69 | *
70 | * Stripped down version from framework that does not attempt to strip _Live and _versions postfixes as
71 | * that throws errors in its preg_match(). (At least it did as of 2018-06-22 on SilverStripe 4.1.1)
72 | *
73 | * @param string $table
74 | * @return string|null The FQN of the class, or null if not found
75 | */
76 | private function tableClass($table)
77 | {
78 | $tables = DataObject::getSchema()->getTableNames();
79 | $class = array_search($table, $tables, true);
80 | if ($class) {
81 | return $class;
82 | }
83 | return null;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Extension/SignificantChangeRecordable.php:
--------------------------------------------------------------------------------
1 | 'DBDatetime',
29 | 'ChangeDescription' => 'Text'
30 | ];
31 |
32 | public function updateCMSFields(FieldList $fields)
33 | {
34 | $fields->removeByName('LastSignificantChange');
35 | $fields->removeByName('ChangeDescription');
36 | if ($this->owner->LastSignificantChange !== null) {
37 | $dateTime = new DateTime($this->owner->LastSignificantChange);
38 | //Put these fields on the top of the First Tab's form
39 | $fields->first()->Tabs()->first()->getChildren()->unshift(
40 | LiteralField::create(
41 | "infoLastSignificantChange",
42 | "Last Significant change was at: "
43 | . "{$dateTime->Format('d/m/Y H:i')}"
44 | )->setAllowHTML(true)
45 | );
46 | $fields->insertAfter(
47 | CheckboxField::create(
48 | "isSignificantChange",
49 | "CLEAR Last Significant change: {$dateTime->Format('d/m/Y H:i')}"
50 | )->setDescription(
51 | 'Check and save this Record again to clear the Last Significant change date.'
52 | )->setValue(false),
53 | 'infoLastSignificantChange'
54 | );
55 | $fields->insertAfter(
56 | TextField::create('ChangeDescription', 'Description of Changes')
57 | ->setDescription('This is an automatically generated list of changes to important fields.'),
58 | 'isSignificantChange'
59 | );
60 | }
61 | }
62 |
63 | public function onBeforeWrite()
64 | {
65 | parent::onBeforeWrite();
66 | // Load the significant_fields and check to see if they have changed if they have record the current DateTime
67 | $significant = Config::inst()->get($this->owner->Classname, 'significant_fields');
68 |
69 | $isSignicantChange = $this->owner->isSignificantChange;
70 |
71 | if (isset($significant) && !$isSignicantChange) {
72 | $significant = array_combine($significant, $significant);
73 |
74 | //If the owner object or an extension of it implements getSignificantChange call it instead of testing here
75 | if ($this->owner->hasMethod('getSignificantChange') && $this->owner->getSignificantChange()) {
76 | //Set LastSignificantChange to now
77 | $this->owner->LastSignificantChange = date(DateTime::ATOM);
78 | } else {
79 | $changes = $this->owner->getChangedFields(true, 2);
80 | //A simple interesect of the keys gives us whether a change has occurred
81 | if (count($changes) && count(array_intersect_key($changes, $significant))) {
82 | //Set LastSignificantChange to now
83 | $this->owner->LastSignificantChange = date(DateTime::ATOM);
84 | }
85 | }
86 | //If we don't have any significant changes leave the field alone as a previous edit may have been
87 | //significant.
88 | } else {
89 | if ($this->owner->isInDB()) {
90 | $this->owner->LastSignificantChange = null;
91 | }
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/Model/DataChangeRecord.php:
--------------------------------------------------------------------------------
1 | 'Varchar',
28 | 'ObjectTitle' => 'Varchar(255)',
29 | 'Before' => 'Text',
30 | 'After' => 'Text',
31 | 'Stage' => 'Text',
32 | 'CurrentEmail' => 'Text',
33 | 'CurrentURL' => 'Varchar(255)',
34 | 'Referer' => 'Varchar(255)',
35 | 'RemoteIP' => 'Varchar(128)',
36 | 'Agent' => 'Varchar(255)',
37 | 'GetVars' => 'Text',
38 | 'PostVars' => 'Text',
39 | ];
40 | private static $has_one = [
41 | 'ChangedBy' => Member::class,
42 | 'ChangeRecord' => DataObject::class
43 | ];
44 | private static $summary_fields = [
45 | 'ChangeType' => 'Change Type',
46 | 'ChangeRecordClass' => 'Record Class',
47 | 'ChangeRecordID' => 'Record ID',
48 | 'ObjectTitle' => 'Record Title',
49 | 'ChangedBy.Title' => 'User',
50 | 'Created' => 'Modification Date'
51 | ];
52 | private static $searchable_fields = [
53 | 'ChangeType',
54 | 'ObjectTitle',
55 | 'ChangeRecordClass',
56 | 'ChangeRecordID'
57 | ];
58 | private static $default_sort = 'ID DESC';
59 |
60 | /**
61 | * Should request variables be saved too?
62 | *
63 | * @var boolean
64 | */
65 | private static $save_request_vars = false;
66 | private static $field_blacklist = ['Password'];
67 | private static $request_vars_blacklist = ['url', 'SecurityID'];
68 |
69 | public function getCMSFields($params = null)
70 | {
71 | Requirements::css('symbiote/silverstripe-datachange-tracker: client/css/datachange-tracker.css');
72 |
73 | $fields = FieldList::create(
74 | ToggleCompositeField::create(
75 | 'Details',
76 | 'Details',
77 | [
78 | ReadonlyField::create('ChangeType', 'Type of change'),
79 | ReadonlyField::create('ChangeRecordClass', 'Record Class'),
80 | ReadonlyField::create('ChangeRecordID', 'Record ID'),
81 | ReadonlyField::create('ObjectTitle', 'Record Title'),
82 | ReadonlyField::create('Created', 'Modification Date'),
83 | ReadonlyField::create('Stage', 'Stage'),
84 | ReadonlyField::create('User', 'User', $this->getMemberDetails()),
85 | ReadonlyField::create('CurrentURL', 'URL'),
86 | ReadonlyField::create('Referer', 'Referer'),
87 | ReadonlyField::create('RemoteIP', 'Remote IP'),
88 | ReadonlyField::create('Agent', 'Agent')
89 | ]
90 | )->setStartClosed(false)->addExtraClass('datachange-field'),
91 | ToggleCompositeField::create(
92 | 'RawData',
93 | 'Raw Data',
94 | [
95 | ReadonlyField::create('Before'),
96 | ReadonlyField::create('After'),
97 | ReadonlyField::create('GetVars'),
98 | ReadonlyField::create('PostVars')
99 | ]
100 | )->setStartClosed(false)->addExtraClass('datachange-field')
101 | );
102 |
103 | if (strlen($this->Before) && strlen($this->ChangeRecordClass) && class_exists($this->ChangeRecordClass)) {
104 | $before = Injector::inst()->create($this->ChangeRecordClass, $this->prepareForDataDifferencer($this->Before), true);
105 | $after = Injector::inst()->create($this->ChangeRecordClass, $this->prepareForDataDifferencer($this->After), true);
106 | $diff = DataDifferencer::create($before, $after);
107 |
108 | // The solr search service injector dependency causes issues with comparison, since it has public variables that are stored in an array.
109 |
110 | $diff->ignoreFields(['searchService']);
111 | $diffed = $diff->diffedData();
112 | $diffText = '';
113 |
114 | $changedFields = [];
115 | foreach ($diffed->toMap() as $field => $prop) {
116 | if (is_object($prop)) {
117 | continue;
118 | }
119 | if (is_array($prop)) {
120 | $prop = json_encode($prop);
121 | }
122 | $changedFields[] = $readOnly = \SilverStripe\Forms\ReadonlyField::create(
123 | 'ChangedField' . $field,
124 | $field,
125 | $prop
126 | );
127 | $readOnly->addExtraClass('datachange-field');
128 | }
129 |
130 | $fields->insertBefore(
131 | 'RawData',
132 | ToggleCompositeField::create('FieldChanges', 'Changed Fields', $changedFields)
133 | ->setStartClosed(false)
134 | ->addExtraClass('datachange-field')
135 | );
136 | }
137 |
138 | // Flags fields that cannot be rendered with 'forTemplate'. This prevents bugs where
139 | // WorkflowService (of AdvancedWorkflow Module) and BlockManager (of Sheadawson/blocks module) get put
140 | // into a field and break the page.
141 | $fieldsToRemove = [];
142 | foreach ($fields->dataFields() as $field) {
143 | $value = $field->Value();
144 | if ($value && is_object($value)) {
145 | if ((method_exists($value, 'hasMethod') && !$value->hasMethod('forTemplate')) || !method_exists(
146 | $value,
147 | 'forTemplate'
148 | )) {
149 | $field->setValue('[Missing ' . $value::class . '::forTemplate]');
150 | }
151 | }
152 | }
153 |
154 | $fields = $fields->makeReadonly();
155 |
156 | return $fields;
157 | }
158 |
159 | /**
160 | * Track a change to a DataObject
161 | *
162 | * @return DataChangeRecord
163 | * */
164 | public function track(DataObject $changedObject, $type = 'Change')
165 | {
166 | $changes = $changedObject->getChangedFields(true, 2);
167 | if (count($changes)) {
168 | // remove any changes to ignored fields
169 | $ignored = $changedObject->hasMethod('getIgnoredFields') ? $changedObject->getIgnoredFields() : null;
170 | if ($ignored) {
171 | $changes = array_diff_key($changes, $ignored);
172 | foreach ($ignored as $ignore) {
173 | if (isset($changes[$ignore])) {
174 | unset($changes[$ignore]);
175 | }
176 | }
177 | }
178 | }
179 |
180 | foreach (self::config()->field_blacklist as $key) {
181 | if (isset($changes[$key])) {
182 | unset($changes[$key]);
183 | }
184 | }
185 |
186 | if ((empty($changes) && $type == 'Change')) {
187 | return;
188 | }
189 |
190 | if ($type === 'Delete' && Versioned::get_reading_mode() === 'Stage.Live') {
191 | $type = 'Delete from Live';
192 | }
193 |
194 | $this->ChangeType = $type;
195 |
196 | $this->ChangeRecordClass = $changedObject->ClassName;
197 | $this->ChangeRecordID = $changedObject->ID;
198 | // @TODO this will cause issue for objects without titles
199 | $this->ObjectTitle = $changedObject->Title;
200 | $this->Stage = Versioned::get_reading_mode();
201 |
202 | $before = [];
203 | $after = [];
204 |
205 | if ($type != 'Change' && $type != 'New') { // If we are (un)publishing we want to store the entire object
206 | $before = ($type === 'Unpublish') ? $changedObject->toMap() : null;
207 | $after = ($type === 'Publish') ? $changedObject->toMap() : null;
208 | } else { // Else we're tracking the changes to the object
209 | foreach ($changes as $field => $change) {
210 | if ($field == 'SecurityID') {
211 | continue;
212 | }
213 | $before[$field] = $change['before'];
214 | $after[$field] = $change['after'];
215 | }
216 | }
217 |
218 | if ($this->Before && $this->Before !== 'null' && is_array($before)) {
219 | //merge the old array last to keep it's value as we want keep the earliest version of each field
220 | $this->Before = json_encode(array_replace(json_decode($this->Before, true), $before));
221 | } else {
222 | $this->Before = json_encode($before);
223 | }
224 | if ($this->After && $this->After !== 'null' && is_array($after)) {
225 | //merge the new array last to keep it's value as we want the newest version of each field
226 | $this->After = json_encode(array_replace($after, json_decode($this->After, true)));
227 | } else {
228 | $this->After = json_encode($after);
229 | }
230 |
231 | if (self::config()->save_request_vars) {
232 | foreach (self::config()->request_vars_blacklist as $key) {
233 | unset($_GET[$key]);
234 | unset($_POST[$key]);
235 | }
236 |
237 | $this->GetVars = isset($_GET) ? json_encode($_GET) : null;
238 | $this->PostVars = isset($_POST) ? json_encode($_POST) : null;
239 | }
240 |
241 | if ($member = Security::getCurrentUser()) {
242 | $this->ChangedByID = $member->ID;
243 | $this->CurrentEmail = $member->Email;
244 | }
245 |
246 | if (isset($_SERVER['SERVER_NAME'])) {
247 | $protocol = 'http';
248 | $protocol = isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on" ? 'https://' : 'http://';
249 | $port = $_SERVER['SERVER_PORT'] ?? '80';
250 |
251 | $this->CurrentURL = $protocol . $_SERVER["SERVER_NAME"] . ":" . $port . $_SERVER["REQUEST_URI"];
252 | } elseif (Director::is_cli()) {
253 | $this->CurrentURL = 'CLI';
254 | } else {
255 | $this->CurrentURL = 'Could not determine current URL';
256 | }
257 |
258 | $this->RemoteIP = $_SERVER['REMOTE_ADDR'] ?? (Director::is_cli() ? 'CLI' : 'Unknown remote addr');
259 | $this->Referer = $_SERVER['HTTP_REFERER'] ?? '';
260 | $this->Agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
261 |
262 | $this->write();
263 | return $this;
264 | }
265 |
266 | /**
267 | * @return boolean
268 | * */
269 | public function canDelete($member = null)
270 | {
271 | return false;
272 | }
273 |
274 | /**
275 | * @return string
276 | * */
277 | public function getTitle()
278 | {
279 | return $this->ChangeRecordClass . ' #' . $this->ChangeRecordID;
280 | }
281 |
282 | /**
283 | * Return a description/summary of the user
284 | *
285 | * @return string
286 | * */
287 | public function getMemberDetails()
288 | {
289 | if ($user = $this->ChangedBy()) {
290 | $name = $user->getTitle();
291 | if ($user->Email) {
292 | $name .= " <$user->Email>";
293 | }
294 | return $name;
295 | }
296 | }
297 |
298 | private function prepareForDataDifferencer($jsonData)
299 | {
300 | // NOTE(Jake): 2018-06-21
301 | //
302 | // Data Differencer cannot handle arrays within an array,
303 | //
304 | // So JSON data that comes from MultiValueField / Text DB fields
305 | // causes errors to be thrown.
306 | //
307 | // So solve this, we simply only decode to a depth of 1. (rather than the 512 default)
308 | //
309 | $resultJsonData = json_decode((string) $jsonData, true, 1);
310 | return $resultJsonData;
311 | }
312 | }
313 |
--------------------------------------------------------------------------------