Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 // Copyright 2017 The Chromium Authors. All rights reserved. | 1 // Copyright 2017 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be | 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. | 3 // found in the LICENSE file. |
| 4 | 4 |
| 5 #include "components/history/core/browser/typed_url_sync_bridge.h" | 5 #include "components/history/core/browser/typed_url_sync_bridge.h" |
| 6 | 6 |
| 7 #include "base/memory/ptr_util.h" | 7 #include "base/memory/ptr_util.h" |
| 8 #include "base/strings/string_number_conversions.h" | |
| 9 #include "base/strings/utf_string_conversions.h" | |
| 10 #include "components/history/core/browser/history_backend.h" | |
| 11 #include "components/sync/model/mutable_data_batch.h" | |
| 8 #include "components/sync/model_impl/sync_metadata_store_change_list.h" | 12 #include "components/sync/model_impl/sync_metadata_store_change_list.h" |
| 9 | 13 |
| 14 using syncer::EntityData; | |
| 15 using sync_pb::TypedUrlSpecifics; | |
| 16 using syncer::MutableDataBatch; | |
| 17 | |
| 10 namespace history { | 18 namespace history { |
| 11 | 19 |
| 20 namespace { | |
| 21 | |
| 22 // The server backend can't handle arbitrarily large node sizes, so to keep | |
| 23 // the size under control we limit the visit array. | |
| 24 static const int kMaxTypedUrlVisits = 100; | |
| 25 | |
| 26 // There's no limit on how many visits the history DB could have for a given | |
| 27 // typed URL, so we limit how many we fetch from the DB to avoid crashes due to | |
| 28 // running out of memory (http://crbug.com/89793). This value is different | |
| 29 // from kMaxTypedUrlVisits, as some of the visits fetched from the DB may be | |
| 30 // RELOAD visits, which will be stripped. | |
| 31 static const int kMaxVisitsToFetch = 1000; | |
| 32 | |
| 33 // Enforce oldest to newest visit order. | |
| 34 static bool CheckVisitOrdering(const VisitVector& visits) { | |
| 35 int64_t previous_visit_time = 0; | |
| 36 for (VisitVector::const_iterator visit = visits.begin(); | |
| 37 visit != visits.end(); ++visit) { | |
| 38 if (visit != visits.begin() && | |
| 39 previous_visit_time > visit->visit_time.ToInternalValue()) | |
| 40 return false; | |
| 41 | |
| 42 previous_visit_time = visit->visit_time.ToInternalValue(); | |
| 43 } | |
| 44 return true; | |
| 45 } | |
| 46 | |
| 47 std::string GetStorageKeyFromURLRow(const URLRow& row) { | |
| 48 return base::Int64ToString(row.id()); | |
|
pavely
2017/05/30 14:25:41
Consider using base::WriteBigEndian(storage_key.da
Gang Wu
2017/05/31 00:17:32
Done.
| |
| 49 } | |
| 50 | |
| 51 } // namespace | |
| 52 | |
| 12 TypedURLSyncBridge::TypedURLSyncBridge( | 53 TypedURLSyncBridge::TypedURLSyncBridge( |
| 13 HistoryBackend* history_backend, | 54 HistoryBackend* history_backend, |
| 14 syncer::SyncMetadataStore* sync_metadata_store, | 55 TypedURLSyncMetadataDatabase* sync_metadata_store, |
|
pavely
2017/05/30 14:25:41
Since you are changing the type doe it make sense
Gang Wu
2017/05/31 00:17:32
Done.
| |
| 15 const ChangeProcessorFactory& change_processor_factory) | 56 const ChangeProcessorFactory& change_processor_factory) |
| 16 : ModelTypeSyncBridge(change_processor_factory, syncer::TYPED_URLS), | 57 : ModelTypeSyncBridge(change_processor_factory, syncer::TYPED_URLS), |
| 17 history_backend_(history_backend), | 58 history_backend_(history_backend), |
| 18 sync_metadata_store_(sync_metadata_store) { | 59 sync_metadata_store_(sync_metadata_store), |
| 60 num_db_accesses_(0), | |
| 61 num_db_errors_(0), | |
| 62 history_backend_observer_(this) { | |
| 19 DCHECK(history_backend_); | 63 DCHECK(history_backend_); |
| 20 DCHECK(sequence_checker_.CalledOnValidSequence()); | 64 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 21 DCHECK(sync_metadata_store_); | 65 DCHECK(sync_metadata_store_); |
| 22 NOTIMPLEMENTED(); | 66 |
| 67 LoadMetadata(); | |
| 23 } | 68 } |
| 24 | 69 |
| 25 TypedURLSyncBridge::~TypedURLSyncBridge() { | 70 TypedURLSyncBridge::~TypedURLSyncBridge() { |
| 26 // TODO(gangwu): unregister as HistoryBackendObserver, can use ScopedObserver | 71 // TODO(gangwu): unregister as HistoryBackendObserver, can use ScopedObserver |
| 27 // to do it. | 72 // to do it. |
| 28 } | 73 } |
| 29 | 74 |
| 30 std::unique_ptr<syncer::MetadataChangeList> | 75 std::unique_ptr<syncer::MetadataChangeList> |
| 31 TypedURLSyncBridge::CreateMetadataChangeList() { | 76 TypedURLSyncBridge::CreateMetadataChangeList() { |
| 32 DCHECK(sequence_checker_.CalledOnValidSequence()); | 77 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 33 return base::MakeUnique<syncer::SyncMetadataStoreChangeList>( | 78 return base::MakeUnique<syncer::SyncMetadataStoreChangeList>( |
| 34 sync_metadata_store_, syncer::TYPED_URLS); | 79 sync_metadata_store_, syncer::TYPED_URLS); |
| 35 } | 80 } |
| 36 | 81 |
| 37 base::Optional<syncer::ModelError> TypedURLSyncBridge::MergeSyncData( | 82 base::Optional<syncer::ModelError> TypedURLSyncBridge::MergeSyncData( |
| 38 std::unique_ptr<syncer::MetadataChangeList> metadata_change_list, | 83 std::unique_ptr<syncer::MetadataChangeList> metadata_change_list, |
| 39 syncer::EntityDataMap entity_data_map) { | 84 syncer::EntityDataMap entity_data_map) { |
| 40 DCHECK(sequence_checker_.CalledOnValidSequence()); | 85 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 86 history_backend_observer_.Add(history_backend_); | |
|
pavely
2017/05/30 14:25:40
Once you performed Merge and restarted, the Merge
Gang Wu
2017/05/31 00:17:32
Done.
| |
| 41 NOTIMPLEMENTED(); | 87 NOTIMPLEMENTED(); |
| 42 return {}; | 88 return {}; |
| 43 } | 89 } |
| 44 | 90 |
| 45 base::Optional<syncer::ModelError> TypedURLSyncBridge::ApplySyncChanges( | 91 base::Optional<syncer::ModelError> TypedURLSyncBridge::ApplySyncChanges( |
| 46 std::unique_ptr<syncer::MetadataChangeList> metadata_change_list, | 92 std::unique_ptr<syncer::MetadataChangeList> metadata_change_list, |
| 47 syncer::EntityChangeList entity_changes) { | 93 syncer::EntityChangeList entity_changes) { |
| 48 DCHECK(sequence_checker_.CalledOnValidSequence()); | 94 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 49 NOTIMPLEMENTED(); | 95 NOTIMPLEMENTED(); |
| 50 return {}; | 96 return {}; |
| 51 } | 97 } |
| 52 | 98 |
| 53 void TypedURLSyncBridge::GetData(StorageKeyList storage_keys, | 99 void TypedURLSyncBridge::GetData(StorageKeyList storage_keys, |
| 54 DataCallback callback) { | 100 DataCallback callback) { |
| 55 DCHECK(sequence_checker_.CalledOnValidSequence()); | 101 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 56 NOTIMPLEMENTED(); | 102 NOTIMPLEMENTED(); |
| 57 } | 103 } |
| 58 | 104 |
| 59 void TypedURLSyncBridge::GetAllData(DataCallback callback) { | 105 void TypedURLSyncBridge::GetAllData(DataCallback callback) { |
| 60 DCHECK(sequence_checker_.CalledOnValidSequence()); | 106 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 61 NOTIMPLEMENTED(); | 107 |
| 108 history::URLRows typed_urls; | |
| 109 ++num_db_accesses_; | |
| 110 if (!history_backend_->GetAllTypedURLs(&typed_urls)) { | |
| 111 ++num_db_errors_; | |
| 112 change_processor()->ReportError(FROM_HERE, | |
| 113 "Could not get the typed_url entries.."); | |
|
pavely
2017/05/30 14:25:40
nit: .. => .
Gang Wu
2017/05/31 00:17:32
Done.
| |
| 114 return; | |
| 115 } | |
| 116 | |
| 117 auto batch = base::MakeUnique<MutableDataBatch>(); | |
| 118 for (auto url : typed_urls) { | |
|
pavely
2017/05/30 14:25:41
What does "auto" resolve to? Does declaring url th
Gang Wu
2017/05/31 00:17:32
Done.
| |
| 119 VisitVector visits_vector; | |
| 120 FixupURLAndGetVisits(&url, &visits_vector); | |
| 121 batch->Put(GetStorageKeyFromURLRow(url), | |
| 122 CreateEntityData(url, visits_vector)); | |
| 123 } | |
| 124 callback.Run(std::move(batch)); | |
| 62 } | 125 } |
| 63 | 126 |
| 64 // Must be exactly the value of GURL::spec() for backwards comparability with | 127 // Must be exactly the value of GURL::spec() for backwards comparability with |
| 65 // the previous (Directory + SyncableService) iteration of sync integration. | 128 // the previous (Directory + SyncableService) iteration of sync integration. |
| 66 // This can be large but it is assumed that this is not held in memory at steady | 129 // This can be large but it is assumed that this is not held in memory at steady |
| 67 // state. | 130 // state. |
| 68 std::string TypedURLSyncBridge::GetClientTag( | 131 std::string TypedURLSyncBridge::GetClientTag(const EntityData& entity_data) { |
| 69 const syncer::EntityData& entity_data) { | |
| 70 DCHECK(sequence_checker_.CalledOnValidSequence()); | 132 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 71 DCHECK(entity_data.specifics.has_typed_url()) | 133 DCHECK(entity_data.specifics.has_typed_url()) |
| 72 << "EntityData does not have typed urls specifics."; | 134 << "EntityData does not have typed urls specifics."; |
| 73 | 135 |
| 74 return entity_data.specifics.typed_url().url(); | 136 return entity_data.specifics.typed_url().url(); |
| 75 } | 137 } |
| 76 | 138 |
| 77 // Prefer to use URLRow::id() to uniquely identify entities when coordinating | 139 // Prefer to use URLRow::id() to uniquely identify entities when coordinating |
| 78 // with sync because it has a significantly low memory cost than a URL. | 140 // with sync because it has a significantly low memory cost than a URL. |
| 79 std::string TypedURLSyncBridge::GetStorageKey( | 141 std::string TypedURLSyncBridge::GetStorageKey(const EntityData& entity_data) { |
| 80 const syncer::EntityData& entity_data) { | |
| 81 DCHECK(sequence_checker_.CalledOnValidSequence()); | 142 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 82 NOTIMPLEMENTED(); | 143 DCHECK(history_backend_); |
| 83 return std::string(); | 144 DCHECK(entity_data.specifics.has_typed_url()) |
| 145 << "EntityData does not have typed urls specifics."; | |
| 146 | |
| 147 const TypedUrlSpecifics& typed_url(entity_data.specifics.typed_url()); | |
| 148 URLRow existing_url; | |
| 149 ++num_db_accesses_; | |
| 150 bool is_existing_url = | |
| 151 history_backend_->GetURL(GURL(typed_url.url()), &existing_url); | |
| 152 | |
| 153 if (!is_existing_url) { | |
| 154 // The typed url did not save to local history database yet, so return URL | |
| 155 // for now. | |
| 156 return entity_data.specifics.typed_url().url(); | |
| 157 } | |
| 158 | |
| 159 return GetStorageKeyFromURLRow(existing_url); | |
| 84 } | 160 } |
| 85 | 161 |
| 86 void TypedURLSyncBridge::OnURLVisited(history::HistoryBackend* history_backend, | 162 void TypedURLSyncBridge::OnURLVisited(history::HistoryBackend* history_backend, |
| 87 ui::PageTransition transition, | 163 ui::PageTransition transition, |
| 88 const history::URLRow& row, | 164 const history::URLRow& row, |
| 89 const history::RedirectList& redirects, | 165 const history::RedirectList& redirects, |
| 90 base::Time visit_time) { | 166 base::Time visit_time) { |
| 91 DCHECK(sequence_checker_.CalledOnValidSequence()); | 167 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 92 NOTIMPLEMENTED(); | 168 NOTIMPLEMENTED(); |
| 93 } | 169 } |
| 94 | 170 |
| 95 void TypedURLSyncBridge::OnURLsModified( | 171 void TypedURLSyncBridge::OnURLsModified( |
| 96 history::HistoryBackend* history_backend, | 172 history::HistoryBackend* history_backend, |
| 97 const history::URLRows& changed_urls) { | 173 const history::URLRows& changed_urls) { |
| 98 DCHECK(sequence_checker_.CalledOnValidSequence()); | 174 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 99 NOTIMPLEMENTED(); | 175 NOTIMPLEMENTED(); |
| 100 } | 176 } |
| 101 | 177 |
| 102 void TypedURLSyncBridge::OnURLsDeleted(history::HistoryBackend* history_backend, | 178 void TypedURLSyncBridge::OnURLsDeleted(history::HistoryBackend* history_backend, |
| 103 bool all_history, | 179 bool all_history, |
| 104 bool expired, | 180 bool expired, |
| 105 const history::URLRows& deleted_rows, | 181 const history::URLRows& deleted_rows, |
| 106 const std::set<GURL>& favicon_urls) { | 182 const std::set<GURL>& favicon_urls) { |
| 107 DCHECK(sequence_checker_.CalledOnValidSequence()); | 183 DCHECK(sequence_checker_.CalledOnValidSequence()); |
| 108 NOTIMPLEMENTED(); | 184 NOTIMPLEMENTED(); |
| 109 } | 185 } |
| 110 | 186 |
| 187 int TypedURLSyncBridge::GetErrorPercentage() const { | |
| 188 return num_db_accesses_ ? (100 * num_db_errors_ / num_db_accesses_) : 0; | |
| 189 } | |
| 190 | |
| 191 bool TypedURLSyncBridge::WriteToTypedUrlSpecifics( | |
| 192 const URLRow& url, | |
| 193 const VisitVector& visits, | |
| 194 TypedUrlSpecifics* typed_url) { | |
| 195 DCHECK(!url.last_visit().is_null()); | |
| 196 DCHECK(!visits.empty()); | |
| 197 DCHECK_EQ(url.last_visit().ToInternalValue(), | |
| 198 visits.back().visit_time.ToInternalValue()); | |
| 199 | |
| 200 typed_url->set_url(url.url().spec()); | |
| 201 typed_url->set_title(base::UTF16ToUTF8(url.title())); | |
| 202 typed_url->set_hidden(url.hidden()); | |
| 203 | |
| 204 DCHECK(CheckVisitOrdering(visits)); | |
| 205 | |
| 206 bool only_typed = false; | |
| 207 int skip_count = 0; | |
| 208 | |
| 209 if (std::find_if(visits.begin(), visits.end(), | |
| 210 [](const history::VisitRow& visit) { | |
| 211 return ui::PageTransitionCoreTypeIs( | |
| 212 visit.transition, ui::PAGE_TRANSITION_TYPED); | |
| 213 }) == visits.end()) { | |
| 214 // This URL has no TYPED visits, don't sync it | |
| 215 return false; | |
| 216 } | |
| 217 | |
| 218 if (visits.size() > static_cast<size_t>(kMaxTypedUrlVisits)) { | |
| 219 int typed_count = 0; | |
| 220 int total = 0; | |
| 221 // Walk the passed-in visit vector and count the # of typed visits. | |
| 222 for (VisitVector::const_iterator visit = visits.begin(); | |
| 223 visit != visits.end(); ++visit) { | |
| 224 // We ignore reload visits. | |
| 225 if (PageTransitionCoreTypeIs(visit->transition, | |
| 226 ui::PAGE_TRANSITION_RELOAD)) { | |
| 227 continue; | |
| 228 } | |
| 229 ++total; | |
| 230 if (PageTransitionCoreTypeIs(visit->transition, | |
| 231 ui::PAGE_TRANSITION_TYPED)) { | |
| 232 ++typed_count; | |
| 233 } | |
| 234 } | |
| 235 | |
| 236 // We should have at least one typed visit. This can sometimes happen if | |
| 237 // the history DB has an inaccurate count for some reason (there's been | |
| 238 // bugs in the history code in the past which has left users in the wild | |
| 239 // with incorrect counts - http://crbug.com/84258). | |
| 240 DCHECK(typed_count > 0); | |
| 241 | |
| 242 if (typed_count > kMaxTypedUrlVisits) { | |
| 243 only_typed = true; | |
| 244 skip_count = typed_count - kMaxTypedUrlVisits; | |
| 245 } else if (total > kMaxTypedUrlVisits) { | |
| 246 skip_count = total - kMaxTypedUrlVisits; | |
| 247 } | |
| 248 } | |
| 249 | |
| 250 for (const auto& visit : visits) { | |
| 251 // Skip reload visits. | |
| 252 if (PageTransitionCoreTypeIs(visit.transition, ui::PAGE_TRANSITION_RELOAD)) | |
| 253 continue; | |
| 254 | |
| 255 // If we only have room for typed visits, then only add typed visits. | |
| 256 if (only_typed && !PageTransitionCoreTypeIs(visit.transition, | |
| 257 ui::PAGE_TRANSITION_TYPED)) { | |
| 258 continue; | |
| 259 } | |
| 260 | |
| 261 if (skip_count > 0) { | |
| 262 // We have too many entries to fit, so we need to skip the oldest ones. | |
| 263 // Only skip typed URLs if there are too many typed URLs to fit. | |
| 264 if (only_typed || !PageTransitionCoreTypeIs(visit.transition, | |
| 265 ui::PAGE_TRANSITION_TYPED)) { | |
| 266 --skip_count; | |
| 267 continue; | |
| 268 } | |
| 269 } | |
| 270 typed_url->add_visits(visit.visit_time.ToInternalValue()); | |
| 271 typed_url->add_visit_transitions(visit.transition); | |
| 272 } | |
| 273 DCHECK_EQ(skip_count, 0); | |
| 274 | |
| 275 CHECK_GT(typed_url->visits_size(), 0); | |
| 276 CHECK_LE(typed_url->visits_size(), kMaxTypedUrlVisits); | |
| 277 CHECK_EQ(typed_url->visits_size(), typed_url->visit_transitions_size()); | |
| 278 | |
| 279 return true; | |
| 280 } | |
| 281 | |
| 282 void TypedURLSyncBridge::LoadMetadata() { | |
| 283 if (!history_backend_ || !sync_metadata_store_) { | |
| 284 change_processor()->ReportError( | |
| 285 FROM_HERE, "Failed to load TypedURLSyncMetadataDatabase."); | |
| 286 return; | |
| 287 } | |
| 288 | |
| 289 auto batch = base::MakeUnique<syncer::MetadataBatch>(); | |
| 290 if (!sync_metadata_store_->GetAllSyncMetadata(batch.get())) { | |
| 291 change_processor()->ReportError( | |
| 292 FROM_HERE, | |
| 293 "Failed reading typed url metadata from TypedURLSyncMetadataDatabase."); | |
| 294 return; | |
| 295 } | |
| 296 change_processor()->ModelReadyToSync(std::move(batch)); | |
| 297 } | |
| 298 | |
| 299 void TypedURLSyncBridge::ClearErrorStats() { | |
| 300 num_db_accesses_ = 0; | |
| 301 num_db_errors_ = 0; | |
| 302 } | |
| 303 | |
| 304 bool TypedURLSyncBridge::FixupURLAndGetVisits(URLRow* url, | |
| 305 VisitVector* visits) { | |
| 306 CHECK(history_backend_); | |
| 307 ++num_db_accesses_; | |
| 308 if (!history_backend_->GetMostRecentVisitsForURL(url->id(), kMaxVisitsToFetch, | |
| 309 visits)) { | |
| 310 ++num_db_errors_; | |
| 311 // Couldn't load the visits for this URL due to some kind of DB error. | |
| 312 // Don't bother writing this URL to the history DB (if we ignore the | |
| 313 // error and continue, we might end up duplicating existing visits). | |
| 314 DLOG(ERROR) << "Could not load visits for url: " << url->url(); | |
| 315 return false; | |
| 316 } | |
| 317 | |
| 318 // Sometimes (due to a bug elsewhere in the history or sync code, or due to | |
| 319 // a crash between adding a URL to the history database and updating the | |
| 320 // visit DB) the visit vector for a URL can be empty. If this happens, just | |
| 321 // create a new visit whose timestamp is the same as the last_visit time. | |
| 322 // This is a workaround for http://crbug.com/84258. | |
| 323 if (visits->empty()) { | |
| 324 DVLOG(1) << "Found empty visits for URL: " << url->url(); | |
| 325 if (url->last_visit().is_null()) { | |
| 326 // If modified URL is bookmarked, history backend treats it as modified | |
| 327 // even if all its visits are deleted. Return false to stop further | |
| 328 // processing because sync expects valid visit time for modified entry. | |
| 329 return false; | |
| 330 } | |
| 331 | |
| 332 VisitRow visit(url->id(), url->last_visit(), 0, ui::PAGE_TRANSITION_TYPED, | |
| 333 0); | |
| 334 visits->push_back(visit); | |
| 335 } | |
| 336 | |
| 337 // GetMostRecentVisitsForURL() returns the data in the opposite order that | |
| 338 // we need it, so reverse it. | |
| 339 std::reverse(visits->begin(), visits->end()); | |
| 340 | |
| 341 // Sometimes, the last_visit field in the URL doesn't match the timestamp of | |
| 342 // the last visit in our visit array (they come from different tables, so | |
| 343 // crashes/bugs can cause them to mismatch), so just set it here. | |
| 344 url->set_last_visit(visits->back().visit_time); | |
| 345 DCHECK(CheckVisitOrdering(*visits)); | |
| 346 | |
| 347 // Removes all visits that are older than the current expiration time. Visits | |
| 348 // are in ascending order now, so we can check from beginning to check how | |
| 349 // many expired visits. | |
| 350 size_t num_expired_visits = 0; | |
| 351 for (auto& visit : *visits) { | |
| 352 base::Time time = visit.visit_time; | |
| 353 if (history_backend_->IsExpiredVisitTime(time)) { | |
| 354 ++num_expired_visits; | |
| 355 } else { | |
| 356 break; | |
| 357 } | |
| 358 } | |
| 359 if (num_expired_visits != 0) { | |
| 360 if (num_expired_visits == visits->size()) { | |
| 361 DVLOG(1) << "All visits are expired for url: " << url->url(); | |
| 362 visits->clear(); | |
| 363 return false; | |
| 364 } | |
| 365 visits->erase(visits->begin(), visits->begin() + num_expired_visits); | |
| 366 } | |
| 367 DCHECK(CheckVisitOrdering(*visits)); | |
| 368 | |
| 369 return true; | |
| 370 } | |
| 371 | |
| 372 std::unique_ptr<EntityData> TypedURLSyncBridge::CreateEntityData( | |
| 373 const URLRow& row, | |
| 374 const VisitVector& visits) { | |
| 375 auto entity_data = base::MakeUnique<EntityData>(); | |
| 376 TypedUrlSpecifics* specifics = entity_data->specifics.mutable_typed_url(); | |
| 377 | |
| 378 if (!WriteToTypedUrlSpecifics(row, visits, specifics)) { | |
| 379 // Cannot write to specifics, ex. no TYPED visits. | |
| 380 return base::MakeUnique<EntityData>(); | |
| 381 } | |
| 382 entity_data->non_unique_name = row.url().spec(); | |
| 383 | |
| 384 return entity_data; | |
| 385 } | |
| 386 | |
| 111 } // namespace history | 387 } // namespace history |
| OLD | NEW |