OLD | NEW |
| (Empty) |
1 // Copyright 2016 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 #include "components/offline_pages/background/request_queue_store_sql.h" | |
6 | |
7 #include <unordered_set> | |
8 | |
9 #include "base/bind.h" | |
10 #include "base/files/file_path.h" | |
11 #include "base/files/file_util.h" | |
12 #include "base/location.h" | |
13 #include "base/logging.h" | |
14 #include "base/sequenced_task_runner.h" | |
15 #include "base/threading/thread_task_runner_handle.h" | |
16 #include "components/offline_pages/background/save_page_request.h" | |
17 #include "sql/connection.h" | |
18 #include "sql/statement.h" | |
19 #include "sql/transaction.h" | |
20 | |
21 namespace offline_pages { | |
22 | |
23 template class StoreUpdateResult<SavePageRequest>; | |
24 | |
25 namespace { | |
26 | |
27 using SuccessCallback = base::Callback<void(bool)>; | |
28 | |
29 // This is a macro instead of a const so that | |
30 // it can be used inline in other SQL statements below. | |
31 #define REQUEST_QUEUE_TABLE_NAME "request_queue_v1" | |
32 const bool kUserRequested = true; | |
33 | |
34 bool CreateRequestQueueTable(sql::Connection* db) { | |
35 const char kSql[] = "CREATE TABLE IF NOT EXISTS " REQUEST_QUEUE_TABLE_NAME | |
36 " (request_id INTEGER PRIMARY KEY NOT NULL," | |
37 " creation_time INTEGER NOT NULL," | |
38 " activation_time INTEGER NOT NULL DEFAULT 0," | |
39 " last_attempt_time INTEGER NOT NULL DEFAULT 0," | |
40 " started_attempt_count INTEGER NOT NULL," | |
41 " completed_attempt_count INTEGER NOT NULL," | |
42 " state INTEGER NOT NULL DEFAULT 0," | |
43 " url VARCHAR NOT NULL," | |
44 " client_namespace VARCHAR NOT NULL," | |
45 " client_id VARCHAR NOT NULL" | |
46 ")"; | |
47 return db->Execute(kSql); | |
48 } | |
49 | |
50 bool CreateSchema(sql::Connection* db) { | |
51 // If there is not already a state column, we need to drop the old table. We | |
52 // are choosing to drop instead of upgrade since the feature is not yet | |
53 // released, so we don't use a transaction to protect existing data, or try to | |
54 // migrate it. | |
55 if (!db->DoesColumnExist(REQUEST_QUEUE_TABLE_NAME, "state")) { | |
56 if (!db->Execute("DROP TABLE IF EXISTS " REQUEST_QUEUE_TABLE_NAME)) | |
57 return false; | |
58 } | |
59 | |
60 if (!CreateRequestQueueTable(db)) | |
61 return false; | |
62 | |
63 // TODO(fgorski): Add indices here. | |
64 return true; | |
65 } | |
66 | |
67 // Create a save page request from a SQL result. Expects complete rows with | |
68 // all columns present. Columns are in order they are defined in select query | |
69 // in |GetOneRequest| method. | |
70 std::unique_ptr<SavePageRequest> MakeSavePageRequest( | |
71 const sql::Statement& statement) { | |
72 const int64_t id = statement.ColumnInt64(0); | |
73 const base::Time creation_time = | |
74 base::Time::FromInternalValue(statement.ColumnInt64(1)); | |
75 const base::Time activation_time = | |
76 base::Time::FromInternalValue(statement.ColumnInt64(2)); | |
77 const base::Time last_attempt_time = | |
78 base::Time::FromInternalValue(statement.ColumnInt64(3)); | |
79 const int64_t started_attempt_count = statement.ColumnInt64(4); | |
80 const int64_t completed_attempt_count = statement.ColumnInt64(5); | |
81 const SavePageRequest::RequestState state = | |
82 static_cast<SavePageRequest::RequestState>(statement.ColumnInt64(6)); | |
83 const GURL url(statement.ColumnString(7)); | |
84 const ClientId client_id(statement.ColumnString(8), | |
85 statement.ColumnString(9)); | |
86 | |
87 DVLOG(2) << "making save page request - id " << id << " url " << url | |
88 << " client_id " << client_id.name_space << "-" << client_id.id | |
89 << " creation time " << creation_time << " user requested " | |
90 << kUserRequested; | |
91 | |
92 std::unique_ptr<SavePageRequest> request(new SavePageRequest( | |
93 id, url, client_id, creation_time, activation_time, kUserRequested)); | |
94 request->set_last_attempt_time(last_attempt_time); | |
95 request->set_started_attempt_count(started_attempt_count); | |
96 request->set_completed_attempt_count(completed_attempt_count); | |
97 request->set_request_state(state); | |
98 return request; | |
99 } | |
100 | |
101 // Get a request for a specific id. | |
102 std::unique_ptr<SavePageRequest> GetOneRequest(sql::Connection* db, | |
103 const int64_t request_id) { | |
104 const char kSql[] = | |
105 "SELECT request_id, creation_time, activation_time," | |
106 " last_attempt_time, started_attempt_count, completed_attempt_count," | |
107 " state, url, client_namespace, client_id" | |
108 " FROM " REQUEST_QUEUE_TABLE_NAME " WHERE request_id=?"; | |
109 | |
110 sql::Statement statement(db->GetCachedStatement(SQL_FROM_HERE, kSql)); | |
111 statement.BindInt64(0, request_id); | |
112 | |
113 if (statement.Step()) | |
114 return MakeSavePageRequest(statement); | |
115 return std::unique_ptr<SavePageRequest>(nullptr); | |
116 } | |
117 | |
118 ItemActionStatus DeleteRequestById(sql::Connection* db, int64_t request_id) { | |
119 const char kSql[] = | |
120 "DELETE FROM " REQUEST_QUEUE_TABLE_NAME " WHERE request_id=?"; | |
121 sql::Statement statement(db->GetCachedStatement(SQL_FROM_HERE, kSql)); | |
122 statement.BindInt64(0, request_id); | |
123 if (!statement.Run()) | |
124 return ItemActionStatus::STORE_ERROR; | |
125 else if (db->GetLastChangeCount() == 0) | |
126 return ItemActionStatus::NOT_FOUND; | |
127 return ItemActionStatus::SUCCESS; | |
128 } | |
129 | |
130 ItemActionStatus Insert(sql::Connection* db, const SavePageRequest& request) { | |
131 const char kSql[] = | |
132 "INSERT OR IGNORE INTO " REQUEST_QUEUE_TABLE_NAME | |
133 " (request_id, creation_time, activation_time," | |
134 " last_attempt_time, started_attempt_count, completed_attempt_count," | |
135 " state, url, client_namespace, client_id)" | |
136 " VALUES " | |
137 " (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; | |
138 | |
139 sql::Statement statement(db->GetCachedStatement(SQL_FROM_HERE, kSql)); | |
140 statement.BindInt64(0, request.request_id()); | |
141 statement.BindInt64(1, request.creation_time().ToInternalValue()); | |
142 statement.BindInt64(2, request.activation_time().ToInternalValue()); | |
143 statement.BindInt64(3, request.last_attempt_time().ToInternalValue()); | |
144 statement.BindInt64(4, request.started_attempt_count()); | |
145 statement.BindInt64(5, request.completed_attempt_count()); | |
146 statement.BindInt64(6, static_cast<int64_t>(request.request_state())); | |
147 statement.BindString(7, request.url().spec()); | |
148 statement.BindString(8, request.client_id().name_space); | |
149 statement.BindString(9, request.client_id().id); | |
150 | |
151 if (!statement.Run()) | |
152 return ItemActionStatus::STORE_ERROR; | |
153 if (db->GetLastChangeCount() == 0) | |
154 return ItemActionStatus::ALREADY_EXISTS; | |
155 return ItemActionStatus::SUCCESS; | |
156 } | |
157 | |
158 ItemActionStatus Update(sql::Connection* db, const SavePageRequest& request) { | |
159 const char kSql[] = | |
160 "UPDATE OR IGNORE " REQUEST_QUEUE_TABLE_NAME | |
161 " SET creation_time = ?, activation_time = ?, last_attempt_time = ?," | |
162 " started_attempt_count = ?, completed_attempt_count = ?, state = ?," | |
163 " url = ?, client_namespace = ?, client_id = ?" | |
164 " WHERE request_id = ?"; | |
165 | |
166 sql::Statement statement(db->GetCachedStatement(SQL_FROM_HERE, kSql)); | |
167 statement.BindInt64(0, request.creation_time().ToInternalValue()); | |
168 statement.BindInt64(1, request.activation_time().ToInternalValue()); | |
169 statement.BindInt64(2, request.last_attempt_time().ToInternalValue()); | |
170 statement.BindInt64(3, request.started_attempt_count()); | |
171 statement.BindInt64(4, request.completed_attempt_count()); | |
172 statement.BindInt64(5, static_cast<int64_t>(request.request_state())); | |
173 statement.BindString(6, request.url().spec()); | |
174 statement.BindString(7, request.client_id().name_space); | |
175 statement.BindString(8, request.client_id().id); | |
176 statement.BindInt64(9, request.request_id()); | |
177 | |
178 if (!statement.Run()) | |
179 return ItemActionStatus::STORE_ERROR; | |
180 if (db->GetLastChangeCount() == 0) | |
181 return ItemActionStatus::NOT_FOUND; | |
182 return ItemActionStatus::SUCCESS; | |
183 } | |
184 | |
185 void PostStoreUpdateResultForIds( | |
186 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
187 StoreState store_state, | |
188 const std::vector<int64_t>& item_ids, | |
189 ItemActionStatus action_status, | |
190 const RequestQueueStore::UpdateCallback& callback) { | |
191 std::unique_ptr<UpdateRequestsResult> result( | |
192 new UpdateRequestsResult(store_state)); | |
193 for (const auto& item_id : item_ids) | |
194 result->item_statuses.push_back(std::make_pair(item_id, action_status)); | |
195 runner->PostTask(FROM_HERE, base::Bind(callback, base::Passed(&result))); | |
196 } | |
197 | |
198 void PostStoreErrorForAllRequests( | |
199 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
200 const std::vector<SavePageRequest>& items, | |
201 const RequestQueueStore::UpdateCallback& callback) { | |
202 std::vector<int64_t> item_ids; | |
203 for (const auto& item : items) | |
204 item_ids.push_back(item.request_id()); | |
205 PostStoreUpdateResultForIds(runner, StoreState::LOADED, item_ids, | |
206 ItemActionStatus::STORE_ERROR, callback); | |
207 } | |
208 | |
209 void PostStoreErrorForAllIds( | |
210 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
211 const std::vector<int64_t>& item_ids, | |
212 const RequestQueueStore::UpdateCallback& callback) { | |
213 PostStoreUpdateResultForIds(runner, StoreState::LOADED, item_ids, | |
214 ItemActionStatus::STORE_ERROR, callback); | |
215 } | |
216 | |
217 bool InitDatabase(sql::Connection* db, const base::FilePath& path) { | |
218 db->set_page_size(4096); | |
219 db->set_cache_size(500); | |
220 db->set_histogram_tag("BackgroundRequestQueue"); | |
221 db->set_exclusive_locking(); | |
222 | |
223 base::File::Error err; | |
224 if (!base::CreateDirectoryAndGetError(path.DirName(), &err)) | |
225 return false; | |
226 if (!db->Open(path)) | |
227 return false; | |
228 db->Preload(); | |
229 | |
230 return CreateSchema(db); | |
231 } | |
232 | |
233 void GetRequestsSync(sql::Connection* db, | |
234 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
235 const RequestQueueStore::GetRequestsCallback& callback) { | |
236 const char kSql[] = | |
237 "SELECT request_id, creation_time, activation_time," | |
238 " last_attempt_time, started_attempt_count, completed_attempt_count," | |
239 " state, url, client_namespace, client_id" | |
240 " FROM " REQUEST_QUEUE_TABLE_NAME; | |
241 | |
242 sql::Statement statement(db->GetCachedStatement(SQL_FROM_HERE, kSql)); | |
243 | |
244 std::vector<std::unique_ptr<SavePageRequest>> requests; | |
245 while (statement.Step()) | |
246 requests.push_back(MakeSavePageRequest(statement)); | |
247 | |
248 runner->PostTask(FROM_HERE, base::Bind(callback, statement.Succeeded(), | |
249 base::Passed(&requests))); | |
250 } | |
251 | |
252 void GetRequestsByIdsSync(sql::Connection* db, | |
253 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
254 const std::vector<int64_t>& request_ids, | |
255 const RequestQueueStore::UpdateCallback& callback) { | |
256 // TODO(fgorski): Perhaps add metrics here. | |
257 std::unique_ptr<UpdateRequestsResult> result( | |
258 new UpdateRequestsResult(StoreState::LOADED)); | |
259 | |
260 // If you create a transaction but don't Commit() it is automatically | |
261 // rolled back by its destructor when it falls out of scope. | |
262 sql::Transaction transaction(db); | |
263 if (!transaction.Begin()) { | |
264 PostStoreErrorForAllIds(runner, request_ids, callback); | |
265 return; | |
266 } | |
267 | |
268 // Make sure not to include the same request multiple times, preserving the | |
269 // order of non-duplicated IDs in the result. | |
270 std::unordered_set<int64_t> processed_ids; | |
271 for (int64_t request_id : request_ids) { | |
272 if (!processed_ids.insert(request_id).second) | |
273 continue; | |
274 std::unique_ptr<SavePageRequest> request = GetOneRequest(db, request_id); | |
275 if (request.get()) | |
276 result->updated_items.push_back(*request); | |
277 ItemActionStatus status = | |
278 request.get() ? ItemActionStatus::SUCCESS : ItemActionStatus::NOT_FOUND; | |
279 result->item_statuses.push_back(std::make_pair(request_id, status)); | |
280 } | |
281 | |
282 if (!transaction.Commit()) { | |
283 PostStoreErrorForAllIds(runner, request_ids, callback); | |
284 return; | |
285 } | |
286 | |
287 runner->PostTask(FROM_HERE, base::Bind(callback, base::Passed(&result))); | |
288 } | |
289 | |
290 void AddRequestSync(sql::Connection* db, | |
291 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
292 const SavePageRequest& request, | |
293 const RequestQueueStore::AddCallback& callback) { | |
294 // TODO(fgorski): add UMA metrics here. | |
295 ItemActionStatus status = Insert(db, request); | |
296 runner->PostTask(FROM_HERE, base::Bind(callback, status)); | |
297 } | |
298 | |
299 void UpdateRequestsSync(sql::Connection* db, | |
300 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
301 const std::vector<SavePageRequest>& requests, | |
302 const RequestQueueStore::UpdateCallback& callback) { | |
303 // TODO(fgorski): add UMA metrics here. | |
304 std::unique_ptr<UpdateRequestsResult> result( | |
305 new UpdateRequestsResult(StoreState::LOADED)); | |
306 | |
307 sql::Transaction transaction(db); | |
308 if (!transaction.Begin()) { | |
309 PostStoreErrorForAllRequests(runner, requests, callback); | |
310 return; | |
311 } | |
312 | |
313 for (const auto& request : requests) { | |
314 ItemActionStatus status = Update(db, request); | |
315 result->item_statuses.push_back( | |
316 std::make_pair(request.request_id(), status)); | |
317 if (status == ItemActionStatus::SUCCESS) | |
318 result->updated_items.push_back(request); | |
319 } | |
320 | |
321 if (!transaction.Commit()) { | |
322 PostStoreErrorForAllRequests(runner, requests, callback); | |
323 return; | |
324 } | |
325 | |
326 runner->PostTask(FROM_HERE, base::Bind(callback, base::Passed(&result))); | |
327 } | |
328 | |
329 void RemoveRequestsSync(sql::Connection* db, | |
330 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
331 const std::vector<int64_t>& request_ids, | |
332 const RequestQueueStore::UpdateCallback& callback) { | |
333 // TODO(fgorski): Perhaps add metrics here. | |
334 std::unique_ptr<UpdateRequestsResult> result( | |
335 new UpdateRequestsResult(StoreState::LOADED)); | |
336 | |
337 // If you create a transaction but don't Commit() it is automatically | |
338 // rolled back by its destructor when it falls out of scope. | |
339 sql::Transaction transaction(db); | |
340 if (!transaction.Begin()) { | |
341 PostStoreErrorForAllIds(runner, request_ids, callback); | |
342 return; | |
343 } | |
344 | |
345 // Read the request before we delete it, and if the delete worked, put it on | |
346 // the queue of requests that got deleted. | |
347 for (int64_t request_id : request_ids) { | |
348 std::unique_ptr<SavePageRequest> request = GetOneRequest(db, request_id); | |
349 ItemActionStatus status = DeleteRequestById(db, request_id); | |
350 result->item_statuses.push_back(std::make_pair(request_id, status)); | |
351 if (status == ItemActionStatus::SUCCESS) | |
352 result->updated_items.push_back(*request); | |
353 } | |
354 | |
355 if (!transaction.Commit()) { | |
356 PostStoreErrorForAllIds(runner, request_ids, callback); | |
357 return; | |
358 } | |
359 | |
360 runner->PostTask(FROM_HERE, base::Bind(callback, base::Passed(&result))); | |
361 } | |
362 | |
363 void OpenConnectionSync(sql::Connection* db, | |
364 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
365 const base::FilePath& path, | |
366 const SuccessCallback& callback) { | |
367 bool success = InitDatabase(db, path); | |
368 runner->PostTask(FROM_HERE, base::Bind(callback, success)); | |
369 } | |
370 | |
371 void ResetSync(sql::Connection* db, | |
372 const base::FilePath& db_file_path, | |
373 scoped_refptr<base::SingleThreadTaskRunner> runner, | |
374 const SuccessCallback& callback) { | |
375 // This method deletes the content of the whole store and reinitializes it. | |
376 bool success = true; | |
377 if (db) { | |
378 success = db->Raze(); | |
379 db->Close(); | |
380 } | |
381 success = base::DeleteFile(db_file_path, true /* recursive */) && success; | |
382 runner->PostTask(FROM_HERE, base::Bind(callback, success)); | |
383 } | |
384 | |
385 } // anonymous namespace | |
386 | |
387 RequestQueueStoreSQL::RequestQueueStoreSQL( | |
388 scoped_refptr<base::SequencedTaskRunner> background_task_runner, | |
389 const base::FilePath& path) | |
390 : background_task_runner_(std::move(background_task_runner)), | |
391 db_file_path_(path.AppendASCII("RequestQueue.db")), | |
392 state_(StoreState::NOT_LOADED), | |
393 weak_ptr_factory_(this) {} | |
394 | |
395 RequestQueueStoreSQL::~RequestQueueStoreSQL() { | |
396 if (db_.get()) | |
397 background_task_runner_->DeleteSoon(FROM_HERE, db_.release()); | |
398 } | |
399 | |
400 void RequestQueueStoreSQL::Initialize(const InitializeCallback& callback) { | |
401 DCHECK(!db_); | |
402 db_.reset(new sql::Connection()); | |
403 background_task_runner_->PostTask( | |
404 FROM_HERE, | |
405 base::Bind(&OpenConnectionSync, db_.get(), | |
406 base::ThreadTaskRunnerHandle::Get(), db_file_path_, | |
407 base::Bind(&RequestQueueStoreSQL::OnOpenConnectionDone, | |
408 weak_ptr_factory_.GetWeakPtr(), callback))); | |
409 } | |
410 | |
411 void RequestQueueStoreSQL::GetRequests(const GetRequestsCallback& callback) { | |
412 DCHECK(db_.get()); | |
413 if (!CheckDb()) { | |
414 std::vector<std::unique_ptr<SavePageRequest>> requests; | |
415 base::ThreadTaskRunnerHandle::Get()->PostTask( | |
416 FROM_HERE, base::Bind(callback, false, base::Passed(&requests))); | |
417 return; | |
418 } | |
419 | |
420 background_task_runner_->PostTask( | |
421 FROM_HERE, base::Bind(&GetRequestsSync, db_.get(), | |
422 base::ThreadTaskRunnerHandle::Get(), callback)); | |
423 } | |
424 | |
425 void RequestQueueStoreSQL::GetRequestsByIds( | |
426 const std::vector<int64_t>& request_ids, | |
427 const UpdateCallback& callback) { | |
428 if (!CheckDb()) { | |
429 PostStoreErrorForAllIds(base::ThreadTaskRunnerHandle::Get(), request_ids, | |
430 callback); | |
431 return; | |
432 } | |
433 | |
434 background_task_runner_->PostTask( | |
435 FROM_HERE, | |
436 base::Bind(&GetRequestsByIdsSync, db_.get(), | |
437 base::ThreadTaskRunnerHandle::Get(), request_ids, callback)); | |
438 } | |
439 | |
440 void RequestQueueStoreSQL::AddRequest(const SavePageRequest& request, | |
441 const AddCallback& callback) { | |
442 if (!CheckDb()) { | |
443 base::ThreadTaskRunnerHandle::Get()->PostTask( | |
444 FROM_HERE, base::Bind(callback, ItemActionStatus::STORE_ERROR)); | |
445 return; | |
446 } | |
447 | |
448 background_task_runner_->PostTask( | |
449 FROM_HERE, | |
450 base::Bind(&AddRequestSync, db_.get(), | |
451 base::ThreadTaskRunnerHandle::Get(), request, callback)); | |
452 } | |
453 | |
454 void RequestQueueStoreSQL::UpdateRequests( | |
455 const std::vector<SavePageRequest>& requests, | |
456 const UpdateCallback& callback) { | |
457 if (!CheckDb()) { | |
458 PostStoreErrorForAllRequests(base::ThreadTaskRunnerHandle::Get(), requests, | |
459 callback); | |
460 return; | |
461 } | |
462 | |
463 background_task_runner_->PostTask( | |
464 FROM_HERE, | |
465 base::Bind(&UpdateRequestsSync, db_.get(), | |
466 base::ThreadTaskRunnerHandle::Get(), requests, callback)); | |
467 } | |
468 | |
469 void RequestQueueStoreSQL::RemoveRequests( | |
470 const std::vector<int64_t>& request_ids, | |
471 const UpdateCallback& callback) { | |
472 if (!CheckDb()) { | |
473 PostStoreErrorForAllIds(base::ThreadTaskRunnerHandle::Get(), request_ids, | |
474 callback); | |
475 return; | |
476 } | |
477 | |
478 background_task_runner_->PostTask( | |
479 FROM_HERE, | |
480 base::Bind(&RemoveRequestsSync, db_.get(), | |
481 base::ThreadTaskRunnerHandle::Get(), request_ids, callback)); | |
482 } | |
483 | |
484 void RequestQueueStoreSQL::Reset(const ResetCallback& callback) { | |
485 background_task_runner_->PostTask( | |
486 FROM_HERE, | |
487 base::Bind(&ResetSync, db_.get(), db_file_path_, | |
488 base::ThreadTaskRunnerHandle::Get(), | |
489 base::Bind(&RequestQueueStoreSQL::OnResetDone, | |
490 weak_ptr_factory_.GetWeakPtr(), callback))); | |
491 } | |
492 | |
493 StoreState RequestQueueStoreSQL::state() const { | |
494 return state_; | |
495 } | |
496 | |
497 void RequestQueueStoreSQL::OnOpenConnectionDone( | |
498 const InitializeCallback& callback, | |
499 bool success) { | |
500 DCHECK(db_.get()); | |
501 state_ = success ? StoreState::LOADED : StoreState::FAILED_LOADING; | |
502 callback.Run(success); | |
503 } | |
504 | |
505 void RequestQueueStoreSQL::OnResetDone(const ResetCallback& callback, | |
506 bool success) { | |
507 state_ = success ? StoreState::NOT_LOADED : StoreState::FAILED_RESET; | |
508 db_.reset(); | |
509 base::ThreadTaskRunnerHandle::Get()->PostTask(FROM_HERE, | |
510 base::Bind(callback, success)); | |
511 } | |
512 | |
513 bool RequestQueueStoreSQL::CheckDb() const { | |
514 return db_ && state_ == StoreState::LOADED; | |
515 } | |
516 | |
517 } // namespace offline_pages | |
OLD | NEW |